Taekwondo CV
Детекция ударов и подсчёт очков в реальном времени по четырём камерам у ринга
Система компьютерного зрения, которая смотрит поединок по тхэквондо сразу с четырёх камер, классифицирует, что именно сделал каждый боец, переводит это в очки и показывает судье живой счёт. ML-часть я сделал с нуля: схему разметки и датасет, каскад детекции, двухуровневую дедупликацию, которая решает, что считать одним ударом, и инференс на GPU в реальном времени. Ниже разобрано, что пробовал, от чего отказался, на каких порогах всё реально работает и почему они асимметричны.
- РольML/CV в одиночку, плюс архитектура и бэкенд
- ВходЧетыре живых RTSP-потока, ресайз до 1280x720 перед инференсом
- ЯдроДвухстадийный каскад YOLO, 20 классов ударов
- ВыходСобытия с очками, аннотированный RTSP, счёт по WebSocket
Ограничение, из которого выросло всё остальное
Система должна была отвечать на судейский вопрос, а не на вопрос детектора: кто, каким приёмом, в какой уровень и на сколько очков. И отвечать во время боя, а не после, с четырёх потоков одновременно, на одной машине.
Два свойства предметки определили все дальнейшие решения. Первое: цена ошибок асимметрична. Пропущенный удар это спор в конце раунда, а придуманный удар это неверный счёт на табло. Второе: один удар это одно событие, но детектор срабатывает на каждом кадре, который ему нравится, и удар, который был виден тридцать кадров, не должен превратиться в тридцать событий.

Пайплайн целиком
- Захват: по одному RTSP-потоку на камеру, декодирование в своём потоке, ресайз до 1280x720 перед инференсом.
- Первая стадия: детектор по полному кадру находит бойцов и отдаёт их боксы.
- Кроп: каждый бокс расширяется на 20 px с каждой стороны и обрезается по границам кадра.
- Вторая стадия: классификатор на 20 классов работает по этому кропу на 640 px и отдаёт технику, уровень и цвет бойца.
- Маппинг: класс переводится в код события, а очки берутся из таблицы, закэшированной из базы, а не зашиты в модель.
- Дедупликация: кулдаун по классу на стороне инференса, затем peak window на стороне событий.
- Сохранение: событие, его уверенность и скриншот в объектном хранилище; аннотированный кадр уходит обратно по RTSP, счёт по WebSocket.
Ветка первая: одна модель сегментации
Первая рабочая версия была одной сегментационной моделью по полному кадру, с качественными масками и рендерером, который сглаживал их во времени: экспоненциальное смешивание с предыдущей маской, размытие краёв и затухание, когда детекция пропадала, чтобы оверлей не мигал.
- Оверлей выглядел действительно хорошо, а это важно, когда картинка уходит в трансляцию.
- Маска идёт по телу, поэтому конечность на фоне соперника остаётся читаемой.
- Одна модель, один набор порогов, нечего согласовывать между собой.
- Маски стоят времени на каждом кадре и ничего не дают решению, которое по сути является классом, а не силуэтом.
- На дальнем краю ринга удар рукой и ногой визуально сходятся, когда боец занимает пару сотен пикселей, а одна модель должна была одновременно локализовать и классифицировать. Деградировала именно классификация.
- Сглаживание, которое делало оверлей приятным, одновременно задерживало момент, когда детекция считается исчезнувшей.
Решение Осталась в коде как legacy-режим одной модели под флагом: это по-прежнему самый быстрый способ визуально отладить новый датасет. Но не продовый путь.
Ветка вторая: оценка позы
Гипотеза была в том, что геометрия суставов разделяет техники лучше, чем внешний вид: удар ногой в голову и в корпус отличаются тем, где оказывается стопа относительно таза и плеч. Я обучил pose-модель со своим скелетом из 35 keypoints и двумя классами, красный и синий, на собственном датасете и экспортировал её в OpenVINO.
- Keypoints компактны и интерпретируемы, а правило поверх них легко объяснить тренеру.
- Поза гораздо меньше зависит от цвета формы и освещения зала, чем сырая картинка.
- Вариантов исполнения одного и того же удара слишком много. Разброс внутри класса съел разницу между классами.
- В клинче keypoints двух бойцов перемешиваются, и скелет садится не на то тело.
- Это лишняя модель в цепочке, ошибки которой видны хуже, чем плохой бокс.
Решение Отказался. В разборе она намеренно: в прод обычно попадает та архитектура, которая пережила несколько таких проверок.
Ветка третья: каскад, который поехал в прод
Разделение локализации и классификации вылечило путаницу на дальнем плане. Первой стадии нужно только найти людей, а это простая половина задачи, устойчивая к масштабу. Вторая стадия видит кроп, где боец занимает весь вход, а это ровно то условие, при котором класс удара разделим.
- У каждой стадии свой порог, и их можно крутить в противоположные стороны, об этом ниже.
- Сложная задача уменьшается: классификация работает на нормализованном кропе, а не на кадре 1280 px.
- Боксы первой стадии полезны сами по себе, поэтому оверлей всегда показывает бойцов, даже когда ни один удар не засчитывается.
- Стоимость перестаёт быть постоянной на кадр: вторая стадия запускается на каждого найденного бойца, поэтому плотный кадр медленнее.
- Промах первой стадии неисправим: вторая никогда не увидит то, что не было вырезано.
- Две модели это два набора весов, два экспорта и две сущности для версионирования.
Решение Это продовый путь.

