Недавно в Dota 2 появилась возможность нарезать видео-ролики в формате .mp4 при просмотре записей матчей. Я не удержался и решил сделать простой алгоритм поиска интересных моментов aka хайлайтов. Вот что из этого получилось на примере последней карты гранд-финала The International 2021, где Collapse из Team Spirit катал PSG.LGD на своем Magnus'е.

Видео ускорено в 1.5 раза.

Под катом

  1. Формат записей матчей в Dota 2

  2. Парсинг реплеев

  3. Анализ событий матча

  4. Кластеризация методом DBSCAN

  5. Идеи по усовершенствованию подхода

  6. Ссылки

Формат записей матчей в Dota 2

Запись матча в Dota 2 называется Replay и представляет из себя файл <match_id>.dem с набором protobuf-событий: клики, урон, хил, сообщения в чат и так далее. Если подсунуть файл клиенту игры, то он воспроизведет все события соответствующего матча. Это замечательная фича, которая позволяет вам открыть пивко и залипнуть в годную каточку после тяжелого дня расковырять структуру реплеев.

Но мы сегодня протобафы ковырять не будем. Вместо этого воспользуемся результатами умельцев из комьюнити (ссылка на репозиторий в конце статьи).

Парсинг реплеев

Естественно умельцы не остановились на достигнутом и реализовали парсер с говорящим названием Clarity. Он написан на Java и представляет из себя набор processor'ов, обрабатывающий события разных типов.

Чтобы не возиться с настройками, поднимем контейнер в Docker. Скачаем репозиторий.

git clone https://github.com/odota/parser.git

Запустим Docker.

sudo service docker start

И выполним build-скрипт.

sudo bash parser/scripts/rebuild.sh
Под капотом скрипт создает контейнер и запускает веб-сервер на локальном порту 5600.
sudo docker build -t odota/parser .
sudo docker rm -fv parser
sudo docker run -d --name parser --net=host odota/parser

У нас появился парсер.Теперь нужен реплей матча. Есть несколько способов его получить.

I. Скачать через клиент игры

Вы можете скачивать реплеи через вкладку Watch в клиенте игры. Я использовал
match_id = 6227492909.

Вкладка Watch в Dota 2, где можно скачать реплей по MatchID
Вкладка Watch в Dota 2, где можно скачать реплей по MatchID

Результат сохраняется в корневую папку игры. Пример пути на машинах под Windows.

C:\Program Files (x86)\Steam\steamapps\common\dota 2 beta\game\dota\replays\

II. Скачать с OpenDota

Есть возможность скачать реплей с сайта OpenDota (ссылка в коцне статьи). Ребята эмулируют поведение игрового клиента и вытаскивают ссылки на CDN Valve. Последний в свою очередь отдает файлы в сжатом формате, поэтому их нужно предварительно распаковывать.

bzcat replays/6227492909_1934613958.dem.bz2 > replays/6227492909.dem

Допустим мы справились с поиском .dem файла. Прогоним его через парсер.

curl localhost:5600 --data-binary "@replays/6227492909.dem" > replays/6227492909.jsonlinesines

На выходе получим JSON'ы, разделенные символами переноса строки и сохраненные в отдельный файл .jsonlines.

Анализ событий матча

Для удобства дальнейшего анализа расчехлим Python и Jupyter. Считаем файл с предыдущего шага и посчитаем количество событий.

import os
import json

REPLAYS_DIR = os.path.join('../replays/')


dem_path = os.path.join(REPLAYS_DIR, '6227492909.jsonlines')
with open(dem_path, 'r') as fin:
    jsonlines = [json.loads(event) for event in fin.readlines()]
    
len(jsonlines)
> 205385

40-минутный матч превратился в ~200k событий. Посмотрим на их структуру.

jsonlines[10]
> {'time': -852, 'type': 'player_slot', 'key': '8', 'value': 131}
jsonlines[100212]

