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

22 KiB
Raw Blame History

上分帝(Climperor)—— 方案与实施细节

本文档记录 上分帝(Climperor 的背景、技术选型、实现细节与推进计划。 README.md 是操作手册,本文是设计依据与决策记录。


1. 背景与目标

要解决的问题

在 Dota 2 选将阶段(以及进入游戏后),几秒内自动获取双方 10 个英雄,用于选将辅助或赛后分析。

为什么 GSI 做不到

官方 Game State Integration 在普通玩家视角下不提供双方 pick

场景 GSI 可获得的阵容数据
排位 / 普通 All Pick 仅自己的 hero.iddraft 通常为空
Captains Mode 历史上有部分 pick/ban,不稳定
观战 / 裁判视角 阵容字段较全
赛后 需依赖 OpenDota 等外部 API

Valve 官方 issue 中已明确:All Pick 的实时 draft 数据因隐私考量被关闭 (#9562#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-pickerdota2-pickerability-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=17,头像区 y=896(高 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/10LC 被段位条打崩) 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.0requests >= 2.32mss(屏幕捕获)

已完成的初始化:

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 现在带 attrname_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. 参考资料