Пороги и почему они смотрят в разные стороны
Стадии настроены друг против друга намеренно. Первая работает мягко, потому что не найденный боец стоит события целиком и второго шанса нет. Вторая работает жёстко, потому что придуманное ею событие попадает на табло, а это дорогая ошибка в этой предметке. По сути первая стадия набирает полноту, а вторая тратит её на точность.
Это значения по умолчанию, с которыми сервис едет. Под каждый зал и постановку камер они перепроверяются на записях перед турниром, поэтому каждый из них вынесен в переменную окружения, а не зашит константой.
- STAGE1_CONF
- 0.20
- Мягко намеренно: не найденный боец это потерянное событие.
- STAGE2_CONF
- 0.68
- Жёстко: ложный класс становится неверным счётом, поэтому слабые свидетельства отвергаются.
- IOU_THRESHOLD
- 0.45
- Перекрытие для NMS, общее для обеих стадий.
- BBOX_PADDING_PX
- 20
- Запас кропа, чтобы уходящая за бокс конечность всё равно попала внутрь.
- imgsz 2-й стадии
- 640
- Кроп уже плотный, больший вход даёт только задержку.
- RESIZE
- 1280x720
- Применяется до инференса, это самый сильный рычаг пропускной способности.
- кулдаун
- 0.7 с
- Подавление повторов на уровне кадров, ключ по классу.
- peak window
- 2.0 с
- Окно на уровне событий, ключ по бойцу, камере и раунду.
- Низкий порог первой стадии умножает вызовы второй, поэтому задержка растёт с числом боксов, а не остаётся постоянной на кадр.
- Жёсткий порог второй стадии молча отбрасывает настоящие, но частично перекрытые удары. Это осознанный размен, а не бесплатный выигрыш.

Дедупликация в два уровня
Первый уровень живёт в инференсе: кулдаун по классу удара, чтобы одна и та же техника от одного и того же бойца не сработала дважды внутри окна. Класс уже содержит цвет, поэтому красный и синий никогда не глушат друг друга.
Второй уровень живёт в слое событий, и это не подавление, а арбитраж. Внутри окна по бойцу, камере и раунду система оставляет самый ценный удар и удаляет более слабый, который уже успела записать: удар ногой в корпус, затем в голову, затем снова в корпус разрешается в пользу удара в голову. Это важно, потому что модель часто видит подготовку и пик одного и того же действия как две детекции, а судья должен видеть пик.
- ключ кулдауна
- класс удара
- Класс несёт цвет бойца, поэтому оба бойца считаются независимо.
- ключ peak window
- раунд + боец + камера
- Арбитраж идёт по бойцу, а не по кадру.
- правило окна
- побеждает дороже
- Более ценный удар заменяет уже сохранённое событие.
- По-настоящему быстрый второй удар внутри окна поглощается первым. Длина окна это и есть ручка размена между дублями и пропущенными добиваниями, и она в конфиге, а не в константе.
- Арбитраж идёт по камере, поэтому один удар, снятый двумя камерами, всё ещё даёт два события. Кросс-камерная фузия это очевидная следующая работа.

