Files
climperor/DESIGN.md
T
vosonandCursor f32d24b8f8 Initial commit: 上分帝(Climperor)
从 dota2-draft-vision 迁出并定名,作为天梯选将识别项目起点。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:47:39 +08:00

429 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 上分帝(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`