
Привет, Хабр!
Распознавание автомобильных номеров (ANPR) — задача не новая. Существует множество коммерческих решений и open-source библиотек. Но что, если стандартные инструменты не не подходят? А что, если нам нужна система, которая будет молниеносно работать на обычном CPU, без дорогих видеокарт?
Недавно я столкнулся именно с такой задачей. Вместо того чтобы просто "склеить" готовые решения, я решил пройти весь путь ML-инженера от начала до конца: от анализа данных до обучения кастомных SOTA-моделей и их финальной оптимизации. В этой статье я поделюсь всем процессом, кодом, результатами и проблемами, с которыми пришлось столкнуться.
Скрытый текст
Спойлер: мне удалось собрать пайплайн, который находит номера с mAP@.5-.95 0.85, распознает их с точностью 98.4% и после INT8-квантизации работает на CPU почти в 2 раза быстрее исходной версии.
Архитектура: Две головы лучше, чем одна
Любая ANPR-система решает две последовательные задачи:
Детекция (Detection): Найти на изображении прямоугольник, где находится номер.
Распознавание (Recognition, OCR): Прочитать символы внутри этого прямоугольника.
Пытаться решить обе задачи одной гигантской моделью — неэффективно. Гораздо надежнее и гибче построить пайплайн из двух специализированных моделей.
-
"Глаза" — Детектор:
На эту роль был выбран YOLOv8 от Ultralytics. Почему? Это текущий индустриальный стандарт для быстрой и точной детекции. Библиотека ultralytics делает процесс обучения невероятно простым, позволяя сфокусироваться на данных, а не на написании бойлерплейт-кода. Я взяли самую легкую версию, YOLOv8n, как идеальный баланс скорости и точности.
-
"Мозг" — Распознаватель:
Здесь мы столкнулись с первым важным выбором. Использовать готовый Tesseract OCR? Он хорош для сканов документов, но на реальных, "грязных" данных с камер он часто проваливается.

Поэтому было принято решение обучать кастомную OCR-модель на архитектуре CRNN (Convolutional Recurrent Neural Network) с функцией потерь CTCLoss. Этот подход идеально "заточен" под распознавание последовательностей (текста) на изображениях и, как мы увидим дальше, позволяет достичь феноменальной точности.
Этап 1: Обучаем "Глаза" (Детектор YOLOv8)
Фундамент любой хорошей модели — качественные данные. К счастью, в открытом доступе нашелся превосходный датасет Car plate detecting dataset на 25 тысяч изображений с готовой разметкой в формате YOLO.
Процесс обучения с ultralytics сводится к нескольким строчкам кода:
from ultralytics import YOLO
# Загружаем предобученную модель
model = YOLO('yolov8n.pt')
# Запускаем дообучение (fine-tuning)
results = model.train(
data='dataset_config.yaml',
epochs=100,
imgsz=640,
batch=16,
name='yolov8n_plate_detector'
)
Процесс обучения занял около 7 часов на NVIDIA RTX 3070 Ti. Кстати, здесь я столкнулся с первым интересным наблюдением. Ultralytics для ускорения работы кэширует предобработанные изображения на диск. Это вызвало легкую панику, когда папка с 5-гигабайтным датасетом внезапно "раздулась" до 87 ГБ! К счастью, после успешного завершения обучения кэш автоматически удаляется. Но имейте в виду: если прервать процесс, чистить его придется вручную.
Еще один момент касался оптимизации загрузки данных. Параметр workers в model.train() отвечает за количество процессорных потоков, готовящих данные для GPU. Экспериментальным путем было выяснено, что для полной загрузки RTX 3070 Ti на моей системе (i7-9700KF + NVMe SSD) достаточно всего workers=2. Увеличение этого параметра не ускоряло обучение, но сильнее нагружало процессор.
В итоге, после 100 эпох мы получили детектор с отличными показателями:
Точность (Precision): 0.975
Полнота (Recall): 0.973
mAP@0.5-0.95 (главная метрика): 0.849
Это означает, что наш детектор не просто находит почти все номера, но и делает это с очень точным позиционированием рамки, что критически важно для следующего этапа.
Выбор оптимального порога уверенности по кривой F1-Confidence. Этот график является одним из самых полезных артефактов обучения. Он показывает, как меняется метрика F1 (гармоническое среднее между Precision и Recall) в зависимости от установленного порога confidence.

