Меня зовут Влад, я работаю Full-stack разработчиком в департаменте «Логистика» КОРУС Консалтинг. Параллельно с этим я учусь на последнем курсе магистратуры в Санкт-Петербургским государственном университете аэрокосмического приборостроения на кафедре компьютерных технологий и программной инженерии.
На бакалавриате я учился прикладной информатике, но во время обучения программированию и разработке ПО было уделено недостаточно времени. В основном акцент был смещен в матстатистику и различный анализ. Текущее направление в магистратуре дает большие возможности в реализации меня именно как разработчика.
Расскажу про свой pet-проект (и дипломную работу) «Интеллектуальная система определения параметров объектов спортивного мероприятия с использованием библиотеки трекинга».
Идея проекта
Все же знают серию компьютерных футбольных симуляторов FIFA? Раньше я много играл в эту игру. Кто-то скажет, что это бесполезная трата времени, но я с этим не согласен. Эта игра вдохновила меня на разработку pet-проекта, который стал моим бакалаврским дипломом.
Во время игры в FIFA пользователь видит небольшую карту с местоположением игроков и мяча на поле, данный элемент интерфейса является очень полезной фичей, без которой невозможно представить полноценный игровой процесс. Мне показалось, что данную карту было бы неплохо перенести в реальный мир, используя видеозапись матча и нейросеть.
Разработку проекта я начал в 2021 году, а сейчас работаю над его усовершенствованием в рамках магистерской выпускной работы.

Несмотря на то, что в 2021 году нейросетями интересовалось гораздо меньше людей, а пользовались ИИ в основном энтузиасты, я захотел приобщиться и попробовать их в своем проекте. Кстати, во время учебы я никак с нейронными сетями не пересекался и приступал к реализации проекта с абсолютно нулевыми знаниями по этой теме.
В магистерской работе, помимо отрисовки карты, я подумал, что было бы неплохо как-то анализировать матч, который воспроизводится, или хотя бы предоставлять какие-то статистические данные. Так пришла идея реализовать отдельные модули приложения, которые будут заниматься сбором статистических параметров на основе исходного изображения. Такими параметрами будут: процент владения мячом, карта пассов, карта касаний мяча.
Технологии и инструменты
В качестве языка программирования я выбрал Python 3.9, потому что меня больше волновала возможность и простота написания кода, особенно при использовании непонятных на тот момент нейронных сетей, а не оптимизация программного кода, которую я буду побеждать уже на финальном этапе реализации проекта в бакалавриате.
В качестве инструментария для работы с видеоизображением был выбран OpenCV2. Эта библиотека – комплексный и простой инструмент, который позволяет читать и сохранять видео, трансформировать перспективу, а также рисовать различные простые геометрические фигуры прямо поверх видео. Можно сказать, что у OpenCV2 нет конкурентов, которые могут быть схожи по покрываемым ею потребностям пользователя.
В качестве нейросети нужно было выбрать готовую модель, так как с нулевыми знаниями построить более менее грамотную нейронную сеть практически невозможно. К тому же для данной задачи была необходима исключительно сверточная нейронная сеть, которая преобразует изображение в свертку, уменьшенную преобразованную копию в видео числового массива, которую в дальнейшем можно подвергать классификации. Было принято решение использовать архитектуру YOLOv3.
Исходя из тестов с использованием метрической системы COCO mAP-50, архитектура YOLOv3 оказалась самой быстрой в идентификации объектов. AP (average precision) вычисляет среднюю точность recall в диапазоне от 0 до 1. Recall измеряет, насколько хорошо находятся положительные образцы нейронной сетью. IoU (intersection over union) измеряет разность перекрытия между двумя областями для определения процента перекрытия предсказ��нной областью нахождения объекта от его реальной области нахождения. Чем выше значение IoU, тем точнее идентифицируется объект. COCO mAP (mean average precision) – среднее значение для AP для набора классов COCO. mAP-50 означает, что IoU должно быть приближенно к 0.5 для проводимых тестов.
Уже приступив к магистерской работе, я решил перейти на более новую модель YOLOv8. На рисунке представлены сравнения, которые повлияли в 2021 на выбор в пользу YOLOv3.

