Initial commit: 上分帝(Climperor)

从 dota2-draft-vision 迁出并定名,作为天梯选将识别项目起点。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-26 11:47:39 +08:00
co-authored by Cursor
commit f32d24b8f8
211 changed files with 8450 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.env
.DS_Store
Thumbs.db
*.log
+428
View File
@@ -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` | Top1Top2 最小分差 | 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/30CDN-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 落在
821,其余 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`
+228
View File
@@ -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`
+223
View File
@@ -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()
+78
View File
@@ -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()
+79
View File
@@ -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])
+79
View File
@@ -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()
+142
View File
@@ -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
+126
View File
@@ -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"
}
+478
View File
@@ -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"]
+77
View File
@@ -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()
+84
View File
@@ -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()
+210
View File
@@ -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()
+142
View File
@@ -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()
+266
View File
@@ -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()
+891
View File
@@ -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": "朗戈"
}
]
+119
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

+174
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
opencv-python>=4.10
numpy>=2.0
requests>=2.32
+435
View File
@@ -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
]
]
}
]
}
+435
View File
@@ -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
]
]
}
]
}
+435
View File
@@ -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
]
]
}
]
}
+435
View File
@@ -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
]
]
}
]
}
+435
View File
@@ -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
]
]
}
]
}
+436
View File
@@ -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
]
]
}
]
}
+198
View File
@@ -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"
}
+250
View File
@@ -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"
}
+256
View File
@@ -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"
}
+240
View File
@@ -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"
}
+141
View File
@@ -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"
}
+148
View File
@@ -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"
}
+273
View File
@@ -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"
}
+273
View File
@@ -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"
}
+220
View File
@@ -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()
+4
View File
@@ -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": {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Some files were not shown because too many files have changed in this diff Show More