Назад к проектам
Разбор проекта

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