Kaggle для футболистов: Классификация событий на футбольном поле

da6cecd06c338d9159f6e4f02483ae66.jpg

Всем привет! В конце 2022 года закончилось соревнование DFL — Bundesliga Data Shootout. Так как мне интересен футбол и в целом спортивная аналитика, то я решил поучаствовать в этом соревновании. Целью данной статьи является описание моего подхода, и я уверен, что многие методы, примененные к этой задаче, могут быть адаптированы для решения других задач в области компьютерного зрения. За подробностями под кат!

Цель соревнования

Из видеозаписей футбольного матча необходимо установить, что за событие происходит на футбольном поле, в результате ожидают csv файл со следующими полями: id видео — момент времени, когда это событие произошло — что за событие произошло — уверенность от 0 до 1 в том, что это событие произошло.  События делят на три группы:

  • Play — это попытка игрока передать контроль над мячом другом члену команды. Оно может быть осуществлено в виде короткого паса, кроса (сильная нижняя или верхняя передача мяча с фланга в штрафную) , углового и свободного удара.

  • Throw-In — это возобновление игры после того, как мяч вышел за боковую линию. Мяч должен быть брошен руками.

  • Challenge — это ситуация, когда два игрока с противоположных команд одновременно пытаются получить контроль над мячом. Это событие происходит, когда игроки пытаются отобрать мяч друг у друга.

Данные

Тренировочный датасет содержал 12 видео длиною по 60 минут. К нему прилагался csv файл со следующей информацией:

  • video_id

  • time: точное время до миллисекунды, когда произошло событие

  • event: что за событие в кадре. Кроме самих Play, Challenge и Throw-In, там встречались классы start и end, то есть в csv документе был такой порядок:   start — play — end; start — challenge — end и так далее. Среднее время между start и end составляло примерно 0.7 секунды

  • event_attributes: дополнительные характеристики событий, которые я никак не использовал. Например ball_action_forced, opponent_dispossessed, pass, openplay и тд.

Разделение по классам:

Более подробно про тренировочные данные можно узнать из этого ноутбука.

Тестовый датасет содержал 32 видео по 30 секунд. Они не были размечены и их я никак не использовал, по той причине, что видео были записаны с одной камеры, которая стояла на месте. В реальности запись матча идёт с нескольких камер, поэтому у меня были опасения, что в тестовый набор организаторов попадут записи с реальных матчей, а это немного другое. Поэтому я разметил часть футбольного матча длиною в 35 минут, который скачал из интернета.

Метрика

Точность вычислялась по метрике average precision, которая учитывает среднюю точность распознавания различных событий с учетом допустимых ошибок по времени для каждой категории событий и усредняется по всем категориям. Допустимые погрешности по времени в секундах для каждого класса:

Challenge: [ 0.30, 0.40, 0.50, 0.60, 0.70 ]
Play: [ 0.15, 0.20, 0.25, 0.30, 0.35 ]
Throw-In: [ 0.15, 0.20, 0.25, 0.30, 0.35 ]

Чтобы было более понятно как вычисляется метрика, организаторы предоставили ноутбук для её вычисления. Предлагаю разобрать пару примеров:

df_true = pd.DataFrame({
    "video_id": ["video_1"] * 9,
    "time": [15, 16, 17] + [21, 22, 23] + [27,28,29],
    "event": ["start", "play", "end"] + 
             ["start", "challenge", "end"] + 
             ["start", "throwin", "end"]
})

df_pred = pd.DataFrame({
    "video_id": ["video_1"] * 3,
    "time": [16, 22, 28],
    "event": ["play", "challenge", "throwin"],
    "score": [1.0, 1.0, 1.0]
})


mean_ap = event_detection_ap(solution=df_true, 
                             submission=df_pred, 
                             tolerances=tolerances)

В данном случае mean_ap = 1. Хочу уточнить, что tolerances это допустимые погрешности по времени, про которые я писал чуть выше. Далее в примерах df_true останется неизменным, меняться будет только df_pred. Рассмотрим следующий пример:

df_pred = pd.DataFrame({
    "video_id": ["video_1"] * 3,
    "time": [16, 22.2, 28],
    "event": ["play", "challenge", "throwin"],
    "score": [1.0, 1.0, 1.0]
})

Тут mean_ap остаётся равен 1, так как tolerance для challenge [ 0.30, 0.40, 0.50, 0.60, 0.70 ], но если время поменять с 22.2 на 22.3, то mean_ap уже будет равен 0.933. И последний пример, который показывает как score влияет на финальную точность:

df_pred = pd.DataFrame({
    "video_id": ["video_1"] * 4,
    "time": [16, 22.3, 22, 28],
    "event": ["play", "challenge", "challenge", "throwin"],
    "score": [1.0, 0.7, 1.0, 1.0]
})

В этой ситуации mean_ap равен 1, но если поменять местами score у классов challenge, то есть сделать вот так:  

df_pred = pd.DataFrame({
    "video_id": ["video_1"] * 4,
    "time": [16, 22.3, 22, 28],
    "event": ["play", "challenge", "challenge", "throwin"],
    "score": [1.0, 1.0, 0.7, 1.0]
})

то значение mean_ap изменится на 0.966. Это связано со следующим утверждением на странице соревнования:

Detections are matched to ground-truth events by class-specific error tolerances, with ambiguities resolved in order of decreasing confidence

Фактически это означает, что если два или более предсказания соответствуют одному и тому же истинному событию, то выбирается предсказание с самым высоким score’ом. Больше примеров можно посмотреть в этом ноутбуке.

Пайплайн

Моё решение выглядит следующим образом:

17ed1667cd0abb7225619c04df512726.jpg

Учитывая, что на вход подаются видеозаписи , а я работаю с картинкой, то соответственно нужно извлечь кадры с видеозаписей. Для этой цели было рассмотрено два варианта: использовать либо cv2.VideoCapture, либо ffmpeg. В результате использования ffmpeg удалось добиться существенно большей скорости, поэтому я сделал выбор в его пользу. Вот ноутбук как я это делал.

Полученные кадры передавал классификационной сетке, которая выдавала два класса: event или no_event, то есть происходит какое-то действие (Play, Challenge или Throw-In) на поле либо нет. Дальше я решил не опираться на абсолютные значения классификации, а сосредоточился на относительных пиках. Есть 4 основных варианта как эти пики искать:

7c08f12761f5a22cf7a4045733fb98a2.jpg

Забегая вперёд скажу, что в моём случае больше подошёл вариант с distance. Таким образом, главной целью этого этапа стало выявление потенциальных кадров, на которых происходят события, то есть я не рассчитываю, что точность у данной модели будет очень высокая. На последнем этапе я использовал одну сетку как для классификации, так и для детекции событий, и уже из этой нейронки брал score.

Аугментации

Вообще, каким образом я подходил к разметке данных для детекции: разделил данные по папкам для каждого события, в каждой папке создал n-подпапок. Сначала разметил данные для каждого события в первых подпапках, дальше обучил нейронную сеть на этих данных, полученную сетку прогнал на данных из 2-ой подпапки. Данные, у которых был низкий score либо истинный класс не совпадал с предказанным классом, я просматривал и если нужно исправлял ошибки. Размечал таким образом данные итеративно. У этого подхода есть название — Noisy Student. Также хочу отметить, что для разметки я использовал сетку с бОльшим количеством параметров нежели для инференса, так как у соревнования было ограничение в 9 часов на инференс.

В какой-то момент мне надоело заниматься разметкой и я решил перейти к аугментациям. Помимо стандартных аугментаций таких как размытие, поворот изображения на определённый угол, отражение изображения горизонтально и т.д, я придумал кастомные. 

Суть первой аугментации заключается в том, что берутся изображения на которых не происходит никаких событий и гармонизированно вставляем кроп с событием с другой картинки. Алгоритм этой аугментации выглядит следующим образом:

152eb8b1004db9b984696e1f611f2bbe.jpg

На первом этапе нужно получить изображения, на которых не встречается никакого события. Найти такие кадры сложности не составит, ведь  у нас есть информация, в какой момент происходят события на поле. Для того, чтобы гладко вставить одно изображение в другое, нам нужно получить маску объекта, который мы вставляем (в нашем случае это игрок/игроки + мяч). Размечать ещё данные для сегментации в мои планы не входило, поэтому я решил посмотреть в сторону чего-нибудь предобученного. Попробовал модели yolo и SAM. Прогнал несколько фоток, увидел , что yolo справляется лучше, хотя первоначально ставил на SAM. Результат работы yolo и SAM на моих примерах можно посмотреть в этом и этом ноутбах соответственно. 

Новых игроков я вставлял рядом с существующими игроками таким образом, чтобы высота у них была примерно одинаковая, дабы избежать гулливеров:

86a8534c2208b3d239dad25dda7da341.png