Для реализации модулей, связанных со статистикой, было решено внедрить библиотеку трекинга за объектами DEEPSORT, но после пары тестов меня не удовлетворила производительность и было решено использовать другой трекер ByteTrack. Он оказался пошустрее и попроще в использовании. Для интересующихся, вот ссылка на описание: https://arxiv.org/pdf/2110.06864.pdf
В итоге получилась следующая схема работы системы:

Подробнее про разработку
В первую очередь нужно было найти запись футбольного матча со статичной фиксацией на поле. Двигающаяся камера с зумом не подходила, потому что нужно было привязываться к координатам карты поля.
Далее я запускал видео используя Python и OpenCV2 и подключал нейросети YOLOv8. Это помогло понять принцип работы и способы подключения и настройки сети. Дополнительно я проводил эксперименты с весами нейронной сети. Для реализации определения принадлежности игрока к той или иной команде я накладывал цветовую маску на объект с помощью метода из OpenCV2 inRange().
После этого я приступил к этапу реализации переноса объектов с видео на карту с помощью трансформации перспективы, которая включена в OpenCV2 методом getPerspectiveTransform. Для маппинга краев поля и объектов использовался класс PixelMapper.
Отрисовка на карту также происходил�� с помощью OpenCV2.


Чтобы добавить возможность работы приложения с CUDA, я использовал CMAKE, с помощью которого можно пересобрать библиотеку OpenCV2 с доступом к видеокарте.
Получился минимально жизнеспособный продукт (MVP), который стал моей бакалаврской работой.

Работу над усовершенствованием проекта я начал с имплементацией DeepSORT – библиотеки трекинга, которая следит за передвижением игроков.

Идентификационный номер у игрока сохраняется. Но после имплементации перестал определяться мяч как объект, поэтому решил обучить свою собственную модель с двумя классами: мяч, игрок. Для этого я нашел датасет для разметки и дальнейшего обучения на платформе roboflow.
В процессе оказалось, что DeepSORT работал не так эффективно, как ожидалось, поэтому я “переехал” на ByteTrack. После этого можно было начать заниматься реализацией маркеров и видоизменения отрисовки на видеоизображении, а также сборщиком статистики: процент владения мячом, карта касаний, карта передач.

В левом верхнем углу видна процентовка владения мячом одной из команд в зависимости от цвета. На листинге 1 представлен код класса сбора статистики о владении мячом.
Листинг 1 – Код класса сбора статистики о владении мячом
@dataclass
class PossessionService:
color_main: Colo
color_reserved: Color
frames_total: Optional[int] = 0
frames_main: Optional[int] = 0
frames_reserve: Optional[int] = 0
possession_main: Optional[float] = 0
possession_reserve: Optional[float] = 0
def __calculate_frames(self, detections: List[Detection]):
for detection in detections:
if detection.team == TEAM1:
self.frames_main += 1
self.frames_total += 1
if detection.team == TEAM2:
self.frames_reserve += 1
self.frames_total += 1
return
def __calculate_possession(self, detections: List[Detection]):
if len(detections) == 0:
return
self.__calculate_frames(detections)
if self.frames_total == 0:
return
self.possession_main = round(self.frames_main / self.frames_total * 100, 1)
self.possession_reserve = round(self.frames_reserve / self.frames_total * 100, 1)
def annotate(self, image: np.ndarray, detections: List[Detection]) -> np.ndarray:
self.__calculate_possession(detections)
annotated_image = image.copy()
annotated_image = draw_text(image=image,
text=f'Team #1: {self.possession_main} %',
anchor=pr.Point(x=POSSESSION_POINT_MAIN[0], y=POSSESSION_POINT_MAIN[1]),
color=self.color_main,
font_scale=0.7)
annotated_image = draw_text(image=annotated_image,
text=f'Team #2: {self.possession_reserve} %',
anchor=pr.Point(x=POSSESSION_POINT_RESERVE[0], y=POSSESSION_POINT_RESERVE[1]),
color=self.color_reserved,
font_scale=0.7)
return annotated_image
Листинг 2 – Класс сбора статистики выполненных пассов
@dataclass
class PreviousPlayerData:
id: int
team: int
x: int
y: int
@dataclass
class Pass:
src_x: int
src_y: int
dest_x: int
dest_y: int
src_id: int
dest_id: int
color: Color
@dataclass
class PassCollector:
pm: PixelMapper
colors: List[Color]
passes: List[Pass] = field(default_factory=list)
previous_player: PreviousPlayerData = None
def __get_color_by_team(self, team) -> Color:
if team == Consts.TEAM1:
return self.colors[0]
if team == Consts.TEAM2:
return self.colors[1]
return self.colors[0]
def __new_previous_player_data(self, detection: Detection) -> PreviousPlayerData:
lonlat = tuple(self.pm.pixel_to_lonlat((int(detection.rect.x), int(detection.rect.y)))[0])
return PreviousPlayerData(
id=detection.tracker_id,
team=detection.team,
x=int(lonlat[0]),
y=int(lonlat[1]))
def append(self, player_in_possession_detection: List[Detection]):
if not player_in_possession_detection:
return
if self.previous_player is None:
self.previous_player = self.__new_previous_player_data(player_in_possession_detection[-1])
return
if self.previous_player.id == player_in_possession_detection[-1].tracker_id:
self.previous_player = self.__new_previous_player_data(player_in_possession_detection[-1])
return
if self.previous_player.team != player_in_possession_detection[-1].team:
self.previous_player = self.__new_previous_player_data(player_in_possession_detection[-1])
return
lonlat = tuple(self.pm.pixel_to_lonlat((int(player_in_possession_detection[-1].rect.x),
int(player_in_possession_detection[-1].rect.y)))[0])
self.passes.append(Pass(
src_x=self.previous_player.x,
src_y=self.previous_player.y,
dest_x=int(lonlat[0]),
dest_y=int(lonlat[1]),
src_id=self.previous_player.id,
dest_id=player_in_possession_detection[-1].tracker_id,
color=self.__get_color_by_team(self.previous_player.team)
))
self.previous_player = self.__new_previous_player_data(player_in_possession_detection[-1])
return
def __draw_adapter(self, passes_pitch, pass_item: Pass):
passes_pitch = DrawUtil.draw_circle(
image=passes_pitch,
lonlat=(math.trunc(pass_item.src_x), math.trunc(pass_item.src_y)),
color=pass_item.color,
radius=3)
passes_pitch = DrawUtil.draw_text(
image=passes_pitch,
anchor=pr.Point(x=pass_item.src_x, y=pass_item.src_y),
text=str(pass_item.src_id),
color=Color(255, 255, 255),
thickness=1)
passes_pitch = DrawUtil.draw_circle(
image=passes_pitch,
lonlat=(math.trunc(pass_item.dest_x), math.trunc(pass_item.dest_y)),
color=pass_item.color,
radius=3)
passes_pitch = DrawUtil.draw_text(
image=passes_pitch,
anchor=pr.Point(x=pass_item.dest_x, y=pass_item.dest_y),
text=str(pass_item.dest_id),
color=Color(255, 255, 255),
thickness=1)
passes_pitch = DrawUtil.draw_line(
image=passes_pitch,
src_x=pass_item.src_x,
src_y=pass_item.src_y,
dest_x=pass_item.dest_x,
dest_y=pass_item.dest_y,
color=pass_item.color
)
return passes_pitch
def get_image(self, pitch):
passes_pitch = copy.deepcopy(pitch)
for pass_item in self.passes:
passes_pitch = self.__draw_adapter(passes_pitch, pass_item)
return passes_pitch

