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

Распознавание автомобильных номеров (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, скорее всего, не осилит. Ну и просто интересно сделать самому
Вот такой номер 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.
Размытие, низкое разрешение, искажения перспективы — типичные "боевые" условия для 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-модели. Видно экспоненциальное падение ошибки и взрывной рост точности.
Кривые обучения 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 почти идеально их распознал, сложности с третьим номером удалённость и засвеченность с размытостью помешали.
Полный успех: детектор нашел все три номера, а 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. Буду рад ответить на вопросы в комментариях

Комментарии (12)


  1. seccom
    25.10.2025 06:57

    А двухрядные номера как читает? ))


    1. Runoi Автор
      25.10.2025 06:57

      Плохо достаточно, для YOLO и OCR использовались датасеты с номерами в один ряд, MVP всё же. Для двурядных/квадратных надо дообучать


  1. customidze
    25.10.2025 06:57

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


    1. Runoi Автор
      25.10.2025 06:57

      Отличный вопрос. Точное количество сильно зависит от разрешения камер, FPS и количества машин в кадре.

      На моем i7-9700KF одна камера ~480p@30fps загружала процессор на ~90%. Использовалось видео с "живого" регистратора.

      С учетом оптимизаций (пропуск кадров, детектор движения) можно ожидать, что такой процессор уверенно "потянет" 2-3 камеры с разрешением 720p или 1-2 камеры 1080p. Для большего количества уже настоятельно рекомендуется использовать GPU.


      1. triller599
        25.10.2025 06:57

        YOLO внутри сразу, скорей всего, "пожмёт" Ваши данные к 640х640 и ничего не поменяется. Причём и снизу(480p) и сверху (1080p). Так что в детекции только небольшие накладные расходы..
        Извините что опять встреваю)


        1. Runoi Автор
          25.10.2025 06:57

          Да, вы правы. YOLO работает с 640х640, нагрузка от работы нейросети что для 480, что для 1080 особо не изменится. А вот привидение к этим 640х640 для 1080 будет несколько "дороже" по ресурсам.


          А в целом по затратам ресурсов, проект сейчас MVP с достаточно "топорной" реализацией, предполагал оптимизировать уже под конкретные задачи/реализации)


          1. customidze
            25.10.2025 06:57

            Может сразу брать 640, с субпотока и получится сэкономить на ресайзе?


            1. Runoi Автор
              25.10.2025 06:57

              Да, это поможет сэкономить от 30 до 50% общей нагрузки процессора


        1. customidze
          25.10.2025 06:57

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


          1. Runoi Автор
            25.10.2025 06:57

            Да, вы абсолютно правы. Для 200 камер потребуется совершенно другой подход к архитектуре и "железу".

            Этот проект вырос из небольшого фриланс-задания для автосервиса, поэтому и фокус в статье был на максимальной оптимизации под CPU.

            Но если говорить о масштабировании на сотни камер, то архитектура решения кардинально меняется - без GPU уже не обойтись


  1. triller599
    25.10.2025 06:57

    simple case
    simple case

    У Вас на видео в простейшем случае вылезла ошибка, а Вы забыли это упомянуть.
    Потому следующие замечания:
    1) 100 эпох на yolo8n для детекции номеров на 25к изображений? У меня не то чтобы много опыта, но тот что есть, говорит - скорей всего переобучение. Для подобной задачи это всё ещё большая модель.
    2) Вот на видео очевидная пробелма в простейшей ситуации. И подозреваю, что Вы не знаете, как это фиксить. Проблема "коробочного" решения в 2 клика.. Но может я и ошибаюсь - распознаватель-то у Вас свой )

    Было бы приятнее прочитать про Ваш опыт, если бы в статье было поменьше восторженных эпитетов, если результат отнюдь не выдающийся.
    Однако какой-то результат получили, что уже неплохо!


    1. 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 эпох были скорее для "чистоты эксперимента".