> 
{'time': 1011,
 'type': 'DOTA_COMBATLOG_MODIFIER_REMOVE',
 'value': 0,
 'attackername': 'npc_dota_badguys_tower2_top',
 'targetname': 'npc_dota_hero_enchantress',
 'sourcename': 'dota_unknown',
 'targetsourcename': 'dota_unknown',
 'attackerhero': False,
 'targethero': True,
 'attackerillusion': False,
 'targetillusion': False,
 'inflictor': 'modifier_tower_aura_bonus'}

Видим, что разные события имеют разные поля. Но все события имеют поля type — тип события и time — время в секундах с начала матча. Стоит отметить, что время может принимать отрицательные значения. Это позволяет отделять события до и после выхода крипов (00:00 по часам матча).

Посчитаем количество событий разных типов.

from collections import Counter

Counter([e['type'] for e in jsonlines])

>
Counter({'DOTA_COMBATLOG_GAME_STATE': 8,
         'player_slot': 10,
         'interval': 30580,
         'draft_start': 1,
         'draft_timings': 24,
         'actions': 113360,
         'CHAT_MESSAGE_ITEM_PURCHASE': 58,
         'DOTA_COMBATLOG_GOLD': 2612,
         'DOTA_COMBATLOG_MODIFIER_ADD': 7058,
         'DOTA_COMBATLOG_PURCHASE': 522,
         'DOTA_ABILITY_LEVEL': 406,
         'DOTA_COMBATLOG_ABILITY': 1245,
         'chatwheel': 22,
         'DOTA_COMBATLOG_ITEM': 1658,
         'chat': 10,
         'DOTA_COMBATLOG_MODIFIER_REMOVE': 7021,
         'pings': 482,
         'obs': 34,
         'DOTA_COMBATLOG_PLAYERSTATS': 225,
         'DOTA_COMBATLOG_DAMAGE': 27580,
         'DOTA_COMBATLOG_DEATH': 3260,
				 ...

Я предположил, что интересными могут оказаться те моменты, когда герои наносят друг другу урон. Рассмотрим подробнее события DOTA_COMBATLOG_DAMAGE. Для начала посчитаем, сколько урона игроки нанесли друг другу.

import pandas as pd

df_damage = pd.DataFrame([
    e for e in jsonlines 
    if e['type'] == 'DOTA_COMBATLOG_DAMAGE' and
    e['attackerhero'] and e['targethero']
])

df_damage.groupby(['attackername', 'targetname']).agg({'value': 'sum'})
Урон персонажей друг по другу
Урон персонажей друг по другу

Полученные значения я сравнил с выводами на все том же сайте OpenDota и остался доволен, потому что они совпали.

Обратим внимание, что в игре используются текстовые идентификаторы персонажей, которые могут не соответствовать привычным именам. Например, герой Magnus в реплеях использует кодовое имя npc_dota_hero_magnataur.

df_damage['attackername'].unique()

> array(['npc_dota_hero_ember_spirit', 'npc_dota_hero_kunkka',
       'npc_dota_hero_enchantress', 'npc_dota_hero_bane',
       'npc_dota_hero_tiny', 'npc_dota_hero_magnataur',
       'npc_dota_hero_lycan', 'npc_dota_hero_skywrath_mage',
       'npc_dota_hero_winter_wyvern', 'npc_dota_hero_terrorblade'],
      dtype=object)

Визуализируем таймлайн урона от игрока Collapse на Magnus по персонажам других игроков.

import matplotlib.pyplot as plt

mask = df_damage['attackername'] == 'npc_dota_hero_magnataur'
df_player_damage = df_damage[mask].copy()
df_player_damage['ones'] = 1

fig, ax = plt.subplots(figsize=(19, 5))
plt.scatter(
    x=df_player_damage['time'] / 60, 
    y=df_player_damage['ones']
)
plt.plot()
События нанесения урона Magnus по другим героям по минутам матча
События нанесения урона Magnus по другим героям по минутам матча

Матчи в Dota 2 можно условно разделить на 2 большие стадии: лайнинг и основная. В случае Collapse это разделение проходит по границе ~10 минут с момента выхода крипов. Герой Magnus раскрывается как раз в основной стадии за счет покупки Blink Dagger и обилия массовых драк. Поэтому отсечем события до 10 минуты, а также увеличим размер точек в зависимости от нанесенного урона.

df_player_late_damage = df_player_damage[df_player_damage['time'] > 10 * 60].copy()

fig, ax = plt.subplots(figsize=(19, 5))
plt.scatter(
    x=df_player_late_damage['time'] / 60, 
    y=df_player_late_damage['ones'], 
    s=df_player_late_damage['value']
)
plt.plot()
Урон от Magnus по героям противников после 10-й минуты матча
Урон от Magnus по героям противников после 10-й минуты матча

Кластеризация методом DBSCAN

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

Задачу можно свести к кластеризации — методу машинного обучения без учителя, где общая идея заключается в том, чтобы разделить исходную выборку на подмножества похожих объектов — кластеры.

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

Сравнение алгоритмов кластеризации в scikit-learn
Источник: https://scikit-learn.org/stable/modules/clustering.html
Сравнение алгоритмов кластеризации в scikit-learn Источник: https://scikit-learn.org/stable/modules/clustering.html

Мы воспользуемся алгоритмом DBSCAN. Суть проста: объединить объекты, которые находятся \epsilon- окрестности друг друга. Расстояние между объектами можно считать с помощью разных метрик, но в нашем примере хватит самой привычной — Евклидовой.

d(p,q) = \sqrt{(p_1 - q_1)^2 + ... + (p_n - q_n)^2}

Причем мы не будем использовать абсолютное значение урона, а только время события. Т.е. для \epsilon = 30мы просто группируем события с интервалом не более ~30 секунд.

Вторым важным параметром алгоритма является min_samples. Он определяет минимальное число объектов в кластерах. Если вокруг точки мало соседей, то ей присваивается метка -1 — выброс. В данном примере можно взять min_samples = 1 и ничего не сломается, но на практике это может привести к клипам с хайлайтами, у которых начало будет совпадать с концом.

from sklearn.cluster import DBSCAN

dbscan = DBSCAN(eps=30, min_samples=2)
cluster = dbscan.fit_predict(df_player_late_damage[['time', 'ones']])
df_player_late_damage['cluster'] = cluster

fig, ax = plt.subplots(figsize=(19, 5))
plt.scatter(
    x=df_player_late_damage['time'] / 60, 
    y=df_player_late_damage['ones'], 
    s=df_player_late_damage['value'],
    c=df_player_late_damage['cluster']
)
Кластеризованный урон от Magnus
Кластеризованный урон от Magnus

Осталось только вспомнить, что кластеры в данном случае — временные промежутки матча. Для нарезки клипов с хайлайтами выделим начало и конец каждего кластера.

df_player_late_damage['stime'] = df_player_late_damage['time'].apply(
    lambda t: f'{t // 60}:{str(t % 60).zfill(2)}')
df_action = df_player_late_damage.groupby('cluster').agg({'stime': ['first', 'last']})
df_action.sort_values(('stime', 'first'))

Успех! Осталось воспользоваться фичей игры и нарезать клипы. Итоговое видео вы уже видели в начале статьи.

Идеи по усовершенствованию подхода

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

  2. Некоторые моменты получились не очень насыщенными. Как отсортировать временные промежутки по эпичности?
    Подсказка: можно использовать не только события урона.

  3. Как реализовать автоматическую запись видео, чтобы не приходилось запускать клиент игры и накликивать руками?
    Подсказка: существуют консольные команды demo_goto, demo_gototick.

Спасибо за внимание. Искушенных читателей приглашаю обсудить MMR автора вопросы в комментариях.

Ссылки

Комментарии (9)


  1. swpo
    20.06.2022 17:45
    +1

    Вау, очень круто! А что если автоматизирвоать эту историю и делать крутые хайлайты для каких-то Esport пабликов или ютуб каналов?


    1. arch1baald Автор
      20.06.2022 17:51
      +2

      Да, было бы классно! Но я пока не придумал, как автоматически запускать клиент игры и записывать видео по демке


      1. swpo
        20.06.2022 17:55

        в теории можно попробовать поснифать момент скачки, и понять откуда он выкачивается, а дальше уже по апи получать киберспортьивные матчи (я такое делал) и прокидывать туда ид матча.


        1. arch1baald Автор
          20.06.2022 18:00
          +1

          Это можно делать с помощью OpenDota API https://docs.opendota.com/#tag/replays. Апишка возвращает JSON, из которого можно конструировать ссылки для скачивания реплеев

          f'http://replay{cluster}.valve.net/570/{match_id}_{replay_salt}.dem.bz2'

          Там же есть возможность вытащить список match_id по турниру


          1. swpo
            20.06.2022 18:16

            Я нашел как я 100 лет назад искал про матчи, все довольно просто было :) я мониторил другой сайт и от туда брал эти самые ID - https://github.com/Gaserd/oddslootcalculator/blob/master/scripts/live.js#L6


      1. swpo
        20.06.2022 17:57

        Вот тут кажется какие то парни в 2012 нашли такой функционал (как всегда спасибо валв за документацию) - https://dev.dota2.com/forum/dota-2-reborn-beta-bugs-suggestions/ui-watch-tab/170414-how-do-i-download-a-replay-using-its-match-id


  1. Flux
    20.06.2022 18:42

    Некоторые моменты получились не очень насыщенными. Как отсортировать временные промежутки по эпичности?

    Честно говоря, ожидал что вся статья будет именно об этом, потому что выделить на таймлайне участки с драками можно и без ML.
    Хайлайтом же является не любая драка, а именно зрелищная и неординарная. Вот о ранжировании драк по зрелищности, фичер-инжиниринге и подборе модели для этого было бы почитать интересно.


    1. arch1baald Автор
      20.06.2022 18:55

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


      1. snaiper04ek
        21.06.2022 18:14

        Я эникей, не шарю в МЛ, пишу на правах бывшего дотера. И заранее, прошу прощения если сморожу дичь.

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

        Пара средних таких рецептов эпического момента:

        Летела смертельная тычка - сработал мисс/прожато бкб/СОЮЗНИК ЗАСЕЙВИЛ!!!, благодаря чему, герой выжил и на развороте убил всю команду

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

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

        Нейтральный медведемон хлопнул в ладоши и сделал трипл килл (ноу комментс)

        Герой с блинком уходит на лоухп, и умирает СРАЗУ ЖЕ после блинка. Сразу же = примерно 0,3 секунды. В большинстве случаев (если его убил враг, и если его не убил ульт бладсикера/зевса/фуриона/спектра/другие глобалспособности) - скорее всего, это было как-то красиво. или смешно (блинк в рут пуджа, который мимопроходил, или прямо в скилл прыгнул, типа подкопа, и сразу сдох))))

        Если герой попадает в скилл который либо имеет долгую зарядку перед срабатываением (пауершот) или попадает не сразу (оба хука, ульт белки, скрим оф пэйн), при этом он не был в стане/замедлении больше 20% (то есть вполне себе сносно передвигался и мог не попадаться, особенно круто если он блинканулся в скилл)

        Рубил всю команду (без комментариев)

        Цмка убила всю команду (есть скиллы с большим потанцевалом, но от которых много не ждёшь, но если они отработали на полую, то это красиво).

        Террорблейд прожал сандер трижды на лоухп в драке, либо прожал сандер на союзника, который внёс много урона (нужно подсчитать, что иначе по урону он бы умер, если бы не сандер)

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