Разработка AI для распознавания автомобильных номеров (ANPR/LPR)
ANPR (Automatic Number Plate Recognition) — одна из наиболее зрелых задач CV: системы работают на скоростях до 200 км/ч, в ночных условиях (ИК-подсветка), при различных углах. Applications: системы въезда/выезда на парковки, мониторинг дорожного трафика, розыск угнанных автомобилей, toll collection, транспортная аналитика. Двухэтапный pipeline: детекция номерного знака + OCR символов.
Детекция и нормализация номерного знака
import cv2
import numpy as np
from ultralytics import YOLO
class LicensePlateDetector:
"""
Stage 1: детекция местоположения номерного знака.
YOLOv8n — оптимальный выбор: быстро (60+ FPS) и точно.
"""
def __init__(self, model_path: str, device: str = 'cuda'):
self.model = YOLO(model_path)
self.device = device
def detect(self, image: np.ndarray,
conf_threshold: float = 0.5) -> list[dict]:
"""Возвращает список детектированных номерных знаков"""
results = self.model(image, conf=conf_threshold)
plates = []
for box in results[0].boxes:
x1, y1, x2, y2 = map(int, box.xyxy[0])
conf = float(box.conf)
plate_crop = image[y1:y2, x1:x2]
# Перспективная нормализация для наклонных номеров
normalized = self._normalize_plate(plate_crop)
plates.append({
'bbox': [x1, y1, x2, y2],
'confidence': conf,
'crop': plate_crop,
'normalized': normalized,
'area': (x2-x1) * (y2-y1)
})
# Сортируем по площади (наиболее крупный = основной)
return sorted(plates, key=lambda p: p['area'], reverse=True)
def _normalize_plate(self, plate: np.ndarray,
target_size: tuple = (440, 140)) -> np.ndarray:
"""
Нормализация номерного знака до стандартного размера.
Соотношение сторон EU номера: 520×110 мм ≈ 4.7:1
"""
h, w = plate.shape[:2]
# Дополнительная коррекция перспективы через Hough
gray = cv2.cvtColor(plate, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 50, 150)
lines = cv2.HoughLinesP(edges, 1, np.pi/180, 30, minLineLength=30, maxLineGap=10)
if lines is not None and len(lines) > 2:
angles = []
for line in lines[:10]:
x1, y1, x2, y2 = line[0]
angle = np.degrees(np.arctan2(y2-y1, x2-x1))
if abs(angle) < 20:
angles.append(angle)
if angles:
avg_angle = np.median(angles)
if abs(avg_angle) > 1.0:
M = cv2.getRotationMatrix2D((w//2, h//2), avg_angle, 1.0)
plate = cv2.warpAffine(plate, M, (w, h))
return cv2.resize(plate, target_size, interpolation=cv2.INTER_CUBIC)
OCR для номерных знаков
from paddleocr import PaddleOCR
import re
class LicensePlateOCR:
"""
Stage 2: распознавание символов номерного знака.
Поддержка: кириллица (UA, RU, BY), латиница (EU, US), арабский, китайский.
"""
PLATE_PATTERNS = {
'UA': r'^[АВЕКМНРСТУХ]{2}\d{4}[АВЕКМНРСТУХ]{2}$', # АА1234ВВ
'UA_custom': r'^\d{4}[АВЕКМНРСТУХ]{2}\d{2}$', # Киев
'EU': r'^[A-Z]{1,3}[0-9]{1,4}[A-Z]{1,3}$',
'US_standard': r'^[A-Z0-9]{5,8}$',
'RU': r'^[АВЕКМНРСТУХ]\d{3}[АВЕКМНРСТУХ]{2}\d{2,3}$',
}
def __init__(self, lang: str = 'en', use_gpu: bool = True):
# PaddleOCR: лучший баланс для коротких текстов (номера)
self.ocr = PaddleOCR(
use_angle_cls=False, # номера не перевёрнуты
lang=lang,
use_gpu=use_gpu,
rec_algorithm='SVTR_LCNet', # быстрее для коротких текстов
show_log=False
)
def recognize(self, plate_image: np.ndarray) -> dict:
"""OCR нормализованного номерного знака"""
result = self.ocr.ocr(plate_image, cls=False)
if not result or not result[0]:
return {'text': None, 'confidence': 0}
# Объединяем все найденные строки текста
texts = []
confidences = []
for line in result[0]:
_, (text, conf) = line
texts.append(text.strip())
confidences.append(conf)
raw_text = ' '.join(texts)
cleaned = self._clean_plate_text(raw_text)
plate_format = self._detect_format(cleaned)
return {
'raw_text': raw_text,
'text': cleaned,
'confidence': float(np.mean(confidences)),
'plate_format': plate_format,
'valid_format': plate_format is not None
}
def _clean_plate_text(self, text: str) -> str:
"""Нормализация: убрать пробелы, спецсимволы, привести к верхнему регистру"""
# Замена похожих символов (OCR-ошибки)
replacements = {'O': '0', 'I': '1', 'l': '1', 'Q': '0', 'D': '0'}
cleaned = re.sub(r'[^A-ZА-Я0-9АВЕКМНРСТУХ]', '', text.upper())
return cleaned
def _detect_format(self, text: str) -> str | None:
for format_name, pattern in self.PLATE_PATTERNS.items():
if re.match(pattern, text):
return format_name
return None
Полный ANPR pipeline
import sqlite3
from datetime import datetime
class ANPRSystem:
def __init__(self, detector: LicensePlateDetector,
ocr: LicensePlateOCR,
watchlist_db: str = None):
self.detector = detector
self.ocr = ocr
self.watchlist = self._load_watchlist(watchlist_db)
def process_frame(self, frame: np.ndarray,
camera_id: str) -> dict:
plates = self.detector.detect(frame)
results = []
for plate in plates[:3]: # не более 3 номеров в кадре
ocr_result = self.ocr.recognize(plate['normalized'])
if not ocr_result['text']:
continue
plate_text = ocr_result['text']
on_watchlist = plate_text in self.watchlist
results.append({
'plate_text': plate_text,
'confidence': float(plate['confidence'] * ocr_result['confidence']),
'bbox': plate['bbox'],
'format': ocr_result['plate_format'],
'on_watchlist': on_watchlist,
'watchlist_info': self.watchlist.get(plate_text) if on_watchlist else None
})
return {
'camera_id': camera_id,
'timestamp': datetime.now().isoformat(),
'plates_detected': len(results),
'results': results,
'alert': any(r['on_watchlist'] for r in results)
}
def _load_watchlist(self, db_path: str) -> dict:
if not db_path:
return {}
conn = sqlite3.connect(db_path)
rows = conn.execute('SELECT plate, reason FROM watchlist').fetchall()
conn.close()
return {row[0]: row[1] for row in rows}
| Условие |
Accuracy (UA/EU номера) |
| День, 0° угол, <80 км/ч |
98–99.5% |
| День, 30° угол, <120 км/ч |
94–97% |
| Ночь, ИК-подсветка |
92–96% |
| Плохая погода (дождь, туман) |
85–92% |
| Грязный номер |
78–88% |
| Задача |
Срок |
| Базовая система ANPR для парковки |
3–5 недель |
| Highspeed ANPR (трасса, 130+ км/ч) с ИК |
8–14 недель |
| Федеральная платформа мониторинга транспорта |
20–36 недель |