Баг, который стоит держать в разборе
У скриншотов событий был свой кулдаун с ключом по камере и коду события. Когда красный и синий проводили один и тот же тип удара в пределах секунды, скрин снимался только для одного, а второе событие сохранялось с пустым URL картинки. События создаются по бойцу, а ключ скриншота этого не учитывал. Добавление бойца в ключ вылечило.
Общий вывод здесь дороже самого фикса: любой ключ дедупликации обязан совпадать с гранулярностью сущности, которую он защищает, и самый лёгкий способ ошибиться это скопировать ключ из соседнего участка кода.
Дешёвая точность: полигон ринга
Не каждый человек в зале участвует в бою. Зрители, тренеры и разминающиеся на фоне спортсмены дают совершенно валидные детекции. Система загружает полигон ринга для каждой камеры и игнорирует всё за его пределами, а сам полигон строится маленьким интерактивным инструментом: кликаешь по углам на живом кадре, он сохраняет JSON.
- Убрал большой класс ложных срабатываний почти без вычислительных затрат.
- Это правило, а не модель: объяснимо, правится мгновенно, переобучение не нужно.
- Ручная настройка под каждую камеру, и она молча ломается, если камеру между сессиями сдвинули.
- Боец у самого края татами может выпасть из слишком плотного полигона.
Как мерилась скорость
Заявления про задержку ничего не стоят без методики, поэтому я написал harness, который прогоняет модели-кандидаты по одной и той же записи боя и печатает тайминги по стадиям, а не одно число. Он отбрасывает прогревочные кадры, синхронизирует GPU вокруг каждого таймера, чтобы мерить работу, а не очередь, и выводит среднее, медиану, p95 и p99 по каждой стадии плюс FPS пайплайна и отношение к частоте исходного видео, то есть ответ на единственный важный вопрос: успевает или нет.
- Одно и то же видео, одни и те же пороги, за раз меняется одна модель.
- Прогревочные кадры исключены, GPU синхронизируется вокруг каждого замера.
- По каждой стадии avg, p50, p95 и p99: кадры роняет хвост задержки, а не среднее.
- FPS пайплайна против FPS источника, выводится как коэффициент реального времени.
- Аннотированное видео тоже пишется, поэтому регресс по скорости и регресс по качеству видны в одном артефакте.
Решение Абсолютные числа принадлежат той видеокарте, на которой их сняли, поэтому из проекта в проект я переношу harness, а не цифры.
Инференс и эксплуатация
- TensorRT-engine собирается из весов при первом запуске и кэшируется под конкретную модель GPU, поэтому смена машины приводит к пересборке, а не к тихой работе на несовместимом engine.
- Половинная точность по умолчанию, вход engine 1280 px, автоматический fallback на обычные веса, если GPU или TensorRT недоступны.
- CPU или GPU выбирается одной переменной окружения, поэтому один и тот же compose работает и на ноутбуке, и на сервере в зале.
- Четыре камера-воркера параллельно, у каждого свой цикл захвата и инференса.
- Аннотированное видео публикуется обратно по RTSP, счёт и управление матчем идут по WebSocket.
- Скриншоты событий в объектное хранилище, метрики в Prometheus и Grafana.
Данные, разметка и сплит
Датасет это кадры, набранные по многим боям, а не подряд идущие кадры нескольких. Схема разметки несёт атрибуты помимо класса: цвет бойца, признак кадра контакта, камеру и флаг качества с тремя значениями: clear, partial, doubtful.
Флаг качества это то, что я снова сделал бы первым. Он позволяет спорным кадрам остаться в датасете как данным о спорности, вместо того чтобы либо отравлять обучающую выборку, либо быть молча удалёнными.
Сплит по боям и никогда по кадрам. Кадры одного боя делят свет, татами, бойцов и ракурс, поэтому сплит по кадрам протаскивает тест в обучение и возвращает метрики, которые выглядят отлично и не предсказывают ничего.
Судья внутри контура
Каждое автоматическое событие сохраняется со своей уверенностью. Судья может добавить пропущенное событие и удалить ложное, и эти два действия хранятся явными флагами с мягким удалением, а не тихой перезаписью.
Поэтому одна и та же таблица является и протоколом матча, и размеченной историей false negative и false positive модели, вместе с уверенностью, которая их породила. Это та часть, которая переносится на любой продукт с человеком в контуре: правки становятся обучающими данными, только если их сохранили структурно в момент, когда их сделали.
Честные ограничения
- Трекинга персон пока нет. Идентичность бойца берётся из класса, а запасная эвристика делит кадр пополам, что хрупко в ту же секунду, когда бойцы поменялись сторонами. Трекинг это первое, что я бы добавил дальше.
- Нет кросс-камерной фузии: четыре камеры, увидевшие один удар, дают четыре события, и держит их только peak window внутри каждой камеры.
- Пороги перепроверяются руками под каждый зал, автоматической калибровки по небольшой размеченной выборке нет.
- Датасет мал по меркам CV, поэтому у метрик по редким техникам широкие доверительные интервалы, и я так их и подаю.

Что я забираю из проекта
Интересной инженерией была не модель. Интересным было определение события, ключи дедупликации, асимметричные пороги, сплит по боям, harness для замеров и решение сохранять каждую правку. Детектор заменяем, а именно эта обвязка делает следующий детектор измеримо лучше предыдущего.
Видео
Ролик с работой системы будет добавлен сюда.
Стек
- Python
- PyTorch
- YOLO / Ultralytics
- OpenCV
- TensorRT
- OpenVINO
- FastAPI
- PostgreSQL
- Redis
- Celery
- WebSocket
- MediaMTX / RTSP
- MinIO
- Docker
- Prometheus
- Grafana