Мы видим, что пик F1-Score (0.97) достигается при пороге confidence около 0.5. Это и есть наша "золотая середина": если мы будем отсекать все детекции с уверенностью ниже этого значения, мы получим наилучший компромисс между минимизацией ложных срабатываний и риском пропустить реальный номер. Именно поэтому в нашем inference.py мы будем использовать порог, близкий к этому значению.

Оценка качества детектора по кривой Precision-Recall. Этот график показывает зависимость метрики Precision (точность) от порога уверенности. Precision отвечает на вопрос: "Если модель сказала, что это номер, какова вероятность, что это действительно номер?".
Наш график имеет почти идеальную форму: он очень быстро выходит на плато близкое к 1.0. Это означает, что:
Низкий уровень "мусора": Даже если мы установим очень низкий порог уверенности (например, 0.2), более 90% найденных объектов все равно будут реальными номерами.
Высокая достоверность: При порогах confidence выше ~0.8, точность предсказаний стремится к 100%. Легенда на графике all classes 1.00 at 0.947 подтверждает, что при пороге 0.947 модель достигает 100% точности — то есть, каждое предсказание с такой уверенностью является абсолютно верным.
Такая кривая говорит о том, что модель очень редко "галлюцинирует" и делает ложные срабатывания, что критически важно для построения надежного ANPR-пайплайна.

PR-кривая — это один из главных способов оценить баланс модели между Точностью (Precision) и Полнотой (Recall).
Precision: Насколько мы можем доверять предсказаниям.
Recall: Насколько хорошо модель находит все целевые объекты.
Идеальная модель имеет Precision=1 и Recall=1. На графике это соответствовало бы точке в правом верхнем углу. Кривая практически достигает этого идеала: она удерживает точность близкую к 100% почти до самого конца, пока полнота не достигает ~98%.
Площадь под этой кривой (Area Under Curve, AUC) и есть метрика mAP@0.5, которая в данном случае равна 0.987. Это визуальное и численное подтверждение того, что наш детектор одновременно и точен (мало ложных срабатываний), и полон (почти не пропускает реальные номера).

Анализ полноты детекции по кривой Recall-Confidence. Этот график показывает зависимость метрики Recall (полнота) от порога уверенности (confidence). Recall отвечает на вопрос: "Какой процент всех существующих номеров на изображениях модель сможет найти?".
Наш график имеет форму "полки", которая резко обрывается. Это идеальная форма, которая говорит о следующем:
Высокая полнота при разумных порогах: В диапазоне confidence от 0.0 до ~0.8, модель находит почти 100% всех существующих номеров. Это значит, что мы можем смело устанавливать порог уверенности (например, 0.542, как мы выяснили по F1-кривой), не боясь, что начнем массово пропускать реальные объекты.
Резкий спад: Обрыв кривой после ~0.85 показывает, что в датасете есть "сложные" объекты (размытые, под углом), которые модель находит, но с не очень высокой уверенностью. Попытка отфильтровать все, кроме самых "уверенных" предсказаний, приведет к потере этих сложных, но валидных объектов.
В совокупности с кривой Precision-Confidence, этот график доказывает, что модель обладает превосходным балансом: она одновременно и точна (не "придумывает" лишнего), и полна (не пропускает реальные номера).

Анализ ошибок детектора по Матрице ошибок (Confusion Matrix). Этот график — как "очная ставка" между предсказаниями модели (ось Y, Predicted) и реальной разметкой (ось X, True).
Давайте расшифруем каждую клетку:
[license_plate, license_plate] = 2649 (True Positives): В 2649 случаях модель правильно нашла реальный номер. Это наш "успех".
[background, license_plate] = 29 (False Negatives): В 29 случаях модель пропустила реальный номер, посчитав его фоном. Это напрямую влияет на нашу метрику Recall (полнота). Как мы видели, Recall ≈ 97%, что является отличным показателем.
[license_plate, background] = 167 (False Positives): В 167 случаях модель "придумала" номер там, где его не было, приняв за него какой-то элемент фона. Это влияет на нашу метрику Precision (точность). Precision ≈ 97.5%, что также является высоким результатом.
[background, background]: Эта ячейка была бы заполнена, если бы мы считали каждый пиксель фона, но для задачи детекции она нерелевантна.
Вывод: Подавляющее большинство предсказаний лежит на "правильной" диагонали. Количество ошибок (29 пропусков и 167 ложных срабатываний) очень мало на фоне 2649 правильных детекций, что и обеспечивает высокое итоговое качество модели.
Этап 2: Создаем "Мозг" — кастомный OCR на PyTorch
Итак, у нас есть детектор, который с высокой точностью вырезает нам изображения номеров. Следующий шаг — распознать на них текст.
Почему не Tesseract?
Первая мысль любого разработчика — использовать Tesseract. Это мощный, проверенный временем OCR-движок. Но давайте посмотрим, с какими данными ему придется работать в реальной жизни:


Tesseract, "заточенный" под сканы документов, показывает на таких данных очень низкую точность. Он склонен "галлюцинировать" и выдавать мусор. Нам нужно решение, которое изначально обучалось на подобных "плохих" примерах.
Решение: Кастомная CRNN-модель
Поэтому было решено обучать собственную OCR-модель на архитектуре CRNN (Convolutional Recurrent Neural Network) с функцией потерь CTC Loss на фреймворке PyTorch.
CNN-часть ("Глаза") извлекает из изображения низкоуровневые признаки (линии, изгибы).
RNN-часть ("Мозг" в виде LSTM) анализирует эти признаки как последовательность, понимая контекст символов.
CTC Loss ("Магия") позволяет обучать модель, не зная точного положения каждого символа, что идеально для нашей задачи.
Для обучения мы использовали еще один великолепный открытый датасет — AUTO.RIA Numberplate Dataset (~1.4 ГБ), содержащий тысячи уже вырезанных, но часто "сложных" изображений российских номеров.
class CRNN(nn.Module):
def __init__(self, num_classes):
super(CRNN, self).__init__()
# --- CNN часть (Глаза) ---
self.cnn = nn.Sequential(
nn.Conv2d(1, 64, kernel_size=3, padding=1),
nn.ReLU(True),
nn.MaxPool2d(2, 2), # -> height: 16
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.ReLU(True),
nn.MaxPool2d(2, 2), # -> height: 8
nn.Conv2d(128, 256, kernel_size=3, padding=1),
nn.BatchNorm2d(256),
nn.ReLU(True),
nn.Conv2d(256, 256, kernel_size=3, padding=1),
nn.ReLU(True),
nn.MaxPool2d((2, 1), (2, 1)), # -> height: 4
nn.Conv2d(256, 512, kernel_size=3, padding=1),
nn.BatchNorm2d(512),
nn.ReLU(True),
nn.Conv2d(512, 512, kernel_size=3, padding=1),
nn.ReLU(True),
nn.MaxPool2d((2, 1), (2, 1)) # -> height: 2.
)
# --- RNN часть (Мозг) ---
self.rnn = nn.LSTM(512 * 2, 256, bidirectional=True, num_layers=2, batch_first=True)
# --- Classifier (Рот) ---
self.classifier = nn.Linear(512, num_classes)
def forward(self, x):
# Прогоняем через CNN
x = self.cnn(x) # -> (batch, 512, 2, 32)
# "Распрямляем" выход CNN для подачи в RNN
# объединяем каналы и высоту
batch, channels, height, width = x.size()
x = x.reshape(batch, channels * height, width)
# Меняем оси местами для RNN, который ожидает (batch, seq_len, features)
x = x.permute(0, 2, 1) # -> (batch, 32, 1024)
# Прогоняем через RNN
x, _ = self.rnn(x) # -> (batch, 32, 512)
# Прогоняем через классификатор
x = self.classifier(x) # -> (batch, 32, num_classes)
# Для CTCLoss нам нужен формат (sequence_length, batch, num_classes)
x = x.permute(1, 0, 2) # -> (32, batch, num_classes)
x = nn.functional.log_softmax(x, dim=2)
return x
Результаты, превзошедшие все ожидания
После настройки пайплайна данных и архитектуры, я запустили обучение. Результаты уже после нескольких эпох были впечатляющими. Финальная модель, полученная всего за 9 эпох, показала феноменальное качество:
Точность полного совпадения (Exact Match Accuracy): 99.08%
Ошибка на уровне символов (Character Error Rate): 0.12% (одна ошибка на ~830 символов!)

Матрица ошибок показала, что те редкие ошибки, которые модель все еще делает, являются "человеческими" — например, путаница между визуально похожими '8' и 'B'

Таким образом, я получил распознаватель, который не просто работает, а делает это с точностью, близкой к идеальной, и идеально подходит для пайплайна.
Этап 2.5: Оптимизация. Заставляем "Мозг" думать быстрее
Итак, у меня есть две высокоточные, но "сырые" модели в формате FP32 (32-битные числа с плавающей точкой). Они отлично работают на GPU, но моя цель — создать решение, эффективное и на обычном CPU. Настало время для квантизации.
Что такое квантизация (простыми словами)?
Представьте, что веса нейросети — это ингредиенты для рецепта, измеренные с ювелирной точностью (10.12345678 грамма). Это очень точно, но долго и требует дорогих "весов" (FP32-вычислений).
Квантизация — это процесс "округления" этих весов до целых чисел (~10 грамм), которые можно измерить простым "мерным стаканчиком". Для нашего "рецепта" (распознавания номеров) такой точности оказывается более чем достаточно, зато "измерение" (вычисления в INT8) происходит в разы быстрее, а сама "поваренная книга" (файл модели) становится в 4 раза меньше.
Квантизация OCR-модели
Для кастомной CRNN-модели я использовал встроенные инструменты torch.ao.quantization (FX Graph Mode). Процесс состоял из трех шагов:
Слияние (Fusing): Объединение слоев Conv-BN-ReLU в один оптимизированный модуль.
Калибровка (Calibration): "Показываем" модели несколько батчей данных, чтобы она поняла диапазоны значений и подобрала оптимальные параметры для округления.
Конвертация (Convert): Преобразование модели в финальный INT8-формат.
Здесь я столкнулся с парой классических проблем PyTorch на Windows: "зависанием" DataLoader с num_workers > 0 и ошибкой view size is not compatible, которая потребовала замены .view() на .reshape() в архитектуре. Решение этих проблем — отдельная интересная история.
Но результат того стоил. Давайте посмотрим на цифры (бенчмарк проводился на Intel Core i7-9700KF):
Модель |
Точность (Exact Match) |
Ошибка (CER) |
Скорость (мс/img) |
Размер |
FP32 (Исходная) |
98.42% |
0.22% |
6.37 мс |
34.9 MB |
INT8 (Квант.) |
98.38% |
0.22% |
3.23 мс |
21.4 MB |
Изменение |
-0.04% |
0% |
1.97x |
-38% |
Вывод: Я получил почти двукратное ускорение на CPU, заплатив за это статистически незначимой потерей точности в 0.04%. Ошибка на уровне символов не изменилась вовсе! Это идеальный результат оптимизации.
Квантизация YOLO-детектора
Для YOLO-детектора я пошл по другому пути. Библиотека ultralytics имеет встроенную поддержку экспорта в OpenVINO — тулкит от Intel, специально созданный для ускорения нейросетей на CPU.
# Казалось бы, все просто...
model.export(format='openvino', int8=True, data='dataset_config.yaml')
На практике же этот вызов приводил к полному крэшу ядра Python (ExitCode: 3221226505 - STATUS_STACK_BUFFER_OVERRUN). После долгой отладки стало ясно: проблема кроется в несовместимости самых "свежих" версий ultralytics и openvino.
Решение, казалось бы, очевидно — откатить версии. Но здесь обнаружился "замок": обе эти библиотеки были жестко завязаны на версию PyTorch. А torch — это фундамент всего проекта. Любое его изменение могло "обрушить" уже работающую OCR-часть. Трогать его было крайне неразумно — посыпалось бы всё.
В реальной разработке часто приходится принимать прагматичные решения. Я решил отложить INT8-квантизацию YOLO на будущее и провести бенчмарк для неоптимизированной FP32-версии на CPU, чтобы понять, насколько это вообще критично. Результат меня приятно удивил:
Модель |
Время на изображение |
Производительность (FPS) |
YOLOv8n FP32 на CPU |
70.93 мс |
14.10 кадров/сек |
Вывод: Даже "сырая" FP32-модель YOLOv8n оказалась настолько эффективной, что способна обрабатывать 14 кадров в секунду на CPU! Погоня за дополнительным ускорением в данный момент не оправдывала риска сломать все окружение.
Итог оптимизации: У меня на руках два финальных артефакта: молниеносный INT8 OCR и достаточно быстрый FP32 Детектор. Этого набора вполне достаточно для сборки производительного пайплайна.
Этап 3: Собираем все вместе. От фото к видео
Итак, у меня на руках два готовых "движка":
Детектор: YOLOv8 FP32 (best.pt)
Распознаватель: CRNN INT8 (crnn_ocr_model_int8_fx.pth)
Осталось написать финальный скрипт inference.py, который будет дирижировать этим оркестром. Чтобы код был чистым, поддерживаемым и легко расширяемым, я построил его на принципах SOLID и ООП, разделив всю логику на независимые классы: Config, YOLODetector, CRNNRecognizer, Visualizer и главный класс-оркестратор ANPR_Pipeline.
Первые тесты на одиночных изображениях(с Яндекс.Картинки) показали великолепный результат:

Но как только я подал на вход видео, проявилась классическая проблема всех "безмозглых" систем...
Драма: "Прыгающие" номера
На видео результат начал "мерцать". На одном кадре номер распознавался как A123BC, на следующем из-за легкого смазывания — как A12SBC, а на третьем из-за блика — как A1B3SC. Система обрабатывала каждый кадр как отдельную фотографию, не имея никакой "памяти" о том, что она видела секунду назад.
Решение: Внедряем трекинг и стабилизацию
Проблема решается добавлением "кратковременной памяти" в наш пайплайн. Вместо того чтобы доверять результату с одного кадра, мы будем накапливать "улики" и проводить "голосование".
Трекинг: Вместо метода .predict() у YOLO начинаем использовать .track(). Он не просто находит объекты, но и присваивает каждому уникальный ID, отслеживая его перемещение между кадрами.
Стабилизация: Для каждого ID мы создаем небольшой буфер, в котором храним N последних распознанных текстов.
Голосование: На каждом кадре мы смотрим в этот буфер и выбираем тот вариант текста, который встречается чаще всего. И только если он набрал достаточное количество "голосов" (например, 3 из 15), мы считаем его "стабильным" и выводим на экран.
Эта простая логика полностью преобразила результат:
Финальный штрих: Коррекция перспективы
На тестах также выяснилось, что на номерах под очень острым углом OCR-модель, даже кастомная, начинала ошибаться. Это было решено добавлением еще одного шага препроцессинга на OpenCV. Перед распознаванием мы находим 4 угла номера и с помощью cv2.getPerspectiveTransform "выпрямляем" его в идеальный прямоугольник. Это значительно повысило точность на сложных ракурсах.
Заключение и Будущие улучшения
Был пройден полный путь от анализа данных до создания готового, оптимизированного и надежного продукта. Я обучил две SOTA-модели, столкнулись с реальными инженерными проблемами, приняли прагматичные решения и получили систему, которая показывает высочайшее качество на "боевых" задачах.
Конечно, всегда есть что улучшить:
Дообучение на целевых данных: Тесты показали, что детектор не всегда находит номера в нетипичном окружении (например, в автосервисе). Решение — дообучить его на небольшом, целевом датасете.
Использование Confidence Score от OCR: Можно добавить логику, которая будет отбрасывать результаты, в которых OCR-модель не уверена, помечая их как "нечитаемые".
Этот проект стал отличным доказательством того, что, имея качественные открытые данные и современные инструменты, можно создать решение высокого уровня.
Спасибо за внимание! Полный код проекта, включая все ноутбуки для обучения и финальный скрипт, доступен на моем GitHub. Буду рад ответить на вопросы в комментариях
Комментарии (12)

customidze
25.10.2025 06:57Просмотрел что ли нагрузку на процессор... Хочется прикинуть сколько камер i7 потянет.

