AI Собираем ANPR-систему на Python: от YOLOv8 и кастомного OCR до INT8-квантизации

AI

Редактор
Регистрация
23 Август 2023
Сообщения
3 022
Лучшие ответы
0
Реакции
0
Баллы
51
Offline
#1


Привет, Хабр!

Распознавание автомобильных номеров (ANPR) — задача не новая. Существует множество коммерческих решений и open-source библиотек. Но что, если стандартные инструменты не не подходят? А что, если нам нужна система, которая будет молниеносно работать на обычном CPU, без дорогих видеокарт?

Недавно я столкнулся именно с такой задачей. Вместо того чтобы просто "склеить" готовые решения, я решил пройти весь путь ML-инженера от начала до конца: от анализа данных до обучения кастомных SOTA-моделей и их финальной оптимизации. В этой статье я поделюсь всем процессом, кодом, результатами и проблемами, с которыми пришлось столкнуться.

Скрытый текст

Спойлер: мне удалось собрать пайплайн, который находит номера с mAP@.5-.95 0.85, распознает их с точностью 98.4% и после INT8-квантизации работает на CPU почти в 2 раза быстрее исходной версии.

Архитектура: Две головы лучше, чем одна


Любая ANPR-система решает две последовательные задачи:


  1. Детекция (Detection): Найти на изображении прямоугольник, где находится номер.


  2. Распознавание (Recognition, OCR): Прочитать символы внутри этого прямоугольника.

Пытаться решить обе задачи одной гигантской моделью — неэффективно. Гораздо надежнее и гибче построить пайплайн из двух специализированных моделей.


  1. "Глаза" — Детектор:

    На эту роль был выбран YOLOv8 от Ultralytics. Почему? Это текущий индустриальный стандарт для быстрой и точной детекции. Библиотека ultralytics делает процесс обучения невероятно простым, позволяя сфокусироваться на данных, а не на написании бойлерплейт-кода. Я взяли самую легкую версию, YOLOv8n, как идеальный баланс скорости и точности.


  2. "Мозг" — Распознаватель:

    Здесь мы столкнулись с первым важным выбором. Использовать готовый Tesseract OCR? Он хорош для сканов документов, но на реальных, "грязных" данных с камер он часто проваливается.

Вот такой номер Tesseract, скорее всего, не осилит. Ну и просто интересно сделать самому

Поэтому было принято решение обучать кастомную 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-движок. Но давайте посмотрим, с какими данными ему придется работать в реальной жизни:



Размытие, низкое разрешение, искажения перспективы — типичные "боевые" условия для ANPR.

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 символов!)

Кривые обучения OCR-модели. Видно экспоненциальное падение ошибки и взрывной рост точности.

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


Матрица ошибок на уровне символов. Почти идеальная диагональ говорит о высочайшем качестве распознавания.

Таким образом, я получил распознаватель, который не просто работает, а делает это с точностью, близкой к идеальной, и идеально подходит для пайплайна.

Этап 2.5: Оптимизация. Заставляем "Мозг" думать быстрее


Итак, у меня есть две высокоточные, но "сырые" модели в формате FP32 (32-битные числа с плавающей точкой). Они отлично работают на GPU, но моя цель — создать решение, эффективное и на обычном CPU. Настало время для квантизации.

Что такое квантизация (простыми словами)?

Представьте, что веса нейросети — это ингредиенты для рецепта, измеренные с ювелирной точностью (10.12345678 грамма). Это очень точно, но долго и требует дорогих "весов" (FP32-вычислений).

Квантизация — это процесс "округления" этих весов до целых чисел (~10 грамм), которые можно измерить простым "мерным стаканчиком". Для нашего "рецепта" (распознавания номеров) такой точности оказывается более чем достаточно, зато "измерение" (вычисления в INT8) происходит в разы быстрее, а сама "поваренная книга" (файл модели) становится в 4 раза меньше.

Квантизация OCR-модели

