# 上分帝(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// ├── results/ # gsi_watch.py 每局的识别结果 JSON ├── templates/cdn/ # 127 张兜底模板(已下载) ├── templates/real/ # 默认脸实拍模板(不含皮肤) ├── preview/ # build_library.py 的裁切预览 └── failures/ # 识别失败的格子,待标注入库 ``` ### config.json 参数说明 | 字段 | 含义 | 默认 | |------|------|------| | `slots[]` | 10 个格子的相对中心坐标 `cx_rel` / `cy_rel` | 待标定 | | `slot_w_rel` / `slot_h_rel` | 格子宽高 / 屏幕高 | 待标定 | | `crop_trim` | 裁掉的边饰比例(上 10% 颜色条、下 22% 名牌、左右各 8%) | 见文件 | | `canonical_size` | 统一缩放后的边长(px) | 96 | | `match.min_score` | Top-1 最低分 | 0.45 | | `match.min_margin` | Top1−Top2 最小分差 | 0.04 | | `match.cdn_penalty` | CDN 模板得分惩罚,优先采信 real 模板 | 0.05 | | `gsi.port` | GSI 监听端口 | 3223 | | `gsi.trigger_states` | 启动跟踪会话的游戏状态 | `[HERO_SELECTION, STRATEGY_TIME]` | | `gsi.poll_interval` | 选将期间的轮询间隔(秒) | 1.0 | | `gsi.confirm_polls` | 同一格连续几帧认出同一英雄才算确认 | 2 | | `gsi.session_timeout` | 单局跟踪的最长时间(秒) | 300 | | `text_rows.name` / `text_rows.role` | 头像下方姓名行 / 位置行的相对纵坐标 | 见文件 | | `roles.min_iou` | 位置文字模板匹配的最低 IoU | 0.55 | | `roles.self_min_gap` | 「我」那格姓名亮度需高出次亮格多少 | 25.0 | 阈值需在积累一定样本后按实测重新调优,当前为经验初值。 ### 自动标定(`autocalibrate.py`) 2560×1440 实测:颜色条位于 y=1~7,头像区 y=8~96(高 88),格宽 111, 槽位中心间距 165px,10 格全部命中。 三个关键判据: 1. **颜色条定位**——10 个玩家颜色是游戏固定值,逐列取最近颜色(容差 60) 找连续段,既给出横向位置又天然给出槽位编号 2. **中心线性拟合**——同队 5 格等距,对 index→center 做最小二乘, 可修复被身后头像污染的个别颜色条(实测槽位 9/10 偏差 3~6px 被纠正) 3. **头像下沿**——用「格内列 vs 格间空隙」的亮度差:有头像时差值 20~67, 头像结束瞬间塌到 0。**不能用逐行差分**,因为下方玩家名字的跳变更大, 会误判到名字行(初版就踩了这个坑,把底边定到 129 而非 96) ### 实测结论(2026-07-25,四局人机,2560×1440 无边框) | 阶段 | CDN-only | 完整库 | |------|----------|--------| | 初版(正方形裁切 + 线性拟合) | 32/40 | — | | 修正裁切 + 鲁棒拟合后 | **40/40**(最低分 0.784) | **40/40**(最低分 1.000) | 其中 `draft_145136.png` 是**修复完成前**就抓好的帧,未参与任何调参, 旧配置下 8/10、新配置下 10/10,属于干净的盲测样本。 单次识别 340~460ms,远低于 1 秒目标。用 `python evaluate.py` 复现。 #### 发现一:顶栏头像是静态图标,可像素级匹配 real 模板命中时分数恒为 **1.000**。顶栏图标每局渲染逐像素相同, 所以一个英雄只要入库一次,之后永远满分命中——不需要"积累多个变体求鲁棒", **一张就够**(同皮肤前提下)。 #### 发现二:CDN 模板的裁切方式原本就是错的 初版按 1:1 正方形取中心裁切,但顶栏格子是 111×88(约 1.26:1),比例根本对不上, 平均匹配分只有 0.649。以 15 张实拍模板为标准答案做网格搜索, 反推出正确窗口是 **x0=38, w=182, 全高**(源图 256×144),平均分升到 **0.936**。 改用相对比例 `CROP_X0/CROP_X1` 写死在 `fetch_cdn_templates.py` 里,重新生成 127 张后, **仅靠 CDN 模板即可 30/30 全对**。这意味着不必靠打几十局去攒模板库—— 全英雄覆盖开箱即用。 > 教训:当兜底层表现明显低于预期时,先怀疑**素材处理方式**, > 而不是急着靠堆数据去补。这里省下了约 70 局的采集成本 > (127 英雄按优惠券收集问题估算)。 #### 发现三:坏点会拖垮最小二乘拟合 夜魇方槽位 9/10 的颜色条被身后头像污染,检测宽度 141/133(正常 105~119), 中心偏了几像素。最小二乘对离群点没有抵抗力,算出的槽距是 166.2 而非真实的 165, 导致槽位 10 裁切偏 4px、同一英雄的匹配分从 1.000 掉到 0.567。 改为**先按条宽剔除不可靠的条**,再取**中位数槽距**、中位数截距, 槽位中心与实测真值完全吻合。 **阈值的已知不足**:瘟疫法师曾出现分差 0.294(很笃定)但分数 0.412 未过 0.45 的情况。 现行门控要求分数与分差同时达标,对"分差极高但分数中等"偏严。 修正裁切后此问题不再出现(最低分 0.784),暂不调整。 #### 发现四:天梯段位徽章是稳定遮挡,可用遮罩匹配规避 天梯全英雄选择时,每个顶栏头像底部有「传奇 III」半透明条、右侧有金色段位勋章。 GSI 的 `map` 不含 `game_mode` / `lobby_type`,但画面上金色勋章可稳定检测 (`has_ranked_overlay`:≥60% 的格子右下角有金色像素 → 判定为天梯 UI)。 处理方式:检测到天梯 UI 后,匹配时屏蔽底部 32% + 右侧 22%(`ranked_match_mask`), 只对剩余面部区域做归一化相关。人机 / 普通匹配无勋章,走原全图匹配,互不干扰。 实测: | 集合 | 修复前 | 遮罩 + real 入库后 | |------|--------|-------------------| | 5 局人机 | 50/50 | 50/50(未误触发遮罩) | | 1 局天梯 | 9/10(LC 被段位条打崩) | **10/10**(分数 1.000) | 注意:遮罩解决的是**遮挡**。皮肤/至宝改头像时不要往 real 里堆变体——选人阶段 用默认脸多帧确认,决策阶段禁止改判即可;CDN 对默认脸仍偏弱的英雄才补一张 real。 #### 发现五:位置文字与「我」那一格都能纯视觉读出 定位匹配(Ranked Roles)里,**只有我方 5 格**的头像下方会画出位置文字: 优势路 / 中路 / 劣势路 / 辅助 / 纯辅助。2560×1440 下位于 y=143~159。 这行文字是纯灰(饱和度≈0),用 `value>110 且 saturation<0.08` 就能干净抠出来。 不做 OCR,改成**二值掩膜 IoU 匹配**:候选只有 5 个,且宽度各不相同 (图标+2字 ~ 图标+3字),同帧自匹配 IoU=1.0,跨类差距极大。掩膜先紧裁再 归一化到固定高度 24px,因此换分辨率不用重建模板。 哪一格是「我」,最终由 GSI 直接回答:`player` 块里带 `team_slot`(队内 0~4), 顶栏就是按队内序号排的,所以 `slot = team_slot + 1`(天辉)或 `+ 6`(夜魇)。 实测 `team_slot=4 / radiant / drow_ranger` 对应顶栏第 5 格,与画面一致。 视觉判据仍保留为兜底(GSI 在载入对局前不发 `player` 块)—— **自己的名字是亮白色,其余九人是偏蓝的灰**: | 局 | 我那格亮度 | 其余最高 | 差值 | |----|-----------|---------|------| | 天梯定位局 | 229.9 | 170.2 | 59.7 | | 人机局 ×3 | 229.8~230.2 | 175.1 | ~55 | 用绝对阈值会在「整屏都亮」的界面上误判(实测游戏内 HUD 帧十格都是 248), 所以改成**相对判据**:最亮格需比次亮格高出 25 以上。8 张选将截图全部命中, 9 张非选将截图全部正确返回 None。 ### 官方选将规则(决定采集节奏) 天梯全英雄选择的规则来自 Dota 2 Wiki / Liquipedia: - **禁用**:全员 15 秒投票,每人 1 个不可重复;每票各有 50% 概率生效; 系统再按该 MMR 段位的 ban 率补随机禁用,**最终固定 16 个** - **选人分 3 轮**:25 秒 / 每队 2 人 → 25 秒 / 每队 2 人 → 20 秒 / 每队 1 人 - **本轮结束前双方互相不可见**;同轮撞英雄则该英雄被禁、本轮重来(最多 2 次) 关键推论:顶栏英雄是**按 2/2/1 成批揭晓**的。原来只在决策时间截一次, 拿到的是最终阵容,选人顺序信息全丢——而顺序恰好是后续给建议最需要的。 ### GSI 自动化链路 GSI 拿不到双方 pick,但能可靠告诉我们**现在处于哪个阶段**,正好用作触发器: ``` Dota 2(-gamestateintegration) ↓ POST JSON,每 0.5s gsi_watch.py 本地 HTTP 服务(127.0.0.1:3223) ↓ map.game_state 进入 HERO_SELECTION(错过则 STRATEGY_TIME 兜底) DraftSession:每秒轮询 → recognize_image() + detect_roles() ↓ 某格连续 2 帧认出同一英雄才算确认,避免头像淡入时的抖动 确认集合变化 → 追加一条时间线事件(轮次由每队已选人数推出) ↓ 状态离开选将 / 10 格认满 终端摘要 + results/draft_<时间戳>.json ``` 设计要点: - **按 `matchid` 去重**,一局一个跟踪会话,不论从哪个阶段接入 - **轮询与识别在工作线程**,HTTP 回调立即返回,不阻塞游戏侧推送 - **确认需连续 2 帧**,单帧可能拍到半透明的入场动画 - **位置与「我」只解析一次**,这两项全局不变,认出后不再重复计算 - **「我」优先用 GSI 的 `team_slot`**,视觉判据仅在 GSI 尚未提供时兜底 - **未标定时降级为「只截图」**,仍在正确时机存帧,供 `calibrate.py` 使用 --- ## 4. 环境与依赖 - Python 3.14(本机已装) - `opencv-python >= 4.10`(实装 5.0.0)、`numpy >= 2.0`、`requests >= 2.32`、`mss`(屏幕捕获) 已完成的初始化: ```powershell pip install -r requirements.txt pip install mss python fetch_cdn_templates.py # 127/127 模板下载成功 ``` 已验证 `capture.py` 可正常截取主显示器,输出为原生 **2560×1440** PNG。 --- ## 5. 推进计划 | 阶段 | 内容 | 状态 | |------|------|------| | 0 | 项目骨架、依赖、CDN 模板库 | **已完成** | | 1 | 采集真实决策时间截图 | **已完成** | | 2 | ROI 标定,生成 `config.json` 坐标 | **已完成**(自动标定) | | 3 | 首轮识别(纯 CDN 兜底),摸底准确率 | **已完成**(8/10) | | 4 | 积累 real 模板,迭代阈值,统计准确率 | **已完成**(30/30,CDN-only 亦 30/30) | | 5 | 锚点自动定位,去除标定依赖 | **已完成**(`autocalibrate.py`) | | 6 | 接入 GSI 自动触发,全流程免操作 | **已完成**(真机人机局已验证) | ### 验收标准(判定方案是否可行) | 指标 | 目标 | |------|------| | real 模板命中格子的准确率 | ≥ 95% | | 仅 CDN 兜底格子的准确率 | ≥ 70% | | 单张图识别耗时 | < 1 秒 | | 错误类型 | 以「输出 null」为主,而非「输出错误英雄」 | --- ## 6. 通用性设计(面向所有玩家) **核心原则:成品阶段玩家零操作。** 当前 demo 的手动步骤均为验证期临时措施。 | Demo 期手动操作 | 成品自动方案 | |----------------|-------------| | 手动截图 | GSI 检测 `DOTA_GAMERULES_STATE_STRATEGY_TIME` 自动触发(`gsi_watch.py`,已实现) | | 手动框选 10 个格子标定 | 锚点自动定位(见下) | | 人工标注建模板库 | 模板库随程序内置,开箱即用 | | `--truth` 对答案 | 仅开发期使用 | | 手动设置无边框窗口 | 首次运行自动检测,或改用 Windows Graphics Capture | ### 分辨率与宽高比适配(三层递进) 1. **相对坐标**(已实现)——覆盖同宽高比下的任意分辨率 2. **锚点自动定位**(阶段 5)——运行时在截图中自动找顶栏: - 中央倒计时区域作水平锚点 - 10 条固定玩家颜色条(蓝、青、紫、黄、橙 / 粉、灰绿、浅蓝、墨绿、棕) 既是定位标记,也天然给出槽位编号 - 由锚点反推格子位置,任何分辨率、宽高比免配置 3. **多尺度匹配**——0.9~1.1 倍尺度搜索,吸收残余缩放误差 ### 已知风险 | 风险 | 应对 | |------|------| | 21:9 等特殊宽高比布局差异 | 阶段 5 锚点定位;短期可分档标定 | | 英雄皮肤(至宝 / 身心)改变头像 | 不入库皮肤模板;决策阶段只补空槽、禁止改判;best 帧优先选人阶段默认脸 | | 头像为平行四边形,矩形 ROI 会带入邻格边缘 | 当前靠内缩裁切规避;必要时加仿射纠正 | | 独占全屏截图可能为黑帧 | 建议无边框窗口;或改用 Windows Graphics Capture | | Dota 更新改动 HUD 布局 | 锚点方案对布局微调更鲁棒;必要时重标定 | ### 禁用英雄识别(grid.py) 天梯 AP 固定禁用 16 个,这个信息不在顶栏,只在英雄选择网格里。最后的做法 **完全不用模板匹配**,因为网格的排布是可推算的。 一开始确实试了匹配。网格用的是竖版英雄卡,Steam CDN 上唯一对得上的素材是 遗留路径 `images/heroes/{key}_vert.jpg`(235×272),拟合出的裁切窗口 x[0.15,0.80] y[0.00,0.85] 宽高比 0.66 与实拍卡片的 52/79 完全吻合。但均分 只有 0.50,且下半段的 top1/top2 几乎没有间距——这批图是 2015 年前后的旧原画, 大量英雄重做过,根本对不上。 真正的突破口是排布本身。127 个英雄按主属性分成四块从左到右排列,块内按 **客户端本地化名称**排序、**行优先**填充,多出来的空格永远在块尾。用实战帧 交叉验证了三点:四块各 36 / 35 / 34 / 22 格,与英雄表的属性数量分毫不差; 5 个空格全在最后一行的块尾;聊天栏里点名的 9 个禁用英雄,按此推算出的格子 **全部**画着禁用斜杠。所以英雄表(`heroes.json` 现在带 `attr` 和 `name_loc`, 都取自 Valve 自己的 `datafeed/herolist`)就足以定位每一格。 禁用态的判定绕了点弯路。斜杠本身不好测——试过方向梯度直方图和错切后找亮脊, 两者都失败,后者甚至完全反向(禁用格反而排在最后)。原因是禁用卡整张被压暗 去饱和,**低对比度**才是主特征。实测那一局:17 张不可选卡片的灰度 std 落在 8–21,其余 110 张全部 ≥32.6,中间空出 11.6 的间隔,直接卡阈值即可。 17 = 16 个禁用 + 1 个已被选走的莉娜,与官方规则严丝合缝。 `read_grid()` 在格数对不上英雄表时返回 `ok=False` 而不是给一份残缺名单—— 鼠标悬停会弹出大号英雄卡遮住网格,这类帧就是这样被挡掉的。禁用名单全程不变, `DraftSession` 只取第一帧读成功的结果,之后不再重复读。 --- ## 7. 与 dota2-hex 的关系 本项目为**独立验证 demo**,不修改 `dota2-hex`。 - 选用 Python 是因为验证期迭代快,非最终技术栈 - 准确率验证通过后,可选:用 Rust 重写并入 `dota2-hex`,或保留为本地旁路服务通过 HTTP 回传 - 无论哪种,都需遵守 `dota2-hex` 的合规边界:**仅使用玩家屏幕上可见的信息, 不读进程内存、不注入**(见 `AGENTS.md`「技术约束」) `dota2-hex` 现有的阶段判定(`src/phase.rs`)可直接复用为截图触发信号。 --- ## 8. 参考资料 - [Valve #9562 GSI get draft data during draft](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562) — 官方关闭 AP 实时 draft - [Valve #14915 How can I get live pick data](https://github.com/ValveSoftware/Dota2-Gameplay/issues/14915) — 开发者自述 OCR 方案约 85% 准确率 - [Overwolf Dota 2 GEP](https://dev.overwolf.com/ow-native/live-game-data-gep/supported-games/dota-2/) — `roster.draft` / `bans` 字段定义 - [OpenCV Template Matching](https://pyimagesearch.com/2021/03/22/opencv-template-matching-cv2-matchtemplate/) - Steam CDN 头像:`https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png`