Runoi Автор
25.10.2025 06:57Отличный вопрос. Точное количество сильно зависит от разрешения камер, FPS и количества машин в кадре.
На моем i7-9700KF одна камера ~480p@30fps загружала процессор на ~90%. Использовалось видео с "живого" регистратора.
С учетом оптимизаций (пропуск кадров, детектор движения) можно ожидать, что такой процессор уверенно "потянет" 2-3 камеры с разрешением 720p или 1-2 камеры 1080p. Для большего количества уже настоятельно рекомендуется использовать GPU.
triller599
25.10.2025 06:57YOLO внутри сразу, скорей всего, "пожмёт" Ваши данные к 640х640 и ничего не поменяется. Причём и снизу(480p) и сверху (1080p). Так что в детекции только небольшие накладные расходы..
Извините что опять встреваю)

Runoi Автор
25.10.2025 06:57Да, вы правы. YOLO работает с 640х640, нагрузка от работы нейросети что для 480, что для 1080 особо не изменится. А вот привидение к этим 640х640 для 1080 будет несколько "дороже" по ресурсам.
А в целом по затратам ресурсов, проект сейчас MVP с достаточно "топорной" реализацией, предполагал оптимизировать уже под конкретные задачи/реализации)

customidze
25.10.2025 06:57Да, не скорей всего, а 100%. ну так в целом получается, дорого-богато. Нам на 200 камер прийдется раскошелиться с таким решением.

Runoi Автор
25.10.2025 06:57Да, вы абсолютно правы. Для 200 камер потребуется совершенно другой подход к архитектуре и "железу".
Этот проект вырос из небольшого фриланс-задания для автосервиса, поэтому и фокус в статье был на максимальной оптимизации под CPU.
Но если говорить о масштабировании на сотни камер, то архитектура решения кардинально меняется - без GPU уже не обойтись

triller599
25.10.2025 06:57
simple case У Вас на видео в простейшем случае вылезла ошибка, а Вы забыли это упомянуть.
Потому следующие замечания:
1) 100 эпох на yolo8n для детекции номеров на 25к изображений? У меня не то чтобы много опыта, но тот что есть, говорит - скорей всего переобучение. Для подобной задачи это всё ещё большая модель.
2) Вот на видео очевидная пробелма в простейшей ситуации. И подозреваю, что Вы не знаете, как это фиксить. Проблема "коробочного" решения в 2 клика.. Но может я и ошибаюсь - распознаватель-то у Вас свой )
Было бы приятнее прочитать про Ваш опыт, если бы в статье было поменьше восторженных эпитетов, если результат отнюдь не выдающийся.
Однако какой-то результат получили, что уже неплохо!
Runoi Автор
25.10.2025 06:57Спасибо за критику по существу. Вы абсолютно правы, результат не идеален, и статья действительно выиграет от более честного анализа. Позвольте уточнить пару моментов.
1. Про ошибку на видео и контекст задачи.
Вы совершенно верно подметили ошибку OCR. Я специально выбрал такой сложный кадр из "живого" трафика, чтобы продемонстрировать пределы возможностей MVP.Изначально система проектировалась для более простых условий — статичных камер в автосервисе, где машины неподвижны. Как вы и сказали, для такой задачи текущего качества (с небольшим дообучением на целевых данных) уже достаточно.
Что касается фикса ошибки на видео, то здесь действительно два пути:
ML-подход: Дообучить OCR-модель с более "агрессивными" аугментациями (перспективные искажения, размытие).
CV-подход: Улучшить алгоритм препроцессинга, добавив, например, более сложную стабилизацию изображения перед распознаванием.
2. Про 100 эпох и переобучение YOLO.
Это очень грамотное замечание. Риск переобучения при таком количестве эпох действительно существует. К счастью, в данном случае его удалось избежать, что подтверждается графиками обучения: кривые ошибок для train и val выборок идут почти в унисон, без значительного расхождения. Современные фреймворки, как ultralytics, имеют хорошие встроенные механизмы регуляризации, которые с этим помогают. Но вы правы в главном: пик метрики mAP@0.5-0.95 был достигнут значительно раньше (примерно на 70-80 эпохе), и для экономии ресурсов можно было смело останавливать обучение тогда. 100 эпох были скорее для "чистоты эксперимента".
seccom
А двухрядные номера как читает? ))
Runoi Автор
Плохо достаточно, для YOLO и OCR использовались датасеты с номерами в один ряд, MVP всё же. Для двурядных/квадратных надо дообучать