Initial commit: 上分帝(Climperor)
从 dota2-draft-vision 迁出并定名,作为天梯选将识别项目起点。 Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
@@ -0,0 +1,428 @@
|
||||
# 上分帝(Climperor)—— 方案与实施细节
|
||||
|
||||
本文档记录 **上分帝(Climperor)** 的背景、技术选型、实现细节与推进计划。
|
||||
`README.md` 是操作手册,本文是设计依据与决策记录。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
在 Dota 2 选将阶段(以及进入游戏后),**几秒内自动获取双方 10 个英雄**,用于选将辅助或赛后分析。
|
||||
|
||||
### 为什么 GSI 做不到
|
||||
|
||||
官方 Game State Integration 在**普通玩家视角**下不提供双方 pick:
|
||||
|
||||
| 场景 | GSI 可获得的阵容数据 |
|
||||
|------|---------------------|
|
||||
| 排位 / 普通 All Pick | 仅自己的 `hero.id`;`draft` 通常为空 |
|
||||
| Captains Mode | 历史上有部分 pick/ban,不稳定 |
|
||||
| 观战 / 裁判视角 | 阵容字段较全 |
|
||||
| 赛后 | 需依赖 OpenDota 等外部 API |
|
||||
|
||||
Valve 官方 issue 中已明确:All Pick 的实时 draft 数据因隐私考量被关闭
|
||||
([#9562](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562)、
|
||||
[#7193](https://github.com/ValveSoftware/Dota2-Gameplay/issues/7193)),
|
||||
且被标记为 not planned。
|
||||
|
||||
`dota2-hex` 中的 `lineup_probe` 埋点(`src/gsi/telemetry.rs`)正是为验证此事而写,
|
||||
其单元测试即假定 AP 模式下 `draft:{}` 不含阵容键。
|
||||
|
||||
### 候选方案对比
|
||||
|
||||
| 方案 | 准确率 | 延迟 | 合规性 | 门槛 | 结论 |
|
||||
|------|--------|------|--------|------|------|
|
||||
| 官方 GSI | — | — | 好 | 低 | 拿不到双方 pick |
|
||||
| Overwolf GEP | 很高 | 实时 | 好(与 Valve 有协议) | 玩家须装 Overwolf | 备选,偏重 |
|
||||
| 读游戏内存 | 高 | 实时 | **风险高** | 低 | **排除**,违反项目合规边界 |
|
||||
| 截屏 + 模板匹配 | 中高(可迭代) | < 1s | 好 | 低 | **选定** |
|
||||
| 截屏 + 云端大模型 | 低(实测不可靠) | 数秒 | 好 | 需联网/付费 | 排除为主路径 |
|
||||
|
||||
### 实测记录:为什么不用「整图问大模型」
|
||||
|
||||
用一张 1024×576 的对局截图直接让多模态模型识别顶栏阵容,
|
||||
**10 个英雄几乎全部识别错误**;换用裁剪后的顶栏特写(1024×71),
|
||||
准确率提升到 8/10。结论:
|
||||
|
||||
- 整图 → 单个英雄头像只有几十像素,信息量不足
|
||||
- 通用视觉模型不按英雄库分类,会「脑补」出看似合理实则错误的阵容
|
||||
- **必须先裁格子再识别**,且输出需约束在英雄白名单内
|
||||
|
||||
社区独立工具([dota-hero-picker](https://github.com/YaShock/dota-hero-picker)、
|
||||
[dota2-picker](https://github.com/mohsenheydari/dota2-picker)、
|
||||
[ability-draft-plus](https://github.com/Tiarin-Hino/ability-draft-plus))
|
||||
的共同做法也是:**裁固定 ROI + OpenCV 模板匹配 / 小型 CNN**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术方案
|
||||
|
||||
### 处理流程
|
||||
|
||||
```
|
||||
决策时间截图(PNG,原生分辨率)
|
||||
↓ ① 按相对坐标裁出 10 个头像格
|
||||
↓ ② 去除 UI 边饰(顶部玩家颜色条、底部 ID 名牌),缩放到统一尺寸
|
||||
↓ ③ 与模板库逐一做归一化相关匹配 TM_CCOEFF_NORMED
|
||||
↓ ④ 双阈值门控:Top-1 分数 + (Top1 - Top2) 分差
|
||||
↓
|
||||
{"radiant": [...], "dire": [...]} 每格给出 hero_key 或 null
|
||||
```
|
||||
|
||||
### 关键设计决策
|
||||
|
||||
**① 相对坐标而非像素坐标**
|
||||
|
||||
Dota 2 的顶栏 UI 以屏幕顶部中央为锚点、随分辨率等比缩放。因此坐标存储为:
|
||||
|
||||
- 横向:`(格子中心 x - 屏幕宽/2) / 屏幕高`
|
||||
- 纵向、宽高:`值 / 屏幕高`
|
||||
|
||||
在 1440p 标定一次,1080p / 4K / 大部分 16:10 可直接套用。
|
||||
这是**通用性的第一层保障**——目标是所有 Dota 玩家可用,不能写死单一分辨率。
|
||||
|
||||
**② 两层模板库**
|
||||
|
||||
| 层 | 路径 | 来源 | 特点 |
|
||||
|----|------|------|------|
|
||||
| real | `templates/real/{hero}/*.png` | 选人阶段默认脸实拍 | 与目标同源;**不收皮肤/至宝**;逐步积累 |
|
||||
| cdn | `templates/cdn/{hero}.png` | Steam 官方 CDN 头像 | 全 127 英雄覆盖;按顶栏实际窗口裁切后实测已可 100% |
|
||||
|
||||
CDN 层保证「库里绝不会缺英雄」——缺模板时匹配器只能在已有英雄里硬选,
|
||||
必然乱配(前述实测错误正是此类)。real 层随使用逐步替换 CDN 层。
|
||||
|
||||
**③ 宁可不认,不可乱认**
|
||||
|
||||
同时满足两个条件才输出结果,否则返回 `null`:
|
||||
|
||||
- `score >= min_score`(默认 0.45)
|
||||
- `margin = Top1 - Top2 >= min_margin`(默认 0.04)
|
||||
|
||||
分差门控用于排除「两个英雄都像」的情况,比单一分数阈值更可靠。
|
||||
|
||||
**④ 失败即样本**
|
||||
|
||||
`recognize.py --truth` 会把认错的格子连同正确答案存入 `failures/`
|
||||
(文件名含正确 hero_key)。确认是默认脸后再移入 `templates/real/{key}/`。
|
||||
皮肤顶栏靠会话策略(决策阶段只补空槽、best 优先选人帧),不靠穷举皮肤模板。
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码结构
|
||||
|
||||
```
|
||||
Climperor/
|
||||
├── config.json # 相对坐标、裁切参数、匹配阈值
|
||||
├── heroes.json # 127 英雄 id / key / 英文名对照(自动生成)
|
||||
├── common.py # 配置读写、坐标换算、裁切预处理、模板库加载
|
||||
├── capture.py # 屏幕捕获(单张 / 定时连拍 / 供程序调用的 grab_frame)
|
||||
├── calibrate.py # ROI 手动标定 + 可视化校验(自动标定失败时的退路)
|
||||
├── autocalibrate.py # 靠玩家颜色条自动标定 10 格,免手工框选
|
||||
├── fetch_cdn_templates.py # 拉取全英雄 CDN 头像 + 生成 heroes.json
|
||||
├── build_library.py # 裁格子 → 人工标注 → 入 real 模板库
|
||||
├── recognize.py # 识别 + 准确率评估 + 失败样本归档
|
||||
├── roles.py # 定位匹配的位置文字识别 + 判断哪一格是「我」
|
||||
├── draft_session.py # 跟踪整局选将过程,逐轮记录 pick 时间线
|
||||
├── evaluate.py # 按 samples/labels.json 批量评测所有已标注截图
|
||||
├── samples/labels.json # 已标注截图的真值(10 个 hero_key,未知用 ?)
|
||||
├── gsi_setup.py # 定位 Dota 目录,安装 / 卸载 GSI 配置
|
||||
├── gsi_watch.py # 监听 GSI 状态 → 跟踪选将 → 识别 → 输出 JSON
|
||||
├── templates/roles/ # 5 个位置文字的二值模板
|
||||
├── samples/raw/ # 手动截图;GSI 会话写入 raw/<matchid>/
|
||||
├── results/ # gsi_watch.py 每局的识别结果 JSON
|
||||
├── templates/cdn/ # 127 张兜底模板(已下载)
|
||||
├── templates/real/ # 默认脸实拍模板(不含皮肤)
|
||||
├── preview/ # build_library.py 的裁切预览
|
||||
└── failures/ # 识别失败的格子,待标注入库
|
||||
```
|
||||
|
||||
### config.json 参数说明
|
||||
|
||||
| 字段 | 含义 | 默认 |
|
||||
|------|------|------|
|
||||
| `slots[]` | 10 个格子的相对中心坐标 `cx_rel` / `cy_rel` | 待标定 |
|
||||
| `slot_w_rel` / `slot_h_rel` | 格子宽高 / 屏幕高 | 待标定 |
|
||||
| `crop_trim` | 裁掉的边饰比例(上 10% 颜色条、下 22% 名牌、左右各 8%) | 见文件 |
|
||||
| `canonical_size` | 统一缩放后的边长(px) | 96 |
|
||||
| `match.min_score` | Top-1 最低分 | 0.45 |
|
||||
| `match.min_margin` | Top1−Top2 最小分差 | 0.04 |
|
||||
| `match.cdn_penalty` | CDN 模板得分惩罚,优先采信 real 模板 | 0.05 |
|
||||
| `gsi.port` | GSI 监听端口 | 3223 |
|
||||
| `gsi.trigger_states` | 启动跟踪会话的游戏状态 | `[HERO_SELECTION, STRATEGY_TIME]` |
|
||||
| `gsi.poll_interval` | 选将期间的轮询间隔(秒) | 1.0 |
|
||||
| `gsi.confirm_polls` | 同一格连续几帧认出同一英雄才算确认 | 2 |
|
||||
| `gsi.session_timeout` | 单局跟踪的最长时间(秒) | 300 |
|
||||
| `text_rows.name` / `text_rows.role` | 头像下方姓名行 / 位置行的相对纵坐标 | 见文件 |
|
||||
| `roles.min_iou` | 位置文字模板匹配的最低 IoU | 0.55 |
|
||||
| `roles.self_min_gap` | 「我」那格姓名亮度需高出次亮格多少 | 25.0 |
|
||||
|
||||
阈值需在积累一定样本后按实测重新调优,当前为经验初值。
|
||||
|
||||
### 自动标定(`autocalibrate.py`)
|
||||
|
||||
2560×1440 实测:颜色条位于 y=1~7,头像区 y=8~96(高 88),格宽 111,
|
||||
槽位中心间距 165px,10 格全部命中。
|
||||
|
||||
三个关键判据:
|
||||
|
||||
1. **颜色条定位**——10 个玩家颜色是游戏固定值,逐列取最近颜色(容差 60)
|
||||
找连续段,既给出横向位置又天然给出槽位编号
|
||||
2. **中心线性拟合**——同队 5 格等距,对 index→center 做最小二乘,
|
||||
可修复被身后头像污染的个别颜色条(实测槽位 9/10 偏差 3~6px 被纠正)
|
||||
3. **头像下沿**——用「格内列 vs 格间空隙」的亮度差:有头像时差值 20~67,
|
||||
头像结束瞬间塌到 0。**不能用逐行差分**,因为下方玩家名字的跳变更大,
|
||||
会误判到名字行(初版就踩了这个坑,把底边定到 129 而非 96)
|
||||
|
||||
### 实测结论(2026-07-25,四局人机,2560×1440 无边框)
|
||||
|
||||
| 阶段 | CDN-only | 完整库 |
|
||||
|------|----------|--------|
|
||||
| 初版(正方形裁切 + 线性拟合) | 32/40 | — |
|
||||
| 修正裁切 + 鲁棒拟合后 | **40/40**(最低分 0.784) | **40/40**(最低分 1.000) |
|
||||
|
||||
其中 `draft_145136.png` 是**修复完成前**就抓好的帧,未参与任何调参,
|
||||
旧配置下 8/10、新配置下 10/10,属于干净的盲测样本。
|
||||
|
||||
单次识别 340~460ms,远低于 1 秒目标。用 `python evaluate.py` 复现。
|
||||
|
||||
#### 发现一:顶栏头像是静态图标,可像素级匹配
|
||||
|
||||
real 模板命中时分数恒为 **1.000**。顶栏图标每局渲染逐像素相同,
|
||||
所以一个英雄只要入库一次,之后永远满分命中——不需要"积累多个变体求鲁棒",
|
||||
**一张就够**(同皮肤前提下)。
|
||||
|
||||
#### 发现二:CDN 模板的裁切方式原本就是错的
|
||||
|
||||
初版按 1:1 正方形取中心裁切,但顶栏格子是 111×88(约 1.26:1),比例根本对不上,
|
||||
平均匹配分只有 0.649。以 15 张实拍模板为标准答案做网格搜索,
|
||||
反推出正确窗口是 **x0=38, w=182, 全高**(源图 256×144),平均分升到 **0.936**。
|
||||
|
||||
改用相对比例 `CROP_X0/CROP_X1` 写死在 `fetch_cdn_templates.py` 里,重新生成 127 张后,
|
||||
**仅靠 CDN 模板即可 30/30 全对**。这意味着不必靠打几十局去攒模板库——
|
||||
全英雄覆盖开箱即用。
|
||||
|
||||
> 教训:当兜底层表现明显低于预期时,先怀疑**素材处理方式**,
|
||||
> 而不是急着靠堆数据去补。这里省下了约 70 局的采集成本
|
||||
> (127 英雄按优惠券收集问题估算)。
|
||||
|
||||
#### 发现三:坏点会拖垮最小二乘拟合
|
||||
|
||||
夜魇方槽位 9/10 的颜色条被身后头像污染,检测宽度 141/133(正常 105~119),
|
||||
中心偏了几像素。最小二乘对离群点没有抵抗力,算出的槽距是 166.2 而非真实的 165,
|
||||
导致槽位 10 裁切偏 4px、同一英雄的匹配分从 1.000 掉到 0.567。
|
||||
|
||||
改为**先按条宽剔除不可靠的条**,再取**中位数槽距**、中位数截距,
|
||||
槽位中心与实测真值完全吻合。
|
||||
|
||||
**阈值的已知不足**:瘟疫法师曾出现分差 0.294(很笃定)但分数 0.412 未过 0.45 的情况。
|
||||
现行门控要求分数与分差同时达标,对"分差极高但分数中等"偏严。
|
||||
修正裁切后此问题不再出现(最低分 0.784),暂不调整。
|
||||
|
||||
#### 发现四:天梯段位徽章是稳定遮挡,可用遮罩匹配规避
|
||||
|
||||
天梯全英雄选择时,每个顶栏头像底部有「传奇 III」半透明条、右侧有金色段位勋章。
|
||||
GSI 的 `map` 不含 `game_mode` / `lobby_type`,但画面上金色勋章可稳定检测
|
||||
(`has_ranked_overlay`:≥60% 的格子右下角有金色像素 → 判定为天梯 UI)。
|
||||
|
||||
处理方式:检测到天梯 UI 后,匹配时屏蔽底部 32% + 右侧 22%(`ranked_match_mask`),
|
||||
只对剩余面部区域做归一化相关。人机 / 普通匹配无勋章,走原全图匹配,互不干扰。
|
||||
|
||||
实测:
|
||||
|
||||
| 集合 | 修复前 | 遮罩 + real 入库后 |
|
||||
|------|--------|-------------------|
|
||||
| 5 局人机 | 50/50 | 50/50(未误触发遮罩) |
|
||||
| 1 局天梯 | 9/10(LC 被段位条打崩) | **10/10**(分数 1.000) |
|
||||
|
||||
注意:遮罩解决的是**遮挡**。皮肤/至宝改头像时不要往 real 里堆变体——选人阶段
|
||||
用默认脸多帧确认,决策阶段禁止改判即可;CDN 对默认脸仍偏弱的英雄才补一张 real。
|
||||
|
||||
#### 发现五:位置文字与「我」那一格都能纯视觉读出
|
||||
|
||||
定位匹配(Ranked Roles)里,**只有我方 5 格**的头像下方会画出位置文字:
|
||||
优势路 / 中路 / 劣势路 / 辅助 / 纯辅助。2560×1440 下位于 y=143~159。
|
||||
这行文字是纯灰(饱和度≈0),用 `value>110 且 saturation<0.08` 就能干净抠出来。
|
||||
|
||||
不做 OCR,改成**二值掩膜 IoU 匹配**:候选只有 5 个,且宽度各不相同
|
||||
(图标+2字 ~ 图标+3字),同帧自匹配 IoU=1.0,跨类差距极大。掩膜先紧裁再
|
||||
归一化到固定高度 24px,因此换分辨率不用重建模板。
|
||||
|
||||
哪一格是「我」,最终由 GSI 直接回答:`player` 块里带 `team_slot`(队内 0~4),
|
||||
顶栏就是按队内序号排的,所以 `slot = team_slot + 1`(天辉)或 `+ 6`(夜魇)。
|
||||
实测 `team_slot=4 / radiant / drow_ranger` 对应顶栏第 5 格,与画面一致。
|
||||
|
||||
视觉判据仍保留为兜底(GSI 在载入对局前不发 `player` 块)——
|
||||
**自己的名字是亮白色,其余九人是偏蓝的灰**:
|
||||
|
||||
| 局 | 我那格亮度 | 其余最高 | 差值 |
|
||||
|----|-----------|---------|------|
|
||||
| 天梯定位局 | 229.9 | 170.2 | 59.7 |
|
||||
| 人机局 ×3 | 229.8~230.2 | 175.1 | ~55 |
|
||||
|
||||
用绝对阈值会在「整屏都亮」的界面上误判(实测游戏内 HUD 帧十格都是 248),
|
||||
所以改成**相对判据**:最亮格需比次亮格高出 25 以上。8 张选将截图全部命中,
|
||||
9 张非选将截图全部正确返回 None。
|
||||
|
||||
### 官方选将规则(决定采集节奏)
|
||||
|
||||
天梯全英雄选择的规则来自 Dota 2 Wiki / Liquipedia:
|
||||
|
||||
- **禁用**:全员 15 秒投票,每人 1 个不可重复;每票各有 50% 概率生效;
|
||||
系统再按该 MMR 段位的 ban 率补随机禁用,**最终固定 16 个**
|
||||
- **选人分 3 轮**:25 秒 / 每队 2 人 → 25 秒 / 每队 2 人 → 20 秒 / 每队 1 人
|
||||
- **本轮结束前双方互相不可见**;同轮撞英雄则该英雄被禁、本轮重来(最多 2 次)
|
||||
|
||||
关键推论:顶栏英雄是**按 2/2/1 成批揭晓**的。原来只在决策时间截一次,
|
||||
拿到的是最终阵容,选人顺序信息全丢——而顺序恰好是后续给建议最需要的。
|
||||
|
||||
### GSI 自动化链路
|
||||
|
||||
GSI 拿不到双方 pick,但能可靠告诉我们**现在处于哪个阶段**,正好用作触发器:
|
||||
|
||||
```
|
||||
Dota 2(-gamestateintegration)
|
||||
↓ POST JSON,每 0.5s
|
||||
gsi_watch.py 本地 HTTP 服务(127.0.0.1:3223)
|
||||
↓ map.game_state 进入 HERO_SELECTION(错过则 STRATEGY_TIME 兜底)
|
||||
DraftSession:每秒轮询 → recognize_image() + detect_roles()
|
||||
↓ 某格连续 2 帧认出同一英雄才算确认,避免头像淡入时的抖动
|
||||
确认集合变化 → 追加一条时间线事件(轮次由每队已选人数推出)
|
||||
↓ 状态离开选将 / 10 格认满
|
||||
终端摘要 + results/draft_<时间戳>.json
|
||||
```
|
||||
|
||||
设计要点:
|
||||
|
||||
- **按 `matchid` 去重**,一局一个跟踪会话,不论从哪个阶段接入
|
||||
- **轮询与识别在工作线程**,HTTP 回调立即返回,不阻塞游戏侧推送
|
||||
- **确认需连续 2 帧**,单帧可能拍到半透明的入场动画
|
||||
- **位置与「我」只解析一次**,这两项全局不变,认出后不再重复计算
|
||||
- **「我」优先用 GSI 的 `team_slot`**,视觉判据仅在 GSI 尚未提供时兜底
|
||||
- **未标定时降级为「只截图」**,仍在正确时机存帧,供 `calibrate.py` 使用
|
||||
|
||||
---
|
||||
|
||||
## 4. 环境与依赖
|
||||
|
||||
- Python 3.14(本机已装)
|
||||
- `opencv-python >= 4.10`(实装 5.0.0)、`numpy >= 2.0`、`requests >= 2.32`、`mss`(屏幕捕获)
|
||||
|
||||
已完成的初始化:
|
||||
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
pip install mss
|
||||
python fetch_cdn_templates.py # 127/127 模板下载成功
|
||||
```
|
||||
|
||||
已验证 `capture.py` 可正常截取主显示器,输出为原生 **2560×1440** PNG。
|
||||
|
||||
---
|
||||
|
||||
## 5. 推进计划
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
|------|------|------|
|
||||
| 0 | 项目骨架、依赖、CDN 模板库 | **已完成** |
|
||||
| 1 | 采集真实决策时间截图 | **已完成** |
|
||||
| 2 | ROI 标定,生成 `config.json` 坐标 | **已完成**(自动标定) |
|
||||
| 3 | 首轮识别(纯 CDN 兜底),摸底准确率 | **已完成**(8/10) |
|
||||
| 4 | 积累 real 模板,迭代阈值,统计准确率 | **已完成**(30/30,CDN-only 亦 30/30) |
|
||||
| 5 | 锚点自动定位,去除标定依赖 | **已完成**(`autocalibrate.py`) |
|
||||
| 6 | 接入 GSI 自动触发,全流程免操作 | **已完成**(真机人机局已验证) |
|
||||
|
||||
### 验收标准(判定方案是否可行)
|
||||
|
||||
| 指标 | 目标 |
|
||||
|------|------|
|
||||
| real 模板命中格子的准确率 | ≥ 95% |
|
||||
| 仅 CDN 兜底格子的准确率 | ≥ 70% |
|
||||
| 单张图识别耗时 | < 1 秒 |
|
||||
| 错误类型 | 以「输出 null」为主,而非「输出错误英雄」 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 通用性设计(面向所有玩家)
|
||||
|
||||
**核心原则:成品阶段玩家零操作。** 当前 demo 的手动步骤均为验证期临时措施。
|
||||
|
||||
| Demo 期手动操作 | 成品自动方案 |
|
||||
|----------------|-------------|
|
||||
| 手动截图 | GSI 检测 `DOTA_GAMERULES_STATE_STRATEGY_TIME` 自动触发(`gsi_watch.py`,已实现) |
|
||||
| 手动框选 10 个格子标定 | 锚点自动定位(见下) |
|
||||
| 人工标注建模板库 | 模板库随程序内置,开箱即用 |
|
||||
| `--truth` 对答案 | 仅开发期使用 |
|
||||
| 手动设置无边框窗口 | 首次运行自动检测,或改用 Windows Graphics Capture |
|
||||
|
||||
### 分辨率与宽高比适配(三层递进)
|
||||
|
||||
1. **相对坐标**(已实现)——覆盖同宽高比下的任意分辨率
|
||||
2. **锚点自动定位**(阶段 5)——运行时在截图中自动找顶栏:
|
||||
- 中央倒计时区域作水平锚点
|
||||
- 10 条固定玩家颜色条(蓝、青、紫、黄、橙 / 粉、灰绿、浅蓝、墨绿、棕)
|
||||
既是定位标记,也天然给出槽位编号
|
||||
- 由锚点反推格子位置,任何分辨率、宽高比免配置
|
||||
3. **多尺度匹配**——0.9~1.1 倍尺度搜索,吸收残余缩放误差
|
||||
|
||||
### 已知风险
|
||||
|
||||
| 风险 | 应对 |
|
||||
|------|------|
|
||||
| 21:9 等特殊宽高比布局差异 | 阶段 5 锚点定位;短期可分档标定 |
|
||||
| 英雄皮肤(至宝 / 身心)改变头像 | 不入库皮肤模板;决策阶段只补空槽、禁止改判;best 帧优先选人阶段默认脸 |
|
||||
| 头像为平行四边形,矩形 ROI 会带入邻格边缘 | 当前靠内缩裁切规避;必要时加仿射纠正 |
|
||||
| 独占全屏截图可能为黑帧 | 建议无边框窗口;或改用 Windows Graphics Capture |
|
||||
| Dota 更新改动 HUD 布局 | 锚点方案对布局微调更鲁棒;必要时重标定 |
|
||||
|
||||
### 禁用英雄识别(grid.py)
|
||||
|
||||
天梯 AP 固定禁用 16 个,这个信息不在顶栏,只在英雄选择网格里。最后的做法
|
||||
**完全不用模板匹配**,因为网格的排布是可推算的。
|
||||
|
||||
一开始确实试了匹配。网格用的是竖版英雄卡,Steam CDN 上唯一对得上的素材是
|
||||
遗留路径 `images/heroes/{key}_vert.jpg`(235×272),拟合出的裁切窗口
|
||||
x[0.15,0.80] y[0.00,0.85] 宽高比 0.66 与实拍卡片的 52/79 完全吻合。但均分
|
||||
只有 0.50,且下半段的 top1/top2 几乎没有间距——这批图是 2015 年前后的旧原画,
|
||||
大量英雄重做过,根本对不上。
|
||||
|
||||
真正的突破口是排布本身。127 个英雄按主属性分成四块从左到右排列,块内按
|
||||
**客户端本地化名称**排序、**行优先**填充,多出来的空格永远在块尾。用实战帧
|
||||
交叉验证了三点:四块各 36 / 35 / 34 / 22 格,与英雄表的属性数量分毫不差;
|
||||
5 个空格全在最后一行的块尾;聊天栏里点名的 9 个禁用英雄,按此推算出的格子
|
||||
**全部**画着禁用斜杠。所以英雄表(`heroes.json` 现在带 `attr` 和 `name_loc`,
|
||||
都取自 Valve 自己的 `datafeed/herolist`)就足以定位每一格。
|
||||
|
||||
禁用态的判定绕了点弯路。斜杠本身不好测——试过方向梯度直方图和错切后找亮脊,
|
||||
两者都失败,后者甚至完全反向(禁用格反而排在最后)。原因是禁用卡整张被压暗
|
||||
去饱和,**低对比度**才是主特征。实测那一局:17 张不可选卡片的灰度 std 落在
|
||||
8–21,其余 110 张全部 ≥32.6,中间空出 11.6 的间隔,直接卡阈值即可。
|
||||
17 = 16 个禁用 + 1 个已被选走的莉娜,与官方规则严丝合缝。
|
||||
|
||||
`read_grid()` 在格数对不上英雄表时返回 `ok=False` 而不是给一份残缺名单——
|
||||
鼠标悬停会弹出大号英雄卡遮住网格,这类帧就是这样被挡掉的。禁用名单全程不变,
|
||||
`DraftSession` 只取第一帧读成功的结果,之后不再重复读。
|
||||
|
||||
---
|
||||
|
||||
## 7. 与 dota2-hex 的关系
|
||||
|
||||
本项目为**独立验证 demo**,不修改 `dota2-hex`。
|
||||
|
||||
- 选用 Python 是因为验证期迭代快,非最终技术栈
|
||||
- 准确率验证通过后,可选:用 Rust 重写并入 `dota2-hex`,或保留为本地旁路服务通过 HTTP 回传
|
||||
- 无论哪种,都需遵守 `dota2-hex` 的合规边界:**仅使用玩家屏幕上可见的信息,
|
||||
不读进程内存、不注入**(见 `AGENTS.md`「技术约束」)
|
||||
|
||||
`dota2-hex` 现有的阶段判定(`src/phase.rs`)可直接复用为截图触发信号。
|
||||
|
||||
---
|
||||
|
||||
## 8. 参考资料
|
||||
|
||||
- [Valve #9562 GSI get draft data during draft](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562) — 官方关闭 AP 实时 draft
|
||||
- [Valve #14915 How can I get live pick data](https://github.com/ValveSoftware/Dota2-Gameplay/issues/14915) — 开发者自述 OCR 方案约 85% 准确率
|
||||
- [Overwolf Dota 2 GEP](https://dev.overwolf.com/ow-native/live-game-data-gep/supported-games/dota-2/) — `roster.draft` / `bans` 字段定义
|
||||
- [OpenCV Template Matching](https://pyimagesearch.com/2021/03/22/opencv-template-matching-cv2-matchtemplate/)
|
||||
- Steam CDN 头像:`https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png`
|
||||
@@ -0,0 +1,228 @@
|
||||
# 上分帝(Climperor)
|
||||
|
||||
Dota 2 天梯选将识别:从「决策时间」截图中识别双方 10 个英雄(模板匹配,本地、离线、秒级)。
|
||||
|
||||
用于验证可行性,成熟后可移植到 [dota2-hex](../dota2-hex) 或独立发布。
|
||||
|
||||
方案背景、技术选型与推进计划见 [DESIGN.md](DESIGN.md);本文是操作手册。
|
||||
|
||||
## 原理
|
||||
|
||||
```
|
||||
决策时间截图(PNG)
|
||||
→ 按相对坐标裁出 10 个头像格(分辨率无关)
|
||||
→ 每格纠裁、去 UI 边饰、缩放到统一尺寸
|
||||
→ 与模板库做归一化相关匹配(TM_CCOEFF_NORMED)
|
||||
→ Top-1 分数 + Top1-Top2 分差 双阈值门控 → hero_key 或 null
|
||||
```
|
||||
|
||||
模板库分两层:
|
||||
|
||||
- `templates/real/{hero}/*.png` —— 真实截图裁出的**默认脸**格子(高精度;不收皮肤变体)
|
||||
- `templates/cdn/{hero}.png` —— Steam CDN 官方头像(全覆盖兜底,匹配时降权)
|
||||
|
||||
## 安装
|
||||
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 1. 下载 CDN 兜底模板(一次性)
|
||||
|
||||
```powershell
|
||||
python fetch_cdn_templates.py
|
||||
```
|
||||
|
||||
生成 `heroes.json`(英雄 id/key 对照)和 `templates/cdn/`(全英雄头像)。
|
||||
|
||||
### 2. 采集截图
|
||||
|
||||
程序可自行截屏,无需手动按 PrintScreen:
|
||||
|
||||
```powershell
|
||||
python capture.py # 单张,存入 samples/raw/
|
||||
python capture.py --loop 300 3 # 每 3 秒一张,持续 300 秒
|
||||
```
|
||||
|
||||
GSI 自动跟踪时,截图按对局写入 `samples/raw/<matchid>/`(`draft_*.png`、`draft_best_*.png` 等)。
|
||||
手动 `capture.py` 仍落在 `samples/raw/` 根目录。
|
||||
|
||||
Dota 2 需运行在**无边框窗口**或窗口模式(独占全屏可能截出黑帧)。
|
||||
|
||||
也可以交给 GSI 自动触发,见下方「自动运行」。
|
||||
|
||||
### 3. 标定 ROI(一次性)
|
||||
|
||||
拿一张**原始分辨率、未裁剪**的决策时间全屏截图,自动标定:
|
||||
|
||||
```powershell
|
||||
python autocalibrate.py samples/raw/draft_141704.png
|
||||
```
|
||||
|
||||
靠顶栏那 10 条固定玩家颜色条定位,无需手动框选,任何分辨率都适用。
|
||||
输出 `preview/autocalibrate_check.png`(整条顶栏带框)和
|
||||
`preview/autocalibrate_slots.png`(10 格裁切拼图)供核对,坐标以相对值存入 `config.json`。
|
||||
|
||||
只核对不写配置:加 `--check`。
|
||||
|
||||
手动标定仍可用(自动失败时的退路):
|
||||
|
||||
```powershell
|
||||
python calibrate.py samples/full.png
|
||||
```
|
||||
|
||||
在弹出窗口中依次框选 10 个英雄头像格(每框完一个按空格,全部完成按 ESC)。
|
||||
|
||||
### 4. 建真实模板库(可多次,逐步积累)
|
||||
|
||||
先预览裁切是否正确:
|
||||
|
||||
```powershell
|
||||
python build_library.py samples/shot1.png
|
||||
# 查看 preview/slot_1.png ... slot_10.png
|
||||
```
|
||||
|
||||
确认无误后带标注入库(10 个 hero_key 从左到右,跳过用 `?`):
|
||||
|
||||
```powershell
|
||||
python build_library.py samples/shot1.png tinker,earthshaker,juggernaut,dazzle,vengefulspirit,axe,sniper,slark,lion,drow_ranger
|
||||
```
|
||||
|
||||
hero_key 见 `heroes.json`(即 Steam 内部名去掉 `npc_dota_hero_` 前缀)。
|
||||
|
||||
### 5. 识别 + 验证准确率
|
||||
|
||||
```powershell
|
||||
python recognize.py samples/shot2.png
|
||||
python recognize.py samples/shot2.png --sheet # 输出带预测标签的对照图
|
||||
python recognize.py samples/shot2.png --truth tinker,earthshaker,... # 对答案
|
||||
```
|
||||
|
||||
`--sheet` 生成 `preview/recognize_sheet.png`:10 格裁切并排,每格标注预测英雄与
|
||||
分数/分差,绿色表示过阈值、橙色表示存疑(前缀 `?`)。核对时比读 JSON 快得多。
|
||||
|
||||
批量评测所有已标注截图(真值写在 `samples/labels.json`):
|
||||
|
||||
```powershell
|
||||
python evaluate.py # 完整模板库
|
||||
python evaluate.py --cdn-only # 只用 CDN 层,衡量开箱即用的表现
|
||||
```
|
||||
|
||||
带 `--truth` 时输出每格对错与总准确率;认错的格子自动存入 `failures/`
|
||||
(文件名含正确 key)。仅当裁切是**选人阶段默认脸**时再挪进 `templates/real/{key}/`;
|
||||
皮肤/至宝头像不要入库(靠会话多帧 + 决策阶段禁改判处理)。
|
||||
|
||||
## 自动运行(GSI 触发)
|
||||
|
||||
装好后全程零操作:进入决策时间自动截图、识别、输出 JSON。
|
||||
|
||||
### 一次性配置
|
||||
|
||||
```powershell
|
||||
python gsi_setup.py # 自动找到 Dota 2 并写入 GSI 配置
|
||||
```
|
||||
|
||||
然后在 **Steam 库 → Dota 2 → 属性 → 启动项**中加上 `-gamestateintegration`,重启游戏。
|
||||
|
||||
其他用法:`--check` 只查看状态,`--remove` 卸载配置,
|
||||
`--path "D:\Steam\steamapps\common\dota 2 beta"` 手动指定目录。
|
||||
|
||||
### 开着它打游戏
|
||||
|
||||
```powershell
|
||||
python gsi_watch.py
|
||||
```
|
||||
|
||||
监听 `127.0.0.1:3223`,游戏一进入英雄选择就开始跟踪整局选将,每秒轮询一次,
|
||||
每有新英雄揭晓就打印一行,结束后把完整时间线存入 `results/draft_<时间戳>.json`。
|
||||
每局只跟踪一次;中途启动程序会从决策时间兜底接入。
|
||||
|
||||
天梯全英雄选择是**分 3 轮成批揭晓**的(每队 2 / 2 / 1,本轮结束前互相不可见),
|
||||
所以输出长这样:
|
||||
|
||||
```
|
||||
[draft] grid: 16 heroes unavailable (contrast margin 11.0) - 术士, 殁境神蚀者, ...
|
||||
[draft] + 4.2s round1 radiant slot2 冥魂大帝 [优势路]
|
||||
[draft] + 4.2s round1 dire slot7 幻影刺客
|
||||
[draft] + 31.5s round2 radiant slot3 狙击手 [中路]
|
||||
[draft] ~ 33.0s slot2 幽鬼 -> 主宰 (score 0.52 -> 0.86)
|
||||
...
|
||||
you : slot 5 radiant 沉默术士 - position 5 (纯辅助)
|
||||
lanes : 1:冥魂大帝, 2:狙击手, 3:军团指挥官, 4:祈求者, 5:沉默术士
|
||||
bans : 16 - 术士, 殁境神蚀者, 拉比克, 冥界亚龙, 沉默术士, ...
|
||||
```
|
||||
|
||||
`~` 开头的是**改判**。刚揭晓的那一帧最不适合下判断——立绘还在淡入、天梯段位条又盖住下半张脸。
|
||||
已确认的槽位仍可被更高分的稳定读数覆盖(`revise_gain`,默认 0.15)。
|
||||
|
||||
顶栏在**所有人选完之前**用默认头像,皮肤要等全员锁定后才上。因此视觉会读完选人阶段,
|
||||
并在决策阶段继续读一段时间(`strategy_tail_polls`),直到凑齐 10 人或超时——避免你已进
|
||||
决策界面、别人还没选完时漏掉最后一人。凑齐后才停视觉,再等 GSI 公布你自己的英雄。
|
||||
|
||||
选人界面顶部计时器下方的模式字(如「全英雄选择」)也会识别,写入结果的 `mode` 字段。
|
||||
|
||||
定位匹配局里,我方 5 格头像下方的位置文字会被一并识别。「我」是哪一格直接取自
|
||||
GSI 的 `player.team_slot`;GSI 还没开始推送时,靠姓名颜色兜底(自己的名字是亮白,
|
||||
其余九人偏蓝)。非定位局没有位置文字,就只报槽位不报位置。
|
||||
|
||||
天梯 AP 固定禁用的 16 个英雄从英雄选择网格里读出,不依赖任何图像匹配:网格按
|
||||
主属性分四块、块内按客户端英雄名排序行优先填充,位置可直接推算;被禁或已被选走的
|
||||
卡片整张压暗,灰度对比度会塌到 21 以下(正常卡 33 以上),据此判定。
|
||||
|
||||
不想开游戏调试时:`python gsi_watch.py --once`(立即截一次并识别)。
|
||||
想改触发时机:`python gsi_watch.py --states HERO_SELECTION,STRATEGY_TIME`。
|
||||
只看某张截图的位置识别:`python roles.py samples/raw/<matchid>/<帧>.png`。
|
||||
只看某张截图的禁用识别:`python grid.py samples/raw/<matchid>/<帧>.png`。
|
||||
|
||||
> **主菜单里收不到数据是正常的。** Dota 的 GSI 与 CS 不同,客户端**第一次载入对局后**
|
||||
> 才开始发 HTTP 请求,挂在主菜单时不会有任何推送。看到 `[gsi] connected` 才算链路通。
|
||||
> 一直没有的话,先确认启动项里有 `-gamestateintegration`。
|
||||
|
||||
人机对战(Play with bots)同样会推送,HUD 顶栏与天梯一致,适合反复测试。
|
||||
|
||||
**尚未标定时**会自动进入「只截图」模式——按 `matchid` 存到 `samples/raw/<matchid>/`,
|
||||
拿其中一张跑 `calibrate.py` 即可完成标定。这是当前推荐的第一步。
|
||||
|
||||
相关参数在 `config.json` 的 `gsi` 段:
|
||||
|
||||
| 字段 | 含义 | 默认 |
|
||||
|------|------|------|
|
||||
| `port` | 监听端口 | 3223 |
|
||||
| `trigger_states` | 启动跟踪的游戏状态 | `HERO_SELECTION` + `STRATEGY_TIME` |
|
||||
| `poll_interval` | 选将期间的轮询间隔(秒) | 1.0 |
|
||||
| `confirm_polls` | 连续几帧认出同一英雄才算数 | 2 |
|
||||
| `session_timeout` | 单局跟踪上限(秒) | 300 |
|
||||
| `keep_event_frames` | 是否为每次揭晓存一张原图 | true |
|
||||
| `dump_selection_every` | 选将网格开着时每几秒存一帧(0 关闭) | 4 |
|
||||
| `target_slots` | 认满几格就提前停 | 10 |
|
||||
|
||||
## 截图要求
|
||||
|
||||
- **PNG 格式、原始分辨率**(不要经聊天工具/微信转发,会被压缩)
|
||||
- 画面为对局内「决策时间」阶段,顶栏 10 个英雄完整可见
|
||||
- 无边框窗口或窗口模式截图均可;直接把文件放入 `samples/`
|
||||
|
||||
## 判定标准(demo 验收)
|
||||
|
||||
| 指标 | 目标 | 实测(6 局 / 60 格,含 1 局天梯) |
|
||||
|------|------|-----------------------------------|
|
||||
| real 模板命中的格子准确率 | ≥ 95% | 100%(分数恒为 1.000) |
|
||||
| 仅 CDN 兜底的格子准确率 | ≥ 70% | 人机 100%;天梯 9/10(皮肤差异) |
|
||||
| 单张图识别耗时 | < 1 秒 | 270~520ms |
|
||||
|
||||
天梯顶栏会叠段位条和勋章:程序检测到后自动屏蔽底部/右侧遮挡区再匹配,
|
||||
人机局不受影响。带皮肤的英雄仍需一张 real 模板。
|
||||
|
||||
## 已知限制 / 后续方向
|
||||
|
||||
- 斜切头像目前按矩形内缩裁切,未做仿射纠正(够用则不加)
|
||||
- 分辨率靠相对坐标适配;宽高比差异大(21:9)时需重标定或做锚点自动定位(玩家颜色条 + 中央倒计时)
|
||||
- 英雄皮肤(至宝/身心)可能改变头像,需为常见皮肤补充 real 模板;
|
||||
计划加入「识别有误时手动校正」的入口,校正结果直接沉淀为 real 模板
|
||||
- 禁用识别依赖网格默认排序(子类=属性)。若在客户端里改了排序或筛选方式,
|
||||
格数会对不上英雄表,此时 `grid.py` 会直接报 `ok=False` 而不是给错名单
|
||||
- 新英雄上线后需重跑 `python fetch_cdn_templates.py` 刷新 `heroes.json`
|
||||
- 位置文字模板取自简体中文客户端,换语言需重新执行
|
||||
`python roles.py <帧>.png --build off,safe,mid,soft_support,hard_support`
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Locate the 10 top-bar hero slots automatically, no manual box drawing.
|
||||
|
||||
The top bar puts a player-coloured strip above every portrait, and those ten
|
||||
colours are fixed by the game. Finding them gives both the horizontal position
|
||||
and the slot order for free, at any resolution.
|
||||
|
||||
Usage:
|
||||
python autocalibrate.py samples/raw/draft_141704.png
|
||||
python autocalibrate.py samples/raw/draft_141704.png --check # inspect only
|
||||
|
||||
Writes slot geometry into config.json and preview/autocalibrate_check.png.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from common import ROOT, load_config, save_config
|
||||
|
||||
PREVIEW_DIR = ROOT / "preview"
|
||||
|
||||
# Dota 2 player colours as RGB: radiant slots 1-5 then dire slots 6-10
|
||||
PLAYER_COLORS = [
|
||||
(51, 117, 255),
|
||||
(102, 255, 191),
|
||||
(191, 0, 191),
|
||||
(243, 240, 11),
|
||||
(255, 107, 0),
|
||||
(254, 134, 194),
|
||||
(161, 180, 71),
|
||||
(101, 217, 247),
|
||||
(0, 131, 33),
|
||||
(164, 105, 0),
|
||||
]
|
||||
|
||||
COLOR_TOLERANCE = 60
|
||||
|
||||
|
||||
def find_color_bar_rows(img: np.ndarray) -> tuple[int, int]:
|
||||
"""Rows spanned by the player-colour strips."""
|
||||
h, w = img.shape[:2]
|
||||
targets = np.array([(b, g, r) for (r, g, b) in PLAYER_COLORS], dtype=np.int16)
|
||||
search = img[: int(h * 0.08)].astype(np.int16)
|
||||
|
||||
hits = []
|
||||
for y in range(search.shape[0]):
|
||||
d = np.linalg.norm(search[y][:, None, :] - targets[None, :, :], axis=2)
|
||||
hits.append(int((d.min(axis=1) < COLOR_TOLERANCE).sum()))
|
||||
|
||||
hits = np.array(hits)
|
||||
strong = np.where(hits > w * 0.15)[0]
|
||||
if strong.size == 0:
|
||||
raise SystemExit(
|
||||
"no player colour bars found - is this really a draft/strategy-time frame?"
|
||||
)
|
||||
runs = np.split(strong, np.where(np.diff(strong) > 2)[0] + 1)
|
||||
run = max(runs, key=len)
|
||||
return int(run[0]), int(run[-1])
|
||||
|
||||
|
||||
def find_slots(img: np.ndarray, y0: int, y1: int) -> tuple[list[float], float]:
|
||||
"""Slot centre x for all ten slots, plus the common slot width."""
|
||||
targets = np.array([(b, g, r) for (r, g, b) in PLAYER_COLORS], dtype=np.int16)
|
||||
band = np.median(img[y0 : y1 + 1].astype(np.int16), axis=0)
|
||||
|
||||
d = np.linalg.norm(band[:, None, :] - targets[None, :, :], axis=2)
|
||||
best, dist = d.argmin(axis=1), d.min(axis=1)
|
||||
ok = dist < COLOR_TOLERANCE
|
||||
|
||||
centers: list[float | None] = []
|
||||
widths: list[int | None] = []
|
||||
for idx in range(10):
|
||||
xs = np.where(ok & (best == idx))[0]
|
||||
if xs.size == 0:
|
||||
centers.append(None)
|
||||
widths.append(None)
|
||||
continue
|
||||
runs = np.split(xs, np.where(np.diff(xs) > 5)[0] + 1)
|
||||
run = max(runs, key=len)
|
||||
centers.append(float(run[0] + run[-1]) / 2)
|
||||
widths.append(int(run[-1] - run[0] + 1))
|
||||
|
||||
if sum(c is not None for c in centers) < 8:
|
||||
raise SystemExit("found fewer than 8 colour bars - frame is probably not a full top bar")
|
||||
|
||||
# A bar whose colour bleeds into the portrait behind it comes out too wide,
|
||||
# and its centre is then wrong by several pixels. Least squares would let
|
||||
# such a bar drag the whole row; judge each bar by its width first and only
|
||||
# trust the well-formed ones.
|
||||
width = float(np.median([wd for wd in widths if wd is not None]))
|
||||
reliable = [
|
||||
c is not None and wd is not None and abs(wd - width) <= width * 0.15
|
||||
for c, wd in zip(centers, widths)
|
||||
]
|
||||
|
||||
# slot pitch is identical for both teams, so take it from every good pair
|
||||
steps = [
|
||||
(centers[j] - centers[i]) / (j - i)
|
||||
for team in (range(0, 5), range(5, 10))
|
||||
for i in team
|
||||
for j in team
|
||||
if j > i and reliable[i] and reliable[j]
|
||||
]
|
||||
if not steps:
|
||||
raise SystemExit("no reliable colour bars to measure slot spacing from")
|
||||
pitch = float(np.median(steps))
|
||||
|
||||
fitted: list[float] = []
|
||||
for team in (range(0, 5), range(5, 10)):
|
||||
idx = [i for i in team if reliable[i]] or list(team)
|
||||
base = float(np.median([centers[i] - pitch * (i - team[0]) for i in idx]))
|
||||
fitted += [base + pitch * (i - team[0]) for i in team]
|
||||
|
||||
return fitted, width
|
||||
|
||||
|
||||
def find_portrait_bottom(img: np.ndarray, bar_bottom: int, centers: list[float], width: float) -> int:
|
||||
"""Row where the portraits give way to the name plates.
|
||||
|
||||
Uses the brightness gap between portrait columns and the gaps between
|
||||
portraits: it is large while portraits are present and collapses to zero
|
||||
the moment they end. A plain row-to-row delta does not work here because
|
||||
the player names further down produce an even bigger jump.
|
||||
"""
|
||||
h, w = img.shape[:2]
|
||||
half = width / 2
|
||||
|
||||
inside = np.concatenate(
|
||||
[np.arange(int(c - half) + 6, int(c + half) - 6) for c in centers]
|
||||
)
|
||||
gaps = np.concatenate(
|
||||
[
|
||||
np.arange(int(a + half) + 10, int(b - half) - 10)
|
||||
for team in (centers[:5], centers[5:])
|
||||
for a, b in zip(team[:-1], team[1:])
|
||||
]
|
||||
)
|
||||
inside = inside[(inside >= 0) & (inside < w)]
|
||||
gaps = gaps[(gaps >= 0) & (gaps < w)]
|
||||
|
||||
top = bar_bottom + 1
|
||||
end = min(h, bar_bottom + int(h * 0.15))
|
||||
strip = img[top:end].astype(np.int16)
|
||||
contrast = np.abs(strip[:, inside].mean(axis=(1, 2)) - strip[:, gaps].mean(axis=(1, 2)))
|
||||
|
||||
faded = np.where(contrast < contrast.max() * 0.05)[0]
|
||||
if faded.size == 0:
|
||||
raise SystemExit("could not find the bottom edge of the portraits")
|
||||
return top + int(faded[0])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
path = sys.argv[1]
|
||||
check_only = "--check" in sys.argv
|
||||
|
||||
img = cv2.imread(path)
|
||||
if img is None:
|
||||
sys.exit(f"cannot read image: {path}")
|
||||
h, w = img.shape[:2]
|
||||
|
||||
bar_top, bar_bottom = find_color_bar_rows(img)
|
||||
centers, width = find_slots(img, bar_top, bar_bottom)
|
||||
portrait_top = bar_bottom + 1
|
||||
portrait_bottom = find_portrait_bottom(img, bar_bottom, centers, width)
|
||||
height = portrait_bottom - portrait_top
|
||||
|
||||
print(f"image : {w}x{h}")
|
||||
print(f"colour bar rows : {bar_top}-{bar_bottom}")
|
||||
print(f"portrait rows : {portrait_top}-{portrait_bottom} (height {height})")
|
||||
print(f"slot width : {width:.0f}")
|
||||
print(f"slot centres : {', '.join(f'{c:.0f}' for c in centers)}")
|
||||
|
||||
if height < 20 or width < 20:
|
||||
sys.exit("detected geometry looks wrong - refusing to write config")
|
||||
|
||||
cy = portrait_top + height / 2
|
||||
slots = [
|
||||
{"index": i + 1, "cx_rel": (c - w / 2) / h, "cy_rel": cy / h}
|
||||
for i, c in enumerate(centers)
|
||||
]
|
||||
|
||||
PREVIEW_DIR.mkdir(exist_ok=True)
|
||||
check = img.copy()
|
||||
for s, c in zip(slots, centers):
|
||||
x0, x1 = int(c - width / 2), int(c + width / 2)
|
||||
cv2.rectangle(check, (x0, portrait_top), (x1, portrait_bottom), (0, 0, 255), 2)
|
||||
cv2.putText(check, str(s["index"]), (x0 + 4, portrait_bottom + 26),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
||||
cv2.imwrite(str(PREVIEW_DIR / "autocalibrate_check.png"), check[: portrait_bottom + 40])
|
||||
|
||||
tiles = [
|
||||
cv2.copyMakeBorder(
|
||||
img[portrait_top:portrait_bottom, int(c - width / 2) : int(c + width / 2)],
|
||||
2, 2, 2, 2, cv2.BORDER_CONSTANT, value=(0, 0, 255),
|
||||
)
|
||||
for c in centers
|
||||
]
|
||||
cv2.imwrite(str(PREVIEW_DIR / "autocalibrate_slots.png"), np.hstack(tiles))
|
||||
print("wrote preview/autocalibrate_check.png and preview/autocalibrate_slots.png")
|
||||
|
||||
if check_only:
|
||||
print("--check given, config.json untouched")
|
||||
return
|
||||
|
||||
cfg = load_config()
|
||||
cfg["calibrated_on"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
cfg["calibrated_from"] = str(Path(path).name)
|
||||
cfg["slots"] = slots
|
||||
cfg["slot_w_rel"] = width / h
|
||||
cfg["slot_h_rel"] = height / h
|
||||
# the ROI is already just the portrait, so nothing left to trim away
|
||||
cfg["crop_trim"] = {"top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0}
|
||||
save_config(cfg)
|
||||
print("config.json updated")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Crop slots from a labeled screenshot and add them to the real template library.
|
||||
|
||||
Usage:
|
||||
# 1) preview: crop 10 slots to preview/ so you can see what each slot contains
|
||||
python build_library.py samples/shot1.png
|
||||
|
||||
# 2) import: provide 10 comma-separated hero keys (left to right), '?' to skip a slot
|
||||
python build_library.py samples/shot1.png tinker,earthshaker,juggernaut,dazzle,vengefulspirit,axe,sniper,slark,lion,drow_ranger
|
||||
|
||||
Hero keys must match Steam internal names without the npc_dota_hero_ prefix
|
||||
(see heroes.json after running fetch_cdn_templates.py).
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
from common import ROOT, TEMPLATES_REAL, crop_slot, load_config
|
||||
|
||||
PREVIEW_DIR = ROOT / "preview"
|
||||
|
||||
|
||||
def known_hero_keys() -> set[str]:
|
||||
path = ROOT / "heroes.json"
|
||||
if not path.exists():
|
||||
return set()
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return {h["key"] for h in json.load(f)}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
image_path = sys.argv[1]
|
||||
labels = sys.argv[2].split(",") if len(sys.argv) > 2 else None
|
||||
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
sys.exit(f"cannot read image: {image_path}")
|
||||
cfg = load_config()
|
||||
if not cfg["slots"]:
|
||||
sys.exit("config.json has no slots - run calibrate.py first")
|
||||
|
||||
crops = [(slot["index"], crop_slot(img, slot, cfg)) for slot in cfg["slots"]]
|
||||
|
||||
if labels is None:
|
||||
PREVIEW_DIR.mkdir(exist_ok=True)
|
||||
for idx, crop in crops:
|
||||
if crop is not None:
|
||||
cv2.imwrite(str(PREVIEW_DIR / f"slot_{idx}.png"), crop)
|
||||
print(f"wrote {len(crops)} crops to {PREVIEW_DIR}/ - inspect them, then rerun with labels")
|
||||
return
|
||||
|
||||
if len(labels) != 10:
|
||||
sys.exit(f"expected 10 labels, got {len(labels)}")
|
||||
known = known_hero_keys()
|
||||
stamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
added = 0
|
||||
for (idx, crop), label in zip(crops, labels):
|
||||
label = label.strip()
|
||||
if label == "?" or crop is None:
|
||||
continue
|
||||
if known and label not in known:
|
||||
print(f" WARNING slot {idx}: '{label}' not in heroes.json - saved anyway, double-check spelling")
|
||||
hero_dir = TEMPLATES_REAL / label
|
||||
hero_dir.mkdir(parents=True, exist_ok=True)
|
||||
cv2.imwrite(str(hero_dir / f"{stamp}_s{idx}.png"), crop)
|
||||
added += 1
|
||||
total = sum(1 for _ in TEMPLATES_REAL.rglob("*.png"))
|
||||
heroes = sum(1 for d in TEMPLATES_REAL.iterdir() if d.is_dir())
|
||||
print(f"added {added} templates; library now {total} images / {heroes} heroes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""One-time ROI calibration.
|
||||
|
||||
Usage:
|
||||
python calibrate.py samples/full_1080p.png # interactive: drag 10 slot boxes
|
||||
python calibrate.py samples/full_1080p.png --check # draw current config on image
|
||||
|
||||
Interactive mode: for each of the 10 hero slots (any order), drag a box around
|
||||
the portrait (include the whole parallelogram, exclude neighbors), then press
|
||||
SPACE/ENTER. Press ESC when all 10 are done. Slots are sorted left-to-right
|
||||
and stored as resolution-independent relative coordinates.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
import cv2
|
||||
|
||||
from common import load_config, save_config, slot_rect_px
|
||||
|
||||
|
||||
def calibrate(image_path: str) -> None:
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
sys.exit(f"cannot read image: {image_path}")
|
||||
ih, iw = img.shape[:2]
|
||||
print(f"image size: {iw}x{ih}")
|
||||
print("Drag a box per slot (10 total), SPACE/ENTER to confirm each, ESC to finish.")
|
||||
|
||||
rois = cv2.selectROIs("calibrate - drag 10 slots", img, showCrosshair=True)
|
||||
cv2.destroyAllWindows()
|
||||
if len(rois) != 10:
|
||||
sys.exit(f"expected 10 boxes, got {len(rois)} - please rerun")
|
||||
|
||||
rois = sorted(rois.tolist(), key=lambda r: r[0])
|
||||
cfg = load_config()
|
||||
cfg["slots"] = []
|
||||
avg_w = sum(r[2] for r in rois) / 10
|
||||
avg_h = sum(r[3] for r in rois) / 10
|
||||
cfg["slot_w_rel"] = round(avg_w / ih, 5)
|
||||
cfg["slot_h_rel"] = round(avg_h / ih, 5)
|
||||
for i, (x, y, w, h) in enumerate(rois):
|
||||
cfg["slots"].append(
|
||||
{
|
||||
"index": i + 1,
|
||||
"cx_rel": round((x + w / 2 - iw / 2) / ih, 5),
|
||||
"cy_rel": round((y + h / 2) / ih, 5),
|
||||
}
|
||||
)
|
||||
cfg["calibrated_on"] = f"{iw}x{ih} {time.strftime('%Y-%m-%d %H:%M')}"
|
||||
save_config(cfg)
|
||||
print(f"saved {len(cfg['slots'])} slots to config.json")
|
||||
check(image_path)
|
||||
|
||||
|
||||
def check(image_path: str) -> None:
|
||||
"""Draw configured slot rects onto the image for visual verification."""
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
sys.exit(f"cannot read image: {image_path}")
|
||||
ih, iw = img.shape[:2]
|
||||
cfg = load_config()
|
||||
if not cfg["slots"]:
|
||||
sys.exit("config.json has no slots - run calibration first")
|
||||
for slot in cfg["slots"]:
|
||||
x, y, w, h = slot_rect_px(slot, cfg, iw, ih)
|
||||
cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2)
|
||||
cv2.putText(img, str(slot["index"]), (x, y - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
|
||||
out = "calibrate_check.png"
|
||||
cv2.imwrite(out, img)
|
||||
print(f"wrote {out} - open it and verify the boxes sit on the 10 portraits")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
if "--check" in sys.argv:
|
||||
check(sys.argv[1])
|
||||
else:
|
||||
calibrate(sys.argv[1])
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Screen capture helper.
|
||||
|
||||
Usage:
|
||||
python capture.py # single full-screen shot -> samples/raw/
|
||||
python capture.py --loop 300 2 # capture every 2s for 300s (Ctrl+C to stop early)
|
||||
|
||||
Notes:
|
||||
- Dota 2 must run in borderless window or windowed mode; exclusive
|
||||
fullscreen may capture a black frame with GDI-based grabbers.
|
||||
- Frames are saved as PNG at native resolution, named cap_HHMMSS.png.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import mss
|
||||
import numpy as np
|
||||
|
||||
RAW_DIR = Path(__file__).parent / "samples" / "raw"
|
||||
|
||||
|
||||
def raw_dir_for_match(match_id: str | None = None) -> Path:
|
||||
"""Per-match screenshot folder under samples/raw/{match_id}/.
|
||||
|
||||
Manual capture (no match) still uses samples/raw/ itself.
|
||||
"""
|
||||
if not match_id:
|
||||
return RAW_DIR
|
||||
safe = "".join(c for c in str(match_id) if c.isalnum() or c in "-_") or "no-match"
|
||||
return RAW_DIR / safe
|
||||
|
||||
|
||||
def grab_frame(sct=None) -> np.ndarray:
|
||||
"""Grab the primary monitor as a BGR image."""
|
||||
if sct is None:
|
||||
with mss.MSS() as own:
|
||||
return grab_frame(own)
|
||||
shot = sct.grab(sct.monitors[1])
|
||||
return cv2.cvtColor(np.asarray(shot), cv2.COLOR_BGRA2BGR)
|
||||
|
||||
|
||||
def save_frame(img: np.ndarray, out_dir: Path = RAW_DIR, prefix: str = "cap") -> str:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = time.strftime("%H%M%S")
|
||||
path = out_dir / f"{prefix}_{stamp}.png"
|
||||
n = 1
|
||||
while path.exists():
|
||||
path = out_dir / f"{prefix}_{stamp}_{n}.png"
|
||||
n += 1
|
||||
cv2.imwrite(str(path), img)
|
||||
return str(path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with mss.MSS() as sct:
|
||||
if "--loop" in sys.argv:
|
||||
i = sys.argv.index("--loop")
|
||||
duration = float(sys.argv[i + 1])
|
||||
interval = float(sys.argv[i + 2]) if len(sys.argv) > i + 2 else 2.0
|
||||
end = time.time() + duration
|
||||
n = 0
|
||||
print(f"capturing every {interval}s for {duration}s -> {RAW_DIR}")
|
||||
try:
|
||||
while time.time() < end:
|
||||
path = save_frame(grab_frame(sct))
|
||||
n += 1
|
||||
print(f"[{n}] {path}")
|
||||
time.sleep(interval)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
print(f"done: {n} frames")
|
||||
else:
|
||||
print(save_frame(grab_frame(sct)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Shared helpers: config IO, slot geometry, crop preprocessing."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
CONFIG_PATH = ROOT / "config.json"
|
||||
TEMPLATES_REAL = ROOT / "templates" / "real"
|
||||
TEMPLATES_CDN = ROOT / "templates" / "cdn"
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
with open(CONFIG_PATH, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_config(cfg: dict) -> None:
|
||||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def slot_rect_px(slot: dict, cfg: dict, img_w: int, img_h: int) -> tuple[int, int, int, int]:
|
||||
"""Convert relative slot coords to pixel rect (x, y, w, h) for this image size."""
|
||||
w = cfg["slot_w_rel"] * img_h
|
||||
h = cfg["slot_h_rel"] * img_h
|
||||
cx = img_w / 2 + slot["cx_rel"] * img_h
|
||||
cy = slot["cy_rel"] * img_h
|
||||
return int(round(cx - w / 2)), int(round(cy - h / 2)), int(round(w)), int(round(h))
|
||||
|
||||
|
||||
def crop_slot(img: np.ndarray, slot: dict, cfg: dict) -> np.ndarray | None:
|
||||
"""Crop one slot, trim UI chrome (color bar / name plate), resize to canonical size."""
|
||||
ih, iw = img.shape[:2]
|
||||
x, y, w, h = slot_rect_px(slot, cfg, iw, ih)
|
||||
if w <= 0 or h <= 0:
|
||||
return None
|
||||
x, y = max(0, x), max(0, y)
|
||||
roi = img[y : min(y + h, ih), x : min(x + w, iw)]
|
||||
if roi.size == 0:
|
||||
return None
|
||||
|
||||
t = cfg["crop_trim"]
|
||||
rh, rw = roi.shape[:2]
|
||||
y0 = int(rh * t["top"])
|
||||
y1 = int(rh * (1 - t["bottom"]))
|
||||
x0 = int(rw * t["left"])
|
||||
x1 = int(rw * (1 - t["right"]))
|
||||
inner = roi[y0:y1, x0:x1]
|
||||
if inner.size == 0:
|
||||
return None
|
||||
|
||||
size = cfg["canonical_size"]
|
||||
return cv2.resize(inner, (size, size), interpolation=cv2.INTER_AREA)
|
||||
|
||||
|
||||
def match_score(crop: np.ndarray, template: np.ndarray, mask: np.ndarray | None = None) -> float:
|
||||
"""Normalized cross-correlation between two same-sized BGR images.
|
||||
|
||||
If mask is given (uint8, nonzero = use), only those pixels contribute.
|
||||
Used to ignore the ranked-medal banner that sits on the bottom/right of
|
||||
every top-bar portrait in ranked matchmaking.
|
||||
"""
|
||||
if crop.shape != template.shape:
|
||||
template = cv2.resize(template, (crop.shape[1], crop.shape[0]), interpolation=cv2.INTER_AREA)
|
||||
if mask is None:
|
||||
res = cv2.matchTemplate(crop, template, cv2.TM_CCOEFF_NORMED)
|
||||
return float(res[0][0])
|
||||
|
||||
if mask.shape[:2] != crop.shape[:2]:
|
||||
mask = cv2.resize(mask, (crop.shape[1], crop.shape[0]), interpolation=cv2.INTER_NEAREST)
|
||||
sel = mask > 0
|
||||
if int(sel.sum()) < 32:
|
||||
return -1.0
|
||||
a = crop[sel].astype(np.float32).ravel()
|
||||
b = template[sel].astype(np.float32).ravel()
|
||||
a -= a.mean()
|
||||
b -= b.mean()
|
||||
denom = float(np.linalg.norm(a) * np.linalg.norm(b))
|
||||
return float(a @ b / denom) if denom > 1e-6 else -1.0
|
||||
|
||||
|
||||
def ranked_match_mask(size: int, cfg: dict) -> np.ndarray:
|
||||
"""Canonical-size mask that zeroes the bottom rank bar and right medal."""
|
||||
rm = cfg.get("match", {}).get("ranked_mask", {})
|
||||
bottom = float(rm.get("bottom", 0.32))
|
||||
right = float(rm.get("right", 0.22))
|
||||
mask = np.ones((size, size), np.uint8) * 255
|
||||
mask[int(size * (1.0 - bottom)) :, :] = 0
|
||||
mask[:, int(size * (1.0 - right)) :] = 0
|
||||
return mask
|
||||
|
||||
|
||||
def has_ranked_overlay(img: np.ndarray, cfg: dict) -> bool:
|
||||
"""True when most slots show the gold rank medal on the right edge.
|
||||
|
||||
Bot / unranked strategy-time frames have no medals, so this stays false
|
||||
and recognition keeps using the full portrait.
|
||||
"""
|
||||
if not cfg.get("slots"):
|
||||
return False
|
||||
ih, iw = img.shape[:2]
|
||||
hits = 0
|
||||
checked = 0
|
||||
for slot in cfg["slots"]:
|
||||
x, y, w, h = slot_rect_px(slot, cfg, iw, ih)
|
||||
if w <= 0 or h <= 0:
|
||||
continue
|
||||
roi = img[max(0, y) : min(ih, y + h), max(0, x) : min(iw, x + w)]
|
||||
if roi.size == 0:
|
||||
continue
|
||||
checked += 1
|
||||
rh, rw = roi.shape[:2]
|
||||
corner = roi[int(rh * 0.35) :, int(rw * 0.68) :]
|
||||
if corner.size == 0:
|
||||
continue
|
||||
hsv = cv2.cvtColor(corner, cv2.COLOR_BGR2HSV)
|
||||
gold = cv2.inRange(hsv, (8, 70, 90), (40, 255, 255))
|
||||
if float(gold.mean()) > 18.0:
|
||||
hits += 1
|
||||
return checked > 0 and hits >= max(6, checked * 0.6)
|
||||
|
||||
|
||||
def load_template_library() -> list[tuple[str, str, np.ndarray]]:
|
||||
"""Return list of (hero_key, source, image). source is 'real' or 'cdn'."""
|
||||
lib: list[tuple[str, str, np.ndarray]] = []
|
||||
if TEMPLATES_REAL.is_dir():
|
||||
for hero_dir in sorted(TEMPLATES_REAL.iterdir()):
|
||||
if not hero_dir.is_dir():
|
||||
continue
|
||||
for png in hero_dir.glob("*.png"):
|
||||
img = cv2.imread(str(png))
|
||||
if img is not None:
|
||||
lib.append((hero_dir.name, "real", img))
|
||||
if TEMPLATES_CDN.is_dir():
|
||||
for png in TEMPLATES_CDN.glob("*.png"):
|
||||
img = cv2.imread(str(png))
|
||||
if img is not None:
|
||||
lib.append((png.stem, "cdn", img))
|
||||
return lib
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"comment": "All coordinates are relative: x is offset from screen center divided by screen height; y/w/h are divided by screen height. Filled in by calibrate.py.",
|
||||
"calibrated_on": "2026-07-25 15:01:11",
|
||||
"slots": [
|
||||
{
|
||||
"index": 1,
|
||||
"cx_rel": -0.6409722222222223,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"cx_rel": -0.5263888888888889,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"cx_rel": -0.41180555555555554,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"cx_rel": -0.2972222222222222,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"cx_rel": -0.18263888888888888,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"cx_rel": 0.18055555555555555,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"cx_rel": 0.2951388888888889,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"cx_rel": 0.4097222222222222,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"cx_rel": 0.5243055555555556,
|
||||
"cy_rel": 0.03611111111111111
|
||||
},
|
||||
{
|
||||
"index": 10,
|
||||
"cx_rel": 0.6388888888888888,
|
||||
"cy_rel": 0.03611111111111111
|
||||
}
|
||||
],
|
||||
"slot_w_rel": 0.07708333333333334,
|
||||
"slot_h_rel": 0.06111111111111111,
|
||||
"crop_trim": {
|
||||
"top": 0.0,
|
||||
"bottom": 0.0,
|
||||
"left": 0.0,
|
||||
"right": 0.0
|
||||
},
|
||||
"canonical_size": 96,
|
||||
"match": {
|
||||
"min_score": 0.45,
|
||||
"min_margin": 0.04,
|
||||
"cdn_penalty": 0.05,
|
||||
"ranked_mask": {
|
||||
"comment": "Ignore the bottom rank-title bar and right-side medal when ranked overlays are detected.",
|
||||
"bottom": 0.32,
|
||||
"right": 0.22
|
||||
}
|
||||
},
|
||||
"mode_label": {
|
||||
"comment": "Strip under the draft timer that shows 全英雄选择 / 队长模式 / ...",
|
||||
"y0_rel": 0.045,
|
||||
"y1_rel": 0.072,
|
||||
"x0_rel": 0.40,
|
||||
"x1_rel": 0.60,
|
||||
"min_score": 0.55
|
||||
},
|
||||
"grid": {
|
||||
"comment": "Hero-selection grid. min_std separates cards from gaps; unavailable_std sits in the gap between banned/taken cards (8-21 measured) and live ones (33+).",
|
||||
"min_std": 18.0,
|
||||
"unavailable_std": 26.0
|
||||
},
|
||||
"text_rows": {
|
||||
"comment": "Rows of text under each top-bar portrait, relative to screen height.",
|
||||
"name": {
|
||||
"y0_rel": 0.075,
|
||||
"y1_rel": 0.09444
|
||||
},
|
||||
"role": {
|
||||
"y0_rel": 0.09722,
|
||||
"y1_rel": 0.11319,
|
||||
"min_value": 110,
|
||||
"max_sat": 0.08
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"comment": "min_iou gates role-label matching; self_* detect your own white name.",
|
||||
"min_iou": 0.55,
|
||||
"self_min_value": 195.0,
|
||||
"self_min_gap": 25.0
|
||||
},
|
||||
"gsi": {
|
||||
"comment": "Either trigger state starts one tracking session per match; strategy time is the fallback for joining late.",
|
||||
"port": 3223,
|
||||
"trigger_states": [
|
||||
"DOTA_GAMERULES_STATE_HERO_SELECTION",
|
||||
"DOTA_GAMERULES_STATE_STRATEGY_TIME"
|
||||
],
|
||||
"poll_interval": 1.0,
|
||||
"confirm_polls": 2,
|
||||
"revise_gain": 0.15,
|
||||
"session_timeout": 300,
|
||||
"keep_event_frames": true,
|
||||
"dump_selection_every": 0,
|
||||
"strategy_tail_polls": 8,
|
||||
"strategy_gsi_wait": 3.0,
|
||||
"capture_interval": 1.0,
|
||||
"target_slots": 10
|
||||
},
|
||||
"calibrated_from": "draft_141704.png"
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
"""Follow a whole draft instead of taking one snapshot at the end.
|
||||
|
||||
Ranked All Pick reveals picks in waves rather than one at a time (official
|
||||
rules: two rounds of 2 picks per team at 25s, then a final round of 1 at 20s,
|
||||
with each round's picks hidden until the round ends). A single grab at
|
||||
strategy time therefore loses the order completely, which is exactly the
|
||||
information you need to reason about what to counter-pick.
|
||||
|
||||
This polls the screen for as long as GSI says we are still drafting and
|
||||
appends a timeline event whenever the confirmed set of picks changes. A pick
|
||||
only becomes confirmed after the same hero lands in the same slot on
|
||||
`confirm_polls` consecutive frames, because the top bar animates portraits in
|
||||
and a single frame catches half-faded artwork.
|
||||
|
||||
While the hero grid is still up it also reads the ban list off it (see
|
||||
grid.py), which the top bar never shows.
|
||||
|
||||
Top-bar portraits use the default icon until everyone has picked; skins
|
||||
land only after the draft is complete. Vision therefore runs through both
|
||||
HERO_SELECTION and early STRATEGY_TIME until all ten slots are filled - the
|
||||
last reveal often lands right as strategy begins, and a player who already
|
||||
locked may be staring at the strategy UI while others are still picking.
|
||||
|
||||
Skinned portraits are not templated (too many variants). Instead:
|
||||
- during STRATEGY_TIME only empty slots may be filled; confirmed picks are
|
||||
never revised (skin art must not overwrite a settled default face);
|
||||
- the saved best lineup frame prefers HERO_SELECTION when recognition
|
||||
counts tie, so draft_best_* stays on default faces when possible.
|
||||
|
||||
Once ten heroes are confirmed, vision stops and only GSI is waited on for self.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import mss
|
||||
|
||||
from capture import grab_frame, raw_dir_for_match, save_frame
|
||||
from grid import bans, hero_table, read_grid
|
||||
from modes import detect_mode, load_mode_templates
|
||||
from recognize import recognize_image
|
||||
from roles import ROLES, detect_roles, load_role_templates
|
||||
|
||||
HERO_SELECTION = "DOTA_GAMERULES_STATE_HERO_SELECTION"
|
||||
STRATEGY_TIME = "DOTA_GAMERULES_STATE_STRATEGY_TIME"
|
||||
DRAFT_STATES = (HERO_SELECTION, STRATEGY_TIME)
|
||||
|
||||
|
||||
def pick_round(per_team_max: int) -> int:
|
||||
"""Which of the three All Pick rounds a given pick count belongs to."""
|
||||
if per_team_max <= 2:
|
||||
return 1
|
||||
if per_team_max <= 4:
|
||||
return 2
|
||||
return 3
|
||||
|
||||
|
||||
class DraftSession:
|
||||
def __init__(self, cfg: dict, library, log=print):
|
||||
self.cfg = cfg
|
||||
self.library = library
|
||||
self.log = log
|
||||
g = cfg.get("gsi", {})
|
||||
self.poll_interval = g.get("poll_interval", 1.0)
|
||||
self.confirm_polls = g.get("confirm_polls", 2)
|
||||
self.timeout = g.get("session_timeout", 300)
|
||||
# how much better a later reading must score before it may overwrite
|
||||
# an already confirmed pick
|
||||
self.revise_gain = g.get("revise_gain", 0.15)
|
||||
self.target = g.get("target_slots", 10)
|
||||
self.keep_frames = g.get("keep_event_frames", True)
|
||||
# >0 saves a frame every N seconds while the hero grid is up
|
||||
self.dump_every = g.get("dump_selection_every", 0)
|
||||
# after selection ends, keep reading strategy frames until 10/10 or
|
||||
# this many polls - catches the last reveal without hanging forever
|
||||
self.strategy_tail = g.get("strategy_tail_polls", 8)
|
||||
self.gsi_wait = g.get("strategy_gsi_wait", 3.0)
|
||||
self.role_templates = load_role_templates()
|
||||
self.mode_templates = load_mode_templates()
|
||||
self.hero_names = {h["key"]: h["name_loc"] for h in hero_table()}
|
||||
self.frame_dir = raw_dir_for_match(None)
|
||||
|
||||
def run(self, match_id: str, state_fn, gsi_fn=None) -> dict:
|
||||
"""Poll until the draft is over. state_fn returns the live GSI state."""
|
||||
self.frame_dir = raw_dir_for_match(match_id)
|
||||
self.log(f"[draft] frames -> {self.frame_dir}")
|
||||
started = time.monotonic()
|
||||
pending: dict[int, tuple[str, int]] = {}
|
||||
confirmed: dict[int, str] = {}
|
||||
scores: dict[int, float] = {}
|
||||
revisions: list[dict] = []
|
||||
timeline: list[dict] = []
|
||||
info = {
|
||||
"self_slot": None,
|
||||
"self_team": None,
|
||||
"roles": {},
|
||||
"unavailable": None,
|
||||
"mode": None,
|
||||
}
|
||||
polls = 0
|
||||
last_frame = None
|
||||
best: dict | None = None
|
||||
next_dump = 0.0
|
||||
saved_milestones: set[int] = set()
|
||||
vision_done = False
|
||||
strategy_polls = 0
|
||||
freeze_at = 0.0
|
||||
|
||||
with mss.MSS() as sct:
|
||||
while True:
|
||||
state = state_fn()
|
||||
if state not in DRAFT_STATES:
|
||||
self.log(f"[draft] session ended (state={state})")
|
||||
break
|
||||
if time.monotonic() - started > self.timeout:
|
||||
self.log(f"[draft] session timed out after {self.timeout}s")
|
||||
break
|
||||
|
||||
elapsed = time.monotonic() - started
|
||||
frame = grab_frame(sct)
|
||||
last_frame = frame
|
||||
polls += 1
|
||||
|
||||
# Keep reading in strategy until the roster is full - the last
|
||||
# pick is often revealed on the same tick selection ends, and
|
||||
# a player who already locked may only see strategy UI.
|
||||
do_vision = not vision_done and (
|
||||
state == HERO_SELECTION
|
||||
or (state == STRATEGY_TIME and strategy_polls < self.strategy_tail)
|
||||
)
|
||||
if state == STRATEGY_TIME:
|
||||
strategy_polls += 1
|
||||
|
||||
if do_vision:
|
||||
if self.dump_every and state == HERO_SELECTION and elapsed >= next_dump:
|
||||
next_dump = elapsed + self.dump_every
|
||||
self.log(f"[draft] grid frame: {save_frame(frame, self.frame_dir, prefix='select')}")
|
||||
|
||||
if info["mode"] is None:
|
||||
found = detect_mode(frame, self.cfg, self.mode_templates)
|
||||
if found:
|
||||
info["mode"] = found
|
||||
self.log(f"[draft] mode: {found['label']} ({found['score']:.2f})")
|
||||
|
||||
result = recognize_image(frame, self.cfg, self.library)
|
||||
self._absorb_roles(frame, info)
|
||||
if state == HERO_SELECTION:
|
||||
self._absorb_grid(frame, info)
|
||||
|
||||
best = self._remember_best(best, frame, result, state, elapsed)
|
||||
# Strategy frames may show skins; only fill empty slots there.
|
||||
added, revised = self._absorb_picks(
|
||||
result, pending, confirmed, scores,
|
||||
allow_revise=(state == HERO_SELECTION),
|
||||
)
|
||||
for rev in revised:
|
||||
rev["t"] = round(elapsed, 1)
|
||||
revisions.append(rev)
|
||||
self._log_revision(rev)
|
||||
if added:
|
||||
event = self._event(added, confirmed, state, elapsed)
|
||||
if self.keep_frames:
|
||||
event["frame"] = save_frame(frame, self.frame_dir, prefix="draft")
|
||||
timeline.append(event)
|
||||
self._log_event(event, info)
|
||||
|
||||
n = len(confirmed)
|
||||
if self.keep_frames and n in (4, 8, 10) and n not in saved_milestones:
|
||||
saved_milestones.add(n)
|
||||
path = save_frame(frame, self.frame_dir, prefix=f"draft_n{n}")
|
||||
self.log(f"[draft] milestone {n}/10: {path}")
|
||||
|
||||
if n >= self.target or (
|
||||
state == STRATEGY_TIME and strategy_polls >= self.strategy_tail
|
||||
):
|
||||
self._finalize_vision(best, confirmed, scores)
|
||||
vision_done = True
|
||||
freeze_at = elapsed
|
||||
self.log("[draft] vision done - waiting on GSI for self hero")
|
||||
|
||||
if vision_done:
|
||||
gsi_hero = (gsi_fn() or {}).get("hero") if gsi_fn else None
|
||||
if gsi_hero or (elapsed - freeze_at) >= self.gsi_wait:
|
||||
break
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
if best and not vision_done:
|
||||
self._finalize_vision(best, confirmed, scores)
|
||||
keep = best["frame"] if best is not None else last_frame
|
||||
return self._summary(match_id, timeline, confirmed, info, polls, started, keep,
|
||||
gsi_fn, revisions, best)
|
||||
|
||||
def _finalize_vision(self, best: dict | None, confirmed: dict, scores: dict) -> None:
|
||||
if not best:
|
||||
return
|
||||
if best.get("path"):
|
||||
return
|
||||
self.log(f"[draft] best lineup frame: {best['recognized']}/10 "
|
||||
f"at t={best['t']:.1f}s ({best['state']})")
|
||||
filled = self._backfill(confirmed, scores, best)
|
||||
if filled:
|
||||
self.log(f"[draft] backfilled {filled} slots from best frame")
|
||||
if self.keep_frames:
|
||||
best["path"] = save_frame(best["frame"], self.frame_dir, prefix="draft_best")
|
||||
self.log(f"[draft] saved best: {best['path']}")
|
||||
|
||||
def _backfill(self, confirmed: dict, scores: dict, best: dict) -> int:
|
||||
"""Copy threshold-passed heroes from the best frame into empty slots."""
|
||||
n = 0
|
||||
for i, hero in enumerate(best.get("heroes") or [], 1):
|
||||
if hero and i not in confirmed:
|
||||
confirmed[i] = hero
|
||||
scores[i] = 0.0
|
||||
n += 1
|
||||
return n
|
||||
|
||||
def _remember_best(self, best: dict | None, frame, result: dict, state: str, t: float) -> dict:
|
||||
"""Track the clearest top-bar reading seen so far.
|
||||
|
||||
Prefer more recognized slots first (so a late last-pick still wins).
|
||||
On a tie, prefer HERO_SELECTION over STRATEGY_TIME so skinned strategy
|
||||
portraits do not replace a cleaner default-face frame. Score sum is
|
||||
the final tie-breaker within the same state preference.
|
||||
"""
|
||||
n = int(result.get("recognized") or 0)
|
||||
if n == 0:
|
||||
return best
|
||||
score_sum = sum(float(r.get("score") or 0) for r in result["slots"] if r.get("hero"))
|
||||
selection = state == HERO_SELECTION
|
||||
cand = {
|
||||
"recognized": n,
|
||||
"score_sum": score_sum,
|
||||
"selection": selection,
|
||||
"t": t,
|
||||
"state": state.replace("DOTA_GAMERULES_STATE_", ""),
|
||||
"frame": frame.copy(),
|
||||
"heroes": [r.get("hero") for r in result["slots"]],
|
||||
}
|
||||
if best is None:
|
||||
return cand
|
||||
if n > best["recognized"]:
|
||||
return cand
|
||||
if n < best["recognized"]:
|
||||
return best
|
||||
# same recognized count: prefer selection-phase default faces
|
||||
if selection and not best.get("selection", False):
|
||||
return cand
|
||||
if selection == best.get("selection", False) and score_sum > best["score_sum"]:
|
||||
return cand
|
||||
return best
|
||||
|
||||
def _absorb_roles(self, frame, info: dict) -> None:
|
||||
"""Roles and your own slot never change, so stop looking once found."""
|
||||
if info["self_slot"] is not None and info["roles"]:
|
||||
return
|
||||
found = detect_roles(frame, self.cfg, self.role_templates)
|
||||
if found["self_slot"] is not None and info["self_slot"] is None:
|
||||
info["self_slot"] = found["self_slot"]
|
||||
if found["roles"] and not info["roles"]:
|
||||
info["roles"] = found["roles"]
|
||||
info["self_team"] = found["self_team"] or info["self_team"]
|
||||
|
||||
def _absorb_grid(self, frame, info: dict) -> None:
|
||||
"""Read the ban list off the hero grid, once.
|
||||
|
||||
The set of banned heroes is fixed before the first pick, so the
|
||||
earliest readable frame is also the cleanest: nothing has been taken
|
||||
yet, so everything greyed out is a ban. Frames where a hover tooltip
|
||||
covers the grid fail the layout check and are simply skipped.
|
||||
"""
|
||||
if info["unavailable"] is not None:
|
||||
return
|
||||
res = read_grid(frame, self.cfg)
|
||||
if not res["ok"]:
|
||||
return
|
||||
info["unavailable"] = res["unavailable"]
|
||||
names = ", ".join(self.hero_names.get(k, k) for k in res["unavailable"])
|
||||
self.log(f"[draft] grid: {len(res['unavailable'])} heroes unavailable "
|
||||
f"(contrast margin {res['margin']}) - {names}")
|
||||
|
||||
def _absorb_picks(self, result: dict, pending: dict, confirmed: dict,
|
||||
scores: dict, *, allow_revise: bool = True,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""Promote picks seen on enough consecutive frames.
|
||||
|
||||
Returns (new picks, revisions). During HERO_SELECTION a slot stays
|
||||
open to revision because the frame that first reveals a portrait is
|
||||
the worst one to judge it on: the art is still fading in and the
|
||||
ranked title bar covers the lower face. Once the portrait settles it
|
||||
scores far higher, and a clearly better reading may overwrite.
|
||||
|
||||
During STRATEGY_TIME set allow_revise=False: only empty slots may be
|
||||
filled. Skinned portraits must not replace a confirmed default face.
|
||||
"""
|
||||
added, revised = [], []
|
||||
for r in result["slots"]:
|
||||
slot, hero, score = r["slot"], r["hero"], r["score"]
|
||||
if hero is None:
|
||||
continue
|
||||
|
||||
if slot in confirmed:
|
||||
if hero == confirmed[slot]:
|
||||
scores[slot] = max(scores.get(slot, 0.0), score)
|
||||
pending.pop(slot, None)
|
||||
continue
|
||||
if not allow_revise:
|
||||
continue
|
||||
if score < scores.get(slot, 0.0) + self.revise_gain:
|
||||
continue
|
||||
prev_hero, streak = pending.get(slot, (None, 0))
|
||||
streak = streak + 1 if hero == prev_hero else 1
|
||||
pending[slot] = (hero, streak)
|
||||
if streak >= self.confirm_polls:
|
||||
revised.append({"slot": slot, "team": team_of(slot),
|
||||
"hero": hero, "was": confirmed[slot],
|
||||
"score": score, "was_score": scores.get(slot, 0.0)})
|
||||
confirmed[slot] = hero
|
||||
scores[slot] = score
|
||||
pending.pop(slot, None)
|
||||
continue
|
||||
|
||||
prev_hero, streak = pending.get(slot, (None, 0))
|
||||
streak = streak + 1 if hero == prev_hero else 1
|
||||
pending[slot] = (hero, streak)
|
||||
if streak >= self.confirm_polls:
|
||||
confirmed[slot] = hero
|
||||
scores[slot] = score
|
||||
pending.pop(slot, None)
|
||||
added.append({"slot": slot, "team": team_of(slot), "hero": hero})
|
||||
return added, revised
|
||||
|
||||
def _event(self, added: list[dict], confirmed: dict, state: str, elapsed: float) -> dict:
|
||||
radiant = sorted(s for s in confirmed if s <= 5)
|
||||
dire = sorted(s for s in confirmed if s > 5)
|
||||
return {
|
||||
"t": round(elapsed, 1),
|
||||
"state": state.replace("DOTA_GAMERULES_STATE_", ""),
|
||||
"round": pick_round(max(len(radiant), len(dire))),
|
||||
"added": added,
|
||||
"radiant": [confirmed[s] for s in radiant],
|
||||
"dire": [confirmed[s] for s in dire],
|
||||
"count": len(confirmed),
|
||||
}
|
||||
|
||||
def _log_event(self, event: dict, info: dict) -> None:
|
||||
for a in event["added"]:
|
||||
mine = " <- you" if a["slot"] == info["self_slot"] else ""
|
||||
role = info["roles"].get(a["slot"])
|
||||
tag = f" [{role['label']}]" if role else ""
|
||||
self.log(
|
||||
f"[draft] +{event['t']:6.1f}s round{event['round']} "
|
||||
f"{a['team']:7s} slot{a['slot']:<2d} {loc(a['hero'], self.hero_names)}{tag}{mine}"
|
||||
)
|
||||
|
||||
def _log_revision(self, rev: dict) -> None:
|
||||
self.log(
|
||||
f"[draft] ~{rev['t']:6.1f}s slot{rev['slot']:<2d} "
|
||||
f"{loc(rev['was'], self.hero_names)} -> {loc(rev['hero'], self.hero_names)} "
|
||||
f"(score {rev['was_score']:.2f} -> {rev['score']:.2f})"
|
||||
)
|
||||
|
||||
def _summary(self, match_id, timeline, confirmed, info, polls, started, frame, gsi_fn,
|
||||
revisions=None, best=None) -> dict:
|
||||
gsi = gsi_fn() if gsi_fn else {}
|
||||
self_slot = gsi_slot(gsi) or info["self_slot"]
|
||||
if self_slot and info["self_slot"] and self_slot != info["self_slot"]:
|
||||
self.log(f"[draft] self slot {info['self_slot']} -> {self_slot} (GSI team_slot)")
|
||||
|
||||
# GSI knows your own hero with certainty once it is locked. Prefer it.
|
||||
gsi_hero = gsi.get("hero")
|
||||
if self_slot and gsi_hero and confirmed.get(self_slot) != gsi_hero:
|
||||
prev = confirmed.get(self_slot)
|
||||
confirmed[self_slot] = gsi_hero
|
||||
self.log(
|
||||
f"[draft] self hero {loc(prev, self.hero_names)} -> "
|
||||
f"{loc(gsi_hero, self.hero_names)} (GSI)"
|
||||
)
|
||||
|
||||
role = info["roles"].get(self_slot) if self_slot else None
|
||||
summary = {
|
||||
"match_id": match_id,
|
||||
"captured_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"duration_s": round(time.monotonic() - started, 1),
|
||||
"polls": polls,
|
||||
"mode": info.get("mode"),
|
||||
"self": {
|
||||
"slot": self_slot,
|
||||
"team": gsi.get("team") or info["self_team"] or (team_of(self_slot) if self_slot else None),
|
||||
"hero": (confirmed.get(self_slot) if self_slot else None) or gsi_hero,
|
||||
"role": role["role"] if role else None,
|
||||
"role_label": role["label"] if role else None,
|
||||
"position": role["position"] if role else None,
|
||||
"gsi_name": gsi.get("name"),
|
||||
},
|
||||
"team_roles": {
|
||||
str(s): {"position": r["position"], "label": r["label"], "hero": confirmed.get(s)}
|
||||
for s, r in sorted(info["roles"].items())
|
||||
},
|
||||
"final": {
|
||||
"radiant": [confirmed.get(s) for s in range(1, 6)],
|
||||
"dire": [confirmed.get(s) for s in range(6, 11)],
|
||||
},
|
||||
"recognized": len(confirmed),
|
||||
"revisions": revisions or [],
|
||||
"timeline": timeline,
|
||||
}
|
||||
if info["unavailable"] is not None:
|
||||
banned = bans({"unavailable": info["unavailable"]}, list(confirmed.values()))
|
||||
summary["bans"] = banned
|
||||
summary["bans_loc"] = [self.hero_names.get(k, k) for k in banned]
|
||||
if best:
|
||||
summary["best_lineup"] = {
|
||||
"recognized": best["recognized"],
|
||||
"t": round(best["t"], 1),
|
||||
"state": best["state"],
|
||||
"heroes": best["heroes"],
|
||||
"frame": best.get("path"),
|
||||
}
|
||||
if frame is not None and self.keep_frames:
|
||||
# `frame` is already the best readable lineup when one was found
|
||||
summary["last_frame"] = best.get("path") if best and best.get("path") else \
|
||||
save_frame(frame, self.frame_dir, prefix="draft")
|
||||
return summary
|
||||
|
||||
|
||||
def team_of(slot: int) -> str:
|
||||
return "radiant" if slot <= 5 else "dire"
|
||||
|
||||
|
||||
def gsi_slot(gsi: dict) -> int | None:
|
||||
"""Top-bar slot from GSI's own team_slot, which beats any pixel heuristic.
|
||||
|
||||
The bar is ordered by team slot, radiant on the left. GSI leaves the
|
||||
player block out until a match is loaded, hence the None path.
|
||||
"""
|
||||
team_slot = gsi.get("team_slot")
|
||||
team = gsi.get("team")
|
||||
if team_slot is None or team not in ("radiant", "dire"):
|
||||
return None
|
||||
return int(team_slot) + (1 if team == "radiant" else 6)
|
||||
|
||||
|
||||
def loc(key: str | None, names: dict[str, str] | None = None) -> str:
|
||||
"""English hero key -> in-client Chinese name, for human-facing output."""
|
||||
if not key:
|
||||
return "?"
|
||||
if names is None:
|
||||
names = {h["key"]: h["name_loc"] for h in hero_table()}
|
||||
return names.get(key, key)
|
||||
|
||||
|
||||
def describe(summary: dict) -> list[str]:
|
||||
"""Human-readable recap printed when a session ends."""
|
||||
names = {h["key"]: h["name_loc"] for h in hero_table()}
|
||||
me = summary["self"]
|
||||
lines = []
|
||||
mode = summary.get("mode")
|
||||
if mode:
|
||||
lines.append(f"mode : {mode.get('label') or mode.get('key')}")
|
||||
for side in ("radiant", "dire"):
|
||||
heroes = [loc(h, names) for h in summary["final"][side]]
|
||||
lines.append(f"{side:7s}: {', '.join(heroes)}")
|
||||
if me["slot"]:
|
||||
pos = f"position {me['position']} ({me['role_label']})" if me["position"] else "position unknown"
|
||||
lines.append(f"you : slot {me['slot']} {me['team']} {loc(me['hero'], names)} - {pos}")
|
||||
if summary["team_roles"]:
|
||||
order = ", ".join(
|
||||
f"{v['position']}:{loc(v['hero'], names)}"
|
||||
for v in sorted(summary["team_roles"].values(), key=lambda v: v["position"])
|
||||
)
|
||||
lines.append(f"lanes : {order}")
|
||||
lines.append(f"rounds : {len(summary['timeline'])} reveal events over {summary['duration_s']}s")
|
||||
if summary.get("bans_loc"):
|
||||
lines.append(f"bans : {len(summary['bans_loc'])} - {', '.join(summary['bans_loc'])}")
|
||||
return lines
|
||||
|
||||
|
||||
__all__ = ["DraftSession", "DRAFT_STATES", "HERO_SELECTION", "STRATEGY_TIME", "describe", "loc", "ROLES"]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Score the recogniser against every labelled frame at once.
|
||||
|
||||
Labels live in samples/labels.json as {frame filename: 10 hero keys}, '?' for
|
||||
slots nobody has identified yet. Those slots are skipped, not counted wrong.
|
||||
|
||||
Usage:
|
||||
python evaluate.py # use the full template library
|
||||
python evaluate.py --cdn-only # ignore templates/real, measure the fallback layer
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
from common import ROOT, load_config, load_template_library
|
||||
from recognize import recognize_image
|
||||
|
||||
LABELS_PATH = ROOT / "samples" / "labels.json"
|
||||
RAW_DIR = ROOT / "samples" / "raw"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cdn_only = "--cdn-only" in sys.argv
|
||||
if not LABELS_PATH.is_file():
|
||||
sys.exit(f"missing {LABELS_PATH}")
|
||||
frames = json.loads(LABELS_PATH.read_text(encoding="utf-8"))["frames"]
|
||||
|
||||
cfg = load_config()
|
||||
if not cfg["slots"]:
|
||||
sys.exit("config.json has no slots - run autocalibrate.py first")
|
||||
library = load_template_library()
|
||||
if cdn_only:
|
||||
library = [t for t in library if t[1] == "cdn"]
|
||||
print(f"library: {len(library)} templates{' (cdn only)' if cdn_only else ''}")
|
||||
|
||||
graded = correct = skipped = 0
|
||||
misses: list[str] = []
|
||||
for name, truth in frames.items():
|
||||
path = RAW_DIR / name
|
||||
img = cv2.imread(str(path))
|
||||
if img is None:
|
||||
print(f" {name}: MISSING, skipped")
|
||||
continue
|
||||
result = recognize_image(img, cfg, library)
|
||||
|
||||
hits = frame_graded = 0
|
||||
worst = 1.0
|
||||
for slot, expected in zip(result["slots"], truth):
|
||||
if expected == "?":
|
||||
skipped += 1
|
||||
continue
|
||||
frame_graded += 1
|
||||
worst = min(worst, slot["score"])
|
||||
if slot["hero"] == expected:
|
||||
hits += 1
|
||||
else:
|
||||
misses.append(
|
||||
f" {name} slot {slot['slot']}: expected {expected}, "
|
||||
f"got {slot['hero']} (raw {slot['raw_best']} "
|
||||
f"score {slot['score']} margin {slot['margin']})"
|
||||
)
|
||||
graded += frame_graded
|
||||
correct += hits
|
||||
flag = " ranked" if result.get("ranked_overlay") else ""
|
||||
print(f" {name}: {hits}/{frame_graded} lowest score {worst:.3f} {result['elapsed_ms']}ms{flag}")
|
||||
|
||||
if misses:
|
||||
print("\nmisses:")
|
||||
print("\n".join(misses))
|
||||
pct = 100 * correct / graded if graded else 0
|
||||
print(f"\ntotal: {correct}/{graded} ({pct:.1f}%){f', {skipped} unlabelled slots skipped' if skipped else ''}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Download all hero portraits from Steam CDN as fallback templates.
|
||||
|
||||
Usage:
|
||||
python fetch_cdn_templates.py
|
||||
|
||||
Writes:
|
||||
heroes.json - hero id / key / English name table (from OpenDota constants)
|
||||
templates/cdn/{key}.png - face-centered square crop resized to canonical size
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
from common import ROOT, TEMPLATES_CDN, load_config
|
||||
|
||||
# Valve's own feed: ids, localized names, primary attribute. The hero-selection
|
||||
# grid groups by that attribute and sorts by that localized name, so taking both
|
||||
# from the same source is what lets grid.py place every cell without matching.
|
||||
HEROES_URL = "https://www.dota2.com/datafeed/herolist?language={lang}"
|
||||
IMG_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png"
|
||||
ATTRS = {0: "str", 1: "agi", 2: "int", 3: "all"}
|
||||
|
||||
# The top bar shows a fixed window of the landscape hero art, not a centred
|
||||
# square. These bounds were fitted against 15 portraits captured in game:
|
||||
# they lift the mean match score from 0.65 to 0.94. Stored as fractions of
|
||||
# the source width so they hold whatever size the CDN serves.
|
||||
CROP_X0, CROP_X1 = 38 / 256, (38 + 182) / 256
|
||||
|
||||
|
||||
def fetch_heroes(lang: str = "schinese") -> list[dict]:
|
||||
data = requests.get(HEROES_URL.format(lang=lang), timeout=30).json()
|
||||
heroes = data.get("result", {}).get("data", {}).get("heroes") or data.get("heroes")
|
||||
if not heroes:
|
||||
raise SystemExit("hero list came back empty")
|
||||
return heroes
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cfg = load_config()
|
||||
size = cfg["canonical_size"]
|
||||
TEMPLATES_CDN.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("fetching hero list from the Dota 2 data feed...")
|
||||
heroes = fetch_heroes()
|
||||
|
||||
table = []
|
||||
ok, fail = 0, 0
|
||||
for h in heroes:
|
||||
key = h["name"].removeprefix("npc_dota_hero_")
|
||||
table.append({
|
||||
"id": h["id"],
|
||||
"key": key,
|
||||
"name": h["name_english_loc"],
|
||||
"attr": ATTRS.get(h["primary_attr"], "all"),
|
||||
"name_loc": h["name_loc"],
|
||||
})
|
||||
out = TEMPLATES_CDN / f"{key}.png"
|
||||
if out.exists():
|
||||
ok += 1
|
||||
continue
|
||||
try:
|
||||
raw = requests.get(IMG_URL.format(key=key), timeout=30).content
|
||||
img = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
raise ValueError("decode failed")
|
||||
iw = img.shape[1]
|
||||
window = img[:, int(iw * CROP_X0) : int(iw * CROP_X1)]
|
||||
cv2.imwrite(str(out), cv2.resize(window, (size, size), interpolation=cv2.INTER_AREA))
|
||||
ok += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" FAILED {key}: {e}")
|
||||
fail += 1
|
||||
|
||||
with open(ROOT / "heroes.json", "w", encoding="utf-8") as f:
|
||||
json.dump(sorted(table, key=lambda t: t["id"]), f, ensure_ascii=False, indent=1)
|
||||
print(f"done: {ok} templates, {fail} failures, {len(table)} heroes in heroes.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Read the hero-selection grid: which heroes are unavailable.
|
||||
|
||||
No template matching is involved, because the grid's layout is fully
|
||||
determined. Heroes are split into four attribute blocks laid out left to
|
||||
right (strength, agility, intelligence, universal); inside a block they are
|
||||
sorted by the in-client localized name and filled row-major, and any leftover
|
||||
cells sit at the tail of the block.
|
||||
|
||||
That was verified against a live ranked draft: the four blocks held exactly
|
||||
36 / 35 / 34 / 22 cells, matching the roster's attribute counts, every empty
|
||||
cell was in the bottom row at the end of its block, and all nine bans that
|
||||
the in-game chat log named landed on cells drawn with the ban slash.
|
||||
|
||||
A card that cannot be picked - banned, or already taken - is drawn dimmed
|
||||
under a diagonal slash, which flattens it. Greyscale contrast is the clean
|
||||
separator: in that same draft the seventeen unavailable cards measured 8-21
|
||||
while every live card measured 33 or more.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from common import ROOT
|
||||
|
||||
ATTR_ORDER = ("str", "agi", "int", "all")
|
||||
|
||||
|
||||
def hero_table() -> list[dict]:
|
||||
table = json.loads((ROOT / "heroes.json").read_text(encoding="utf-8"))
|
||||
if table and "attr" not in table[0]:
|
||||
raise SystemExit("heroes.json predates grid support - rerun fetch_cdn_templates.py")
|
||||
return table
|
||||
|
||||
|
||||
def _runs(flags: np.ndarray, min_len: int) -> list[tuple[int, int]]:
|
||||
out, start = [], None
|
||||
for i, v in enumerate(flags):
|
||||
if v and start is None:
|
||||
start = i
|
||||
elif not v and start is not None:
|
||||
if i - start >= min_len:
|
||||
out.append((start, i))
|
||||
start = None
|
||||
if start is not None and len(flags) - start >= min_len:
|
||||
out.append((start, len(flags)))
|
||||
return out
|
||||
|
||||
|
||||
def detect_grid(img: np.ndarray, cfg: dict | None = None) -> dict | None:
|
||||
"""Locate the card lattice. Returns column and row spans, or None.
|
||||
|
||||
Cards are busy and the gaps between them are flat, so a per-column and
|
||||
per-row standard deviation profile separates them without any thresholds
|
||||
that depend on resolution.
|
||||
"""
|
||||
g = cfg.get("grid", {}) if cfg else {}
|
||||
floor = g.get("min_std", 18.0)
|
||||
ih = img.shape[0]
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.float32)
|
||||
|
||||
band = gray[int(ih * 0.20):int(ih * 0.60), :]
|
||||
cols = _plausible(_runs(band.std(axis=0) > _ink(band.std(axis=0), floor), int(ih * 0.025)))
|
||||
if len(cols) < 8:
|
||||
return None
|
||||
|
||||
strip = gray[:, cols[0][0]:cols[-1][1]]
|
||||
prof = strip.std(axis=1)
|
||||
rows = [r for r in _plausible(_runs(prof > _ink(prof, floor), int(ih * 0.03))) if r[0] > ih * 0.10]
|
||||
if len(rows) < 2:
|
||||
return None
|
||||
return {"cols": cols, "rows": rows}
|
||||
|
||||
|
||||
def _ink(profile: np.ndarray, floor: float) -> float:
|
||||
"""Threshold that follows the frame's own contrast.
|
||||
|
||||
Banners and tooltips dim the whole grid for a moment; a fixed cut loses
|
||||
rows and columns on those frames, which would silently truncate the layout.
|
||||
"""
|
||||
return max(floor * 0.5, 0.35 * float(np.percentile(profile, 75)))
|
||||
|
||||
|
||||
def _plausible(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
||||
"""Drop side panels and stray runs by keeping spans near the median width."""
|
||||
if not spans:
|
||||
return []
|
||||
med = float(np.median([b - a for a, b in spans]))
|
||||
return [s for s in spans if 0.7 * med <= (s[1] - s[0]) <= 1.4 * med]
|
||||
|
||||
|
||||
def block_of_column(cols: list[tuple[int, int]]) -> list[int]:
|
||||
"""Tag every column with its attribute block index.
|
||||
|
||||
Blocks are separated by a visibly wider gutter than the gap between two
|
||||
cards in the same block.
|
||||
"""
|
||||
gaps = [cols[i + 1][0] - cols[i][1] for i in range(len(cols) - 1)]
|
||||
if not gaps:
|
||||
return [0] * len(cols)
|
||||
cut = float(np.median(gaps)) * 1.8
|
||||
block, out = 0, [0]
|
||||
for gap in gaps:
|
||||
if gap > cut:
|
||||
block += 1
|
||||
out.append(block)
|
||||
return out
|
||||
|
||||
|
||||
def build_layout(grid: dict, table: list[dict]) -> dict[tuple[int, int], str] | None:
|
||||
"""Map every cell to a hero from the roster alone. None if the shape is off."""
|
||||
cols, rows = grid["cols"], grid["rows"]
|
||||
blocks = block_of_column(cols)
|
||||
if len(set(blocks)) != len(ATTR_ORDER):
|
||||
return None
|
||||
|
||||
layout: dict[tuple[int, int], str] = {}
|
||||
for bi, attr in enumerate(ATTR_ORDER):
|
||||
cells = [(r, c) for r in range(len(rows)) for c in range(len(cols)) if blocks[c] == bi]
|
||||
cells.sort()
|
||||
heroes = sorted((h for h in table if h["attr"] == attr), key=lambda h: h["name_loc"])
|
||||
if len(heroes) > len(cells):
|
||||
return None
|
||||
for cell, hero in zip(cells, heroes):
|
||||
layout[cell] = hero["key"]
|
||||
return layout
|
||||
|
||||
|
||||
def cell_contrast(img: np.ndarray, grid: dict, r: int, c: int) -> float:
|
||||
x0, x1 = grid["cols"][c]
|
||||
y0, y1 = grid["rows"][r]
|
||||
patch = img[y0:y1, x0:x1]
|
||||
if patch.size == 0:
|
||||
return 0.0
|
||||
# trim the level badge and attribute gem the client paints over the art
|
||||
h, w = patch.shape[:2]
|
||||
inner = patch[int(h * 0.04):int(h * 0.86), int(w * 0.05):int(w * 0.95)]
|
||||
return float(cv2.cvtColor(inner, cv2.COLOR_BGR2GRAY).std())
|
||||
|
||||
|
||||
def read_grid(img: np.ndarray, cfg: dict | None = None) -> dict:
|
||||
"""Heroes that cannot be picked right now, read off the selection grid.
|
||||
|
||||
"unavailable" covers bans and heroes already taken by either team; the
|
||||
caller separates them using the picks it already recognized from the top
|
||||
bar. Returns ok=False when the lattice does not look like a full roster,
|
||||
so a mis-detected grid never turns into a bogus ban list.
|
||||
"""
|
||||
cfg = cfg or {}
|
||||
table = hero_table()
|
||||
grid = detect_grid(img, cfg)
|
||||
if grid is None:
|
||||
return {"ok": False, "reason": "no grid detected", "unavailable": []}
|
||||
|
||||
layout = build_layout(grid, table)
|
||||
if layout is None:
|
||||
return {"ok": False, "reason": "grid shape does not fit the roster", "unavailable": []}
|
||||
if len(layout) != len(table):
|
||||
return {"ok": False,
|
||||
"reason": f"placed {len(layout)} of {len(table)} heroes",
|
||||
"unavailable": []}
|
||||
|
||||
cut = cfg.get("grid", {}).get("unavailable_std", 26.0)
|
||||
scored = {key: cell_contrast(img, grid, r, c) for (r, c), key in layout.items()}
|
||||
unavailable = sorted((k for k, s in scored.items() if s < cut), key=lambda k: scored[k])
|
||||
live = [s for s in scored.values() if s >= cut]
|
||||
return {
|
||||
"ok": True,
|
||||
"unavailable": unavailable,
|
||||
"grid": {"cols": len(grid["cols"]), "rows": len(grid["rows"])},
|
||||
"margin": round(min(live) - max((scored[k] for k in unavailable), default=0.0), 1) if live and unavailable else None,
|
||||
}
|
||||
|
||||
|
||||
def bans(grid_result: dict, picked: list[str]) -> list[str]:
|
||||
"""Unavailable minus whatever the top bar already showed as picked."""
|
||||
taken = {p for p in picked if p}
|
||||
return [k for k in grid_result.get("unavailable", []) if k not in taken]
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
import sys
|
||||
|
||||
from common import load_config
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
raise SystemExit("usage: python grid.py <frame.png> [--picked key,key,...]")
|
||||
img = cv2.imread(sys.argv[1])
|
||||
if img is None:
|
||||
raise SystemExit(f"cannot read {sys.argv[1]}")
|
||||
picked = []
|
||||
if "--picked" in sys.argv:
|
||||
picked = [s.strip() for s in sys.argv[sys.argv.index("--picked") + 1].split(",")]
|
||||
|
||||
res = read_grid(img, load_config())
|
||||
names = {h["key"]: h["name_loc"] for h in hero_table()}
|
||||
if not res["ok"]:
|
||||
print(f"grid not readable: {res['reason']}")
|
||||
return
|
||||
print(f"grid {res['grid']['rows']}x{res['grid']['cols']}, "
|
||||
f"{len(res['unavailable'])} unavailable, contrast margin {res['margin']}")
|
||||
print("unavailable:", ", ".join(names.get(k, k) for k in res["unavailable"]))
|
||||
if picked:
|
||||
b = bans(res, picked)
|
||||
print(f"bans ({len(b)}):", ", ".join(names.get(k, k) for k in b))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Install the Game State Integration config that lets Dota 2 talk to gsi_watch.py.
|
||||
|
||||
Usage:
|
||||
python gsi_setup.py # auto-detect Dota 2 and write the cfg
|
||||
python gsi_setup.py --path "D:\\Steam\\steamapps\\common\\dota 2 beta"
|
||||
python gsi_setup.py --remove # uninstall the cfg
|
||||
python gsi_setup.py --check # only report where things are
|
||||
|
||||
After installing, add -gamestateintegration to Dota 2's launch options
|
||||
(Steam library -> right-click Dota 2 -> Properties) and restart the game.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from common import load_config
|
||||
|
||||
CFG_NAME = "gamestate_integration_climperor.cfg"
|
||||
|
||||
CFG_TEMPLATE = """"Climperor"
|
||||
{{
|
||||
"uri" "http://127.0.0.1:{port}/"
|
||||
"timeout" "5.0"
|
||||
"buffer" "0.1"
|
||||
"throttle" "0.5"
|
||||
"heartbeat" "30.0"
|
||||
"data"
|
||||
{{
|
||||
"provider" "1"
|
||||
"map" "1"
|
||||
"player" "1"
|
||||
"hero" "1"
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
FALLBACK_ROOTS = [
|
||||
r"C:\Program Files (x86)\Steam",
|
||||
r"C:\Steam",
|
||||
r"D:\Steam",
|
||||
r"D:\SteamLibrary",
|
||||
r"E:\SteamLibrary",
|
||||
]
|
||||
|
||||
|
||||
def steam_roots() -> list[Path]:
|
||||
"""Candidate Steam library roots, from the registry plus libraryfolders.vdf."""
|
||||
roots: list[Path] = []
|
||||
|
||||
try:
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Valve\Steam") as key:
|
||||
roots.append(Path(winreg.QueryValueEx(key, "SteamPath")[0]))
|
||||
except Exception: # noqa: BLE001 - registry is best-effort
|
||||
pass
|
||||
|
||||
roots += [Path(p) for p in FALLBACK_ROOTS]
|
||||
|
||||
# libraryfolders.vdf lists every additional install drive
|
||||
for root in list(roots):
|
||||
vdf = root / "steamapps" / "libraryfolders.vdf"
|
||||
if not vdf.is_file():
|
||||
continue
|
||||
try:
|
||||
text = vdf.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
for match in re.finditer(r'"path"\s+"([^"]+)"', text):
|
||||
roots.append(Path(match.group(1).replace("\\\\", "\\")))
|
||||
|
||||
seen: set[str] = set()
|
||||
unique: list[Path] = []
|
||||
for r in roots:
|
||||
k = str(r).lower()
|
||||
if k not in seen:
|
||||
seen.add(k)
|
||||
unique.append(r)
|
||||
return unique
|
||||
|
||||
|
||||
def find_dota() -> Path | None:
|
||||
"""Locate the 'dota 2 beta' install directory."""
|
||||
for root in steam_roots():
|
||||
candidate = root / "steamapps" / "common" / "dota 2 beta"
|
||||
if (candidate / "game" / "dota").is_dir():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def gsi_dir(dota: Path) -> Path:
|
||||
return dota / "game" / "dota" / "cfg" / "gamestate_integration"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = sys.argv[1:]
|
||||
|
||||
if "--path" in args:
|
||||
dota = Path(args[args.index("--path") + 1])
|
||||
if not (dota / "game" / "dota").is_dir():
|
||||
sys.exit(f"not a Dota 2 install directory: {dota}")
|
||||
else:
|
||||
dota = find_dota()
|
||||
if dota is None:
|
||||
sys.exit(
|
||||
"could not find Dota 2 automatically.\n"
|
||||
"Pass it explicitly, e.g.:\n"
|
||||
' python gsi_setup.py --path "D:\\Steam\\steamapps\\common\\dota 2 beta"'
|
||||
)
|
||||
|
||||
target = gsi_dir(dota) / CFG_NAME
|
||||
print(f"dota 2 : {dota}")
|
||||
print(f"gsi cfg : {target}")
|
||||
|
||||
if "--check" in args:
|
||||
print(f"installed: {target.is_file()}")
|
||||
return
|
||||
|
||||
if "--remove" in args:
|
||||
if target.is_file():
|
||||
target.unlink()
|
||||
print("removed.")
|
||||
else:
|
||||
print("nothing to remove.")
|
||||
return
|
||||
|
||||
port = load_config().get("gsi", {}).get("port", 3223)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(CFG_TEMPLATE.format(port=port), encoding="utf-8")
|
||||
|
||||
print(f"installed, endpoint http://127.0.0.1:{port}/")
|
||||
print()
|
||||
print("Next steps:")
|
||||
print(" 1. Steam library -> Dota 2 -> Properties -> Launch Options:")
|
||||
print(" add -gamestateintegration")
|
||||
print(" 2. Restart Dota 2.")
|
||||
print(" 3. Run python gsi_watch.py")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Watch Dota 2 via Game State Integration and recognize the draft automatically.
|
||||
|
||||
Usage:
|
||||
python gsi_setup.py # once: install the GSI cfg into Dota 2
|
||||
python gsi_watch.py # then leave this running while you play
|
||||
python gsi_watch.py --once # capture+recognize right now, no GSI
|
||||
python gsi_watch.py --port 3223
|
||||
python gsi_watch.py --states HERO_SELECTION,STRATEGY_TIME
|
||||
|
||||
When the game enters hero selection the watcher follows the whole draft,
|
||||
polling the screen and logging each pick as it is revealed (All Pick reveals
|
||||
them in waves of 2/2/1 per team). It also works out which slot is you and
|
||||
which lane role you queued for. The result lands in results/draft_<ts>.json.
|
||||
|
||||
If config.json has no calibrated slots yet the watcher runs in capture-only
|
||||
mode: it still saves frames to samples/raw/<matchid>/ so you can calibrate from them.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import mss
|
||||
|
||||
from capture import grab_frame, raw_dir_for_match, save_frame
|
||||
from common import ROOT, load_config, load_template_library
|
||||
from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc
|
||||
from recognize import recognize_image
|
||||
from roles import detect_roles
|
||||
|
||||
RESULTS_DIR = ROOT / "results"
|
||||
|
||||
# entering any of these means a new match is starting - allow triggering again
|
||||
RESET_STATES = {
|
||||
"DOTA_GAMERULES_STATE_INIT",
|
||||
"DOTA_GAMERULES_STATE_WAIT_FOR_PLAYERS_TO_LOAD",
|
||||
"DOTA_GAMERULES_STATE_POST_GAME",
|
||||
"DOTA_GAMERULES_STATE_DISCONNECT",
|
||||
}
|
||||
|
||||
|
||||
class Watcher:
|
||||
"""Turns GSI state changes into capture+recognize runs."""
|
||||
|
||||
def __init__(self, cfg: dict, calibrated: bool, verbose: bool = False):
|
||||
self.cfg = cfg
|
||||
self.calibrated = calibrated
|
||||
self.verbose = verbose
|
||||
gsi = cfg.get("gsi", {})
|
||||
self.trigger_states = set(gsi.get("trigger_states", ["DOTA_GAMERULES_STATE_STRATEGY_TIME"]))
|
||||
self.interval = gsi.get("capture_interval", 1.0)
|
||||
|
||||
self.library = load_template_library() if calibrated else []
|
||||
self.last_state: str | None = None
|
||||
self.last_match_id: str | None = None
|
||||
self.handled_matches: set[str] = set()
|
||||
self.connected = False
|
||||
self.busy = threading.Lock()
|
||||
self.self_info: dict = {}
|
||||
|
||||
def on_payload(self, payload: dict) -> None:
|
||||
if not self.connected:
|
||||
self.connected = True
|
||||
name = (payload.get("provider") or {}).get("name", "Dota 2")
|
||||
print(f"[gsi] connected to {name}", flush=True)
|
||||
|
||||
p = payload.get("player") or {}
|
||||
h = payload.get("hero") or {}
|
||||
self.self_info = {
|
||||
"name": p.get("name"),
|
||||
"team": p.get("team_name"),
|
||||
"team_slot": p.get("team_slot"),
|
||||
"hero": (h.get("name") or "").replace("npc_dota_hero_", "") or None,
|
||||
}
|
||||
|
||||
state = (payload.get("map") or {}).get("game_state")
|
||||
match_id = str((payload.get("map") or {}).get("matchid") or "no-match")
|
||||
self.last_match_id = match_id
|
||||
if state is None:
|
||||
# main menu / no active match
|
||||
if self.last_state is not None:
|
||||
print("[gsi] left match (back in menu)", flush=True)
|
||||
self.last_state = None
|
||||
return
|
||||
|
||||
if state in RESET_STATES and self.handled_matches:
|
||||
self.handled_matches.clear()
|
||||
|
||||
if state != self.last_state:
|
||||
m = payload.get("map") or {}
|
||||
extras = {k: m[k] for k in ("game_mode", "lobby_type", "customgamename", "name") if k in m}
|
||||
print(f"[gsi] {self.last_state} -> {state} (match {match_id}) {extras or ''}", flush=True)
|
||||
if not getattr(self, "_dumped_map_keys", False) and m:
|
||||
self._dumped_map_keys = True
|
||||
print(f"[gsi] map keys: {sorted(m.keys())}", flush=True)
|
||||
print(f"[gsi] player keys: {sorted(p.keys())}", flush=True)
|
||||
print(f"[gsi] self: {self.self_info} -> top-bar slot {gsi_slot(self.self_info)}", flush=True)
|
||||
self.last_state = state
|
||||
|
||||
if state not in self.trigger_states:
|
||||
return
|
||||
# one tracking session per match, however far into the draft we joined
|
||||
key = f"{match_id}:draft"
|
||||
if key in self.handled_matches:
|
||||
return
|
||||
threading.Thread(target=self.track, args=(match_id, state, key), daemon=True).start()
|
||||
|
||||
def track(self, match_id: str, state: str, key: str) -> dict | None:
|
||||
"""Follow the draft from here to the end, recording every reveal."""
|
||||
if not self.busy.acquire(blocking=False):
|
||||
return None
|
||||
try:
|
||||
if key in self.handled_matches:
|
||||
return None
|
||||
self.handled_matches.add(key)
|
||||
if not self.calibrated:
|
||||
return self._capture_only(match_id, state)
|
||||
|
||||
late = " (joined late)" if state != HERO_SELECTION else ""
|
||||
print(f"[draft] tracking match {match_id} from {state}{late}", flush=True)
|
||||
session = DraftSession(self.cfg, self.library)
|
||||
summary = session.run(match_id, lambda: self.last_state, lambda: self.self_info)
|
||||
if summary["recognized"] == 0:
|
||||
print("[draft] nothing recognized - no result written", flush=True)
|
||||
return None
|
||||
self.report_session(summary)
|
||||
return summary
|
||||
finally:
|
||||
self.busy.release()
|
||||
|
||||
def _capture_only(self, match_id: str, state: str) -> None:
|
||||
"""No calibration yet: just bank a few frames to calibrate from later."""
|
||||
out = raw_dir_for_match(match_id)
|
||||
print(f"[run] capture-only -> {out} (state={state})", flush=True)
|
||||
with mss.MSS() as sct:
|
||||
for attempt in range(1, 4):
|
||||
path = save_frame(grab_frame(sct), out, prefix="draft")
|
||||
print(f"[run] capture-only {attempt}/3: {path}", flush=True)
|
||||
time.sleep(self.interval)
|
||||
print("[run] no calibrated slots - run calibrate.py on one of these frames", flush=True)
|
||||
return None
|
||||
|
||||
def report_session(self, summary: dict) -> None:
|
||||
RESULTS_DIR.mkdir(exist_ok=True)
|
||||
out_path = RESULTS_DIR / f"draft_{time.strftime('%Y%m%d_%H%M%S')}.json"
|
||||
out_path.write_text(json.dumps(summary, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
print("", flush=True)
|
||||
for line in describe(summary):
|
||||
print(line, flush=True)
|
||||
print(f"saved : {out_path}", flush=True)
|
||||
|
||||
def run_once(self) -> dict | None:
|
||||
"""One snapshot of whatever is on screen right now, for manual checks."""
|
||||
frame = grab_frame()
|
||||
out = raw_dir_for_match(self.last_match_id)
|
||||
if not self.calibrated:
|
||||
print(f"[run] capture-only: {save_frame(frame, out, prefix='draft')}", flush=True)
|
||||
return None
|
||||
|
||||
result = recognize_image(frame, self.cfg, self.library)
|
||||
found = detect_roles(frame, self.cfg)
|
||||
print(f"[run] {result['recognized']}/10 slots, {result['elapsed_ms']}ms", flush=True)
|
||||
print(f"radiant: {', '.join(loc(r['hero']) for r in result['radiant'])}", flush=True)
|
||||
print(f"dire : {', '.join(loc(r['hero']) for r in result['dire'])}", flush=True)
|
||||
slot = gsi_slot(self.self_info) or found["self_slot"]
|
||||
if slot:
|
||||
role = found["roles"].get(slot)
|
||||
pos = f"position {role['position']} ({role['label']})" if role else "position unknown"
|
||||
print(f"you : slot {slot} {found['self_team']} - {pos}", flush=True)
|
||||
result["roles"] = found
|
||||
return result
|
||||
|
||||
|
||||
class SingleBindServer(HTTPServer):
|
||||
"""Fail loudly when the port is taken.
|
||||
|
||||
Windows honours SO_REUSEADDR literally, so the stdlib default would let a
|
||||
second watcher bind 3223 silently and steal half the GSI payloads.
|
||||
"""
|
||||
|
||||
allow_reuse_address = False
|
||||
|
||||
|
||||
def make_handler(watcher: Watcher):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
|
||||
def do_POST(self): # noqa: N802 - required by BaseHTTPRequestHandler
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length) if length else b"{}"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
try:
|
||||
watcher.on_payload(json.loads(body.decode("utf-8")))
|
||||
except (ValueError, UnicodeDecodeError) as e:
|
||||
print(f"[gsi] bad payload: {e}", flush=True)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
if watcher.verbose:
|
||||
super().log_message(fmt, *args)
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Keep progress visible when stdout is a pipe or file, not just a console,
|
||||
# and force UTF-8 so the Chinese hero names survive the default Windows
|
||||
# console code page (which mangles them into mojibake).
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
stream.reconfigure(encoding="utf-8", errors="replace", line_buffering=True)
|
||||
|
||||
args = sys.argv[1:]
|
||||
cfg = load_config()
|
||||
calibrated = bool(cfg.get("slots"))
|
||||
verbose = "--verbose" in args
|
||||
|
||||
if not calibrated:
|
||||
print("WARNING: config.json has no calibrated slots - running in capture-only mode.")
|
||||
print(" Play one draft, then: python calibrate.py samples/raw/<matchid>/<frame>.png")
|
||||
print()
|
||||
|
||||
watcher = Watcher(cfg, calibrated, verbose)
|
||||
|
||||
if "--states" in args:
|
||||
names = args[args.index("--states") + 1].split(",")
|
||||
watcher.trigger_states = {
|
||||
n if n.startswith("DOTA_GAMERULES_STATE_") else f"DOTA_GAMERULES_STATE_{n}"
|
||||
for n in (s.strip().upper() for s in names)
|
||||
if n
|
||||
}
|
||||
|
||||
if "--once" in args:
|
||||
watcher.run_once()
|
||||
return
|
||||
|
||||
port = int(args[args.index("--port") + 1]) if "--port" in args else cfg.get("gsi", {}).get("port", 3223)
|
||||
try:
|
||||
server = SingleBindServer(("127.0.0.1", port), make_handler(watcher))
|
||||
except OSError as e:
|
||||
sys.exit(
|
||||
f"cannot bind 127.0.0.1:{port} ({e}).\n"
|
||||
"Another gsi_watch.py is probably still running - stop it first:\n"
|
||||
" Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" |\n"
|
||||
" Where-Object { $_.CommandLine -like '*gsi_watch*' } |\n"
|
||||
" ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"
|
||||
)
|
||||
print(f"listening on http://127.0.0.1:{port}/ (Ctrl+C to stop)")
|
||||
print(f"trigger states: {', '.join(sorted(watcher.trigger_states))}")
|
||||
if calibrated:
|
||||
print(f"template library: {len(watcher.library)} entries")
|
||||
print("waiting for Dota 2 ... (needs -gamestateintegration launch option)")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nstopped.")
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,891 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"key": "antimage",
|
||||
"name": "Anti-Mage",
|
||||
"attr": "agi",
|
||||
"name_loc": "敌法师"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"key": "axe",
|
||||
"name": "Axe",
|
||||
"attr": "str",
|
||||
"name_loc": "斧王"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"key": "bane",
|
||||
"name": "Bane",
|
||||
"attr": "all",
|
||||
"name_loc": "祸乱之源"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"key": "bloodseeker",
|
||||
"name": "Bloodseeker",
|
||||
"attr": "agi",
|
||||
"name_loc": "血魔"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"key": "crystal_maiden",
|
||||
"name": "Crystal Maiden",
|
||||
"attr": "int",
|
||||
"name_loc": "水晶室女"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"key": "drow_ranger",
|
||||
"name": "Drow Ranger",
|
||||
"attr": "agi",
|
||||
"name_loc": "卓尔游侠"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"key": "earthshaker",
|
||||
"name": "Earthshaker",
|
||||
"attr": "str",
|
||||
"name_loc": "撼地者"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"key": "juggernaut",
|
||||
"name": "Juggernaut",
|
||||
"attr": "agi",
|
||||
"name_loc": "主宰"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"key": "mirana",
|
||||
"name": "Mirana",
|
||||
"attr": "agi",
|
||||
"name_loc": "米拉娜"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"key": "morphling",
|
||||
"name": "Morphling",
|
||||
"attr": "agi",
|
||||
"name_loc": "变体精灵"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"key": "nevermore",
|
||||
"name": "Shadow Fiend",
|
||||
"attr": "agi",
|
||||
"name_loc": "影魔"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"key": "phantom_lancer",
|
||||
"name": "Phantom Lancer",
|
||||
"attr": "agi",
|
||||
"name_loc": "幻影长矛手"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"key": "puck",
|
||||
"name": "Puck",
|
||||
"attr": "int",
|
||||
"name_loc": "帕克"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"key": "pudge",
|
||||
"name": "Pudge",
|
||||
"attr": "str",
|
||||
"name_loc": "帕吉"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"key": "razor",
|
||||
"name": "Razor",
|
||||
"attr": "agi",
|
||||
"name_loc": "雷泽"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"key": "sand_king",
|
||||
"name": "Sand King",
|
||||
"attr": "all",
|
||||
"name_loc": "沙王"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"key": "storm_spirit",
|
||||
"name": "Storm Spirit",
|
||||
"attr": "int",
|
||||
"name_loc": "风暴之灵"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"key": "sven",
|
||||
"name": "Sven",
|
||||
"attr": "str",
|
||||
"name_loc": "斯温"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"key": "tiny",
|
||||
"name": "Tiny",
|
||||
"attr": "str",
|
||||
"name_loc": "小小"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"key": "vengefulspirit",
|
||||
"name": "Vengeful Spirit",
|
||||
"attr": "agi",
|
||||
"name_loc": "复仇之魂"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"key": "windrunner",
|
||||
"name": "Windranger",
|
||||
"attr": "all",
|
||||
"name_loc": "风行者"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"key": "zuus",
|
||||
"name": "Zeus",
|
||||
"attr": "int",
|
||||
"name_loc": "宙斯"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"key": "kunkka",
|
||||
"name": "Kunkka",
|
||||
"attr": "str",
|
||||
"name_loc": "昆卡"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"key": "lina",
|
||||
"name": "Lina",
|
||||
"attr": "int",
|
||||
"name_loc": "莉娜"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"key": "lion",
|
||||
"name": "Lion",
|
||||
"attr": "int",
|
||||
"name_loc": "莱恩"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"key": "shadow_shaman",
|
||||
"name": "Shadow Shaman",
|
||||
"attr": "int",
|
||||
"name_loc": "暗影萨满"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"key": "slardar",
|
||||
"name": "Slardar",
|
||||
"attr": "str",
|
||||
"name_loc": "斯拉达"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"key": "tidehunter",
|
||||
"name": "Tidehunter",
|
||||
"attr": "str",
|
||||
"name_loc": "潮汐猎人"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"key": "witch_doctor",
|
||||
"name": "Witch Doctor",
|
||||
"attr": "int",
|
||||
"name_loc": "巫医"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"key": "lich",
|
||||
"name": "Lich",
|
||||
"attr": "int",
|
||||
"name_loc": "巫妖"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"key": "riki",
|
||||
"name": "Riki",
|
||||
"attr": "agi",
|
||||
"name_loc": "力丸"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"key": "enigma",
|
||||
"name": "Enigma",
|
||||
"attr": "all",
|
||||
"name_loc": "谜团"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"key": "tinker",
|
||||
"name": "Tinker",
|
||||
"attr": "int",
|
||||
"name_loc": "修补匠"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"key": "sniper",
|
||||
"name": "Sniper",
|
||||
"attr": "agi",
|
||||
"name_loc": "狙击手"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"key": "necrolyte",
|
||||
"name": "Necrophos",
|
||||
"attr": "int",
|
||||
"name_loc": "瘟疫法师"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"key": "warlock",
|
||||
"name": "Warlock",
|
||||
"attr": "int",
|
||||
"name_loc": "术士"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"key": "beastmaster",
|
||||
"name": "Beastmaster",
|
||||
"attr": "all",
|
||||
"name_loc": "兽王"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"key": "queenofpain",
|
||||
"name": "Queen of Pain",
|
||||
"attr": "int",
|
||||
"name_loc": "痛苦女王"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"key": "venomancer",
|
||||
"name": "Venomancer",
|
||||
"attr": "all",
|
||||
"name_loc": "剧毒术士"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"key": "faceless_void",
|
||||
"name": "Faceless Void",
|
||||
"attr": "agi",
|
||||
"name_loc": "虚空假面"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"key": "skeleton_king",
|
||||
"name": "Wraith King",
|
||||
"attr": "str",
|
||||
"name_loc": "冥魂大帝"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"key": "death_prophet",
|
||||
"name": "Death Prophet",
|
||||
"attr": "all",
|
||||
"name_loc": "死亡先知"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"key": "phantom_assassin",
|
||||
"name": "Phantom Assassin",
|
||||
"attr": "agi",
|
||||
"name_loc": "幻影刺客"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"key": "pugna",
|
||||
"name": "Pugna",
|
||||
"attr": "int",
|
||||
"name_loc": "帕格纳"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"key": "templar_assassin",
|
||||
"name": "Templar Assassin",
|
||||
"attr": "agi",
|
||||
"name_loc": "圣堂刺客"
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"key": "viper",
|
||||
"name": "Viper",
|
||||
"attr": "agi",
|
||||
"name_loc": "冥界亚龙"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"key": "luna",
|
||||
"name": "Luna",
|
||||
"attr": "agi",
|
||||
"name_loc": "露娜"
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"key": "dragon_knight",
|
||||
"name": "Dragon Knight",
|
||||
"attr": "str",
|
||||
"name_loc": "龙骑士"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"key": "dazzle",
|
||||
"name": "Dazzle",
|
||||
"attr": "all",
|
||||
"name_loc": "戴泽"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"key": "rattletrap",
|
||||
"name": "Clockwerk",
|
||||
"attr": "str",
|
||||
"name_loc": "发条技师"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"key": "leshrac",
|
||||
"name": "Leshrac",
|
||||
"attr": "int",
|
||||
"name_loc": "拉席克"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"key": "furion",
|
||||
"name": "Nature's Prophet",
|
||||
"attr": "all",
|
||||
"name_loc": "自然先知"
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"key": "life_stealer",
|
||||
"name": "Lifestealer",
|
||||
"attr": "str",
|
||||
"name_loc": "噬魂鬼"
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"key": "dark_seer",
|
||||
"name": "Dark Seer",
|
||||
"attr": "int",
|
||||
"name_loc": "黑暗贤者"
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"key": "clinkz",
|
||||
"name": "Clinkz",
|
||||
"attr": "agi",
|
||||
"name_loc": "克林克兹"
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"key": "omniknight",
|
||||
"name": "Omniknight",
|
||||
"attr": "str",
|
||||
"name_loc": "全能骑士"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"key": "enchantress",
|
||||
"name": "Enchantress",
|
||||
"attr": "int",
|
||||
"name_loc": "魅惑魔女"
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"key": "huskar",
|
||||
"name": "Huskar",
|
||||
"attr": "str",
|
||||
"name_loc": "哈斯卡"
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"key": "night_stalker",
|
||||
"name": "Night Stalker",
|
||||
"attr": "str",
|
||||
"name_loc": "暗夜魔王"
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"key": "broodmother",
|
||||
"name": "Broodmother",
|
||||
"attr": "agi",
|
||||
"name_loc": "育母蜘蛛"
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"key": "bounty_hunter",
|
||||
"name": "Bounty Hunter",
|
||||
"attr": "agi",
|
||||
"name_loc": "赏金猎人"
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"key": "weaver",
|
||||
"name": "Weaver",
|
||||
"attr": "agi",
|
||||
"name_loc": "编织者"
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"key": "jakiro",
|
||||
"name": "Jakiro",
|
||||
"attr": "int",
|
||||
"name_loc": "杰奇洛"
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"key": "batrider",
|
||||
"name": "Batrider",
|
||||
"attr": "all",
|
||||
"name_loc": "蝙蝠骑士"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"key": "chen",
|
||||
"name": "Chen",
|
||||
"attr": "int",
|
||||
"name_loc": "陈"
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"key": "spectre",
|
||||
"name": "Spectre",
|
||||
"attr": "agi",
|
||||
"name_loc": "幽鬼"
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"key": "ancient_apparition",
|
||||
"name": "Ancient Apparition",
|
||||
"attr": "int",
|
||||
"name_loc": "远古冰魄"
|
||||
},
|
||||
{
|
||||
"id": 69,
|
||||
"key": "doom_bringer",
|
||||
"name": "Doom",
|
||||
"attr": "str",
|
||||
"name_loc": "末日使者"
|
||||
},
|
||||
{
|
||||
"id": 70,
|
||||
"key": "ursa",
|
||||
"name": "Ursa",
|
||||
"attr": "agi",
|
||||
"name_loc": "熊战士"
|
||||
},
|
||||
{
|
||||
"id": 71,
|
||||
"key": "spirit_breaker",
|
||||
"name": "Spirit Breaker",
|
||||
"attr": "str",
|
||||
"name_loc": "裂魂人"
|
||||
},
|
||||
{
|
||||
"id": 72,
|
||||
"key": "gyrocopter",
|
||||
"name": "Gyrocopter",
|
||||
"attr": "agi",
|
||||
"name_loc": "矮人直升机"
|
||||
},
|
||||
{
|
||||
"id": 73,
|
||||
"key": "alchemist",
|
||||
"name": "Alchemist",
|
||||
"attr": "str",
|
||||
"name_loc": "炼金术士"
|
||||
},
|
||||
{
|
||||
"id": 74,
|
||||
"key": "invoker",
|
||||
"name": "Invoker",
|
||||
"attr": "int",
|
||||
"name_loc": "祈求者"
|
||||
},
|
||||
{
|
||||
"id": 75,
|
||||
"key": "silencer",
|
||||
"name": "Silencer",
|
||||
"attr": "int",
|
||||
"name_loc": "沉默术士"
|
||||
},
|
||||
{
|
||||
"id": 76,
|
||||
"key": "obsidian_destroyer",
|
||||
"name": "Outworld Destroyer",
|
||||
"attr": "int",
|
||||
"name_loc": "殁境神蚀者"
|
||||
},
|
||||
{
|
||||
"id": 77,
|
||||
"key": "lycan",
|
||||
"name": "Lycan",
|
||||
"attr": "str",
|
||||
"name_loc": "狼人"
|
||||
},
|
||||
{
|
||||
"id": 78,
|
||||
"key": "brewmaster",
|
||||
"name": "Brewmaster",
|
||||
"attr": "all",
|
||||
"name_loc": "酒仙"
|
||||
},
|
||||
{
|
||||
"id": 79,
|
||||
"key": "shadow_demon",
|
||||
"name": "Shadow Demon",
|
||||
"attr": "int",
|
||||
"name_loc": "暗影恶魔"
|
||||
},
|
||||
{
|
||||
"id": 80,
|
||||
"key": "lone_druid",
|
||||
"name": "Lone Druid",
|
||||
"attr": "agi",
|
||||
"name_loc": "独行德鲁伊"
|
||||
},
|
||||
{
|
||||
"id": 81,
|
||||
"key": "chaos_knight",
|
||||
"name": "Chaos Knight",
|
||||
"attr": "str",
|
||||
"name_loc": "混沌骑士"
|
||||
},
|
||||
{
|
||||
"id": 82,
|
||||
"key": "meepo",
|
||||
"name": "Meepo",
|
||||
"attr": "agi",
|
||||
"name_loc": "米波"
|
||||
},
|
||||
{
|
||||
"id": 83,
|
||||
"key": "treant",
|
||||
"name": "Treant Protector",
|
||||
"attr": "str",
|
||||
"name_loc": "树精卫士"
|
||||
},
|
||||
{
|
||||
"id": 84,
|
||||
"key": "ogre_magi",
|
||||
"name": "Ogre Magi",
|
||||
"attr": "str",
|
||||
"name_loc": "食人魔魔法师"
|
||||
},
|
||||
{
|
||||
"id": 85,
|
||||
"key": "undying",
|
||||
"name": "Undying",
|
||||
"attr": "str",
|
||||
"name_loc": "不朽尸王"
|
||||
},
|
||||
{
|
||||
"id": 86,
|
||||
"key": "rubick",
|
||||
"name": "Rubick",
|
||||
"attr": "int",
|
||||
"name_loc": "拉比克"
|
||||
},
|
||||
{
|
||||
"id": 87,
|
||||
"key": "disruptor",
|
||||
"name": "Disruptor",
|
||||
"attr": "int",
|
||||
"name_loc": "干扰者"
|
||||
},
|
||||
{
|
||||
"id": 88,
|
||||
"key": "nyx_assassin",
|
||||
"name": "Nyx Assassin",
|
||||
"attr": "all",
|
||||
"name_loc": "司夜刺客"
|
||||
},
|
||||
{
|
||||
"id": 89,
|
||||
"key": "naga_siren",
|
||||
"name": "Naga Siren",
|
||||
"attr": "agi",
|
||||
"name_loc": "娜迦海妖"
|
||||
},
|
||||
{
|
||||
"id": 90,
|
||||
"key": "keeper_of_the_light",
|
||||
"name": "Keeper of the Light",
|
||||
"attr": "int",
|
||||
"name_loc": "光之守卫"
|
||||
},
|
||||
{
|
||||
"id": 91,
|
||||
"key": "wisp",
|
||||
"name": "Io",
|
||||
"attr": "all",
|
||||
"name_loc": "艾欧"
|
||||
},
|
||||
{
|
||||
"id": 92,
|
||||
"key": "visage",
|
||||
"name": "Visage",
|
||||
"attr": "all",
|
||||
"name_loc": "维萨吉"
|
||||
},
|
||||
{
|
||||
"id": 93,
|
||||
"key": "slark",
|
||||
"name": "Slark",
|
||||
"attr": "agi",
|
||||
"name_loc": "斯拉克"
|
||||
},
|
||||
{
|
||||
"id": 94,
|
||||
"key": "medusa",
|
||||
"name": "Medusa",
|
||||
"attr": "agi",
|
||||
"name_loc": "美杜莎"
|
||||
},
|
||||
{
|
||||
"id": 95,
|
||||
"key": "troll_warlord",
|
||||
"name": "Troll Warlord",
|
||||
"attr": "agi",
|
||||
"name_loc": "巨魔战将"
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"key": "centaur",
|
||||
"name": "Centaur Warrunner",
|
||||
"attr": "str",
|
||||
"name_loc": "半人马战行者"
|
||||
},
|
||||
{
|
||||
"id": 97,
|
||||
"key": "magnataur",
|
||||
"name": "Magnus",
|
||||
"attr": "all",
|
||||
"name_loc": "马格纳斯"
|
||||
},
|
||||
{
|
||||
"id": 98,
|
||||
"key": "shredder",
|
||||
"name": "Timbersaw",
|
||||
"attr": "str",
|
||||
"name_loc": "伐木机"
|
||||
},
|
||||
{
|
||||
"id": 99,
|
||||
"key": "bristleback",
|
||||
"name": "Bristleback",
|
||||
"attr": "str",
|
||||
"name_loc": "钢背兽"
|
||||
},
|
||||
{
|
||||
"id": 100,
|
||||
"key": "tusk",
|
||||
"name": "Tusk",
|
||||
"attr": "str",
|
||||
"name_loc": "巨牙海民"
|
||||
},
|
||||
{
|
||||
"id": 101,
|
||||
"key": "skywrath_mage",
|
||||
"name": "Skywrath Mage",
|
||||
"attr": "int",
|
||||
"name_loc": "天怒法师"
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"key": "abaddon",
|
||||
"name": "Abaddon",
|
||||
"attr": "all",
|
||||
"name_loc": "亚巴顿"
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"key": "elder_titan",
|
||||
"name": "Elder Titan",
|
||||
"attr": "str",
|
||||
"name_loc": "上古巨神"
|
||||
},
|
||||
{
|
||||
"id": 104,
|
||||
"key": "legion_commander",
|
||||
"name": "Legion Commander",
|
||||
"attr": "str",
|
||||
"name_loc": "军团指挥官"
|
||||
},
|
||||
{
|
||||
"id": 105,
|
||||
"key": "techies",
|
||||
"name": "Techies",
|
||||
"attr": "all",
|
||||
"name_loc": "工程师"
|
||||
},
|
||||
{
|
||||
"id": 106,
|
||||
"key": "ember_spirit",
|
||||
"name": "Ember Spirit",
|
||||
"attr": "agi",
|
||||
"name_loc": "灰烬之灵"
|
||||
},
|
||||
{
|
||||
"id": 107,
|
||||
"key": "earth_spirit",
|
||||
"name": "Earth Spirit",
|
||||
"attr": "str",
|
||||
"name_loc": "大地之灵"
|
||||
},
|
||||
{
|
||||
"id": 108,
|
||||
"key": "abyssal_underlord",
|
||||
"name": "Underlord",
|
||||
"attr": "str",
|
||||
"name_loc": "孽主"
|
||||
},
|
||||
{
|
||||
"id": 109,
|
||||
"key": "terrorblade",
|
||||
"name": "Terrorblade",
|
||||
"attr": "agi",
|
||||
"name_loc": "恐怖利刃"
|
||||
},
|
||||
{
|
||||
"id": 110,
|
||||
"key": "phoenix",
|
||||
"name": "Phoenix",
|
||||
"attr": "str",
|
||||
"name_loc": "凤凰"
|
||||
},
|
||||
{
|
||||
"id": 111,
|
||||
"key": "oracle",
|
||||
"name": "Oracle",
|
||||
"attr": "int",
|
||||
"name_loc": "神谕者"
|
||||
},
|
||||
{
|
||||
"id": 112,
|
||||
"key": "winter_wyvern",
|
||||
"name": "Winter Wyvern",
|
||||
"attr": "int",
|
||||
"name_loc": "寒冬飞龙"
|
||||
},
|
||||
{
|
||||
"id": 113,
|
||||
"key": "arc_warden",
|
||||
"name": "Arc Warden",
|
||||
"attr": "all",
|
||||
"name_loc": "天穹守望者"
|
||||
},
|
||||
{
|
||||
"id": 114,
|
||||
"key": "monkey_king",
|
||||
"name": "Monkey King",
|
||||
"attr": "agi",
|
||||
"name_loc": "齐天大圣"
|
||||
},
|
||||
{
|
||||
"id": 119,
|
||||
"key": "dark_willow",
|
||||
"name": "Dark Willow",
|
||||
"attr": "int",
|
||||
"name_loc": "邪影芳灵"
|
||||
},
|
||||
{
|
||||
"id": 120,
|
||||
"key": "pangolier",
|
||||
"name": "Pangolier",
|
||||
"attr": "all",
|
||||
"name_loc": "石鳞剑士"
|
||||
},
|
||||
{
|
||||
"id": 121,
|
||||
"key": "grimstroke",
|
||||
"name": "Grimstroke",
|
||||
"attr": "int",
|
||||
"name_loc": "天涯墨客"
|
||||
},
|
||||
{
|
||||
"id": 123,
|
||||
"key": "hoodwink",
|
||||
"name": "Hoodwink",
|
||||
"attr": "agi",
|
||||
"name_loc": "森海飞霞"
|
||||
},
|
||||
{
|
||||
"id": 126,
|
||||
"key": "void_spirit",
|
||||
"name": "Void Spirit",
|
||||
"attr": "all",
|
||||
"name_loc": "虚无之灵"
|
||||
},
|
||||
{
|
||||
"id": 128,
|
||||
"key": "snapfire",
|
||||
"name": "Snapfire",
|
||||
"attr": "all",
|
||||
"name_loc": "电炎绝手"
|
||||
},
|
||||
{
|
||||
"id": 129,
|
||||
"key": "mars",
|
||||
"name": "Mars",
|
||||
"attr": "str",
|
||||
"name_loc": "玛尔斯"
|
||||
},
|
||||
{
|
||||
"id": 131,
|
||||
"key": "ringmaster",
|
||||
"name": "Ringmaster",
|
||||
"attr": "int",
|
||||
"name_loc": "百戏大王"
|
||||
},
|
||||
{
|
||||
"id": 135,
|
||||
"key": "dawnbreaker",
|
||||
"name": "Dawnbreaker",
|
||||
"attr": "str",
|
||||
"name_loc": "破晓辰星"
|
||||
},
|
||||
{
|
||||
"id": 136,
|
||||
"key": "marci",
|
||||
"name": "Marci",
|
||||
"attr": "all",
|
||||
"name_loc": "玛西"
|
||||
},
|
||||
{
|
||||
"id": 137,
|
||||
"key": "primal_beast",
|
||||
"name": "Primal Beast",
|
||||
"attr": "str",
|
||||
"name_loc": "獸"
|
||||
},
|
||||
{
|
||||
"id": 138,
|
||||
"key": "muerta",
|
||||
"name": "Muerta",
|
||||
"attr": "int",
|
||||
"name_loc": "琼英碧灵"
|
||||
},
|
||||
{
|
||||
"id": 145,
|
||||
"key": "kez",
|
||||
"name": "Kez",
|
||||
"attr": "agi",
|
||||
"name_loc": "凯"
|
||||
},
|
||||
{
|
||||
"id": 155,
|
||||
"key": "largo",
|
||||
"name": "Largo",
|
||||
"attr": "str",
|
||||
"name_loc": "朗戈"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Read the game-mode label under the top-center timer (e.g. 全英雄选择)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from common import ROOT
|
||||
|
||||
TEMPLATES = ROOT / "templates" / "modes"
|
||||
|
||||
# key -> Chinese label as drawn under the timer during hero selection
|
||||
MODES = {
|
||||
"all_pick": "全英雄选择",
|
||||
"captains_mode": "队长模式",
|
||||
"random_draft": "随机征召",
|
||||
"single_draft": "单一征召",
|
||||
"ability_draft": "技能征召",
|
||||
}
|
||||
|
||||
|
||||
def mode_roi(img: np.ndarray, cfg: dict) -> np.ndarray:
|
||||
"""Crop the strip under the draft timer where the mode name sits."""
|
||||
m = cfg.get("mode_label", {})
|
||||
ih, iw = img.shape[:2]
|
||||
y0 = int(ih * m.get("y0_rel", 0.045))
|
||||
y1 = int(ih * m.get("y1_rel", 0.072))
|
||||
x0 = int(iw * m.get("x0_rel", 0.40))
|
||||
x1 = int(iw * m.get("x1_rel", 0.60))
|
||||
return img[y0:y1, x0:x1]
|
||||
|
||||
|
||||
def _ink(roi: np.ndarray) -> np.ndarray:
|
||||
"""Binary mask of the bright mode glyphs on the dark header."""
|
||||
if roi.size == 0:
|
||||
return np.zeros((1, 1), np.uint8)
|
||||
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
|
||||
return (gray > 160).astype(np.uint8) * 255
|
||||
|
||||
|
||||
def _tight(mask: np.ndarray, height: int = 28) -> np.ndarray | None:
|
||||
ys, xs = np.where(mask > 0)
|
||||
if len(xs) < 8:
|
||||
return None
|
||||
crop = mask[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
|
||||
h, w = crop.shape
|
||||
nh = height
|
||||
nw = max(8, int(round(w * (nh / h))))
|
||||
return cv2.resize(crop, (nw, nh), interpolation=cv2.INTER_AREA)
|
||||
|
||||
|
||||
def load_mode_templates() -> dict[str, np.ndarray]:
|
||||
out = {}
|
||||
if not TEMPLATES.is_dir():
|
||||
return out
|
||||
for p in TEMPLATES.glob("*.png"):
|
||||
img = cv2.imread(str(p), cv2.IMREAD_GRAYSCALE)
|
||||
if img is not None:
|
||||
out[p.stem] = img
|
||||
return out
|
||||
|
||||
|
||||
def detect_mode(img: np.ndarray, cfg: dict, templates: dict | None = None) -> dict | None:
|
||||
"""Return {key, label, score} or None."""
|
||||
templates = templates if templates is not None else load_mode_templates()
|
||||
if not templates:
|
||||
return None
|
||||
ink = _tight(_ink(mode_roi(img, cfg)))
|
||||
if ink is None:
|
||||
return None
|
||||
best_key, best = None, -1.0
|
||||
for key, tmpl in templates.items():
|
||||
h = min(ink.shape[0], tmpl.shape[0])
|
||||
a = cv2.resize(ink, (max(8, int(ink.shape[1] * h / ink.shape[0])), h))
|
||||
b = cv2.resize(tmpl, (max(8, int(tmpl.shape[1] * h / tmpl.shape[0])), h))
|
||||
big, small = (a, b) if a.shape[1] >= b.shape[1] else (b, a)
|
||||
if big.shape[0] < small.shape[0] or big.shape[1] < small.shape[1]:
|
||||
continue
|
||||
score = float(cv2.matchTemplate(big, small, cv2.TM_CCOEFF_NORMED).max())
|
||||
if score > best:
|
||||
best, best_key = score, key
|
||||
min_score = cfg.get("mode_label", {}).get("min_score", 0.55)
|
||||
if best_key is None or best < min_score:
|
||||
return None
|
||||
return {"key": best_key, "label": MODES.get(best_key, best_key), "score": round(best, 3)}
|
||||
|
||||
|
||||
def build_template(img: np.ndarray, cfg: dict, key: str) -> Path:
|
||||
"""Save a mode template from a live selection frame."""
|
||||
TEMPLATES.mkdir(parents=True, exist_ok=True)
|
||||
ink = _tight(_ink(mode_roi(img, cfg)))
|
||||
if ink is None:
|
||||
raise SystemExit("no mode glyphs found in ROI - check mode_label coords")
|
||||
out = TEMPLATES / f"{key}.png"
|
||||
cv2.imwrite(str(out), ink)
|
||||
return out
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
import sys
|
||||
|
||||
from common import load_config
|
||||
|
||||
cfg = load_config()
|
||||
if len(sys.argv) < 2:
|
||||
raise SystemExit("usage: python modes.py <frame.png> [--build KEY]")
|
||||
img = cv2.imread(sys.argv[1])
|
||||
if img is None:
|
||||
raise SystemExit(f"cannot read {sys.argv[1]}")
|
||||
if "--build" in sys.argv:
|
||||
key = sys.argv[sys.argv.index("--build") + 1]
|
||||
print(build_template(img, cfg, key))
|
||||
return
|
||||
found = detect_mode(img, cfg)
|
||||
print(found or "no mode matched")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 644 KiB |
|
After Width: | Height: | Size: 386 KiB |
@@ -0,0 +1,174 @@
|
||||
"""Recognize the 10 drafted heroes from a strategy-time screenshot.
|
||||
|
||||
Usage:
|
||||
python recognize.py samples/shot2.png
|
||||
python recognize.py samples/shot2.png --truth tinker,earthshaker,...,drow_ranger
|
||||
|
||||
Outputs per-slot JSON with top-1 hero, score and margin; slots failing the
|
||||
confidence gate are reported as null. With --truth, prints accuracy and saves
|
||||
misrecognized crops to failures/ for later labeling.
|
||||
|
||||
recognize_image() is the reusable entry point used by gsi_watch.py.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from common import (
|
||||
ROOT,
|
||||
crop_slot,
|
||||
has_ranked_overlay,
|
||||
load_config,
|
||||
load_template_library,
|
||||
match_score,
|
||||
ranked_match_mask,
|
||||
)
|
||||
|
||||
FAILURES_DIR = ROOT / "failures"
|
||||
PREVIEW_DIR = ROOT / "preview"
|
||||
|
||||
|
||||
def write_sheet(img: np.ndarray, results: list[dict], cfg: dict) -> str:
|
||||
"""Contact sheet of every slot with its predicted hero, for eyeballing."""
|
||||
scale = 2
|
||||
tiles = []
|
||||
for r in results:
|
||||
crop = crop_slot(img, cfg["slots"][r["slot"] - 1], cfg)
|
||||
if crop is None:
|
||||
continue
|
||||
tile = cv2.resize(crop, None, fx=scale, fy=scale, interpolation=cv2.INTER_LANCZOS4)
|
||||
label = np.zeros((54, tile.shape[1], 3), np.uint8)
|
||||
name = r["hero"] or f"?{r['raw_best']}"
|
||||
colour = (120, 255, 120) if r["hero"] else (120, 200, 255)
|
||||
cv2.putText(label, f"{r['slot']} {name[:16]}", (4, 20),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.42, colour, 1, cv2.LINE_AA)
|
||||
cv2.putText(label, f"s{r['score']:.2f} m{r['margin']:.2f}", (4, 42),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.42, (170, 170, 170), 1, cv2.LINE_AA)
|
||||
stack = np.vstack([tile, label])
|
||||
tiles.append(cv2.copyMakeBorder(stack, 2, 2, 2, 2, cv2.BORDER_CONSTANT, value=(60, 60, 60)))
|
||||
|
||||
PREVIEW_DIR.mkdir(exist_ok=True)
|
||||
out = PREVIEW_DIR / "recognize_sheet.png"
|
||||
cv2.imwrite(str(out), np.hstack(tiles))
|
||||
return str(out)
|
||||
|
||||
|
||||
def recognize_slot(crop, library, cfg, mask=None):
|
||||
"""Return (best_hero, best_score, margin, scored list)."""
|
||||
penalty = cfg["match"]["cdn_penalty"]
|
||||
best_per_hero: dict[str, float] = {}
|
||||
for hero, source, tmpl in library:
|
||||
s = match_score(crop, tmpl, mask)
|
||||
if source == "cdn":
|
||||
s -= penalty
|
||||
if s > best_per_hero.get(hero, -2.0):
|
||||
best_per_hero[hero] = s
|
||||
ranked = sorted(best_per_hero.items(), key=lambda kv: kv[1], reverse=True)
|
||||
if not ranked:
|
||||
return None, 0.0, 0.0, []
|
||||
top1 = ranked[0]
|
||||
margin = top1[1] - ranked[1][1] if len(ranked) > 1 else 1.0
|
||||
return top1[0], top1[1], margin, ranked[:3]
|
||||
|
||||
|
||||
def recognize_image(img: np.ndarray, cfg: dict | None = None, library=None) -> dict:
|
||||
"""Recognize all slots in a full-screen frame.
|
||||
|
||||
cfg and library are accepted so a long-running caller can load the
|
||||
template library once instead of on every frame.
|
||||
|
||||
Ranked matchmaking draws a title bar + medal over every portrait; when
|
||||
that overlay is detected we match only the unoccluded face region.
|
||||
"""
|
||||
cfg = cfg if cfg is not None else load_config()
|
||||
library = library if library is not None else load_template_library()
|
||||
|
||||
t0 = time.perf_counter()
|
||||
min_score = cfg["match"]["min_score"]
|
||||
min_margin = cfg["match"]["min_margin"]
|
||||
ranked_ui = has_ranked_overlay(img, cfg)
|
||||
mask = ranked_match_mask(cfg["canonical_size"], cfg) if ranked_ui else None
|
||||
|
||||
results = []
|
||||
for slot in cfg["slots"]:
|
||||
crop = crop_slot(img, slot, cfg)
|
||||
if crop is None:
|
||||
results.append({"slot": slot["index"], "hero": None, "score": 0, "margin": 0, "top3": []})
|
||||
continue
|
||||
hero, score, margin, top3 = recognize_slot(crop, library, cfg, mask)
|
||||
passed = score >= min_score and margin >= min_margin
|
||||
results.append(
|
||||
{
|
||||
"slot": slot["index"],
|
||||
"hero": hero if passed else None,
|
||||
"raw_best": hero,
|
||||
"score": round(score, 3),
|
||||
"margin": round(margin, 3),
|
||||
"top3": [[h, round(s, 3)] for h, s in top3],
|
||||
}
|
||||
)
|
||||
elapsed = time.perf_counter() - t0
|
||||
|
||||
return {
|
||||
"radiant": results[:5],
|
||||
"dire": results[5:],
|
||||
"slots": results,
|
||||
"recognized": sum(1 for r in results if r["hero"]),
|
||||
"ranked_overlay": ranked_ui,
|
||||
"library_size": len(library),
|
||||
"elapsed_ms": round(elapsed * 1000),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
image_path = sys.argv[1]
|
||||
truth = None
|
||||
if "--truth" in sys.argv:
|
||||
truth = sys.argv[sys.argv.index("--truth") + 1].split(",")
|
||||
if len(truth) != 10:
|
||||
sys.exit(f"--truth expects 10 comma-separated keys, got {len(truth)}")
|
||||
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
sys.exit(f"cannot read image: {image_path}")
|
||||
cfg = load_config()
|
||||
if not cfg["slots"]:
|
||||
sys.exit("config.json has no slots - run calibrate.py first")
|
||||
library = load_template_library()
|
||||
if not library:
|
||||
sys.exit("template library is empty - run fetch_cdn_templates.py and/or build_library.py")
|
||||
|
||||
out = recognize_image(img, cfg, library)
|
||||
results = out.pop("slots")
|
||||
print(json.dumps(out, ensure_ascii=False, indent=1))
|
||||
|
||||
if "--sheet" in sys.argv:
|
||||
print(f"sheet: {write_sheet(img, results, cfg)}")
|
||||
|
||||
if truth:
|
||||
FAILURES_DIR.mkdir(exist_ok=True)
|
||||
stamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
correct = 0
|
||||
for r, expected in zip(results, truth):
|
||||
expected = expected.strip()
|
||||
got = r["hero"]
|
||||
ok = got == expected
|
||||
correct += ok
|
||||
mark = "OK " if ok else "ERR"
|
||||
print(f"{mark} slot {r['slot']}: expected={expected} got={got} (raw={r.get('raw_best')} score={r['score']} margin={r['margin']})")
|
||||
if not ok:
|
||||
slot_cfg = cfg["slots"][r["slot"] - 1]
|
||||
crop = crop_slot(img, slot_cfg, cfg)
|
||||
if crop is not None:
|
||||
cv2.imwrite(str(FAILURES_DIR / f"{stamp}_s{r['slot']}_{expected}.png"), crop)
|
||||
print(f"accuracy: {correct}/10, misses saved to failures/ (filename contains the correct key)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,3 @@
|
||||
opencv-python>=4.10
|
||||
numpy>=2.0
|
||||
requests>=2.32
|
||||
@@ -0,0 +1,435 @@
|
||||
{
|
||||
"radiant": [
|
||||
{
|
||||
"slot": 1,
|
||||
"hero": "drow_ranger",
|
||||
"raw_best": "drow_ranger",
|
||||
"score": 0.598,
|
||||
"margin": 0.296,
|
||||
"top3": [
|
||||
[
|
||||
"drow_ranger",
|
||||
0.598
|
||||
],
|
||||
[
|
||||
"treant",
|
||||
0.302
|
||||
],
|
||||
[
|
||||
"kez",
|
||||
0.237
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"hero": "warlock",
|
||||
"raw_best": "warlock",
|
||||
"score": 0.676,
|
||||
"margin": 0.367,
|
||||
"top3": [
|
||||
[
|
||||
"warlock",
|
||||
0.676
|
||||
],
|
||||
[
|
||||
"treant",
|
||||
0.309
|
||||
],
|
||||
[
|
||||
"monkey_king",
|
||||
0.221
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"hero": "viper",
|
||||
"raw_best": "viper",
|
||||
"score": 0.651,
|
||||
"margin": 0.256,
|
||||
"top3": [
|
||||
[
|
||||
"viper",
|
||||
0.651
|
||||
],
|
||||
[
|
||||
"faceless_void",
|
||||
0.396
|
||||
],
|
||||
[
|
||||
"lycan",
|
||||
0.317
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"hero": null,
|
||||
"raw_best": "earthshaker",
|
||||
"score": 0.434,
|
||||
"margin": 0.128,
|
||||
"top3": [
|
||||
[
|
||||
"earthshaker",
|
||||
0.434
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.306
|
||||
],
|
||||
[
|
||||
"bounty_hunter",
|
||||
0.297
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"hero": null,
|
||||
"raw_best": "vengefulspirit",
|
||||
"score": 0.327,
|
||||
"margin": 0.052,
|
||||
"top3": [
|
||||
[
|
||||
"vengefulspirit",
|
||||
0.327
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.275
|
||||
],
|
||||
[
|
||||
"bounty_hunter",
|
||||
0.266
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"dire": [
|
||||
{
|
||||
"slot": 6,
|
||||
"hero": "lich",
|
||||
"raw_best": "lich",
|
||||
"score": 0.603,
|
||||
"margin": 0.393,
|
||||
"top3": [
|
||||
[
|
||||
"lich",
|
||||
0.603
|
||||
],
|
||||
[
|
||||
"phantom_lancer",
|
||||
0.21
|
||||
],
|
||||
[
|
||||
"monkey_king",
|
||||
0.197
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"hero": "zuus",
|
||||
"raw_best": "zuus",
|
||||
"score": 0.461,
|
||||
"margin": 0.231,
|
||||
"top3": [
|
||||
[
|
||||
"zuus",
|
||||
0.461
|
||||
],
|
||||
[
|
||||
"abyssal_underlord",
|
||||
0.23
|
||||
],
|
||||
[
|
||||
"witch_doctor",
|
||||
0.206
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"hero": "skeleton_king",
|
||||
"raw_best": "skeleton_king",
|
||||
"score": 0.691,
|
||||
"margin": 0.247,
|
||||
"top3": [
|
||||
[
|
||||
"skeleton_king",
|
||||
0.691
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.445
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.396
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"hero": "juggernaut",
|
||||
"raw_best": "juggernaut",
|
||||
"score": 0.67,
|
||||
"margin": 0.201,
|
||||
"top3": [
|
||||
[
|
||||
"juggernaut",
|
||||
0.67
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.469
|
||||
],
|
||||
[
|
||||
"ogre_magi",
|
||||
0.379
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"hero": "lion",
|
||||
"raw_best": "lion",
|
||||
"score": 0.509,
|
||||
"margin": 0.194,
|
||||
"top3": [
|
||||
[
|
||||
"lion",
|
||||
0.509
|
||||
],
|
||||
[
|
||||
"ogre_magi",
|
||||
0.315
|
||||
],
|
||||
[
|
||||
"juggernaut",
|
||||
0.275
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"recognized": 8,
|
||||
"library_size": 127,
|
||||
"elapsed_ms": 358,
|
||||
"match_id": "0",
|
||||
"trigger_state": "DOTA_GAMERULES_STATE_STRATEGY_TIME",
|
||||
"captured_at": "2026-07-25 14:37:42",
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_143742.png",
|
||||
"slots": [
|
||||
{
|
||||
"slot": 1,
|
||||
"hero": "drow_ranger",
|
||||
"raw_best": "drow_ranger",
|
||||
"score": 0.598,
|
||||
"margin": 0.296,
|
||||
"top3": [
|
||||
[
|
||||
"drow_ranger",
|
||||
0.598
|
||||
],
|
||||
[
|
||||
"treant",
|
||||
0.302
|
||||
],
|
||||
[
|
||||
"kez",
|
||||
0.237
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"hero": "warlock",
|
||||
"raw_best": "warlock",
|
||||
"score": 0.676,
|
||||
"margin": 0.367,
|
||||
"top3": [
|
||||
[
|
||||
"warlock",
|
||||
0.676
|
||||
],
|
||||
[
|
||||
"treant",
|
||||
0.309
|
||||
],
|
||||
[
|
||||
"monkey_king",
|
||||
0.221
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"hero": "viper",
|
||||
"raw_best": "viper",
|
||||
"score": 0.651,
|
||||
"margin": 0.256,
|
||||
"top3": [
|
||||
[
|
||||
"viper",
|
||||
0.651
|
||||
],
|
||||
[
|
||||
"faceless_void",
|
||||
0.396
|
||||
],
|
||||
[
|
||||
"lycan",
|
||||
0.317
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"hero": null,
|
||||
"raw_best": "earthshaker",
|
||||
"score": 0.434,
|
||||
"margin": 0.128,
|
||||
"top3": [
|
||||
[
|
||||
"earthshaker",
|
||||
0.434
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.306
|
||||
],
|
||||
[
|
||||
"bounty_hunter",
|
||||
0.297
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"hero": null,
|
||||
"raw_best": "vengefulspirit",
|
||||
"score": 0.327,
|
||||
"margin": 0.052,
|
||||
"top3": [
|
||||
[
|
||||
"vengefulspirit",
|
||||
0.327
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.275
|
||||
],
|
||||
[
|
||||
"bounty_hunter",
|
||||
0.266
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"hero": "lich",
|
||||
"raw_best": "lich",
|
||||
"score": 0.603,
|
||||
"margin": 0.393,
|
||||
"top3": [
|
||||
[
|
||||
"lich",
|
||||
0.603
|
||||
],
|
||||
[
|
||||
"phantom_lancer",
|
||||
0.21
|
||||
],
|
||||
[
|
||||
"monkey_king",
|
||||
0.197
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"hero": "zuus",
|
||||
"raw_best": "zuus",
|
||||
"score": 0.461,
|
||||
"margin": 0.231,
|
||||
"top3": [
|
||||
[
|
||||
"zuus",
|
||||
0.461
|
||||
],
|
||||
[
|
||||
"abyssal_underlord",
|
||||
0.23
|
||||
],
|
||||
[
|
||||
"witch_doctor",
|
||||
0.206
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"hero": "skeleton_king",
|
||||
"raw_best": "skeleton_king",
|
||||
"score": 0.691,
|
||||
"margin": 0.247,
|
||||
"top3": [
|
||||
[
|
||||
"skeleton_king",
|
||||
0.691
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.445
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.396
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"hero": "juggernaut",
|
||||
"raw_best": "juggernaut",
|
||||
"score": 0.67,
|
||||
"margin": 0.201,
|
||||
"top3": [
|
||||
[
|
||||
"juggernaut",
|
||||
0.67
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.469
|
||||
],
|
||||
[
|
||||
"ogre_magi",
|
||||
0.379
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"hero": "lion",
|
||||
"raw_best": "lion",
|
||||
"score": 0.509,
|
||||
"margin": 0.194,
|
||||
"top3": [
|
||||
[
|
||||
"lion",
|
||||
0.509
|
||||
],
|
||||
[
|
||||
"ogre_magi",
|
||||
0.315
|
||||
],
|
||||
[
|
||||
"juggernaut",
|
||||
0.275
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
{
|
||||
"radiant": [
|
||||
{
|
||||
"slot": 1,
|
||||
"hero": "crystal_maiden",
|
||||
"raw_best": "crystal_maiden",
|
||||
"score": 0.454,
|
||||
"margin": 0.222,
|
||||
"top3": [
|
||||
[
|
||||
"crystal_maiden",
|
||||
0.454
|
||||
],
|
||||
[
|
||||
"naga_siren",
|
||||
0.232
|
||||
],
|
||||
[
|
||||
"visage",
|
||||
0.206
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"hero": "witch_doctor",
|
||||
"raw_best": "witch_doctor",
|
||||
"score": 0.637,
|
||||
"margin": 0.277,
|
||||
"top3": [
|
||||
[
|
||||
"witch_doctor",
|
||||
0.637
|
||||
],
|
||||
[
|
||||
"abyssal_underlord",
|
||||
0.361
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.298
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"hero": "skeleton_king",
|
||||
"raw_best": "skeleton_king",
|
||||
"score": 0.688,
|
||||
"margin": 0.255,
|
||||
"top3": [
|
||||
[
|
||||
"skeleton_king",
|
||||
0.688
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.433
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.401
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"hero": "sniper",
|
||||
"raw_best": "sniper",
|
||||
"score": 0.652,
|
||||
"margin": 0.523,
|
||||
"top3": [
|
||||
[
|
||||
"sniper",
|
||||
0.652
|
||||
],
|
||||
[
|
||||
"primal_beast",
|
||||
0.129
|
||||
],
|
||||
[
|
||||
"naga_siren",
|
||||
0.127
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"hero": "sven",
|
||||
"raw_best": "sven",
|
||||
"score": 0.693,
|
||||
"margin": 0.356,
|
||||
"top3": [
|
||||
[
|
||||
"sven",
|
||||
0.693
|
||||
],
|
||||
[
|
||||
"furion",
|
||||
0.337
|
||||
],
|
||||
[
|
||||
"ringmaster",
|
||||
0.324
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"dire": [
|
||||
{
|
||||
"slot": 6,
|
||||
"hero": null,
|
||||
"raw_best": "earthshaker",
|
||||
"score": 0.434,
|
||||
"margin": 0.128,
|
||||
"top3": [
|
||||
[
|
||||
"earthshaker",
|
||||
0.434
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.306
|
||||
],
|
||||
[
|
||||
"bounty_hunter",
|
||||
0.297
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"hero": null,
|
||||
"raw_best": "necrolyte",
|
||||
"score": 0.412,
|
||||
"margin": 0.294,
|
||||
"top3": [
|
||||
[
|
||||
"necrolyte",
|
||||
0.412
|
||||
],
|
||||
[
|
||||
"omniknight",
|
||||
0.118
|
||||
],
|
||||
[
|
||||
"alchemist",
|
||||
0.111
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"hero": "warlock",
|
||||
"raw_best": "warlock",
|
||||
"score": 0.711,
|
||||
"margin": 0.413,
|
||||
"top3": [
|
||||
[
|
||||
"warlock",
|
||||
0.711
|
||||
],
|
||||
[
|
||||
"treant",
|
||||
0.297
|
||||
],
|
||||
[
|
||||
"bristleback",
|
||||
0.241
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"hero": "drow_ranger",
|
||||
"raw_best": "drow_ranger",
|
||||
"score": 0.657,
|
||||
"margin": 0.286,
|
||||
"top3": [
|
||||
[
|
||||
"drow_ranger",
|
||||
0.657
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.372
|
||||
],
|
||||
[
|
||||
"treant",
|
||||
0.273
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"hero": "lich",
|
||||
"raw_best": "lich",
|
||||
"score": 0.519,
|
||||
"margin": 0.336,
|
||||
"top3": [
|
||||
[
|
||||
"lich",
|
||||
0.519
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.183
|
||||
],
|
||||
[
|
||||
"nyx_assassin",
|
||||
0.16
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"recognized": 8,
|
||||
"library_size": 127,
|
||||
"elapsed_ms": 362,
|
||||
"match_id": "0",
|
||||
"trigger_state": "DOTA_GAMERULES_STATE_STRATEGY_TIME",
|
||||
"captured_at": "2026-07-25 14:41:31",
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_144131.png",
|
||||
"slots": [
|
||||
{
|
||||
"slot": 1,
|
||||
"hero": "crystal_maiden",
|
||||
"raw_best": "crystal_maiden",
|
||||
"score": 0.454,
|
||||
"margin": 0.222,
|
||||
"top3": [
|
||||
[
|
||||
"crystal_maiden",
|
||||
0.454
|
||||
],
|
||||
[
|
||||
"naga_siren",
|
||||
0.232
|
||||
],
|
||||
[
|
||||
"visage",
|
||||
0.206
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"hero": "witch_doctor",
|
||||
"raw_best": "witch_doctor",
|
||||
"score": 0.637,
|
||||
"margin": 0.277,
|
||||
"top3": [
|
||||
[
|
||||
"witch_doctor",
|
||||
0.637
|
||||
],
|
||||
[
|
||||
"abyssal_underlord",
|
||||
0.361
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.298
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"hero": "skeleton_king",
|
||||
"raw_best": "skeleton_king",
|
||||
"score": 0.688,
|
||||
"margin": 0.255,
|
||||
"top3": [
|
||||
[
|
||||
"skeleton_king",
|
||||
0.688
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.433
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.401
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"hero": "sniper",
|
||||
"raw_best": "sniper",
|
||||
"score": 0.652,
|
||||
"margin": 0.523,
|
||||
"top3": [
|
||||
[
|
||||
"sniper",
|
||||
0.652
|
||||
],
|
||||
[
|
||||
"primal_beast",
|
||||
0.129
|
||||
],
|
||||
[
|
||||
"naga_siren",
|
||||
0.127
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"hero": "sven",
|
||||
"raw_best": "sven",
|
||||
"score": 0.693,
|
||||
"margin": 0.356,
|
||||
"top3": [
|
||||
[
|
||||
"sven",
|
||||
0.693
|
||||
],
|
||||
[
|
||||
"furion",
|
||||
0.337
|
||||
],
|
||||
[
|
||||
"ringmaster",
|
||||
0.324
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"hero": null,
|
||||
"raw_best": "earthshaker",
|
||||
"score": 0.434,
|
||||
"margin": 0.128,
|
||||
"top3": [
|
||||
[
|
||||
"earthshaker",
|
||||
0.434
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.306
|
||||
],
|
||||
[
|
||||
"bounty_hunter",
|
||||
0.297
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"hero": null,
|
||||
"raw_best": "necrolyte",
|
||||
"score": 0.412,
|
||||
"margin": 0.294,
|
||||
"top3": [
|
||||
[
|
||||
"necrolyte",
|
||||
0.412
|
||||
],
|
||||
[
|
||||
"omniknight",
|
||||
0.118
|
||||
],
|
||||
[
|
||||
"alchemist",
|
||||
0.111
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"hero": "warlock",
|
||||
"raw_best": "warlock",
|
||||
"score": 0.711,
|
||||
"margin": 0.413,
|
||||
"top3": [
|
||||
[
|
||||
"warlock",
|
||||
0.711
|
||||
],
|
||||
[
|
||||
"treant",
|
||||
0.297
|
||||
],
|
||||
[
|
||||
"bristleback",
|
||||
0.241
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"hero": "drow_ranger",
|
||||
"raw_best": "drow_ranger",
|
||||
"score": 0.657,
|
||||
"margin": 0.286,
|
||||
"top3": [
|
||||
[
|
||||
"drow_ranger",
|
||||
0.657
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.372
|
||||
],
|
||||
[
|
||||
"treant",
|
||||
0.273
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"hero": "lich",
|
||||
"raw_best": "lich",
|
||||
"score": 0.519,
|
||||
"margin": 0.336,
|
||||
"top3": [
|
||||
[
|
||||
"lich",
|
||||
0.519
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.183
|
||||
],
|
||||
[
|
||||
"nyx_assassin",
|
||||
0.16
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
{
|
||||
"radiant": [
|
||||
{
|
||||
"slot": 1,
|
||||
"hero": null,
|
||||
"raw_best": "elder_titan",
|
||||
"score": 0.446,
|
||||
"margin": 0.184,
|
||||
"top3": [
|
||||
[
|
||||
"elder_titan",
|
||||
0.446
|
||||
],
|
||||
[
|
||||
"warlock",
|
||||
0.262
|
||||
],
|
||||
[
|
||||
"lion",
|
||||
0.222
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"hero": "sniper",
|
||||
"raw_best": "sniper",
|
||||
"score": 1.0,
|
||||
"margin": 0.871,
|
||||
"top3": [
|
||||
[
|
||||
"sniper",
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"primal_beast",
|
||||
0.129
|
||||
],
|
||||
[
|
||||
"naga_siren",
|
||||
0.127
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"hero": "lion",
|
||||
"raw_best": "lion",
|
||||
"score": 1.0,
|
||||
"margin": 0.633,
|
||||
"top3": [
|
||||
[
|
||||
"lion",
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"juggernaut",
|
||||
0.367
|
||||
],
|
||||
[
|
||||
"skeleton_king",
|
||||
0.34
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"hero": "juggernaut",
|
||||
"raw_best": "juggernaut",
|
||||
"score": 1.0,
|
||||
"margin": 0.516,
|
||||
"top3": [
|
||||
[
|
||||
"juggernaut",
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.484
|
||||
],
|
||||
[
|
||||
"ogre_magi",
|
||||
0.395
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"hero": null,
|
||||
"raw_best": "vengefulspirit",
|
||||
"score": 0.327,
|
||||
"margin": 0.052,
|
||||
"top3": [
|
||||
[
|
||||
"vengefulspirit",
|
||||
0.327
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.275
|
||||
],
|
||||
[
|
||||
"death_prophet",
|
||||
0.266
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"dire": [
|
||||
{
|
||||
"slot": 6,
|
||||
"hero": "death_prophet",
|
||||
"raw_best": "death_prophet",
|
||||
"score": 0.951,
|
||||
"margin": 0.551,
|
||||
"top3": [
|
||||
[
|
||||
"death_prophet",
|
||||
0.951
|
||||
],
|
||||
[
|
||||
"ogre_magi",
|
||||
0.4
|
||||
],
|
||||
[
|
||||
"undying",
|
||||
0.372
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"hero": "tidehunter",
|
||||
"raw_best": "tidehunter",
|
||||
"score": 0.528,
|
||||
"margin": 0.271,
|
||||
"top3": [
|
||||
[
|
||||
"tidehunter",
|
||||
0.528
|
||||
],
|
||||
[
|
||||
"juggernaut",
|
||||
0.257
|
||||
],
|
||||
[
|
||||
"lich",
|
||||
0.208
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"hero": "lich",
|
||||
"raw_best": "lich",
|
||||
"score": 0.96,
|
||||
"margin": 0.703,
|
||||
"top3": [
|
||||
[
|
||||
"lich",
|
||||
0.96
|
||||
],
|
||||
[
|
||||
"warlock",
|
||||
0.257
|
||||
],
|
||||
[
|
||||
"skeleton_king",
|
||||
0.201
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"hero": "sven",
|
||||
"raw_best": "sven",
|
||||
"score": 0.912,
|
||||
"margin": 0.576,
|
||||
"top3": [
|
||||
[
|
||||
"sven",
|
||||
0.912
|
||||
],
|
||||
[
|
||||
"lion",
|
||||
0.336
|
||||
],
|
||||
[
|
||||
"furion",
|
||||
0.324
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"hero": "witch_doctor",
|
||||
"raw_best": "witch_doctor",
|
||||
"score": 0.706,
|
||||
"margin": 0.338,
|
||||
"top3": [
|
||||
[
|
||||
"witch_doctor",
|
||||
0.706
|
||||
],
|
||||
[
|
||||
"abyssal_underlord",
|
||||
0.368
|
||||
],
|
||||
[
|
||||
"zuus",
|
||||
0.298
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"recognized": 8,
|
||||
"library_size": 155,
|
||||
"elapsed_ms": 447,
|
||||
"match_id": "0",
|
||||
"trigger_state": "DOTA_GAMERULES_STATE_STRATEGY_TIME",
|
||||
"captured_at": "2026-07-25 14:51:36",
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_145136.png",
|
||||
"slots": [
|
||||
{
|
||||
"slot": 1,
|
||||
"hero": null,
|
||||
"raw_best": "elder_titan",
|
||||
"score": 0.446,
|
||||
"margin": 0.184,
|
||||
"top3": [
|
||||
[
|
||||
"elder_titan",
|
||||
0.446
|
||||
],
|
||||
[
|
||||
"warlock",
|
||||
0.262
|
||||
],
|
||||
[
|
||||
"lion",
|
||||
0.222
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"hero": "sniper",
|
||||
"raw_best": "sniper",
|
||||
"score": 1.0,
|
||||
"margin": 0.871,
|
||||
"top3": [
|
||||
[
|
||||
"sniper",
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"primal_beast",
|
||||
0.129
|
||||
],
|
||||
[
|
||||
"naga_siren",
|
||||
0.127
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"hero": "lion",
|
||||
"raw_best": "lion",
|
||||
"score": 1.0,
|
||||
"margin": 0.633,
|
||||
"top3": [
|
||||
[
|
||||
"lion",
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"juggernaut",
|
||||
0.367
|
||||
],
|
||||
[
|
||||
"skeleton_king",
|
||||
0.34
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"hero": "juggernaut",
|
||||
"raw_best": "juggernaut",
|
||||
"score": 1.0,
|
||||
"margin": 0.516,
|
||||
"top3": [
|
||||
[
|
||||
"juggernaut",
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.484
|
||||
],
|
||||
[
|
||||
"ogre_magi",
|
||||
0.395
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"hero": null,
|
||||
"raw_best": "vengefulspirit",
|
||||
"score": 0.327,
|
||||
"margin": 0.052,
|
||||
"top3": [
|
||||
[
|
||||
"vengefulspirit",
|
||||
0.327
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.275
|
||||
],
|
||||
[
|
||||
"death_prophet",
|
||||
0.266
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"hero": "death_prophet",
|
||||
"raw_best": "death_prophet",
|
||||
"score": 0.951,
|
||||
"margin": 0.551,
|
||||
"top3": [
|
||||
[
|
||||
"death_prophet",
|
||||
0.951
|
||||
],
|
||||
[
|
||||
"ogre_magi",
|
||||
0.4
|
||||
],
|
||||
[
|
||||
"undying",
|
||||
0.372
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"hero": "tidehunter",
|
||||
"raw_best": "tidehunter",
|
||||
"score": 0.528,
|
||||
"margin": 0.271,
|
||||
"top3": [
|
||||
[
|
||||
"tidehunter",
|
||||
0.528
|
||||
],
|
||||
[
|
||||
"juggernaut",
|
||||
0.257
|
||||
],
|
||||
[
|
||||
"lich",
|
||||
0.208
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"hero": "lich",
|
||||
"raw_best": "lich",
|
||||
"score": 0.96,
|
||||
"margin": 0.703,
|
||||
"top3": [
|
||||
[
|
||||
"lich",
|
||||
0.96
|
||||
],
|
||||
[
|
||||
"warlock",
|
||||
0.257
|
||||
],
|
||||
[
|
||||
"skeleton_king",
|
||||
0.201
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"hero": "sven",
|
||||
"raw_best": "sven",
|
||||
"score": 0.912,
|
||||
"margin": 0.576,
|
||||
"top3": [
|
||||
[
|
||||
"sven",
|
||||
0.912
|
||||
],
|
||||
[
|
||||
"lion",
|
||||
0.336
|
||||
],
|
||||
[
|
||||
"furion",
|
||||
0.324
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"hero": "witch_doctor",
|
||||
"raw_best": "witch_doctor",
|
||||
"score": 0.706,
|
||||
"margin": 0.338,
|
||||
"top3": [
|
||||
[
|
||||
"witch_doctor",
|
||||
0.706
|
||||
],
|
||||
[
|
||||
"abyssal_underlord",
|
||||
0.368
|
||||
],
|
||||
[
|
||||
"zuus",
|
||||
0.298
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
{
|
||||
"radiant": [
|
||||
{
|
||||
"slot": 1,
|
||||
"hero": "undying",
|
||||
"raw_best": "undying",
|
||||
"score": 0.877,
|
||||
"margin": 0.441,
|
||||
"top3": [
|
||||
[
|
||||
"undying",
|
||||
0.877
|
||||
],
|
||||
[
|
||||
"death_prophet",
|
||||
0.437
|
||||
],
|
||||
[
|
||||
"faceless_void",
|
||||
0.387
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"hero": "death_prophet",
|
||||
"raw_best": "death_prophet",
|
||||
"score": 1.0,
|
||||
"margin": 0.601,
|
||||
"top3": [
|
||||
[
|
||||
"death_prophet",
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"ogre_magi",
|
||||
0.399
|
||||
],
|
||||
[
|
||||
"undying",
|
||||
0.382
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"hero": "dragon_knight",
|
||||
"raw_best": "dragon_knight",
|
||||
"score": 0.917,
|
||||
"margin": 0.629,
|
||||
"top3": [
|
||||
[
|
||||
"dragon_knight",
|
||||
0.917
|
||||
],
|
||||
[
|
||||
"juggernaut",
|
||||
0.289
|
||||
],
|
||||
[
|
||||
"lion",
|
||||
0.209
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"hero": "drow_ranger",
|
||||
"raw_best": "drow_ranger",
|
||||
"score": 0.955,
|
||||
"margin": 0.583,
|
||||
"top3": [
|
||||
[
|
||||
"drow_ranger",
|
||||
0.955
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.372
|
||||
],
|
||||
[
|
||||
"lycan",
|
||||
0.316
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"hero": "witch_doctor",
|
||||
"raw_best": "witch_doctor",
|
||||
"score": 1.0,
|
||||
"margin": 0.641,
|
||||
"top3": [
|
||||
[
|
||||
"witch_doctor",
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"abyssal_underlord",
|
||||
0.359
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.321
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"dire": [
|
||||
{
|
||||
"slot": 6,
|
||||
"hero": "vengefulspirit",
|
||||
"raw_best": "vengefulspirit",
|
||||
"score": 1.0,
|
||||
"margin": 0.728,
|
||||
"top3": [
|
||||
[
|
||||
"vengefulspirit",
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.272
|
||||
],
|
||||
[
|
||||
"death_prophet",
|
||||
0.269
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"hero": "warlock",
|
||||
"raw_best": "warlock",
|
||||
"score": 0.98,
|
||||
"margin": 0.704,
|
||||
"top3": [
|
||||
[
|
||||
"warlock",
|
||||
0.98
|
||||
],
|
||||
[
|
||||
"treant",
|
||||
0.276
|
||||
],
|
||||
[
|
||||
"bristleback",
|
||||
0.263
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"hero": "tidehunter",
|
||||
"raw_best": "tidehunter",
|
||||
"score": 0.968,
|
||||
"margin": 0.71,
|
||||
"top3": [
|
||||
[
|
||||
"tidehunter",
|
||||
0.968
|
||||
],
|
||||
[
|
||||
"juggernaut",
|
||||
0.258
|
||||
],
|
||||
[
|
||||
"lich",
|
||||
0.199
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"hero": "zuus",
|
||||
"raw_best": "zuus",
|
||||
"score": 1.0,
|
||||
"margin": 0.691,
|
||||
"top3": [
|
||||
[
|
||||
"zuus",
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"witch_doctor",
|
||||
0.309
|
||||
],
|
||||
[
|
||||
"dark_willow",
|
||||
0.243
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"hero": "sven",
|
||||
"raw_best": "sven",
|
||||
"score": 0.974,
|
||||
"margin": 0.631,
|
||||
"top3": [
|
||||
[
|
||||
"sven",
|
||||
0.974
|
||||
],
|
||||
[
|
||||
"lion",
|
||||
0.342
|
||||
],
|
||||
[
|
||||
"ringmaster",
|
||||
0.327
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"recognized": 10,
|
||||
"library_size": 167,
|
||||
"elapsed_ms": 497,
|
||||
"match_id": "0",
|
||||
"trigger_state": "DOTA_GAMERULES_STATE_STRATEGY_TIME",
|
||||
"captured_at": "2026-07-25 15:10:21",
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_151021.png",
|
||||
"slots": [
|
||||
{
|
||||
"slot": 1,
|
||||
"hero": "undying",
|
||||
"raw_best": "undying",
|
||||
"score": 0.877,
|
||||
"margin": 0.441,
|
||||
"top3": [
|
||||
[
|
||||
"undying",
|
||||
0.877
|
||||
],
|
||||
[
|
||||
"death_prophet",
|
||||
0.437
|
||||
],
|
||||
[
|
||||
"faceless_void",
|
||||
0.387
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"hero": "death_prophet",
|
||||
"raw_best": "death_prophet",
|
||||
"score": 1.0,
|
||||
"margin": 0.601,
|
||||
"top3": [
|
||||
[
|
||||
"death_prophet",
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"ogre_magi",
|
||||
0.399
|
||||
],
|
||||
[
|
||||
"undying",
|
||||
0.382
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"hero": "dragon_knight",
|
||||
"raw_best": "dragon_knight",
|
||||
"score": 0.917,
|
||||
"margin": 0.629,
|
||||
"top3": [
|
||||
[
|
||||
"dragon_knight",
|
||||
0.917
|
||||
],
|
||||
[
|
||||
"juggernaut",
|
||||
0.289
|
||||
],
|
||||
[
|
||||
"lion",
|
||||
0.209
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"hero": "drow_ranger",
|
||||
"raw_best": "drow_ranger",
|
||||
"score": 0.955,
|
||||
"margin": 0.583,
|
||||
"top3": [
|
||||
[
|
||||
"drow_ranger",
|
||||
0.955
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.372
|
||||
],
|
||||
[
|
||||
"lycan",
|
||||
0.316
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"hero": "witch_doctor",
|
||||
"raw_best": "witch_doctor",
|
||||
"score": 1.0,
|
||||
"margin": 0.641,
|
||||
"top3": [
|
||||
[
|
||||
"witch_doctor",
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"abyssal_underlord",
|
||||
0.359
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.321
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"hero": "vengefulspirit",
|
||||
"raw_best": "vengefulspirit",
|
||||
"score": 1.0,
|
||||
"margin": 0.728,
|
||||
"top3": [
|
||||
[
|
||||
"vengefulspirit",
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.272
|
||||
],
|
||||
[
|
||||
"death_prophet",
|
||||
0.269
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"hero": "warlock",
|
||||
"raw_best": "warlock",
|
||||
"score": 0.98,
|
||||
"margin": 0.704,
|
||||
"top3": [
|
||||
[
|
||||
"warlock",
|
||||
0.98
|
||||
],
|
||||
[
|
||||
"treant",
|
||||
0.276
|
||||
],
|
||||
[
|
||||
"bristleback",
|
||||
0.263
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"hero": "tidehunter",
|
||||
"raw_best": "tidehunter",
|
||||
"score": 0.968,
|
||||
"margin": 0.71,
|
||||
"top3": [
|
||||
[
|
||||
"tidehunter",
|
||||
0.968
|
||||
],
|
||||
[
|
||||
"juggernaut",
|
||||
0.258
|
||||
],
|
||||
[
|
||||
"lich",
|
||||
0.199
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"hero": "zuus",
|
||||
"raw_best": "zuus",
|
||||
"score": 1.0,
|
||||
"margin": 0.691,
|
||||
"top3": [
|
||||
[
|
||||
"zuus",
|
||||
1.0
|
||||
],
|
||||
[
|
||||
"witch_doctor",
|
||||
0.309
|
||||
],
|
||||
[
|
||||
"dark_willow",
|
||||
0.243
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"hero": "sven",
|
||||
"raw_best": "sven",
|
||||
"score": 0.974,
|
||||
"margin": 0.631,
|
||||
"top3": [
|
||||
[
|
||||
"sven",
|
||||
0.974
|
||||
],
|
||||
[
|
||||
"lion",
|
||||
0.342
|
||||
],
|
||||
[
|
||||
"ringmaster",
|
||||
0.327
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
{
|
||||
"radiant": [
|
||||
{
|
||||
"slot": 1,
|
||||
"hero": null,
|
||||
"raw_best": "bounty_hunter",
|
||||
"score": 0.283,
|
||||
"margin": 0.01,
|
||||
"top3": [
|
||||
[
|
||||
"bounty_hunter",
|
||||
0.283
|
||||
],
|
||||
[
|
||||
"grimstroke",
|
||||
0.273
|
||||
],
|
||||
[
|
||||
"witch_doctor",
|
||||
0.257
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"hero": "skeleton_king",
|
||||
"raw_best": "skeleton_king",
|
||||
"score": 0.793,
|
||||
"margin": 0.431,
|
||||
"top3": [
|
||||
[
|
||||
"skeleton_king",
|
||||
0.793
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.362
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.328
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"hero": "sniper",
|
||||
"raw_best": "sniper",
|
||||
"score": 0.717,
|
||||
"margin": 0.448,
|
||||
"top3": [
|
||||
[
|
||||
"sniper",
|
||||
0.717
|
||||
],
|
||||
[
|
||||
"undying",
|
||||
0.27
|
||||
],
|
||||
[
|
||||
"spirit_breaker",
|
||||
0.178
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"hero": "invoker",
|
||||
"raw_best": "invoker",
|
||||
"score": 0.839,
|
||||
"margin": 0.385,
|
||||
"top3": [
|
||||
[
|
||||
"invoker",
|
||||
0.839
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.454
|
||||
],
|
||||
[
|
||||
"sand_king",
|
||||
0.445
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"hero": "silencer",
|
||||
"raw_best": "silencer",
|
||||
"score": 0.577,
|
||||
"margin": 0.43,
|
||||
"top3": [
|
||||
[
|
||||
"silencer",
|
||||
0.577
|
||||
],
|
||||
[
|
||||
"troll_warlord",
|
||||
0.147
|
||||
],
|
||||
[
|
||||
"necrolyte",
|
||||
0.137
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"dire": [
|
||||
{
|
||||
"slot": 6,
|
||||
"hero": "skywrath_mage",
|
||||
"raw_best": "skywrath_mage",
|
||||
"score": 0.61,
|
||||
"margin": 0.282,
|
||||
"top3": [
|
||||
[
|
||||
"skywrath_mage",
|
||||
0.61
|
||||
],
|
||||
[
|
||||
"undying",
|
||||
0.328
|
||||
],
|
||||
[
|
||||
"ogre_magi",
|
||||
0.322
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"hero": "phantom_assassin",
|
||||
"raw_best": "phantom_assassin",
|
||||
"score": 0.621,
|
||||
"margin": 0.382,
|
||||
"top3": [
|
||||
[
|
||||
"phantom_assassin",
|
||||
0.621
|
||||
],
|
||||
[
|
||||
"crystal_maiden",
|
||||
0.24
|
||||
],
|
||||
[
|
||||
"drow_ranger",
|
||||
0.235
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"hero": "queenofpain",
|
||||
"raw_best": "queenofpain",
|
||||
"score": 0.686,
|
||||
"margin": 0.395,
|
||||
"top3": [
|
||||
[
|
||||
"queenofpain",
|
||||
0.686
|
||||
],
|
||||
[
|
||||
"lycan",
|
||||
0.291
|
||||
],
|
||||
[
|
||||
"drow_ranger",
|
||||
0.283
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"hero": "centaur",
|
||||
"raw_best": "centaur",
|
||||
"score": 0.71,
|
||||
"margin": 0.428,
|
||||
"top3": [
|
||||
[
|
||||
"centaur",
|
||||
0.71
|
||||
],
|
||||
[
|
||||
"sven",
|
||||
0.282
|
||||
],
|
||||
[
|
||||
"ringmaster",
|
||||
0.281
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"hero": "rubick",
|
||||
"raw_best": "rubick",
|
||||
"score": 0.623,
|
||||
"margin": 0.415,
|
||||
"top3": [
|
||||
[
|
||||
"rubick",
|
||||
0.623
|
||||
],
|
||||
[
|
||||
"death_prophet",
|
||||
0.208
|
||||
],
|
||||
[
|
||||
"elder_titan",
|
||||
0.153
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"recognized": 9,
|
||||
"library_size": 177,
|
||||
"elapsed_ms": 492,
|
||||
"match_id": "8912562036",
|
||||
"trigger_state": "DOTA_GAMERULES_STATE_STRATEGY_TIME",
|
||||
"captured_at": "2026-07-25 15:18:28",
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_151828.png",
|
||||
"slots": [
|
||||
{
|
||||
"slot": 1,
|
||||
"hero": null,
|
||||
"raw_best": "bounty_hunter",
|
||||
"score": 0.283,
|
||||
"margin": 0.01,
|
||||
"top3": [
|
||||
[
|
||||
"bounty_hunter",
|
||||
0.283
|
||||
],
|
||||
[
|
||||
"grimstroke",
|
||||
0.273
|
||||
],
|
||||
[
|
||||
"witch_doctor",
|
||||
0.257
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"hero": "skeleton_king",
|
||||
"raw_best": "skeleton_king",
|
||||
"score": 0.793,
|
||||
"margin": 0.431,
|
||||
"top3": [
|
||||
[
|
||||
"skeleton_king",
|
||||
0.793
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.362
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.328
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"hero": "sniper",
|
||||
"raw_best": "sniper",
|
||||
"score": 0.717,
|
||||
"margin": 0.448,
|
||||
"top3": [
|
||||
[
|
||||
"sniper",
|
||||
0.717
|
||||
],
|
||||
[
|
||||
"undying",
|
||||
0.27
|
||||
],
|
||||
[
|
||||
"spirit_breaker",
|
||||
0.178
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"hero": "invoker",
|
||||
"raw_best": "invoker",
|
||||
"score": 0.839,
|
||||
"margin": 0.385,
|
||||
"top3": [
|
||||
[
|
||||
"invoker",
|
||||
0.839
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.454
|
||||
],
|
||||
[
|
||||
"sand_king",
|
||||
0.445
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"hero": "silencer",
|
||||
"raw_best": "silencer",
|
||||
"score": 0.577,
|
||||
"margin": 0.43,
|
||||
"top3": [
|
||||
[
|
||||
"silencer",
|
||||
0.577
|
||||
],
|
||||
[
|
||||
"troll_warlord",
|
||||
0.147
|
||||
],
|
||||
[
|
||||
"necrolyte",
|
||||
0.137
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"hero": "skywrath_mage",
|
||||
"raw_best": "skywrath_mage",
|
||||
"score": 0.61,
|
||||
"margin": 0.282,
|
||||
"top3": [
|
||||
[
|
||||
"skywrath_mage",
|
||||
0.61
|
||||
],
|
||||
[
|
||||
"undying",
|
||||
0.328
|
||||
],
|
||||
[
|
||||
"ogre_magi",
|
||||
0.322
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"hero": "phantom_assassin",
|
||||
"raw_best": "phantom_assassin",
|
||||
"score": 0.621,
|
||||
"margin": 0.382,
|
||||
"top3": [
|
||||
[
|
||||
"phantom_assassin",
|
||||
0.621
|
||||
],
|
||||
[
|
||||
"crystal_maiden",
|
||||
0.24
|
||||
],
|
||||
[
|
||||
"drow_ranger",
|
||||
0.235
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"hero": "queenofpain",
|
||||
"raw_best": "queenofpain",
|
||||
"score": 0.686,
|
||||
"margin": 0.395,
|
||||
"top3": [
|
||||
[
|
||||
"queenofpain",
|
||||
0.686
|
||||
],
|
||||
[
|
||||
"lycan",
|
||||
0.291
|
||||
],
|
||||
[
|
||||
"drow_ranger",
|
||||
0.283
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"hero": "centaur",
|
||||
"raw_best": "centaur",
|
||||
"score": 0.71,
|
||||
"margin": 0.428,
|
||||
"top3": [
|
||||
[
|
||||
"centaur",
|
||||
0.71
|
||||
],
|
||||
[
|
||||
"sven",
|
||||
0.282
|
||||
],
|
||||
[
|
||||
"ringmaster",
|
||||
0.281
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"hero": "rubick",
|
||||
"raw_best": "rubick",
|
||||
"score": 0.623,
|
||||
"margin": 0.415,
|
||||
"top3": [
|
||||
[
|
||||
"rubick",
|
||||
0.623
|
||||
],
|
||||
[
|
||||
"death_prophet",
|
||||
0.208
|
||||
],
|
||||
[
|
||||
"elder_titan",
|
||||
0.153
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
{
|
||||
"radiant": [
|
||||
{
|
||||
"slot": 1,
|
||||
"hero": "clinkz",
|
||||
"raw_best": "clinkz",
|
||||
"score": 0.873,
|
||||
"margin": 0.478,
|
||||
"top3": [
|
||||
[
|
||||
"clinkz",
|
||||
0.873
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.395
|
||||
],
|
||||
[
|
||||
"centaur",
|
||||
0.392
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"hero": "witch_doctor",
|
||||
"raw_best": "witch_doctor",
|
||||
"score": 0.962,
|
||||
"margin": 0.672,
|
||||
"top3": [
|
||||
[
|
||||
"witch_doctor",
|
||||
0.962
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.29
|
||||
],
|
||||
[
|
||||
"tiny",
|
||||
0.26
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"hero": "abyssal_underlord",
|
||||
"raw_best": "abyssal_underlord",
|
||||
"score": 0.878,
|
||||
"margin": 0.418,
|
||||
"top3": [
|
||||
[
|
||||
"abyssal_underlord",
|
||||
0.878
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.46
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.432
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"hero": "pudge",
|
||||
"raw_best": "pudge",
|
||||
"score": 0.884,
|
||||
"margin": 0.359,
|
||||
"top3": [
|
||||
[
|
||||
"pudge",
|
||||
0.884
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.526
|
||||
],
|
||||
[
|
||||
"centaur",
|
||||
0.443
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"hero": "drow_ranger",
|
||||
"raw_best": "drow_ranger",
|
||||
"score": 0.902,
|
||||
"margin": 0.491,
|
||||
"top3": [
|
||||
[
|
||||
"drow_ranger",
|
||||
0.902
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.411
|
||||
],
|
||||
[
|
||||
"arc_warden",
|
||||
0.317
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"dire": [
|
||||
{
|
||||
"slot": 6,
|
||||
"hero": "life_stealer",
|
||||
"raw_best": "life_stealer",
|
||||
"score": 0.894,
|
||||
"margin": 0.525,
|
||||
"top3": [
|
||||
[
|
||||
"life_stealer",
|
||||
0.894
|
||||
],
|
||||
[
|
||||
"dawnbreaker",
|
||||
0.369
|
||||
],
|
||||
[
|
||||
"silencer",
|
||||
0.313
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"hero": "weaver",
|
||||
"raw_best": "weaver",
|
||||
"score": 0.877,
|
||||
"margin": 0.379,
|
||||
"top3": [
|
||||
[
|
||||
"weaver",
|
||||
0.877
|
||||
],
|
||||
[
|
||||
"sven",
|
||||
0.498
|
||||
],
|
||||
[
|
||||
"spirit_breaker",
|
||||
0.497
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"hero": "skywrath_mage",
|
||||
"raw_best": "skywrath_mage",
|
||||
"score": 0.998,
|
||||
"margin": 0.701,
|
||||
"top3": [
|
||||
[
|
||||
"skywrath_mage",
|
||||
0.998
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.297
|
||||
],
|
||||
[
|
||||
"sand_king",
|
||||
0.259
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"hero": "skeleton_king",
|
||||
"raw_best": "skeleton_king",
|
||||
"score": 0.972,
|
||||
"margin": 0.621,
|
||||
"top3": [
|
||||
[
|
||||
"skeleton_king",
|
||||
0.972
|
||||
],
|
||||
[
|
||||
"phantom_assassin",
|
||||
0.351
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.305
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"hero": "storm_spirit",
|
||||
"raw_best": "storm_spirit",
|
||||
"score": 0.821,
|
||||
"margin": 0.306,
|
||||
"top3": [
|
||||
[
|
||||
"storm_spirit",
|
||||
0.821
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.515
|
||||
],
|
||||
[
|
||||
"skeleton_king",
|
||||
0.46
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"recognized": 10,
|
||||
"ranked_overlay": true,
|
||||
"library_size": 187,
|
||||
"elapsed_ms": 507,
|
||||
"match_id": "8912632259",
|
||||
"trigger_state": "DOTA_GAMERULES_STATE_STRATEGY_TIME",
|
||||
"captured_at": "2026-07-25 16:24:04",
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_162404.png",
|
||||
"slots": [
|
||||
{
|
||||
"slot": 1,
|
||||
"hero": "clinkz",
|
||||
"raw_best": "clinkz",
|
||||
"score": 0.873,
|
||||
"margin": 0.478,
|
||||
"top3": [
|
||||
[
|
||||
"clinkz",
|
||||
0.873
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.395
|
||||
],
|
||||
[
|
||||
"centaur",
|
||||
0.392
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"hero": "witch_doctor",
|
||||
"raw_best": "witch_doctor",
|
||||
"score": 0.962,
|
||||
"margin": 0.672,
|
||||
"top3": [
|
||||
[
|
||||
"witch_doctor",
|
||||
0.962
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.29
|
||||
],
|
||||
[
|
||||
"tiny",
|
||||
0.26
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"hero": "abyssal_underlord",
|
||||
"raw_best": "abyssal_underlord",
|
||||
"score": 0.878,
|
||||
"margin": 0.418,
|
||||
"top3": [
|
||||
[
|
||||
"abyssal_underlord",
|
||||
0.878
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.46
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.432
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"hero": "pudge",
|
||||
"raw_best": "pudge",
|
||||
"score": 0.884,
|
||||
"margin": 0.359,
|
||||
"top3": [
|
||||
[
|
||||
"pudge",
|
||||
0.884
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.526
|
||||
],
|
||||
[
|
||||
"centaur",
|
||||
0.443
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"hero": "drow_ranger",
|
||||
"raw_best": "drow_ranger",
|
||||
"score": 0.902,
|
||||
"margin": 0.491,
|
||||
"top3": [
|
||||
[
|
||||
"drow_ranger",
|
||||
0.902
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.411
|
||||
],
|
||||
[
|
||||
"arc_warden",
|
||||
0.317
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"hero": "life_stealer",
|
||||
"raw_best": "life_stealer",
|
||||
"score": 0.894,
|
||||
"margin": 0.525,
|
||||
"top3": [
|
||||
[
|
||||
"life_stealer",
|
||||
0.894
|
||||
],
|
||||
[
|
||||
"dawnbreaker",
|
||||
0.369
|
||||
],
|
||||
[
|
||||
"silencer",
|
||||
0.313
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"hero": "weaver",
|
||||
"raw_best": "weaver",
|
||||
"score": 0.877,
|
||||
"margin": 0.379,
|
||||
"top3": [
|
||||
[
|
||||
"weaver",
|
||||
0.877
|
||||
],
|
||||
[
|
||||
"sven",
|
||||
0.498
|
||||
],
|
||||
[
|
||||
"spirit_breaker",
|
||||
0.497
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"hero": "skywrath_mage",
|
||||
"raw_best": "skywrath_mage",
|
||||
"score": 0.998,
|
||||
"margin": 0.701,
|
||||
"top3": [
|
||||
[
|
||||
"skywrath_mage",
|
||||
0.998
|
||||
],
|
||||
[
|
||||
"legion_commander",
|
||||
0.297
|
||||
],
|
||||
[
|
||||
"sand_king",
|
||||
0.259
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"hero": "skeleton_king",
|
||||
"raw_best": "skeleton_king",
|
||||
"score": 0.972,
|
||||
"margin": 0.621,
|
||||
"top3": [
|
||||
[
|
||||
"skeleton_king",
|
||||
0.972
|
||||
],
|
||||
[
|
||||
"phantom_assassin",
|
||||
0.351
|
||||
],
|
||||
[
|
||||
"invoker",
|
||||
0.305
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"hero": "storm_spirit",
|
||||
"raw_best": "storm_spirit",
|
||||
"score": 0.821,
|
||||
"margin": 0.306,
|
||||
"top3": [
|
||||
[
|
||||
"storm_spirit",
|
||||
0.821
|
||||
],
|
||||
[
|
||||
"wisp",
|
||||
0.515
|
||||
],
|
||||
[
|
||||
"skeleton_king",
|
||||
0.46
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
{
|
||||
"match_id": "8912704440",
|
||||
"captured_at": "2026-07-25 17:25:30",
|
||||
"duration_s": 111.0,
|
||||
"polls": 70,
|
||||
"self": {
|
||||
"slot": 3,
|
||||
"team": "radiant",
|
||||
"hero": "life_stealer",
|
||||
"role": "safe",
|
||||
"role_label": "优势路",
|
||||
"position": 1,
|
||||
"gsi_name": "refining"
|
||||
},
|
||||
"team_roles": {
|
||||
"1": {
|
||||
"position": 2,
|
||||
"label": "中路",
|
||||
"hero": "queenofpain"
|
||||
},
|
||||
"2": {
|
||||
"position": 5,
|
||||
"label": "纯辅助",
|
||||
"hero": "lina"
|
||||
},
|
||||
"3": {
|
||||
"position": 1,
|
||||
"label": "优势路",
|
||||
"hero": "life_stealer"
|
||||
},
|
||||
"4": {
|
||||
"position": 3,
|
||||
"label": "劣势路",
|
||||
"hero": "lion"
|
||||
},
|
||||
"5": {
|
||||
"position": 4,
|
||||
"label": "辅助",
|
||||
"hero": null
|
||||
}
|
||||
},
|
||||
"final": {
|
||||
"radiant": [
|
||||
"queenofpain",
|
||||
"lina",
|
||||
"life_stealer",
|
||||
"lion",
|
||||
null
|
||||
],
|
||||
"dire": [
|
||||
"spectre",
|
||||
"lich",
|
||||
"earth_spirit",
|
||||
"undying",
|
||||
"necrolyte"
|
||||
]
|
||||
},
|
||||
"recognized": 9,
|
||||
"timeline": [
|
||||
{
|
||||
"t": 13.8,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 1,
|
||||
"added": [
|
||||
{
|
||||
"slot": 2,
|
||||
"team": "radiant",
|
||||
"hero": "lina"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"lina"
|
||||
],
|
||||
"dire": [],
|
||||
"count": 1,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_172352.png"
|
||||
},
|
||||
{
|
||||
"t": 30.0,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 1,
|
||||
"added": [
|
||||
{
|
||||
"slot": 4,
|
||||
"team": "radiant",
|
||||
"hero": "lion"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"team": "dire",
|
||||
"hero": "lich"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"team": "dire",
|
||||
"hero": "undying"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"lina",
|
||||
"lion"
|
||||
],
|
||||
"dire": [
|
||||
"lich",
|
||||
"undying"
|
||||
],
|
||||
"count": 4,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_172409.png"
|
||||
},
|
||||
{
|
||||
"t": 52.9,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 3,
|
||||
"team": "radiant",
|
||||
"hero": "life_stealer"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"lina",
|
||||
"life_stealer",
|
||||
"lion"
|
||||
],
|
||||
"dire": [
|
||||
"lich",
|
||||
"undying"
|
||||
],
|
||||
"count": 5,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_172431.png"
|
||||
},
|
||||
{
|
||||
"t": 54.6,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 1,
|
||||
"team": "radiant",
|
||||
"hero": "queenofpain"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"team": "dire",
|
||||
"hero": "spectre"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"team": "dire",
|
||||
"hero": "earth_spirit"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"queenofpain",
|
||||
"lina",
|
||||
"life_stealer",
|
||||
"lion"
|
||||
],
|
||||
"dire": [
|
||||
"spectre",
|
||||
"lich",
|
||||
"earth_spirit",
|
||||
"undying"
|
||||
],
|
||||
"count": 8,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_172433.png"
|
||||
},
|
||||
{
|
||||
"t": 82.2,
|
||||
"state": "STRATEGY_TIME",
|
||||
"round": 3,
|
||||
"added": [
|
||||
{
|
||||
"slot": 10,
|
||||
"team": "dire",
|
||||
"hero": "necrolyte"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"queenofpain",
|
||||
"lina",
|
||||
"life_stealer",
|
||||
"lion"
|
||||
],
|
||||
"dire": [
|
||||
"spectre",
|
||||
"lich",
|
||||
"earth_spirit",
|
||||
"undying",
|
||||
"necrolyte"
|
||||
],
|
||||
"count": 9,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_172501.png"
|
||||
}
|
||||
],
|
||||
"last_frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_172530.png"
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
{
|
||||
"match_id": "8912791143",
|
||||
"captured_at": "2026-07-25 18:38:26",
|
||||
"duration_s": 124.4,
|
||||
"polls": 84,
|
||||
"self": {
|
||||
"slot": 9,
|
||||
"team": "dire",
|
||||
"hero": "drow_ranger",
|
||||
"role": "safe",
|
||||
"role_label": "优势路",
|
||||
"position": 1,
|
||||
"gsi_name": "refining"
|
||||
},
|
||||
"team_roles": {
|
||||
"6": {
|
||||
"position": 5,
|
||||
"label": "纯辅助",
|
||||
"hero": "witch_doctor"
|
||||
},
|
||||
"7": {
|
||||
"position": 3,
|
||||
"label": "劣势路",
|
||||
"hero": null
|
||||
},
|
||||
"8": {
|
||||
"position": 4,
|
||||
"label": "辅助",
|
||||
"hero": "faceless_void"
|
||||
},
|
||||
"9": {
|
||||
"position": 1,
|
||||
"label": "优势路",
|
||||
"hero": "drow_ranger"
|
||||
},
|
||||
"10": {
|
||||
"position": 2,
|
||||
"label": "中路",
|
||||
"hero": "queenofpain"
|
||||
}
|
||||
},
|
||||
"final": {
|
||||
"radiant": [
|
||||
"razor",
|
||||
"nyx_assassin",
|
||||
"hoodwink",
|
||||
"zuus",
|
||||
"antimage"
|
||||
],
|
||||
"dire": [
|
||||
"witch_doctor",
|
||||
null,
|
||||
"faceless_void",
|
||||
"drow_ranger",
|
||||
"queenofpain"
|
||||
]
|
||||
},
|
||||
"recognized": 9,
|
||||
"timeline": [
|
||||
{
|
||||
"t": 11.0,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 1,
|
||||
"added": [
|
||||
{
|
||||
"slot": 9,
|
||||
"team": "dire",
|
||||
"hero": "drow_ranger"
|
||||
}
|
||||
],
|
||||
"radiant": [],
|
||||
"dire": [
|
||||
"drow_ranger"
|
||||
],
|
||||
"count": 1,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_183633.png"
|
||||
},
|
||||
{
|
||||
"t": 25.7,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 1,
|
||||
"added": [
|
||||
{
|
||||
"slot": 8,
|
||||
"team": "dire",
|
||||
"hero": "faceless_void"
|
||||
}
|
||||
],
|
||||
"radiant": [],
|
||||
"dire": [
|
||||
"faceless_void",
|
||||
"drow_ranger"
|
||||
],
|
||||
"count": 2,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_183648.png"
|
||||
},
|
||||
{
|
||||
"t": 39.2,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 1,
|
||||
"team": "radiant",
|
||||
"hero": "razor"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"team": "radiant",
|
||||
"hero": "zuus"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"team": "dire",
|
||||
"hero": "witch_doctor"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"razor",
|
||||
"zuus"
|
||||
],
|
||||
"dire": [
|
||||
"witch_doctor",
|
||||
"faceless_void",
|
||||
"drow_ranger"
|
||||
],
|
||||
"count": 5,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_183701.png"
|
||||
},
|
||||
{
|
||||
"t": 40.7,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 10,
|
||||
"team": "dire",
|
||||
"hero": "queenofpain"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"razor",
|
||||
"zuus"
|
||||
],
|
||||
"dire": [
|
||||
"witch_doctor",
|
||||
"faceless_void",
|
||||
"drow_ranger",
|
||||
"queenofpain"
|
||||
],
|
||||
"count": 6,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_183703.png"
|
||||
},
|
||||
{
|
||||
"t": 62.9,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 2,
|
||||
"team": "radiant",
|
||||
"hero": "nyx_assassin"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"team": "radiant",
|
||||
"hero": "antimage"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"razor",
|
||||
"nyx_assassin",
|
||||
"zuus",
|
||||
"antimage"
|
||||
],
|
||||
"dire": [
|
||||
"witch_doctor",
|
||||
"faceless_void",
|
||||
"drow_ranger",
|
||||
"queenofpain"
|
||||
],
|
||||
"count": 8,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_183725.png"
|
||||
},
|
||||
{
|
||||
"t": 96.8,
|
||||
"state": "STRATEGY_TIME",
|
||||
"round": 3,
|
||||
"added": [
|
||||
{
|
||||
"slot": 3,
|
||||
"team": "radiant",
|
||||
"hero": "hoodwink"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"razor",
|
||||
"nyx_assassin",
|
||||
"hoodwink",
|
||||
"zuus",
|
||||
"antimage"
|
||||
],
|
||||
"dire": [
|
||||
"witch_doctor",
|
||||
"faceless_void",
|
||||
"drow_ranger",
|
||||
"queenofpain"
|
||||
],
|
||||
"count": 9,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_183759.png"
|
||||
}
|
||||
],
|
||||
"bans": [
|
||||
"venomancer",
|
||||
"obsidian_destroyer",
|
||||
"necrolyte",
|
||||
"silencer",
|
||||
"ursa",
|
||||
"nevermore",
|
||||
"lina",
|
||||
"lion",
|
||||
"magnataur",
|
||||
"ember_spirit",
|
||||
"snapfire",
|
||||
"alchemist",
|
||||
"windrunner",
|
||||
"sniper",
|
||||
"pudge",
|
||||
"abyssal_underlord"
|
||||
],
|
||||
"bans_loc": [
|
||||
"剧毒术士",
|
||||
"殁境神蚀者",
|
||||
"瘟疫法师",
|
||||
"沉默术士",
|
||||
"熊战士",
|
||||
"影魔",
|
||||
"莉娜",
|
||||
"莱恩",
|
||||
"马格纳斯",
|
||||
"灰烬之灵",
|
||||
"电炎绝手",
|
||||
"炼金术士",
|
||||
"风行者",
|
||||
"狙击手",
|
||||
"帕吉",
|
||||
"孽主"
|
||||
],
|
||||
"last_frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_183826.png"
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"match_id": "8912846004",
|
||||
"captured_at": "2026-07-25 19:21:11",
|
||||
"duration_s": 65.7,
|
||||
"polls": 45,
|
||||
"self": {
|
||||
"slot": 2,
|
||||
"team": "radiant",
|
||||
"hero": "juggernaut",
|
||||
"role": "safe",
|
||||
"role_label": "优势路",
|
||||
"position": 1,
|
||||
"gsi_name": "refining"
|
||||
},
|
||||
"team_roles": {
|
||||
"2": {
|
||||
"position": 1,
|
||||
"label": "优势路",
|
||||
"hero": "juggernaut"
|
||||
},
|
||||
"3": {
|
||||
"position": 5,
|
||||
"label": "纯辅助",
|
||||
"hero": "shadow_shaman"
|
||||
},
|
||||
"4": {
|
||||
"position": 4,
|
||||
"label": "辅助",
|
||||
"hero": "death_prophet"
|
||||
},
|
||||
"5": {
|
||||
"position": 3,
|
||||
"label": "劣势路",
|
||||
"hero": "tidehunter"
|
||||
}
|
||||
},
|
||||
"final": {
|
||||
"radiant": [
|
||||
"necrolyte",
|
||||
"juggernaut",
|
||||
"shadow_shaman",
|
||||
"death_prophet",
|
||||
"tidehunter"
|
||||
],
|
||||
"dire": [
|
||||
"invoker",
|
||||
"hoodwink",
|
||||
"axe",
|
||||
"earthshaker",
|
||||
"skeleton_king"
|
||||
]
|
||||
},
|
||||
"recognized": 10,
|
||||
"timeline": [
|
||||
{
|
||||
"t": 7.0,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 1,
|
||||
"added": [
|
||||
{
|
||||
"slot": 5,
|
||||
"team": "radiant",
|
||||
"hero": "tidehunter"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"tidehunter"
|
||||
],
|
||||
"dire": [],
|
||||
"count": 1,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_192012.png"
|
||||
},
|
||||
{
|
||||
"t": 10.0,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 1,
|
||||
"added": [
|
||||
{
|
||||
"slot": 3,
|
||||
"team": "radiant",
|
||||
"hero": "shadow_shaman"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"team": "dire",
|
||||
"hero": "hoodwink"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"team": "dire",
|
||||
"hero": "earthshaker"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"shadow_shaman",
|
||||
"tidehunter"
|
||||
],
|
||||
"dire": [
|
||||
"hoodwink",
|
||||
"earthshaker"
|
||||
],
|
||||
"count": 4,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_192015.png"
|
||||
},
|
||||
{
|
||||
"t": 13.0,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 2,
|
||||
"team": "radiant",
|
||||
"hero": "juggernaut"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"spectre",
|
||||
"shadow_shaman",
|
||||
"tidehunter"
|
||||
],
|
||||
"dire": [
|
||||
"hoodwink",
|
||||
"earthshaker"
|
||||
],
|
||||
"count": 5,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_192018.png"
|
||||
},
|
||||
{
|
||||
"t": 32.2,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 4,
|
||||
"team": "radiant",
|
||||
"hero": "death_prophet"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"spectre",
|
||||
"shadow_shaman",
|
||||
"death_prophet",
|
||||
"tidehunter"
|
||||
],
|
||||
"dire": [
|
||||
"hoodwink",
|
||||
"earthshaker"
|
||||
],
|
||||
"count": 6,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_192038.png"
|
||||
},
|
||||
{
|
||||
"t": 36.6,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 8,
|
||||
"team": "dire",
|
||||
"hero": "axe"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"team": "dire",
|
||||
"hero": "skeleton_king"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"spectre",
|
||||
"shadow_shaman",
|
||||
"death_prophet",
|
||||
"tidehunter"
|
||||
],
|
||||
"dire": [
|
||||
"hoodwink",
|
||||
"axe",
|
||||
"earthshaker",
|
||||
"skeleton_king"
|
||||
],
|
||||
"count": 8,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_192042.png"
|
||||
},
|
||||
{
|
||||
"t": 65.6,
|
||||
"state": "STRATEGY_TIME",
|
||||
"round": 3,
|
||||
"added": [
|
||||
{
|
||||
"slot": 1,
|
||||
"team": "radiant",
|
||||
"hero": "necrolyte"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"team": "dire",
|
||||
"hero": "invoker"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"necrolyte",
|
||||
"spectre",
|
||||
"shadow_shaman",
|
||||
"death_prophet",
|
||||
"tidehunter"
|
||||
],
|
||||
"dire": [
|
||||
"invoker",
|
||||
"hoodwink",
|
||||
"axe",
|
||||
"earthshaker",
|
||||
"skeleton_king"
|
||||
],
|
||||
"count": 10,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_192111.png"
|
||||
}
|
||||
],
|
||||
"bans": [
|
||||
"disruptor",
|
||||
"phantom_lancer",
|
||||
"lion",
|
||||
"zuus",
|
||||
"rubick",
|
||||
"silencer",
|
||||
"weaver",
|
||||
"dark_willow",
|
||||
"abyssal_underlord",
|
||||
"windrunner",
|
||||
"medusa",
|
||||
"razor",
|
||||
"pudge",
|
||||
"bristleback",
|
||||
"drow_ranger",
|
||||
"sniper",
|
||||
"huskar"
|
||||
],
|
||||
"bans_loc": [
|
||||
"干扰者",
|
||||
"幻影长矛手",
|
||||
"莱恩",
|
||||
"宙斯",
|
||||
"拉比克",
|
||||
"沉默术士",
|
||||
"编织者",
|
||||
"邪影芳灵",
|
||||
"孽主",
|
||||
"风行者",
|
||||
"美杜莎",
|
||||
"雷泽",
|
||||
"帕吉",
|
||||
"钢背兽",
|
||||
"卓尔游侠",
|
||||
"狙击手",
|
||||
"哈斯卡"
|
||||
],
|
||||
"last_frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_192111_1.png"
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
{
|
||||
"match_id": "8912903800",
|
||||
"captured_at": "2026-07-25 20:03:24",
|
||||
"duration_s": 80.0,
|
||||
"polls": 51,
|
||||
"self": {
|
||||
"slot": 5,
|
||||
"team": "radiant",
|
||||
"hero": "bloodseeker",
|
||||
"role": "safe",
|
||||
"role_label": "优势路",
|
||||
"position": 1,
|
||||
"gsi_name": "refining"
|
||||
},
|
||||
"team_roles": {
|
||||
"1": {
|
||||
"position": 3,
|
||||
"label": "劣势路",
|
||||
"hero": "pudge"
|
||||
},
|
||||
"2": {
|
||||
"position": 5,
|
||||
"label": "纯辅助",
|
||||
"hero": "venomancer"
|
||||
},
|
||||
"3": {
|
||||
"position": 4,
|
||||
"label": "辅助",
|
||||
"hero": "weaver"
|
||||
},
|
||||
"4": {
|
||||
"position": 2,
|
||||
"label": "中路",
|
||||
"hero": "kunkka"
|
||||
},
|
||||
"5": {
|
||||
"position": 1,
|
||||
"label": "优势路",
|
||||
"hero": "bloodseeker"
|
||||
}
|
||||
},
|
||||
"final": {
|
||||
"radiant": [
|
||||
"pudge",
|
||||
"venomancer",
|
||||
"weaver",
|
||||
"kunkka",
|
||||
"bloodseeker"
|
||||
],
|
||||
"dire": [
|
||||
"undying",
|
||||
"warlock",
|
||||
"luna",
|
||||
"hoodwink",
|
||||
"queenofpain"
|
||||
]
|
||||
},
|
||||
"recognized": 10,
|
||||
"timeline": [
|
||||
{
|
||||
"t": 5.7,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 1,
|
||||
"added": [
|
||||
{
|
||||
"slot": 1,
|
||||
"team": "radiant",
|
||||
"hero": "pudge"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"pudge"
|
||||
],
|
||||
"dire": [],
|
||||
"count": 1,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_200210.png"
|
||||
},
|
||||
{
|
||||
"t": 28.4,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 1,
|
||||
"added": [
|
||||
{
|
||||
"slot": 2,
|
||||
"team": "radiant",
|
||||
"hero": "venomancer"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"team": "dire",
|
||||
"hero": "hoodwink"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"team": "dire",
|
||||
"hero": "queenofpain"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"pudge",
|
||||
"venomancer"
|
||||
],
|
||||
"dire": [
|
||||
"hoodwink",
|
||||
"queenofpain"
|
||||
],
|
||||
"count": 4,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_200233.png"
|
||||
},
|
||||
{
|
||||
"t": 51.2,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 3,
|
||||
"team": "radiant",
|
||||
"hero": "weaver"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"pudge",
|
||||
"venomancer",
|
||||
"weaver"
|
||||
],
|
||||
"dire": [
|
||||
"hoodwink",
|
||||
"queenofpain"
|
||||
],
|
||||
"count": 5,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_200256.png"
|
||||
},
|
||||
{
|
||||
"t": 54.3,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 5,
|
||||
"team": "radiant",
|
||||
"hero": "bloodseeker"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"team": "dire",
|
||||
"hero": "undying"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"team": "dire",
|
||||
"hero": "warlock"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"pudge",
|
||||
"venomancer",
|
||||
"weaver",
|
||||
"bloodseeker"
|
||||
],
|
||||
"dire": [
|
||||
"undying",
|
||||
"warlock",
|
||||
"hoodwink",
|
||||
"queenofpain"
|
||||
],
|
||||
"count": 8,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_200259.png"
|
||||
},
|
||||
{
|
||||
"t": 79.9,
|
||||
"state": "STRATEGY_TIME",
|
||||
"round": 3,
|
||||
"added": [
|
||||
{
|
||||
"slot": 4,
|
||||
"team": "radiant",
|
||||
"hero": "kunkka"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"team": "dire",
|
||||
"hero": "luna"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"pudge",
|
||||
"venomancer",
|
||||
"weaver",
|
||||
"kunkka",
|
||||
"bloodseeker"
|
||||
],
|
||||
"dire": [
|
||||
"undying",
|
||||
"warlock",
|
||||
"luna",
|
||||
"hoodwink",
|
||||
"queenofpain"
|
||||
],
|
||||
"count": 10,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_200324.png"
|
||||
}
|
||||
],
|
||||
"bans": [
|
||||
"disruptor",
|
||||
"skywrath_mage",
|
||||
"phantom_assassin",
|
||||
"lich",
|
||||
"lion",
|
||||
"meepo",
|
||||
"riki",
|
||||
"tidehunter",
|
||||
"axe",
|
||||
"abyssal_underlord",
|
||||
"tusk",
|
||||
"phoenix",
|
||||
"legion_commander",
|
||||
"snapfire",
|
||||
"bounty_hunter",
|
||||
"sniper"
|
||||
],
|
||||
"bans_loc": [
|
||||
"干扰者",
|
||||
"天怒法师",
|
||||
"幻影刺客",
|
||||
"巫妖",
|
||||
"莱恩",
|
||||
"米波",
|
||||
"力丸",
|
||||
"潮汐猎人",
|
||||
"斧王",
|
||||
"孽主",
|
||||
"巨牙海民",
|
||||
"凤凰",
|
||||
"军团指挥官",
|
||||
"电炎绝手",
|
||||
"赏金猎人",
|
||||
"狙击手"
|
||||
],
|
||||
"last_frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_200324_1.png"
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"match_id": "8913003670",
|
||||
"captured_at": "2026-07-25 21:07:27",
|
||||
"duration_s": 116.2,
|
||||
"polls": 76,
|
||||
"self": {
|
||||
"slot": 10,
|
||||
"team": "dire",
|
||||
"hero": "juggernaut",
|
||||
"role": "safe",
|
||||
"role_label": "优势路",
|
||||
"position": 1,
|
||||
"gsi_name": "refining"
|
||||
},
|
||||
"team_roles": {
|
||||
"6": {
|
||||
"position": 3,
|
||||
"label": "劣势路",
|
||||
"hero": null
|
||||
},
|
||||
"7": {
|
||||
"position": 2,
|
||||
"label": "中路",
|
||||
"hero": null
|
||||
},
|
||||
"8": {
|
||||
"position": 5,
|
||||
"label": "纯辅助",
|
||||
"hero": null
|
||||
},
|
||||
"9": {
|
||||
"position": 4,
|
||||
"label": "辅助",
|
||||
"hero": null
|
||||
},
|
||||
"10": {
|
||||
"position": 1,
|
||||
"label": "优势路",
|
||||
"hero": "juggernaut"
|
||||
}
|
||||
},
|
||||
"final": {
|
||||
"radiant": [
|
||||
"spirit_breaker",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"dire": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"juggernaut"
|
||||
]
|
||||
},
|
||||
"recognized": 2,
|
||||
"revisions": [],
|
||||
"timeline": [
|
||||
{
|
||||
"t": 42.0,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 1,
|
||||
"added": [
|
||||
{
|
||||
"slot": 1,
|
||||
"team": "radiant",
|
||||
"hero": "spirit_breaker"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"spirit_breaker"
|
||||
],
|
||||
"dire": [],
|
||||
"count": 1,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_210612.png"
|
||||
},
|
||||
{
|
||||
"t": 56.9,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 1,
|
||||
"added": [
|
||||
{
|
||||
"slot": 10,
|
||||
"team": "dire",
|
||||
"hero": "invoker"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"spirit_breaker"
|
||||
],
|
||||
"dire": [
|
||||
"invoker"
|
||||
],
|
||||
"count": 2,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_210627.png"
|
||||
}
|
||||
],
|
||||
"bans": [
|
||||
"invoker",
|
||||
"zuus",
|
||||
"pugna",
|
||||
"silencer",
|
||||
"nevermore",
|
||||
"nyx_assassin",
|
||||
"magnataur",
|
||||
"lion",
|
||||
"pudge",
|
||||
"gyrocopter",
|
||||
"storm_spirit",
|
||||
"mirana",
|
||||
"axe",
|
||||
"pangolier",
|
||||
"abyssal_underlord",
|
||||
"sven",
|
||||
"legion_commander",
|
||||
"dawnbreaker"
|
||||
],
|
||||
"bans_loc": [
|
||||
"祈求者",
|
||||
"宙斯",
|
||||
"帕格纳",
|
||||
"沉默术士",
|
||||
"影魔",
|
||||
"司夜刺客",
|
||||
"马格纳斯",
|
||||
"莱恩",
|
||||
"帕吉",
|
||||
"矮人直升机",
|
||||
"风暴之灵",
|
||||
"米拉娜",
|
||||
"斧王",
|
||||
"石鳞剑士",
|
||||
"孽主",
|
||||
"斯温",
|
||||
"军团指挥官",
|
||||
"破晓辰星"
|
||||
],
|
||||
"last_frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_210727.png"
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"match_id": "8913098624",
|
||||
"captured_at": "2026-07-25 22:02:49",
|
||||
"duration_s": 12.3,
|
||||
"polls": 8,
|
||||
"self": {
|
||||
"slot": 7,
|
||||
"team": "dire",
|
||||
"hero": "phantom_lancer",
|
||||
"role": "safe",
|
||||
"role_label": "优势路",
|
||||
"position": 1,
|
||||
"gsi_name": "refining"
|
||||
},
|
||||
"team_roles": {
|
||||
"6": {
|
||||
"position": 5,
|
||||
"label": "纯辅助",
|
||||
"hero": "lina"
|
||||
},
|
||||
"7": {
|
||||
"position": 1,
|
||||
"label": "优势路",
|
||||
"hero": "phantom_lancer"
|
||||
},
|
||||
"8": {
|
||||
"position": 3,
|
||||
"label": "劣势路",
|
||||
"hero": "necrolyte"
|
||||
},
|
||||
"9": {
|
||||
"position": 4,
|
||||
"label": "辅助",
|
||||
"hero": "zuus"
|
||||
},
|
||||
"10": {
|
||||
"position": 2,
|
||||
"label": "中路",
|
||||
"hero": "lion"
|
||||
}
|
||||
},
|
||||
"final": {
|
||||
"radiant": [
|
||||
"weaver",
|
||||
"huskar",
|
||||
"venomancer",
|
||||
"furion",
|
||||
"juggernaut"
|
||||
],
|
||||
"dire": [
|
||||
"lina",
|
||||
"phantom_lancer",
|
||||
"necrolyte",
|
||||
"zuus",
|
||||
"lion"
|
||||
]
|
||||
},
|
||||
"recognized": 9,
|
||||
"revisions": [],
|
||||
"timeline": [
|
||||
{
|
||||
"t": 2.1,
|
||||
"state": "STRATEGY_TIME",
|
||||
"round": 3,
|
||||
"added": [
|
||||
{
|
||||
"slot": 1,
|
||||
"team": "radiant",
|
||||
"hero": "weaver"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"team": "radiant",
|
||||
"hero": "huskar"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"team": "radiant",
|
||||
"hero": "venomancer"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"team": "radiant",
|
||||
"hero": "furion"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"team": "radiant",
|
||||
"hero": "juggernaut"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"team": "dire",
|
||||
"hero": "lina"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"team": "dire",
|
||||
"hero": "phantom_lancer"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"team": "dire",
|
||||
"hero": "necrolyte"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"team": "dire",
|
||||
"hero": "lion"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"weaver",
|
||||
"huskar",
|
||||
"venomancer",
|
||||
"furion",
|
||||
"juggernaut"
|
||||
],
|
||||
"dire": [
|
||||
"lina",
|
||||
"phantom_lancer",
|
||||
"necrolyte",
|
||||
"lion"
|
||||
],
|
||||
"count": 9,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_220239.png"
|
||||
}
|
||||
],
|
||||
"best_lineup": {
|
||||
"recognized": 10,
|
||||
"t": 0.1,
|
||||
"state": "STRATEGY_TIME",
|
||||
"heroes": [
|
||||
"weaver",
|
||||
"huskar",
|
||||
"venomancer",
|
||||
"furion",
|
||||
"juggernaut",
|
||||
"lina",
|
||||
"phantom_lancer",
|
||||
"necrolyte",
|
||||
"zuus",
|
||||
"lion"
|
||||
],
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_best_220249.png"
|
||||
},
|
||||
"last_frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_best_220249.png"
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"match_id": "8913190580",
|
||||
"captured_at": "2026-07-25 22:54:09",
|
||||
"duration_s": 119.6,
|
||||
"polls": 81,
|
||||
"self": {
|
||||
"slot": 9,
|
||||
"team": "dire",
|
||||
"hero": "templar_assassin",
|
||||
"role": "safe",
|
||||
"role_label": "优势路",
|
||||
"position": 1,
|
||||
"gsi_name": "refining"
|
||||
},
|
||||
"team_roles": {
|
||||
"6": {
|
||||
"position": 4,
|
||||
"label": "辅助",
|
||||
"hero": "windrunner"
|
||||
},
|
||||
"7": {
|
||||
"position": 3,
|
||||
"label": "劣势路",
|
||||
"hero": "slardar"
|
||||
},
|
||||
"8": {
|
||||
"position": 5,
|
||||
"label": "纯辅助",
|
||||
"hero": "lina"
|
||||
},
|
||||
"9": {
|
||||
"position": 1,
|
||||
"label": "优势路",
|
||||
"hero": "templar_assassin"
|
||||
},
|
||||
"10": {
|
||||
"position": 2,
|
||||
"label": "中路",
|
||||
"hero": "void_spirit"
|
||||
}
|
||||
},
|
||||
"final": {
|
||||
"radiant": [
|
||||
"undying",
|
||||
"rubick",
|
||||
"death_prophet",
|
||||
"legion_commander",
|
||||
"luna"
|
||||
],
|
||||
"dire": [
|
||||
"windrunner",
|
||||
"slardar",
|
||||
"lina",
|
||||
"templar_assassin",
|
||||
"void_spirit"
|
||||
]
|
||||
},
|
||||
"recognized": 9,
|
||||
"revisions": [],
|
||||
"timeline": [
|
||||
{
|
||||
"t": 12.6,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 1,
|
||||
"added": [
|
||||
{
|
||||
"slot": 8,
|
||||
"team": "dire",
|
||||
"hero": "lina"
|
||||
}
|
||||
],
|
||||
"radiant": [],
|
||||
"dire": [
|
||||
"lina"
|
||||
],
|
||||
"count": 1,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_225222.png"
|
||||
},
|
||||
{
|
||||
"t": 29.8,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 1,
|
||||
"added": [
|
||||
{
|
||||
"slot": 2,
|
||||
"team": "radiant",
|
||||
"hero": "rubick"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"team": "radiant",
|
||||
"hero": "legion_commander"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"team": "dire",
|
||||
"hero": "windrunner"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"rubick",
|
||||
"legion_commander"
|
||||
],
|
||||
"dire": [
|
||||
"windrunner",
|
||||
"lina"
|
||||
],
|
||||
"count": 4,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_225239.png"
|
||||
},
|
||||
{
|
||||
"t": 43.4,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 9,
|
||||
"team": "dire",
|
||||
"hero": "templar_assassin"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"rubick",
|
||||
"legion_commander"
|
||||
],
|
||||
"dire": [
|
||||
"windrunner",
|
||||
"lina",
|
||||
"templar_assassin"
|
||||
],
|
||||
"count": 5,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_225252.png"
|
||||
},
|
||||
{
|
||||
"t": 64.6,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 1,
|
||||
"team": "radiant",
|
||||
"hero": "undying"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"team": "radiant",
|
||||
"hero": "luna"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"undying",
|
||||
"rubick",
|
||||
"legion_commander",
|
||||
"luna"
|
||||
],
|
||||
"dire": [
|
||||
"windrunner",
|
||||
"lina",
|
||||
"templar_assassin"
|
||||
],
|
||||
"count": 7,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_225314.png"
|
||||
},
|
||||
{
|
||||
"t": 84.4,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 10,
|
||||
"team": "dire",
|
||||
"hero": "void_spirit"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"undying",
|
||||
"rubick",
|
||||
"legion_commander",
|
||||
"luna"
|
||||
],
|
||||
"dire": [
|
||||
"windrunner",
|
||||
"lina",
|
||||
"templar_assassin",
|
||||
"void_spirit"
|
||||
],
|
||||
"count": 8,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_225333.png"
|
||||
},
|
||||
{
|
||||
"t": 90.0,
|
||||
"state": "STRATEGY_TIME",
|
||||
"round": 3,
|
||||
"added": [
|
||||
{
|
||||
"slot": 3,
|
||||
"team": "radiant",
|
||||
"hero": "death_prophet"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"undying",
|
||||
"rubick",
|
||||
"death_prophet",
|
||||
"legion_commander",
|
||||
"luna"
|
||||
],
|
||||
"dire": [
|
||||
"windrunner",
|
||||
"lina",
|
||||
"templar_assassin",
|
||||
"void_spirit"
|
||||
],
|
||||
"count": 9,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_225339.png"
|
||||
}
|
||||
],
|
||||
"bans": [
|
||||
"leshrac",
|
||||
"techies",
|
||||
"bane",
|
||||
"antimage",
|
||||
"puck",
|
||||
"queenofpain",
|
||||
"magnataur",
|
||||
"ringmaster",
|
||||
"lion",
|
||||
"snapfire",
|
||||
"juggernaut",
|
||||
"clinkz",
|
||||
"axe",
|
||||
"treant",
|
||||
"pudge",
|
||||
"rattletrap"
|
||||
],
|
||||
"bans_loc": [
|
||||
"拉席克",
|
||||
"工程师",
|
||||
"祸乱之源",
|
||||
"敌法师",
|
||||
"帕克",
|
||||
"痛苦女王",
|
||||
"马格纳斯",
|
||||
"百戏大王",
|
||||
"莱恩",
|
||||
"电炎绝手",
|
||||
"主宰",
|
||||
"克林克兹",
|
||||
"斧王",
|
||||
"树精卫士",
|
||||
"帕吉",
|
||||
"发条技师"
|
||||
],
|
||||
"best_lineup": {
|
||||
"recognized": 9,
|
||||
"t": 101.0,
|
||||
"state": "STRATEGY_TIME",
|
||||
"heroes": [
|
||||
"undying",
|
||||
"rubick",
|
||||
"death_prophet",
|
||||
"legion_commander",
|
||||
"luna",
|
||||
null,
|
||||
"slardar",
|
||||
"lina",
|
||||
"templar_assassin",
|
||||
"void_spirit"
|
||||
],
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_best_225408.png"
|
||||
},
|
||||
"last_frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_best_225408.png"
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"match_id": "8913254704",
|
||||
"captured_at": "2026-07-25 23:30:32",
|
||||
"duration_s": 124.6,
|
||||
"polls": 84,
|
||||
"self": {
|
||||
"slot": 10,
|
||||
"team": "dire",
|
||||
"hero": "antimage",
|
||||
"role": "safe",
|
||||
"role_label": "优势路",
|
||||
"position": 1,
|
||||
"gsi_name": "refining"
|
||||
},
|
||||
"team_roles": {
|
||||
"6": {
|
||||
"position": 4,
|
||||
"label": "辅助",
|
||||
"hero": "snapfire"
|
||||
},
|
||||
"7": {
|
||||
"position": 2,
|
||||
"label": "中路",
|
||||
"hero": "sniper"
|
||||
},
|
||||
"8": {
|
||||
"position": 5,
|
||||
"label": "纯辅助",
|
||||
"hero": "abyssal_underlord"
|
||||
},
|
||||
"9": {
|
||||
"position": 3,
|
||||
"label": "劣势路",
|
||||
"hero": "undying"
|
||||
},
|
||||
"10": {
|
||||
"position": 1,
|
||||
"label": "优势路",
|
||||
"hero": "antimage"
|
||||
}
|
||||
},
|
||||
"final": {
|
||||
"radiant": [
|
||||
"vengefulspirit",
|
||||
"earthshaker",
|
||||
"ogre_magi",
|
||||
"bristleback",
|
||||
"enchantress"
|
||||
],
|
||||
"dire": [
|
||||
"snapfire",
|
||||
"sniper",
|
||||
"abyssal_underlord",
|
||||
"undying",
|
||||
"antimage"
|
||||
]
|
||||
},
|
||||
"recognized": 10,
|
||||
"revisions": [],
|
||||
"timeline": [
|
||||
{
|
||||
"t": 22.9,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 1,
|
||||
"added": [
|
||||
{
|
||||
"slot": 8,
|
||||
"team": "dire",
|
||||
"hero": "abyssal_underlord"
|
||||
}
|
||||
],
|
||||
"radiant": [],
|
||||
"dire": [
|
||||
"abyssal_underlord"
|
||||
],
|
||||
"count": 1,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_232850.png"
|
||||
},
|
||||
{
|
||||
"t": 35.3,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 1,
|
||||
"added": [
|
||||
{
|
||||
"slot": 2,
|
||||
"team": "radiant",
|
||||
"hero": "earthshaker"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"team": "radiant",
|
||||
"hero": "ogre_magi"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"team": "dire",
|
||||
"hero": "snapfire"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"earthshaker",
|
||||
"ogre_magi"
|
||||
],
|
||||
"dire": [
|
||||
"snapfire",
|
||||
"abyssal_underlord"
|
||||
],
|
||||
"count": 4,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_232902.png"
|
||||
},
|
||||
{
|
||||
"t": 47.5,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 9,
|
||||
"team": "dire",
|
||||
"hero": "undying"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"earthshaker",
|
||||
"ogre_magi"
|
||||
],
|
||||
"dire": [
|
||||
"snapfire",
|
||||
"abyssal_underlord",
|
||||
"undying"
|
||||
],
|
||||
"count": 5,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_232915.png"
|
||||
},
|
||||
{
|
||||
"t": 61.5,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 1,
|
||||
"team": "radiant",
|
||||
"hero": "vengefulspirit"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"team": "radiant",
|
||||
"hero": "bristleback"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"vengefulspirit",
|
||||
"earthshaker",
|
||||
"ogre_magi",
|
||||
"bristleback"
|
||||
],
|
||||
"dire": [
|
||||
"snapfire",
|
||||
"abyssal_underlord",
|
||||
"undying"
|
||||
],
|
||||
"count": 7,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_232928.png"
|
||||
},
|
||||
{
|
||||
"t": 91.5,
|
||||
"state": "HERO_SELECTION",
|
||||
"round": 2,
|
||||
"added": [
|
||||
{
|
||||
"slot": 7,
|
||||
"team": "dire",
|
||||
"hero": "sniper"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"vengefulspirit",
|
||||
"earthshaker",
|
||||
"ogre_magi",
|
||||
"bristleback"
|
||||
],
|
||||
"dire": [
|
||||
"snapfire",
|
||||
"sniper",
|
||||
"abyssal_underlord",
|
||||
"undying"
|
||||
],
|
||||
"count": 8,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_232959.png"
|
||||
},
|
||||
{
|
||||
"t": 95.4,
|
||||
"state": "STRATEGY_TIME",
|
||||
"round": 3,
|
||||
"added": [
|
||||
{
|
||||
"slot": 5,
|
||||
"team": "radiant",
|
||||
"hero": "enchantress"
|
||||
}
|
||||
],
|
||||
"radiant": [
|
||||
"vengefulspirit",
|
||||
"earthshaker",
|
||||
"ogre_magi",
|
||||
"bristleback",
|
||||
"enchantress"
|
||||
],
|
||||
"dire": [
|
||||
"snapfire",
|
||||
"sniper",
|
||||
"abyssal_underlord",
|
||||
"undying"
|
||||
],
|
||||
"count": 9,
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_233002.png"
|
||||
}
|
||||
],
|
||||
"bans": [
|
||||
"invoker",
|
||||
"meepo",
|
||||
"sven",
|
||||
"clinkz",
|
||||
"chaos_knight",
|
||||
"juggernaut",
|
||||
"slark",
|
||||
"elder_titan",
|
||||
"ember_spirit",
|
||||
"necrolyte",
|
||||
"kunkka",
|
||||
"lion",
|
||||
"magnataur",
|
||||
"monkey_king",
|
||||
"drow_ranger",
|
||||
"legion_commander"
|
||||
],
|
||||
"bans_loc": [
|
||||
"祈求者",
|
||||
"米波",
|
||||
"斯温",
|
||||
"克林克兹",
|
||||
"混沌骑士",
|
||||
"主宰",
|
||||
"斯拉克",
|
||||
"上古巨神",
|
||||
"灰烬之灵",
|
||||
"瘟疫法师",
|
||||
"昆卡",
|
||||
"莱恩",
|
||||
"马格纳斯",
|
||||
"齐天大圣",
|
||||
"卓尔游侠",
|
||||
"军团指挥官"
|
||||
],
|
||||
"best_lineup": {
|
||||
"recognized": 8,
|
||||
"t": 89.6,
|
||||
"state": "HERO_SELECTION",
|
||||
"heroes": [
|
||||
"vengefulspirit",
|
||||
"earthshaker",
|
||||
"ogre_magi",
|
||||
"bristleback",
|
||||
null,
|
||||
"snapfire",
|
||||
"sniper",
|
||||
"abyssal_underlord",
|
||||
"undying",
|
||||
null
|
||||
],
|
||||
"frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_best_233032.png"
|
||||
},
|
||||
"last_frame": "C:\\Users\\Administrator\\Documents\\wrok\\dota2-draft-vision\\samples\\raw\\draft_best_233032.png"
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Read the role-queue labels and find which top-bar slot is you.
|
||||
|
||||
Two things live in the strip of text under each top-bar portrait:
|
||||
|
||||
row 1 (name) - your own name renders white and bold, everyone else's is
|
||||
tinted blue, which is enough to tell which slot is you.
|
||||
row 2 (role) - only drawn for your own team, and only in role-queue
|
||||
(定位匹配) matches: 优势路 / 中路 / 劣势路 / 辅助 / 纯辅助.
|
||||
|
||||
The role text is flat grey with zero saturation, so a threshold on
|
||||
value+saturation isolates it cleanly. Matching is done on the binary mask
|
||||
(icon included) rather than OCR: there are only five possible strings and
|
||||
they differ in width, so mask IoU separates them by a wide margin.
|
||||
"""
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from common import ROOT, slot_rect_px
|
||||
|
||||
TEMPLATES_ROLES = ROOT / "templates" / "roles"
|
||||
|
||||
# key -> (in-game text, lane position number)
|
||||
ROLES = {
|
||||
"safe": ("优势路", 1),
|
||||
"mid": ("中路", 2),
|
||||
"off": ("劣势路", 3),
|
||||
"soft_support": ("辅助", 4),
|
||||
"hard_support": ("纯辅助", 5),
|
||||
}
|
||||
|
||||
# canonical mask geometry, chosen so 1440p text (~17px tall) upsamples slightly
|
||||
STRIP_H = 24
|
||||
STRIP_W = 160
|
||||
|
||||
|
||||
def _row_rect(slot: dict, cfg: dict, img_w: int, img_h: int, row: str) -> tuple[int, int, int, int]:
|
||||
r = cfg["text_rows"][row]
|
||||
x, _, w, _ = slot_rect_px(slot, cfg, img_w, img_h)
|
||||
pad = int(round(w * 0.35)) # names/roles overflow the portrait width
|
||||
y0 = int(round(r["y0_rel"] * img_h))
|
||||
y1 = int(round(r["y1_rel"] * img_h))
|
||||
return x - pad, y0, w + 2 * pad, y1 - y0
|
||||
|
||||
|
||||
def _text_mask(patch: np.ndarray, min_value: int, max_sat: float) -> np.ndarray:
|
||||
"""Isolate the flat light-grey glyphs from the dark blurred background."""
|
||||
p = patch.astype(np.float32)
|
||||
mx = p.max(axis=2)
|
||||
mn = p.min(axis=2)
|
||||
sat = (mx - mn) / np.maximum(mx, 1.0)
|
||||
return ((mx > min_value) & (sat < max_sat)).astype(np.uint8) * 255
|
||||
|
||||
|
||||
def _tight(mask: np.ndarray) -> np.ndarray | None:
|
||||
"""Crop to the ink, then normalize height so resolution stops mattering."""
|
||||
ys, xs = np.nonzero(mask)
|
||||
if ys.size < 40:
|
||||
return None
|
||||
m = mask[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
h, w = m.shape
|
||||
scale = STRIP_H / h
|
||||
m = cv2.resize(m, (max(1, int(round(w * scale))), STRIP_H), interpolation=cv2.INTER_AREA)
|
||||
canvas = np.zeros((STRIP_H, STRIP_W), np.uint8)
|
||||
m = m[:, :STRIP_W]
|
||||
canvas[:, : m.shape[1]] = m
|
||||
return (canvas > 127).astype(np.uint8) * 255
|
||||
|
||||
|
||||
def role_mask(img: np.ndarray, slot: dict, cfg: dict) -> np.ndarray | None:
|
||||
"""Binary mask of one slot's role label, or None when there is no label."""
|
||||
ih, iw = img.shape[:2]
|
||||
x, y, w, h = _row_rect(slot, cfg, iw, ih, "role")
|
||||
patch = img[max(0, y) : min(ih, y + h), max(0, x) : min(iw, x + w)]
|
||||
if patch.size == 0:
|
||||
return None
|
||||
t = cfg["text_rows"]["role"]
|
||||
return _tight(_text_mask(patch, t.get("min_value", 110), t.get("max_sat", 0.08)))
|
||||
|
||||
|
||||
def name_tint(img: np.ndarray, slot: dict, cfg: dict) -> tuple[float, float] | None:
|
||||
"""Mean value and saturation of the name glyphs: (value, saturation)."""
|
||||
ih, iw = img.shape[:2]
|
||||
x, y, w, h = _row_rect(slot, cfg, iw, ih, "name")
|
||||
patch = img[max(0, y) : min(ih, y + h), max(0, x) : min(iw, x + w)]
|
||||
if patch.size == 0:
|
||||
return None
|
||||
p = patch.astype(np.float32)
|
||||
mx = p.max(axis=2)
|
||||
thr = max(90.0, float(mx.max()) * 0.7)
|
||||
sel = mx > thr
|
||||
if int(sel.sum()) < 30:
|
||||
return None
|
||||
px = p[sel]
|
||||
hi = px.max(axis=1)
|
||||
lo = px.min(axis=1)
|
||||
return float(hi.mean()), float(((hi - lo) / np.maximum(hi, 1.0)).mean())
|
||||
|
||||
|
||||
def iou(a: np.ndarray, b: np.ndarray) -> float:
|
||||
ab = a > 0
|
||||
bb = b > 0
|
||||
union = int((ab | bb).sum())
|
||||
return float((ab & bb).sum()) / union if union else 0.0
|
||||
|
||||
|
||||
def load_role_templates() -> dict[str, np.ndarray]:
|
||||
if not TEMPLATES_ROLES.is_dir():
|
||||
return {}
|
||||
out = {}
|
||||
for key in ROLES:
|
||||
f = TEMPLATES_ROLES / f"{key}.png"
|
||||
if f.is_file():
|
||||
img = cv2.imread(str(f), cv2.IMREAD_GRAYSCALE)
|
||||
if img is not None:
|
||||
out[key] = img
|
||||
return out
|
||||
|
||||
|
||||
def detect_roles(img: np.ndarray, cfg: dict, templates: dict[str, np.ndarray] | None = None) -> dict:
|
||||
"""Per-slot role plus which slot is you.
|
||||
|
||||
Returns {"self_slot": int|None, "self_team": str|None, "roles": {slot: {...}}}.
|
||||
Slots without a role label (the enemy team, or any non-role-queue mode)
|
||||
are simply absent from "roles".
|
||||
"""
|
||||
if templates is None:
|
||||
templates = load_role_templates()
|
||||
cutoff = cfg.get("roles", {}).get("min_iou", 0.55)
|
||||
|
||||
roles: dict[int, dict] = {}
|
||||
for slot in cfg.get("slots", []):
|
||||
mask = role_mask(img, slot, cfg)
|
||||
if mask is None:
|
||||
continue
|
||||
ranked = sorted(((iou(mask, t), k) for k, t in templates.items()), reverse=True)
|
||||
if not ranked or ranked[0][0] < cutoff:
|
||||
continue
|
||||
score, key = ranked[0]
|
||||
roles[slot["index"]] = {
|
||||
"role": key,
|
||||
"label": ROLES[key][0],
|
||||
"position": ROLES[key][1],
|
||||
"score": round(score, 3),
|
||||
}
|
||||
|
||||
self_slot = _detect_self(img, cfg)
|
||||
self_team = None
|
||||
if roles:
|
||||
self_team = "radiant" if min(roles) <= 5 else "dire"
|
||||
elif self_slot is not None:
|
||||
self_team = "radiant" if self_slot <= 5 else "dire"
|
||||
|
||||
return {"self_slot": self_slot, "self_team": self_team, "roles": roles}
|
||||
|
||||
|
||||
def _detect_self(img: np.ndarray, cfg: dict) -> int | None:
|
||||
"""Your own name is drawn bright white, the other nine a dimmer blue-grey.
|
||||
|
||||
The gap is ~55 units of brightness, so compare slots against each other
|
||||
instead of a fixed threshold - that survives HUD skins and any screen
|
||||
where every row happens to be bright (a menu, a loading overlay), because
|
||||
there the runner-up is just as bright and the match is rejected.
|
||||
"""
|
||||
r = cfg.get("roles", {})
|
||||
min_value = r.get("self_min_value", 195.0)
|
||||
min_gap = r.get("self_min_gap", 25.0)
|
||||
found = []
|
||||
for slot in cfg.get("slots", []):
|
||||
tint = name_tint(img, slot, cfg)
|
||||
if tint is not None:
|
||||
found.append((tint[0], slot["index"]))
|
||||
if len(found) < 2:
|
||||
return None
|
||||
found.sort(reverse=True)
|
||||
if found[0][0] < min_value or found[0][0] - found[1][0] < min_gap:
|
||||
return None
|
||||
return found[0][1]
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
"""python roles.py <frame.png> - report roles found
|
||||
python roles.py <frame.png> --build off,safe,mid,soft_support,hard_support
|
||||
- save templates from slots 1..N
|
||||
"""
|
||||
import sys
|
||||
|
||||
from common import load_config
|
||||
|
||||
args = sys.argv[1:]
|
||||
if not args:
|
||||
print(_main.__doc__)
|
||||
return
|
||||
frame = cv2.imread(args[0])
|
||||
if frame is None:
|
||||
raise SystemExit(f"cannot read {args[0]}")
|
||||
cfg = load_config()
|
||||
|
||||
if "--build" in args:
|
||||
labels = args[args.index("--build") + 1].split(",")
|
||||
TEMPLATES_ROLES.mkdir(parents=True, exist_ok=True)
|
||||
for slot, key in zip(cfg["slots"], labels):
|
||||
key = key.strip()
|
||||
if key not in ROLES:
|
||||
raise SystemExit(f"unknown role {key!r}, expected one of {list(ROLES)}")
|
||||
mask = role_mask(frame, slot, cfg)
|
||||
if mask is None:
|
||||
raise SystemExit(f"slot {slot['index']} has no role text")
|
||||
out = TEMPLATES_ROLES / f"{key}.png"
|
||||
cv2.imwrite(str(out), mask)
|
||||
print(f"slot {slot['index']} -> {key} ({ROLES[key][0]}) {out}")
|
||||
return
|
||||
|
||||
import json
|
||||
|
||||
print(json.dumps(detect_roles(frame, cfg), ensure_ascii=False, indent=1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"comment": "Ground truth for captured frames: 10 hero keys left to right, '?' for unknown. Used by evaluate.py and build_library.py. Paths are filenames under samples/raw/ (or samples/raw/<matchid>/ when using per-match folders).",
|
||||
"frames": {}
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |