Логотип Корус Консалтинг

Как за один pet-проект получить два диплома

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

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

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

Идея проекта

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

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

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

Ниже пример интерфейса игры 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 от времени на обнаружение объектов для различных архитектур сверточных сетей.

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



@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 – Класс сбора статистики касаний мяча



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

Твое резюме отправлено

Мы сохраним его в базе.
Если у нас появится подходящая вакансия, мы обязательно тебе напишем.

Ошибка при отправке