Счетчик показал десятки проездов за одну красную фазу светофора. Машина была одна, и она стояла.
Нейросеть при этом отработала правильно. Она видела машину и держала ее рамку все это время. Ошибка была в семидесяти строках обычного Python, которые я написал сам.
Система считает машины, автобусы и трамваи на видео с неподвижной камеры, различает их по типам и определяет направление движения. Детекция на YOLO11l, дообученной на собственном датасете, сопровождение между кадрами делает BoT-SORT, подсчет идет по факту пересечения виртуальной линии. Готовых наборов данных не брал: хотел пройти весь путь от съемки до обученной модели сам и посмотреть, где он ломается.
Ломался он пять раз. Метрики тоже будут, вместе с разбором, почему верить им нельзя.

Реальный выход системы: детекция, id треков, счетчик по направлениям. Ночь, мокрый асфальт, блики фар.
43 минуты с балкона
Снимал на телефон с балкона четвертого этажа: 1920×1080, ночь, четырехполосная городская дорога, 43 минуты непрерывной записи.
Ракурс неподвижный, и это условие, а не удобство. BoT-SORT компенсирует движение камеры, и если камера сама едет, компенсация начинает бороться с реальным движением объектов. Виртуальная линия подсчета на подвижном кадре тоже теряет смысл.
Ночь выбрана не специально, так совпало, и это дорого обошлось: датасет получился одного режима и одного освещения.
Нарезка по кадру в секунду, то есть каждый 25-й:
python scripts/extract_frames.py road.MOV data/frames --every 1.0
Чаще брать нельзя. При 25 fps соседние кадры отличаются на несколько пикселей, новой информации модели они не дают, зато раздувают датасет. Запомните этот момент, через два раздела он выстрелит.
Вышло 2600 кадров.
Ошибка первая: предразметка стоковой моделью
Размечать руками 2600 кадров долго, поэтому я решил сэкономить. Написал скрипт на cvat-sdk: он забирал кадры из задачи CVAT, прогонял через стоковую YOLO11n, конвертировал результат в формат CVAT и заливал обратно. Дальше по плану оставалось только проверить.
Проверкой это не стало. YOLO11n обучена на COCO и размечает его классами:
трамвай уходит то в
train, то вbus;ночные машины на дальнем плане просто пропускаются;
зато появляются уверенные рамки на фонарях и рекламных щитах.
Правок вышло почти столько же, сколько заняла бы разметка с нуля. Экономии не получилось никакой.
Предразметка окупается только тогда, когда предразмечает своя модель на своих классах. На втором датасете так и вышло, но до него было еще далеко.
267 кадров выбросил как брак: смазанные, засвеченные фарами, без транспорта в кадре. Итог: 2333 размеченных изображения, 1867 train и 467 val.
Класс | Объектов | Доля |
|---|---|---|
car | 7159 | 93.2 % |
bus | 409 | 5.3 % |
tram | 114 | 1.5 % |


Разметка в CVAT: ночной кадр, рамки на машины, автобус и трамвай.
Дисбаланс отражает реальный поток на этой дороге, но для обучения он неудобен. На трамвай приходится 114 объектов, и метрика по нему потом будет посчитана на сотне штук.
Час обучения, из которого пригодились 23 минуты
Дообучение от весов YOLO11l, предобученных на COCO. 2333 кадра для обучения с нуля мало, transfer learning здесь не оптимизация, а необходимость.
Параметр | Значение |
|---|---|
Модель | YOLO11l (25.3 M параметров) |
Стартовые веса | COCO |
Эпох | 100 |
Batch | 16 |
Размер входа | 640 |
lr0 | 0.01 |
Железо | RTX 4070 Ti, 12 ГБ |
batch=16 это потолок для 12 ГБ VRAM при 640×640 и модели l. YOLO11l выбрана не как самая большая из возможных: на этом объеме данных x переобучается быстрее, чем дает прирост, а n и s заметно хуже находят трамваи на дальнем плане.
Сто эпох заняли ровно час, 3602 секунды, по 36 секунд на эпоху. Дальше начинается интересное, и это видно прямо в логе обучения.
Лучший mAP50 равен 0.9704 и получен на 38-й эпохе. По времени это 23-я минута. За оставшиеся 37 минут метрика не поднялась выше ни разу: максимум после пика 0.9691, минимум 0.9379.
То есть больше половины обучения я грел видеокарту впустую. Датасет был исчерпан на 23-й минуте, и добирать качество надо было данными, а не эпохами. Лог по эпохам лежит в репозитории, можно проверить.