Для кастомной CRNN-модели я использовал встроенные инструменты torch.ao.quantization (FX Graph Mode). Процесс состоял из трех шагов:


  1. Слияние (Fusing): Объединение слоев Conv-BN-ReLU в один оптимизированный модуль.


  2. Калибровка (Calibration): "Показываем" модели несколько батчей данных, чтобы она поняла диапазоны значений и подобрала оптимальные параметры для округления.


  3. Конвертация (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: Собираем все вместе. От фото к видео


Итак, у меня на руках два готовых "движка":


  1. Детектор: YOLOv8 FP32 (best.pt)


  2. Распознаватель: CRNN INT8 (crnn_ocr_model_int8_fx.pth)

Осталось написать финальный скрипт inference.py, который будет дирижировать этим оркестром. Чтобы код был чистым, поддерживаемым и легко расширяемым, я построил его на принципах SOLID и ООП, разделив всю логику на независимые классы: Config, YOLODetector, CRNNRecognizer, Visualizer и главный класс-оркестратор ANPR_Pipeline.

Первые тесты на одиночных изображениях(с Яндекс.Картинки) показали великолепный результат:


Полный успех: детектор нашел все три номера, а OCR почти идеально их распознал, сложности с третьим номером удалённость и засвеченность с размытостью помешали.

Но как только я подал на вход видео, проявилась классическая проблема всех "безмозглых" систем...

Драма: "Прыгающие" номера

На видео результат начал "мерцать". На одном кадре номер распознавался как A123BC, на следующем из-за легкого смазывания — как A12SBC, а на третьем из-за блика — как A1B3SC. Система обрабатывала каждый кадр как отдельную фотографию, не имея никакой "памяти" о том, что она видела секунду назад.

Решение: Внедряем трекинг и стабилизацию

Проблема решается добавлением "кратковременной памяти" в наш пайплайн. Вместо того чтобы доверять результату с одного кадра, мы будем накапливать "улики" и проводить "голосование".


  1. Трекинг: Вместо метода .predict() у YOLO начинаем использовать .track(). Он не просто находит объекты, но и присваивает каждому уникальный ID, отслеживая его перемещение между кадрами.


  2. Стабилизация: Для каждого ID мы создаем небольшой буфер, в котором храним N последних распознанных текстов.


  3. Голосование: На каждом кадре мы смотрим в этот буфер и выбираем тот вариант текста, который встречается чаще всего. И только если он набрал достаточное количество "голосов" (например, 3 из 15), мы считаем его "стабильным" и выводим на экран.

Эта простая логика полностью преобразила результат:


Финальный штрих: Коррекция перспективы

На тестах также выяснилось, что на номерах под очень острым углом OCR-модель, даже кастомная, начинала ошибаться. Это было решено добавлением еще одного шага препроцессинга на OpenCV. Перед распознаванием мы находим 4 угла номера и с помощью cv2.getPerspectiveTransform "выпрямляем" его в идеальный прямоугольник. Это значительно повысило точность на сложных ракурсах.

Заключение и Будущие улучшения


Был пройден полный путь от анализа данных до создания готового, оптимизированного и надежного продукта. Я обучил две SOTA-модели, столкнулись с реальными инженерными проблемами, приняли прагматичные решения и получили систему, которая показывает высочайшее качество на "боевых" задачах.

Конечно, всегда есть что улучшить:


  • Дообучение на целевых данных: Тесты показали, что детектор не всегда находит номера в нетипичном окружении (например, в автосервисе). Решение — дообучить его на небольшом, целевом датасете.


  • Использование Confidence Score от OCR: Можно добавить логику, которая будет отбрасывать результаты, в которых OCR-модель не уверена, помечая их как "нечитаемые".

Этот проект стал отличным доказательством того, что, имея качественные открытые данные и современные инструменты, можно создать решение высокого уровня.

Спасибо за внимание! Полный код проекта, включая все ноутбуки для обучения и финальный скрипт, доступен на моем GitHub. Буду рад ответить на вопросы в комментариях
 
Сверху Снизу