Для этого нужно задектировать игроков на поле. В этом случае я тоже использовал предобученную модель yolo,   только уже для детекции. Если с сегментацией игроков на некоторых фотках возникали проблемы, то к предобученной модели детекции вообще вопросов нет:) На следующем этапе как раз вставляем игрока в изображение. Для этого можно воспользоваться этим алгоритмом. Честно, я был приятно удивлён насколько хорошо он работает, но всё-таки иногда было отчётливо видно, что игрок вставлен, поэтому я продолжил поиски похожих алгоритмов и наткнулся на это решение — работает вообще супер! Решил использовать два этих решения в связке и вот какого результата мне удалось добиться:  

acf64486147c39540e27218d2f0fee36.jpeg

Интересный факт, два идентичных игрока встречаются вместе на одном изображении:

29b483db2c8985eb852387f4de43ff37.jpeg

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

def get_side_line(image):
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    _, thresh_img = cv2.threshold(gray, 130, 200, cv2.THRESH_BINARY)

    lines = cv2.HoughLinesP(thresh_img, 1, np.pi / 180, 10, 
                            np.array([]), 300, 10)

    target_lines = [(x0, y0, x1, y1) for line in lines for x0, y0, x1, y1
                    in [list(line[0])] if x1 - x0 > abs(y1 - y0) 
                    and abs(y1 - y0) < 150]
    max_line = max(target_lines, key=lambda x: x[2] - x[0])
    return max_line

Получается вот так детектить линию:

877d3a33de7bcadd455136756f3f1682.jpg

И финальный результат:

265abb55ba76a3db28b450dec33a27c8.jpeg

Суть второй аугментации заключается в том, что можно взять небольшой отступ вперёд или назад по времени (я брал 0.2 секунды) от основного кадра с событием:

Слева - кадр с действием; справа - +0.2 секунды от левого кадра

Слева — кадр с действием; справа — +0.2 секунды от левого кадра

Результат

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

Цель первой модели с точки зрения метрик, сделать так, чтобы recall был как можно выше, то есть чтобы было меньше случаев, когда отбросили кадр, на котором было событие. Вторую модель я оценивал по TP, FP, FN и сводил это к precision и recall. Кратко напомню, что это значит относительно этой задачи:

  • true positive —  когда действие было классифицировано правильно в нужный момент

  • false negative — не нашли действие, когда оно действительно было

  • false positive — когда распознали действие, где его не было или когда , например, challenge перепутали с play

Вот , кстати, ноутбук как я считал эти метрики, а ниже таблица с результатами:

c51d6b5768a3df12d24b720957a21c97.png

Конечно, экспериментов я проводил гораздо больше, но это самые значимые. К сожалению, во время проведения соревнования у меня не было возможности в нём поучаствовать, но там было доступно Late Submission. Как можно увидеть, самый большой score у меня получился 0.661. На лидерборде я бы мог расположиться между 11 и 12 местом. Датасеты для классификации (event или no_event)  и для детекции + классификации событий доступны на Kaggle, вдруг кому-то пригодится:)

Проблемы

Конечно, само по себе моё решение не идеально, но вот несколько причин почему мой финальный score мог бы быть лучше:

  • есть кадры, на которых происходит разминка команды и там игроки пасуются, но это не считается за play:

11853e719f256ad5fe79fb142d9d3e28.jpeg

  • в процессе разметки заметил, что некоторые классы были проставлены неверно + иногда вообще не были проставлены, когда они должны были быть

Не исключено, что такие ситуации могли бы быть в тестовых данных организаторов. Помимо этого, были ситуации , когда необходимо было владеть контекстом, чтобы определить, что за действие происходит на поле, то есть одного кадра не было достаточно для определения события. По этой причине очень часто путались классы play и challenge.

На втором изображении ниже может показаться, что игрок в белой майке выполняет передачу/удар, хотя на самом деле это так шла передача от одного игрока в жёлтой футболке к другому, что доказывают 1 и 3 кадр, но нейронка определила, что на втором кадре play:

f3cfd14572ff6b85c2225ff4ad1fa1f1.jpg

Заключение

Скорее всего, продолжая и дальше размечать данные, можно было бы получить ещё плюс пару процентов к точности, но у меня не было больше желания заниматься разметкой данных + организаторы соревнования недавно запретили подавать поздние заявки. Вот какие идеи можно было бы ещё попробовать:

  • вместо того, чтобы делить данные для первой нейронки на event и no_event, можно поделить данные на 4 класса: Play, Challenge, Throw-In и no_event;

  • для классификации действий использовать специальные сетки. Гуглится по запросу human action recognition.

© Habrahabr.ru