Кривые потерь на обучении и валидации снижаются согласованно, расхождения между ними нет, явного переобучения не видно. Это, как выяснилось дальше, тоже ничего не доказывало.
Красивые цифры
Валидация лучших весов на 467 отложенных кадрах:
Класс | Precision | Recall | mAP50 | mAP50-95 |
|---|---|---|---|---|
Все классы | 0.912 | 0.953 | 0.960 | 0.882 |
car | 0.991 | 0.980 | 0.993 | 0.900 |
bus | 0.897 | 0.935 | 0.937 | 0.851 |
tram | 0.847 | 0.944 | 0.948 | 0.894 |

Потом отдельно проверил сам подсчет. Минута ночной записи, просмотрел видео глазами, для каждой машины зафиксировал направление и момент пересечения линии, сравнил с выводом модуля.
Показатель | Вручную | Автоматически |
|---|---|---|
Всего | 22 | 22 |
Влево | 11 | 11 |
Вправо | 11 | 11 |
Ошибок | - | 0 |
mAP50-95 0.882 на ночном видео с телефона и ноль расхождений в подсчете. Я посидел, посмотрел на это и понял, что так хорошо не бывает.
Ошибка вторая: этим цифрам нельзя верить
Подвох нашелся там, где я его не искал, в разбиении датасета.
Кадры нарезаны из одной непрерывной записи с шагом в секунду. Разбиение было случайным. Значит кадр N уходит в train, а кадр N+1 в val, хотя это практически одно изображение: те же фонари, тот же асфальт, часто те же машины, сдвинутые на несколько метров.
Модель валидировалась на том, что уже видела. Отсюда и 0.882, и согласованные кривые потерь: расхождения между train и val не будет, если train и val это одно и то же.
Насколько цифры завышены, на этом датасете измерить нельзя вообще. Независимого куска записи у меня нет.
Лечится разбиением по времени: train берется из начала записи, val из конца.
python scripts/split_dataset.py data/frames data/labels data/dataset --split-by time
В скрипте time теперь стоит по умолчанию. Режим random оставлен только чтобы воспроизвести таблицу выше, иначе цифры в репозитории повисли бы в воздухе.
Ограничений на самом деле четыре, и я держу их в README рядом с метриками, а не в примечании мелким шрифтом:
Только ночь. Одна точка, одно освещение. День, дождь и снег не проверял вообще.
Трамваев мало. 114 объектов. Отсюда же часть путаницы bus и tram в матрице ошибок: сверху и сзади в темноте у них похожий силуэт.
Валидация из той же записи. Разобрано выше.
Тест подсчета короткий. Минута и 22 машины показывают, что конвейер сходится и не сыплется на ровном месте. Точность на плотном потоке он не показывает: перекрытий машин там почти нет.

