Меня зовут Влад, я работаю Full-stack разработчиком в департаменте «Логистика» КОРУС Консалтинг. Параллельно с этим я учусь на последнем курсе магистратуры в Санкт-Петербургским государственном университете аэрокосмического приборостроения на кафедре компьютерных технологий и программной инженерии. 

На бакалавриате я учился прикладной информатике, но во время обучения программированию и разработке ПО было уделено недостаточно времени. В основном акцент был смещен в матстатистику и различный анализ. Текущее направление в магистратуре дает большие возможности в реализации меня именно как разработчика.

Расскажу про свой pet-проект (и дипломную работу) «Интеллектуальная система определения параметров объектов спортивного мероприятия с использованием библиотеки трекинга».

Идея проекта

Все же знают серию компьютерных футбольных симуляторов FIFA? Раньше я много играл в эту игру. Кто-то скажет, что это бесполезная трата времени, но я с этим не согласен. Эта игра вдохновила меня на разработку pet-проекта, который стал моим бакалаврским дипломом.

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

Разработку проекта я начал в 2021 году, а сейчас работаю над его усовершенствованием в рамках магистерской выпускной работы.  

Пример интерфейса игры FIFA
Пример интерфейса игры FIFA

Несмотря на то, что в 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.

График зависимости mAP-50 от времени на обнаружение объектов для различных архитектур сверточных сетей
График зависимости mAP-50 от времени на обнар��жение объектов для различных архитектур сверточных сетей

Для реализации модулей, связанных со статистикой, было решено внедрить библиотеку трекинга за объектами 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 представлен класс сбора статистики выполненных пассов.
Это карта пассов. На листинге 2 представлен класс сбора статистики выполненных пассов.

Листинг 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 представлен класс сбора статистики касаний мяча.
Это карта касаний мяча. На листинге 3 представлен класс сбора статистики касаний мяча.

Листинг 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-проектах вне учебы особо нет, но есть некоторые мысли о других разработках, связанных с нейронными сетями и не только, которые в ближайшем будущем я планирую реализовать, но пока конкретикой не буду делиться, чтобы сохранить оригинальность своих задумок :)