Листинг 3 – Класс сбора статистики касаний мяча
@dataclass
class Touch:
x: int
y: int
id: int
color: Color
@dataclass
class TouchCollector:
pm: PixelMapper
colors: List[Color]
touches: List[Touch] = field(default_factory=list)
def __get_color_by_team(self, team) -> Color:
if team == Consts.TEAM1:
return self.colors[0]
if team == Consts.TEAM2:
return self.colors[1]
return self.colors[0]
def __get_touch(self, detections: List[Detection]) -> Touch:
for detection in detections:
lonlat = tuple(self.pm.pixel_to_lonlat((int(detection.rect.x), int(detection.rect.y)))[0])
id = detection.tracker_id
color = self.__get_color_by_team(detection.team)
return Touch(x=lonlat[0], y=lonlat[1], id=id, color=color)
def append(self, player_in_possession_detection: List[Detection], ball_detections: List[Detection] = None):
if not player_in_possession_detection:
return
self.touches.append(self.__get_touch(player_in_possession_detection))
def __draw_adapter(self, touches_pitch, touch: Touch):
touches_pitch = DrawUtil.draw_circle(
image=touches_pitch,
lonlat=(math.trunc(touch.x), math.trunc(touch.y)),
color=touch.color)
touches_pitch = DrawUtil.draw_text(
image=touches_pitch,
anchor=pr.Point(x=touch.x, y=touch.y),
text=str(touch.id),
color=Color(255, 255, 255),
thickness=1)
return touches_pitch
def get_image(self, pitch):
touches_pitch = copy.deepcopy(pitch)
for touch in self.touches:
touches_pitch = self.__draw_adapter(touches_pitch, touch)
return touches_pitch
«Я что-то нажал и все исчезло» или какие были сложности
Первой сложностью была ситуация, когда в MVP итоговое видео воспроизводилось в 13-15 fps, что раздражало при тестировании и демонстрации. После включения CUDA и пересборки библиотеки OpenCV2 ситуация улучшилась. После перехода на YOLOv8 стало достаточно лишь подключить библиотеку torch и внутри нее разблокировать CUDA, которая распространяется на весь программный код.
Вторая сложность – после подключения DeepSORT перестал определяться мяч, который был самым важным объектом на поле для сбора статистики. Тогда я решил обучать свою модель. Много времени ушло на поиск датасета и его разметку. И около 10 часов ушло на обучение модели YOLOv8x с 50 эпохами. Нельзя сказать, что результат обучения отличный – мяч до сих пор иногда пропадает, игроки классифицируются не всегда точно, но его достаточно на текущий момент.
Сейчас вся программная часть системы реализована, и я пишу выпускную квалификационную работу, которая будет документацией данной системы.
Вижу потенциал в расширении количества статистических значений, поэтому планирую добавить карту перехватов и ударов по воротам.
Но выкладывать систему для пользователей еще не готов, так как, на мой взгляд, она еще слишком сыра. :)
Во время работы над этим pet-проектом не только познал всю боль работы с нейросетевыми инструментами, но и понял принцип их работы, узнал их слабые и сильные стороны, научился работать с метриками определяющими качество работы нейронной сети. Ну еще раз убедился, что лучший способ изучения чего-то нового – это практика работы с этим самым новым, а также, что наличие уверенности и достаточной любознательности сильно раздвигают границы возможностей. На данный момент не думаю, что конкретный pet-проект в текущей реализации можно назвать профессиональным инструментом, которым должен пользоваться каждый, кто имеет дело с аналитикой спортивных мероприятий, но, думаю, что подобные разработки в целом двигают мир в более автоматизированное и технологически прогрессивное будущее.
Учеба занимает большую часть моего свободного времени, поэтому часов для рефлексии о других pet-проектах вне учебы особо нет, но есть некоторые мысли о других разработках, связанных с нейронными сетями и не только, которые в ближайшем будущем я планирую реализовать, но пока конкретикой не буду делиться, чтобы сохранить оригинальность своих задумок :)