Ошибка третья: машина, которая стоит на линии
Вернемся к светофору из начала.
Логика подсчета живет отдельно от всего остального. Всего три файла:
Файл | Что делает | Строк |
|---|---|---|
| вся логика подсчета, без OpenCV и YOLO | 75 |
| рисует рамки, линию и панель | 55 |
| читает видео, зовет модель, печатает итог | 130 |
counter.py не импортирует ни OpenCV, ни ultralytics. На вход идут номер трека и координата x, на выход факт проезда. Поэтому логику можно прогнать тестами, не запуская видео и не имея весов модели:
pytest # 10 тестов, доли секунды
Наивная реализация подсчета очевидна: запомнить, с какой стороны линии был трек, и засчитать проезд при смене стороны.
Разваливается она на первом же светофоре. Машина встает ровно на линии, рамка детектора дрожит на пиксель туда-обратно от кадра к кадру, сторона меняется каждый кадр. Одна красная фаза дает десятки ложных проездов.
Лечится зоной нечувствительности:
def side_of(self, x): """С какой стороны линии точка. None - слишком близко к линии.""" if abs(x - self.line_x) < self.min_travel: return None return "left" if x < self.line_x else "right"
Пока объект ближе min_travel пикселей к линии, стороны у него нет вообще. Переход засчитывается, только когда трек отошел от линии дальше порога. По умолчанию min_travel=6.
Тест, который это фиксирует:
def test_drozhanie_na_linii_ne_schitaetsya(counter): """Машина стоит на линии в пробке, рамка дрожит на пиксель.""" assert drive(counter, 1, [99, 101, 100, 102, 98, 101, 99]) == [] assert counter.total == 0
Ошибка четвертая: счетчик, который ничего не забывает
Счетчик держит три структуры: сторону каждого трека, номер последнего кадра и множество уже посчитанных треков. Наивная реализация копит track_id в словарях вечно.
На минутном тесте это незаметно. На устройстве, которое стоит у дороги сутками, каждая проехавшая машина оставляет по три записи навсегда. Это утечка памяти, и на Jetson она особенно неприятна: 8 ГБ там делят между собой CPU и GPU.
def forget_stale(self, frame): """Выбросить треки, которых давно не видно. Звать раз в кадр.""" old = [t for t, seen in self.last_seen.items() if frame - seen > self.forget_after] for t in old: del self.last_seen[t] self.side.pop(t, None) self.counted.discard(t)
Трек, которого не видно forget_after кадров, по умолчанию 90, удаляется.
Остальные тесты про машину, которая проехала, развернулась и уехала обратно, про подъезд к линии без пересечения и про переиспользование трекером старого id для новой машины. Все они сняты с реального видео, а не придуманы.
Обе ошибки в этом разделе не имеют отношения к машинному обучению. Нейросеть работала предсказуемо. Ломалось то, что вокруг нее.
Ошибка пятая: torchvision сносит PyTorch от NVIDIA
Целевая платформа это Jetson Orin Nano 8 ГБ, который должен стоять у дороги сам, без монитора и без меня рядом.
На Jetson нельзя ставить torch и torchvision из PyPI: там сборки под x86 и обычную CUDA, а нужны сборки NVIDIA под aarch64 и L4T. pip3 install torchvision тянет за собой обычный torch и молча заменяет правильный.
После этого torch.cuda.is_available() возвращает False, а ultralytics без единой жалобы уходит считать на CPU. Ошибки нет, трейсбека нет, просто все стало медленным.
Лечится переустановкой в правильном порядке, колесами с сайта NVIDIA:
pip3 uninstall torch torchvision -y pip3 install --no-cache-dir <torch-*-linux_aarch64.whl> pip3 install --no-cache-dir <torchvision-*-linux_aarch64.whl>
Версии колес должны соответствовать установленному JetPack, брать с developer.download.nvidia.com/compute/redist/jp/. И проверять после установки чего угодно:
python3 -c "import torch; print(torch.cuda.is_available())" # ожидается True
PyTorch-веса на Jetson дают 5-8 FPS, для потока по четырем полосам мало. TensorRT в FP16 ускоряет в 2-3 раза, точность падает в пределах погрешности:
yolo export model=best.pt format=engine device=0 half=True
Реальные замеры после экспорта еще не сняты.
Второй датасет: 13 часов в CVAT
Первый датасет это телефон, ночь и одна точка. Работать система должна с другой камеры, другого ракурса и при любом освещении, поэтому второй датасет снимался уже целевым железом.
Поставил на Jetson камеру Arducam и снимал ей. Кадр раз в 5 секунд, набралось около 11.7 тысяч изображений: день и ночь.


Слева стенд на столе: Jetson Orin Nano в печатном корпусе и камера Arducam на шлейфе. Справа он же на подоконнике во время съемки. Штатива не было, стойкой послужила коробка конфет.
На разметку этих 11.7 тысяч кадров в CVAT ушло около 13 часов.
Предразметку в этот раз делала модель первого этапа. Она уже знает нужные три класса и этот ракурс, так что мне оставалось проверять, а не переделывать. Разница с первой попыткой примерно вся.
Датасет собран и размечен. Осталось обучить, и модель будет другая.
Выбор YOLO11l сделан под условия первого этапа: 2333 кадра и видеокарта на 12 ГБ. Данных теперь в разы больше, и на таком объеме модели поменьше перестают проигрывать: тяжелая сеть нужна там, где данных мало и приходится опираться на предобучение. Требования тоже другие, на Jetson важны не столько метрики, сколько кадры в секунду при 15 ваттах и объем памяти, которую делят CPU и GPU. l там избыточна.
Поэтому дальше беру s или m и сравниваю с текущими весами по трем показателям сразу: mAP, FPS после экспорта в TensorRT и потребление.
Что дальше
обучить модель полегче на датасете второго этапа и сравнить с текущей
экспорт в TensorRT FP16 и замер реального FPS на Jetson
тест на плотном потоке с перекрытием машин
несколько линий подсчета в кадре, по одной на полосу
Стек
Python 3.10+, Ultralytics YOLO11, OpenCV, BoT-SORT, CVAT, PyTorch/CUDA. Обучение шло на RTX 4070 Ti 12 ГБ, целевая платформа Jetson Orin Nano 8 ГБ.
Код, полные графики обучения, лог по эпохам и подробное описание датасета: github.com/Japour/vehicle-counting
Под этот проект изучал основы OpenCV, устройство YOLO и трекинг объектов между кадрами. Датасет, обучение и алгоритм подсчета через виртуальную линию мои, включая все пять ошибок выше. Код потом рефакторил с ИИ-инструментами.
Мне 17, учусь в частном IT-колледже, параллельно веду свои треки по Linux и системе.

