commit f32d24b8f80a2185c3762be70185d280d723082a Author: voson Date: Sun Jul 26 11:47:39 2026 +0800 Initial commit: 上分帝(Climperor) 从 dota2-draft-vision 迁出并定名,作为天梯选将识别项目起点。 Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6bf07e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.env +.DS_Store +Thumbs.db +*.log diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..8032602 --- /dev/null +++ b/DESIGN.md @@ -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// +├── 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` diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bac36c --- /dev/null +++ b/README.md @@ -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//`(`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//<帧>.png`。 +只看某张截图的禁用识别:`python grid.py samples/raw//<帧>.png`。 + +> **主菜单里收不到数据是正常的。** Dota 的 GSI 与 CS 不同,客户端**第一次载入对局后** +> 才开始发 HTTP 请求,挂在主菜单时不会有任何推送。看到 `[gsi] connected` 才算链路通。 +> 一直没有的话,先确认启动项里有 `-gamestateintegration`。 + +人机对战(Play with bots)同样会推送,HUD 顶栏与天梯一致,适合反复测试。 + +**尚未标定时**会自动进入「只截图」模式——按 `matchid` 存到 `samples/raw//`, +拿其中一张跑 `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` diff --git a/autocalibrate.py b/autocalibrate.py new file mode 100644 index 0000000..2cf13cd --- /dev/null +++ b/autocalibrate.py @@ -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() diff --git a/build_library.py b/build_library.py new file mode 100644 index 0000000..6292600 --- /dev/null +++ b/build_library.py @@ -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() diff --git a/calibrate.py b/calibrate.py new file mode 100644 index 0000000..032faef --- /dev/null +++ b/calibrate.py @@ -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]) diff --git a/capture.py b/capture.py new file mode 100644 index 0000000..917aa7c --- /dev/null +++ b/capture.py @@ -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() diff --git a/common.py b/common.py new file mode 100644 index 0000000..7fc2c67 --- /dev/null +++ b/common.py @@ -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 diff --git a/config.json b/config.json new file mode 100644 index 0000000..39b0a04 --- /dev/null +++ b/config.json @@ -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" +} \ No newline at end of file diff --git a/draft_session.py b/draft_session.py new file mode 100644 index 0000000..997e0f6 --- /dev/null +++ b/draft_session.py @@ -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"] diff --git a/evaluate.py b/evaluate.py new file mode 100644 index 0000000..21f63de --- /dev/null +++ b/evaluate.py @@ -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() diff --git a/fetch_cdn_templates.py b/fetch_cdn_templates.py new file mode 100644 index 0000000..0d18463 --- /dev/null +++ b/fetch_cdn_templates.py @@ -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() diff --git a/grid.py b/grid.py new file mode 100644 index 0000000..5c6b538 --- /dev/null +++ b/grid.py @@ -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 [--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() diff --git a/gsi_setup.py b/gsi_setup.py new file mode 100644 index 0000000..2556084 --- /dev/null +++ b/gsi_setup.py @@ -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() diff --git a/gsi_watch.py b/gsi_watch.py new file mode 100644 index 0000000..59e6d31 --- /dev/null +++ b/gsi_watch.py @@ -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_.json. + +If config.json has no calibrated slots yet the watcher runs in capture-only +mode: it still saves frames to samples/raw// 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//.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() diff --git a/heroes.json b/heroes.json new file mode 100644 index 0000000..3ecfbd3 --- /dev/null +++ b/heroes.json @@ -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": "朗戈" + } +] \ No newline at end of file diff --git a/modes.py b/modes.py new file mode 100644 index 0000000..503660d --- /dev/null +++ b/modes.py @@ -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 [--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() diff --git a/preview/ask_slot1.png b/preview/ask_slot1.png new file mode 100644 index 0000000..10c0995 Binary files /dev/null and b/preview/ask_slot1.png differ diff --git a/preview/ask_slot6.png b/preview/ask_slot6.png new file mode 100644 index 0000000..627eb71 Binary files /dev/null and b/preview/ask_slot6.png differ diff --git a/preview/ask_slot7.png b/preview/ask_slot7.png new file mode 100644 index 0000000..27ef785 Binary files /dev/null and b/preview/ask_slot7.png differ diff --git a/preview/autocalibrate_check.png b/preview/autocalibrate_check.png new file mode 100644 index 0000000..132d47c Binary files /dev/null and b/preview/autocalibrate_check.png differ diff --git a/preview/autocalibrate_slots.png b/preview/autocalibrate_slots.png new file mode 100644 index 0000000..00da160 Binary files /dev/null and b/preview/autocalibrate_slots.png differ diff --git a/preview/recognize_sheet.png b/preview/recognize_sheet.png new file mode 100644 index 0000000..d5927e0 Binary files /dev/null and b/preview/recognize_sheet.png differ diff --git a/preview/slot1_vs_templates.png b/preview/slot1_vs_templates.png new file mode 100644 index 0000000..64ebacd Binary files /dev/null and b/preview/slot1_vs_templates.png differ diff --git a/recognize.py b/recognize.py new file mode 100644 index 0000000..50ec674 --- /dev/null +++ b/recognize.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d12425e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +opencv-python>=4.10 +numpy>=2.0 +requests>=2.32 diff --git a/results/draft_20260725_143742.json b/results/draft_20260725_143742.json new file mode 100644 index 0000000..8b55c46 --- /dev/null +++ b/results/draft_20260725_143742.json @@ -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 + ] + ] + } + ] +} \ No newline at end of file diff --git a/results/draft_20260725_144131.json b/results/draft_20260725_144131.json new file mode 100644 index 0000000..ad046be --- /dev/null +++ b/results/draft_20260725_144131.json @@ -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 + ] + ] + } + ] +} \ No newline at end of file diff --git a/results/draft_20260725_145136.json b/results/draft_20260725_145136.json new file mode 100644 index 0000000..48886a0 --- /dev/null +++ b/results/draft_20260725_145136.json @@ -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 + ] + ] + } + ] +} \ No newline at end of file diff --git a/results/draft_20260725_151021.json b/results/draft_20260725_151021.json new file mode 100644 index 0000000..5bf326f --- /dev/null +++ b/results/draft_20260725_151021.json @@ -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 + ] + ] + } + ] +} \ No newline at end of file diff --git a/results/draft_20260725_151828.json b/results/draft_20260725_151828.json new file mode 100644 index 0000000..9508f5f --- /dev/null +++ b/results/draft_20260725_151828.json @@ -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 + ] + ] + } + ] +} \ No newline at end of file diff --git a/results/draft_20260725_162404.json b/results/draft_20260725_162404.json new file mode 100644 index 0000000..ed1d508 --- /dev/null +++ b/results/draft_20260725_162404.json @@ -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 + ] + ] + } + ] +} \ No newline at end of file diff --git a/results/draft_20260725_172530.json b/results/draft_20260725_172530.json new file mode 100644 index 0000000..fa09bcd --- /dev/null +++ b/results/draft_20260725_172530.json @@ -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" +} \ No newline at end of file diff --git a/results/draft_20260725_183826.json b/results/draft_20260725_183826.json new file mode 100644 index 0000000..9ea263a --- /dev/null +++ b/results/draft_20260725_183826.json @@ -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" +} \ No newline at end of file diff --git a/results/draft_20260725_192111.json b/results/draft_20260725_192111.json new file mode 100644 index 0000000..766cd49 --- /dev/null +++ b/results/draft_20260725_192111.json @@ -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" +} \ No newline at end of file diff --git a/results/draft_20260725_200324.json b/results/draft_20260725_200324.json new file mode 100644 index 0000000..ee20436 --- /dev/null +++ b/results/draft_20260725_200324.json @@ -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" +} \ No newline at end of file diff --git a/results/draft_20260725_210727.json b/results/draft_20260725_210727.json new file mode 100644 index 0000000..c507bb2 --- /dev/null +++ b/results/draft_20260725_210727.json @@ -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" +} \ No newline at end of file diff --git a/results/draft_20260725_220249.json b/results/draft_20260725_220249.json new file mode 100644 index 0000000..7682c3a --- /dev/null +++ b/results/draft_20260725_220249.json @@ -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" +} \ No newline at end of file diff --git a/results/draft_20260725_225409.json b/results/draft_20260725_225409.json new file mode 100644 index 0000000..1b03d53 --- /dev/null +++ b/results/draft_20260725_225409.json @@ -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" +} \ No newline at end of file diff --git a/results/draft_20260725_233032.json b/results/draft_20260725_233032.json new file mode 100644 index 0000000..67a49b3 --- /dev/null +++ b/results/draft_20260725_233032.json @@ -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" +} \ No newline at end of file diff --git a/roles.py b/roles.py new file mode 100644 index 0000000..c6ebedc --- /dev/null +++ b/roles.py @@ -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 - report roles found + python roles.py --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() diff --git a/samples/labels.json b/samples/labels.json new file mode 100644 index 0000000..58dad2c --- /dev/null +++ b/samples/labels.json @@ -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// when using per-match folders).", + "frames": {} +} diff --git a/templates/cdn/abaddon.png b/templates/cdn/abaddon.png new file mode 100644 index 0000000..d166135 Binary files /dev/null and b/templates/cdn/abaddon.png differ diff --git a/templates/cdn/abyssal_underlord.png b/templates/cdn/abyssal_underlord.png new file mode 100644 index 0000000..cc4cfdb Binary files /dev/null and b/templates/cdn/abyssal_underlord.png differ diff --git a/templates/cdn/alchemist.png b/templates/cdn/alchemist.png new file mode 100644 index 0000000..183e2ce Binary files /dev/null and b/templates/cdn/alchemist.png differ diff --git a/templates/cdn/ancient_apparition.png b/templates/cdn/ancient_apparition.png new file mode 100644 index 0000000..50950ca Binary files /dev/null and b/templates/cdn/ancient_apparition.png differ diff --git a/templates/cdn/antimage.png b/templates/cdn/antimage.png new file mode 100644 index 0000000..1eeb5fa Binary files /dev/null and b/templates/cdn/antimage.png differ diff --git a/templates/cdn/arc_warden.png b/templates/cdn/arc_warden.png new file mode 100644 index 0000000..44c4201 Binary files /dev/null and b/templates/cdn/arc_warden.png differ diff --git a/templates/cdn/axe.png b/templates/cdn/axe.png new file mode 100644 index 0000000..a2e4bb1 Binary files /dev/null and b/templates/cdn/axe.png differ diff --git a/templates/cdn/bane.png b/templates/cdn/bane.png new file mode 100644 index 0000000..5053681 Binary files /dev/null and b/templates/cdn/bane.png differ diff --git a/templates/cdn/batrider.png b/templates/cdn/batrider.png new file mode 100644 index 0000000..437816c Binary files /dev/null and b/templates/cdn/batrider.png differ diff --git a/templates/cdn/beastmaster.png b/templates/cdn/beastmaster.png new file mode 100644 index 0000000..5b6b342 Binary files /dev/null and b/templates/cdn/beastmaster.png differ diff --git a/templates/cdn/bloodseeker.png b/templates/cdn/bloodseeker.png new file mode 100644 index 0000000..52890b5 Binary files /dev/null and b/templates/cdn/bloodseeker.png differ diff --git a/templates/cdn/bounty_hunter.png b/templates/cdn/bounty_hunter.png new file mode 100644 index 0000000..93cd2ed Binary files /dev/null and b/templates/cdn/bounty_hunter.png differ diff --git a/templates/cdn/brewmaster.png b/templates/cdn/brewmaster.png new file mode 100644 index 0000000..19dc134 Binary files /dev/null and b/templates/cdn/brewmaster.png differ diff --git a/templates/cdn/bristleback.png b/templates/cdn/bristleback.png new file mode 100644 index 0000000..6f8034f Binary files /dev/null and b/templates/cdn/bristleback.png differ diff --git a/templates/cdn/broodmother.png b/templates/cdn/broodmother.png new file mode 100644 index 0000000..a503054 Binary files /dev/null and b/templates/cdn/broodmother.png differ diff --git a/templates/cdn/centaur.png b/templates/cdn/centaur.png new file mode 100644 index 0000000..3767041 Binary files /dev/null and b/templates/cdn/centaur.png differ diff --git a/templates/cdn/chaos_knight.png b/templates/cdn/chaos_knight.png new file mode 100644 index 0000000..7d82bd0 Binary files /dev/null and b/templates/cdn/chaos_knight.png differ diff --git a/templates/cdn/chen.png b/templates/cdn/chen.png new file mode 100644 index 0000000..19cef76 Binary files /dev/null and b/templates/cdn/chen.png differ diff --git a/templates/cdn/clinkz.png b/templates/cdn/clinkz.png new file mode 100644 index 0000000..36cb208 Binary files /dev/null and b/templates/cdn/clinkz.png differ diff --git a/templates/cdn/crystal_maiden.png b/templates/cdn/crystal_maiden.png new file mode 100644 index 0000000..51df569 Binary files /dev/null and b/templates/cdn/crystal_maiden.png differ diff --git a/templates/cdn/dark_seer.png b/templates/cdn/dark_seer.png new file mode 100644 index 0000000..de861fc Binary files /dev/null and b/templates/cdn/dark_seer.png differ diff --git a/templates/cdn/dark_willow.png b/templates/cdn/dark_willow.png new file mode 100644 index 0000000..912fff5 Binary files /dev/null and b/templates/cdn/dark_willow.png differ diff --git a/templates/cdn/dawnbreaker.png b/templates/cdn/dawnbreaker.png new file mode 100644 index 0000000..ff270c2 Binary files /dev/null and b/templates/cdn/dawnbreaker.png differ diff --git a/templates/cdn/dazzle.png b/templates/cdn/dazzle.png new file mode 100644 index 0000000..5c1e16d Binary files /dev/null and b/templates/cdn/dazzle.png differ diff --git a/templates/cdn/death_prophet.png b/templates/cdn/death_prophet.png new file mode 100644 index 0000000..b3846cc Binary files /dev/null and b/templates/cdn/death_prophet.png differ diff --git a/templates/cdn/disruptor.png b/templates/cdn/disruptor.png new file mode 100644 index 0000000..b9f66b4 Binary files /dev/null and b/templates/cdn/disruptor.png differ diff --git a/templates/cdn/doom_bringer.png b/templates/cdn/doom_bringer.png new file mode 100644 index 0000000..630382c Binary files /dev/null and b/templates/cdn/doom_bringer.png differ diff --git a/templates/cdn/dragon_knight.png b/templates/cdn/dragon_knight.png new file mode 100644 index 0000000..e6d7b36 Binary files /dev/null and b/templates/cdn/dragon_knight.png differ diff --git a/templates/cdn/drow_ranger.png b/templates/cdn/drow_ranger.png new file mode 100644 index 0000000..7f71d07 Binary files /dev/null and b/templates/cdn/drow_ranger.png differ diff --git a/templates/cdn/earth_spirit.png b/templates/cdn/earth_spirit.png new file mode 100644 index 0000000..1a68cfd Binary files /dev/null and b/templates/cdn/earth_spirit.png differ diff --git a/templates/cdn/earthshaker.png b/templates/cdn/earthshaker.png new file mode 100644 index 0000000..72b6d78 Binary files /dev/null and b/templates/cdn/earthshaker.png differ diff --git a/templates/cdn/elder_titan.png b/templates/cdn/elder_titan.png new file mode 100644 index 0000000..6b951ad Binary files /dev/null and b/templates/cdn/elder_titan.png differ diff --git a/templates/cdn/ember_spirit.png b/templates/cdn/ember_spirit.png new file mode 100644 index 0000000..6d0bf8f Binary files /dev/null and b/templates/cdn/ember_spirit.png differ diff --git a/templates/cdn/enchantress.png b/templates/cdn/enchantress.png new file mode 100644 index 0000000..943b4da Binary files /dev/null and b/templates/cdn/enchantress.png differ diff --git a/templates/cdn/enigma.png b/templates/cdn/enigma.png new file mode 100644 index 0000000..e98481e Binary files /dev/null and b/templates/cdn/enigma.png differ diff --git a/templates/cdn/faceless_void.png b/templates/cdn/faceless_void.png new file mode 100644 index 0000000..3522a48 Binary files /dev/null and b/templates/cdn/faceless_void.png differ diff --git a/templates/cdn/furion.png b/templates/cdn/furion.png new file mode 100644 index 0000000..79e2b96 Binary files /dev/null and b/templates/cdn/furion.png differ diff --git a/templates/cdn/grimstroke.png b/templates/cdn/grimstroke.png new file mode 100644 index 0000000..3b6169c Binary files /dev/null and b/templates/cdn/grimstroke.png differ diff --git a/templates/cdn/gyrocopter.png b/templates/cdn/gyrocopter.png new file mode 100644 index 0000000..ed3733d Binary files /dev/null and b/templates/cdn/gyrocopter.png differ diff --git a/templates/cdn/hoodwink.png b/templates/cdn/hoodwink.png new file mode 100644 index 0000000..b140d25 Binary files /dev/null and b/templates/cdn/hoodwink.png differ diff --git a/templates/cdn/huskar.png b/templates/cdn/huskar.png new file mode 100644 index 0000000..20fa6af Binary files /dev/null and b/templates/cdn/huskar.png differ diff --git a/templates/cdn/invoker.png b/templates/cdn/invoker.png new file mode 100644 index 0000000..3ab9380 Binary files /dev/null and b/templates/cdn/invoker.png differ diff --git a/templates/cdn/jakiro.png b/templates/cdn/jakiro.png new file mode 100644 index 0000000..05db34d Binary files /dev/null and b/templates/cdn/jakiro.png differ diff --git a/templates/cdn/juggernaut.png b/templates/cdn/juggernaut.png new file mode 100644 index 0000000..9116429 Binary files /dev/null and b/templates/cdn/juggernaut.png differ diff --git a/templates/cdn/keeper_of_the_light.png b/templates/cdn/keeper_of_the_light.png new file mode 100644 index 0000000..c64a63e Binary files /dev/null and b/templates/cdn/keeper_of_the_light.png differ diff --git a/templates/cdn/kez.png b/templates/cdn/kez.png new file mode 100644 index 0000000..858d0bc Binary files /dev/null and b/templates/cdn/kez.png differ diff --git a/templates/cdn/kunkka.png b/templates/cdn/kunkka.png new file mode 100644 index 0000000..9eda34c Binary files /dev/null and b/templates/cdn/kunkka.png differ diff --git a/templates/cdn/largo.png b/templates/cdn/largo.png new file mode 100644 index 0000000..3d42a2f Binary files /dev/null and b/templates/cdn/largo.png differ diff --git a/templates/cdn/legion_commander.png b/templates/cdn/legion_commander.png new file mode 100644 index 0000000..3015d65 Binary files /dev/null and b/templates/cdn/legion_commander.png differ diff --git a/templates/cdn/leshrac.png b/templates/cdn/leshrac.png new file mode 100644 index 0000000..d022319 Binary files /dev/null and b/templates/cdn/leshrac.png differ diff --git a/templates/cdn/lich.png b/templates/cdn/lich.png new file mode 100644 index 0000000..32fd350 Binary files /dev/null and b/templates/cdn/lich.png differ diff --git a/templates/cdn/life_stealer.png b/templates/cdn/life_stealer.png new file mode 100644 index 0000000..6b0d26d Binary files /dev/null and b/templates/cdn/life_stealer.png differ diff --git a/templates/cdn/lina.png b/templates/cdn/lina.png new file mode 100644 index 0000000..2efd777 Binary files /dev/null and b/templates/cdn/lina.png differ diff --git a/templates/cdn/lion.png b/templates/cdn/lion.png new file mode 100644 index 0000000..8f7a4d3 Binary files /dev/null and b/templates/cdn/lion.png differ diff --git a/templates/cdn/lone_druid.png b/templates/cdn/lone_druid.png new file mode 100644 index 0000000..c95d869 Binary files /dev/null and b/templates/cdn/lone_druid.png differ diff --git a/templates/cdn/luna.png b/templates/cdn/luna.png new file mode 100644 index 0000000..513ea20 Binary files /dev/null and b/templates/cdn/luna.png differ diff --git a/templates/cdn/lycan.png b/templates/cdn/lycan.png new file mode 100644 index 0000000..32d4bc6 Binary files /dev/null and b/templates/cdn/lycan.png differ diff --git a/templates/cdn/magnataur.png b/templates/cdn/magnataur.png new file mode 100644 index 0000000..bdb03f4 Binary files /dev/null and b/templates/cdn/magnataur.png differ diff --git a/templates/cdn/marci.png b/templates/cdn/marci.png new file mode 100644 index 0000000..c4938af Binary files /dev/null and b/templates/cdn/marci.png differ diff --git a/templates/cdn/mars.png b/templates/cdn/mars.png new file mode 100644 index 0000000..0c29673 Binary files /dev/null and b/templates/cdn/mars.png differ diff --git a/templates/cdn/medusa.png b/templates/cdn/medusa.png new file mode 100644 index 0000000..69e0b1a Binary files /dev/null and b/templates/cdn/medusa.png differ diff --git a/templates/cdn/meepo.png b/templates/cdn/meepo.png new file mode 100644 index 0000000..fe8df71 Binary files /dev/null and b/templates/cdn/meepo.png differ diff --git a/templates/cdn/mirana.png b/templates/cdn/mirana.png new file mode 100644 index 0000000..39e103f Binary files /dev/null and b/templates/cdn/mirana.png differ diff --git a/templates/cdn/monkey_king.png b/templates/cdn/monkey_king.png new file mode 100644 index 0000000..d31bc7a Binary files /dev/null and b/templates/cdn/monkey_king.png differ diff --git a/templates/cdn/morphling.png b/templates/cdn/morphling.png new file mode 100644 index 0000000..e99b799 Binary files /dev/null and b/templates/cdn/morphling.png differ diff --git a/templates/cdn/muerta.png b/templates/cdn/muerta.png new file mode 100644 index 0000000..6ad7ae2 Binary files /dev/null and b/templates/cdn/muerta.png differ diff --git a/templates/cdn/naga_siren.png b/templates/cdn/naga_siren.png new file mode 100644 index 0000000..1a4a365 Binary files /dev/null and b/templates/cdn/naga_siren.png differ diff --git a/templates/cdn/necrolyte.png b/templates/cdn/necrolyte.png new file mode 100644 index 0000000..8c76dd9 Binary files /dev/null and b/templates/cdn/necrolyte.png differ diff --git a/templates/cdn/nevermore.png b/templates/cdn/nevermore.png new file mode 100644 index 0000000..cbe5171 Binary files /dev/null and b/templates/cdn/nevermore.png differ diff --git a/templates/cdn/night_stalker.png b/templates/cdn/night_stalker.png new file mode 100644 index 0000000..6b94bc2 Binary files /dev/null and b/templates/cdn/night_stalker.png differ diff --git a/templates/cdn/nyx_assassin.png b/templates/cdn/nyx_assassin.png new file mode 100644 index 0000000..be5f944 Binary files /dev/null and b/templates/cdn/nyx_assassin.png differ diff --git a/templates/cdn/obsidian_destroyer.png b/templates/cdn/obsidian_destroyer.png new file mode 100644 index 0000000..dcf7a3e Binary files /dev/null and b/templates/cdn/obsidian_destroyer.png differ diff --git a/templates/cdn/ogre_magi.png b/templates/cdn/ogre_magi.png new file mode 100644 index 0000000..0db0c6a Binary files /dev/null and b/templates/cdn/ogre_magi.png differ diff --git a/templates/cdn/omniknight.png b/templates/cdn/omniknight.png new file mode 100644 index 0000000..36c51e3 Binary files /dev/null and b/templates/cdn/omniknight.png differ diff --git a/templates/cdn/oracle.png b/templates/cdn/oracle.png new file mode 100644 index 0000000..235406d Binary files /dev/null and b/templates/cdn/oracle.png differ diff --git a/templates/cdn/pangolier.png b/templates/cdn/pangolier.png new file mode 100644 index 0000000..8e7f8dd Binary files /dev/null and b/templates/cdn/pangolier.png differ diff --git a/templates/cdn/phantom_assassin.png b/templates/cdn/phantom_assassin.png new file mode 100644 index 0000000..69518b7 Binary files /dev/null and b/templates/cdn/phantom_assassin.png differ diff --git a/templates/cdn/phantom_lancer.png b/templates/cdn/phantom_lancer.png new file mode 100644 index 0000000..acda6cc Binary files /dev/null and b/templates/cdn/phantom_lancer.png differ diff --git a/templates/cdn/phoenix.png b/templates/cdn/phoenix.png new file mode 100644 index 0000000..d82534f Binary files /dev/null and b/templates/cdn/phoenix.png differ diff --git a/templates/cdn/primal_beast.png b/templates/cdn/primal_beast.png new file mode 100644 index 0000000..d6308b6 Binary files /dev/null and b/templates/cdn/primal_beast.png differ diff --git a/templates/cdn/puck.png b/templates/cdn/puck.png new file mode 100644 index 0000000..26f065e Binary files /dev/null and b/templates/cdn/puck.png differ diff --git a/templates/cdn/pudge.png b/templates/cdn/pudge.png new file mode 100644 index 0000000..5d41bc0 Binary files /dev/null and b/templates/cdn/pudge.png differ diff --git a/templates/cdn/pugna.png b/templates/cdn/pugna.png new file mode 100644 index 0000000..af422c6 Binary files /dev/null and b/templates/cdn/pugna.png differ diff --git a/templates/cdn/queenofpain.png b/templates/cdn/queenofpain.png new file mode 100644 index 0000000..f57fb9a Binary files /dev/null and b/templates/cdn/queenofpain.png differ diff --git a/templates/cdn/rattletrap.png b/templates/cdn/rattletrap.png new file mode 100644 index 0000000..b41d7eb Binary files /dev/null and b/templates/cdn/rattletrap.png differ diff --git a/templates/cdn/razor.png b/templates/cdn/razor.png new file mode 100644 index 0000000..24e4726 Binary files /dev/null and b/templates/cdn/razor.png differ diff --git a/templates/cdn/riki.png b/templates/cdn/riki.png new file mode 100644 index 0000000..56e35e3 Binary files /dev/null and b/templates/cdn/riki.png differ diff --git a/templates/cdn/ringmaster.png b/templates/cdn/ringmaster.png new file mode 100644 index 0000000..2741bea Binary files /dev/null and b/templates/cdn/ringmaster.png differ diff --git a/templates/cdn/rubick.png b/templates/cdn/rubick.png new file mode 100644 index 0000000..051e63b Binary files /dev/null and b/templates/cdn/rubick.png differ diff --git a/templates/cdn/sand_king.png b/templates/cdn/sand_king.png new file mode 100644 index 0000000..cd86700 Binary files /dev/null and b/templates/cdn/sand_king.png differ diff --git a/templates/cdn/shadow_demon.png b/templates/cdn/shadow_demon.png new file mode 100644 index 0000000..f923e48 Binary files /dev/null and b/templates/cdn/shadow_demon.png differ diff --git a/templates/cdn/shadow_shaman.png b/templates/cdn/shadow_shaman.png new file mode 100644 index 0000000..c85f1c9 Binary files /dev/null and b/templates/cdn/shadow_shaman.png differ diff --git a/templates/cdn/shredder.png b/templates/cdn/shredder.png new file mode 100644 index 0000000..7ebbbc1 Binary files /dev/null and b/templates/cdn/shredder.png differ diff --git a/templates/cdn/silencer.png b/templates/cdn/silencer.png new file mode 100644 index 0000000..292a9fb Binary files /dev/null and b/templates/cdn/silencer.png differ diff --git a/templates/cdn/skeleton_king.png b/templates/cdn/skeleton_king.png new file mode 100644 index 0000000..df31e51 Binary files /dev/null and b/templates/cdn/skeleton_king.png differ diff --git a/templates/cdn/skywrath_mage.png b/templates/cdn/skywrath_mage.png new file mode 100644 index 0000000..870d762 Binary files /dev/null and b/templates/cdn/skywrath_mage.png differ diff --git a/templates/cdn/slardar.png b/templates/cdn/slardar.png new file mode 100644 index 0000000..cbf53f8 Binary files /dev/null and b/templates/cdn/slardar.png differ diff --git a/templates/cdn/slark.png b/templates/cdn/slark.png new file mode 100644 index 0000000..900cb94 Binary files /dev/null and b/templates/cdn/slark.png differ diff --git a/templates/cdn/snapfire.png b/templates/cdn/snapfire.png new file mode 100644 index 0000000..69d889f Binary files /dev/null and b/templates/cdn/snapfire.png differ diff --git a/templates/cdn/sniper.png b/templates/cdn/sniper.png new file mode 100644 index 0000000..a6335f8 Binary files /dev/null and b/templates/cdn/sniper.png differ diff --git a/templates/cdn/spectre.png b/templates/cdn/spectre.png new file mode 100644 index 0000000..73e5372 Binary files /dev/null and b/templates/cdn/spectre.png differ diff --git a/templates/cdn/spirit_breaker.png b/templates/cdn/spirit_breaker.png new file mode 100644 index 0000000..12bd29c Binary files /dev/null and b/templates/cdn/spirit_breaker.png differ diff --git a/templates/cdn/storm_spirit.png b/templates/cdn/storm_spirit.png new file mode 100644 index 0000000..58ab792 Binary files /dev/null and b/templates/cdn/storm_spirit.png differ diff --git a/templates/cdn/sven.png b/templates/cdn/sven.png new file mode 100644 index 0000000..6576c6e Binary files /dev/null and b/templates/cdn/sven.png differ diff --git a/templates/cdn/techies.png b/templates/cdn/techies.png new file mode 100644 index 0000000..2b15a3e Binary files /dev/null and b/templates/cdn/techies.png differ diff --git a/templates/cdn/templar_assassin.png b/templates/cdn/templar_assassin.png new file mode 100644 index 0000000..171d3e0 Binary files /dev/null and b/templates/cdn/templar_assassin.png differ diff --git a/templates/cdn/terrorblade.png b/templates/cdn/terrorblade.png new file mode 100644 index 0000000..e4aff13 Binary files /dev/null and b/templates/cdn/terrorblade.png differ diff --git a/templates/cdn/tidehunter.png b/templates/cdn/tidehunter.png new file mode 100644 index 0000000..dff3437 Binary files /dev/null and b/templates/cdn/tidehunter.png differ diff --git a/templates/cdn/tinker.png b/templates/cdn/tinker.png new file mode 100644 index 0000000..72f8989 Binary files /dev/null and b/templates/cdn/tinker.png differ diff --git a/templates/cdn/tiny.png b/templates/cdn/tiny.png new file mode 100644 index 0000000..5b6888e Binary files /dev/null and b/templates/cdn/tiny.png differ diff --git a/templates/cdn/treant.png b/templates/cdn/treant.png new file mode 100644 index 0000000..6626d8a Binary files /dev/null and b/templates/cdn/treant.png differ diff --git a/templates/cdn/troll_warlord.png b/templates/cdn/troll_warlord.png new file mode 100644 index 0000000..b3b26c1 Binary files /dev/null and b/templates/cdn/troll_warlord.png differ diff --git a/templates/cdn/tusk.png b/templates/cdn/tusk.png new file mode 100644 index 0000000..5f89014 Binary files /dev/null and b/templates/cdn/tusk.png differ diff --git a/templates/cdn/undying.png b/templates/cdn/undying.png new file mode 100644 index 0000000..951a0c3 Binary files /dev/null and b/templates/cdn/undying.png differ diff --git a/templates/cdn/ursa.png b/templates/cdn/ursa.png new file mode 100644 index 0000000..9646d93 Binary files /dev/null and b/templates/cdn/ursa.png differ diff --git a/templates/cdn/vengefulspirit.png b/templates/cdn/vengefulspirit.png new file mode 100644 index 0000000..22bbd2e Binary files /dev/null and b/templates/cdn/vengefulspirit.png differ diff --git a/templates/cdn/venomancer.png b/templates/cdn/venomancer.png new file mode 100644 index 0000000..5f8a731 Binary files /dev/null and b/templates/cdn/venomancer.png differ diff --git a/templates/cdn/viper.png b/templates/cdn/viper.png new file mode 100644 index 0000000..741f276 Binary files /dev/null and b/templates/cdn/viper.png differ diff --git a/templates/cdn/visage.png b/templates/cdn/visage.png new file mode 100644 index 0000000..39fea47 Binary files /dev/null and b/templates/cdn/visage.png differ diff --git a/templates/cdn/void_spirit.png b/templates/cdn/void_spirit.png new file mode 100644 index 0000000..538f3ad Binary files /dev/null and b/templates/cdn/void_spirit.png differ diff --git a/templates/cdn/warlock.png b/templates/cdn/warlock.png new file mode 100644 index 0000000..0079ef7 Binary files /dev/null and b/templates/cdn/warlock.png differ diff --git a/templates/cdn/weaver.png b/templates/cdn/weaver.png new file mode 100644 index 0000000..2671871 Binary files /dev/null and b/templates/cdn/weaver.png differ diff --git a/templates/cdn/windrunner.png b/templates/cdn/windrunner.png new file mode 100644 index 0000000..7afb98b Binary files /dev/null and b/templates/cdn/windrunner.png differ diff --git a/templates/cdn/winter_wyvern.png b/templates/cdn/winter_wyvern.png new file mode 100644 index 0000000..34d6df4 Binary files /dev/null and b/templates/cdn/winter_wyvern.png differ diff --git a/templates/cdn/wisp.png b/templates/cdn/wisp.png new file mode 100644 index 0000000..537f036 Binary files /dev/null and b/templates/cdn/wisp.png differ diff --git a/templates/cdn/witch_doctor.png b/templates/cdn/witch_doctor.png new file mode 100644 index 0000000..97d611f Binary files /dev/null and b/templates/cdn/witch_doctor.png differ diff --git a/templates/cdn/zuus.png b/templates/cdn/zuus.png new file mode 100644 index 0000000..cceda39 Binary files /dev/null and b/templates/cdn/zuus.png differ diff --git a/templates/modes/all_pick.png b/templates/modes/all_pick.png new file mode 100644 index 0000000..c414b0a Binary files /dev/null and b/templates/modes/all_pick.png differ diff --git a/templates/real/axe/20260725_200018_s8.png b/templates/real/axe/20260725_200018_s8.png new file mode 100644 index 0000000..4157f59 Binary files /dev/null and b/templates/real/axe/20260725_200018_s8.png differ diff --git a/templates/real/bloodseeker/20260725_210215_s5.png b/templates/real/bloodseeker/20260725_210215_s5.png new file mode 100644 index 0000000..15c8ea7 Binary files /dev/null and b/templates/real/bloodseeker/20260725_210215_s5.png differ diff --git a/templates/real/centaur/20260725_160212_s9.png b/templates/real/centaur/20260725_160212_s9.png new file mode 100644 index 0000000..eec7279 Binary files /dev/null and b/templates/real/centaur/20260725_160212_s9.png differ diff --git a/templates/real/crystal_maiden/20260725_150302_s1.png b/templates/real/crystal_maiden/20260725_150302_s1.png new file mode 100644 index 0000000..0a3f88c Binary files /dev/null and b/templates/real/crystal_maiden/20260725_150302_s1.png differ diff --git a/templates/real/death_prophet/20260725_150246_s8.png b/templates/real/death_prophet/20260725_150246_s8.png new file mode 100644 index 0000000..acbd476 Binary files /dev/null and b/templates/real/death_prophet/20260725_150246_s8.png differ diff --git a/templates/real/dragon_knight/20260725_151116_s3.png b/templates/real/dragon_knight/20260725_151116_s3.png new file mode 100644 index 0000000..81bd1ac Binary files /dev/null and b/templates/real/dragon_knight/20260725_151116_s3.png differ diff --git a/templates/real/drow_ranger/20260725_150246_s7.png b/templates/real/drow_ranger/20260725_150246_s7.png new file mode 100644 index 0000000..0975141 Binary files /dev/null and b/templates/real/drow_ranger/20260725_150246_s7.png differ diff --git a/templates/real/earthshaker/20260725_150246_s2.png b/templates/real/earthshaker/20260725_150246_s2.png new file mode 100644 index 0000000..5a430dd Binary files /dev/null and b/templates/real/earthshaker/20260725_150246_s2.png differ diff --git a/templates/real/elder_titan/20260725_150711_s1.png b/templates/real/elder_titan/20260725_150711_s1.png new file mode 100644 index 0000000..b3d80e3 Binary files /dev/null and b/templates/real/elder_titan/20260725_150711_s1.png differ diff --git a/templates/real/hoodwink/20260725_200018_s7.png b/templates/real/hoodwink/20260725_200018_s7.png new file mode 100644 index 0000000..2fd2bb1 Binary files /dev/null and b/templates/real/hoodwink/20260725_200018_s7.png differ diff --git a/templates/real/invoker/20260725_160212_s4.png b/templates/real/invoker/20260725_160212_s4.png new file mode 100644 index 0000000..8ac7257 Binary files /dev/null and b/templates/real/invoker/20260725_160212_s4.png differ diff --git a/templates/real/juggernaut/20260725_150302_s9.png b/templates/real/juggernaut/20260725_150302_s9.png new file mode 100644 index 0000000..d428e16 Binary files /dev/null and b/templates/real/juggernaut/20260725_150302_s9.png differ diff --git a/templates/real/kunkka/20260725_210215_s4.png b/templates/real/kunkka/20260725_210215_s4.png new file mode 100644 index 0000000..75ae874 Binary files /dev/null and b/templates/real/kunkka/20260725_210215_s4.png differ diff --git a/templates/real/legion_commander/20260725_160212_s1.png b/templates/real/legion_commander/20260725_160212_s1.png new file mode 100644 index 0000000..da97f6c Binary files /dev/null and b/templates/real/legion_commander/20260725_160212_s1.png differ diff --git a/templates/real/lich/20260725_150246_s9.png b/templates/real/lich/20260725_150246_s9.png new file mode 100644 index 0000000..c7dfc6b Binary files /dev/null and b/templates/real/lich/20260725_150246_s9.png differ diff --git a/templates/real/lion/20260725_150246_s5.png b/templates/real/lion/20260725_150246_s5.png new file mode 100644 index 0000000..4bd78cb Binary files /dev/null and b/templates/real/lion/20260725_150246_s5.png differ diff --git a/templates/real/luna/20260725_210215_s8.png b/templates/real/luna/20260725_210215_s8.png new file mode 100644 index 0000000..482c708 Binary files /dev/null and b/templates/real/luna/20260725_210215_s8.png differ diff --git a/templates/real/necrolyte/20260725_150302_s7.png b/templates/real/necrolyte/20260725_150302_s7.png new file mode 100644 index 0000000..0a5fe46 Binary files /dev/null and b/templates/real/necrolyte/20260725_150302_s7.png differ diff --git a/templates/real/phantom_assassin/20260725_160212_s7.png b/templates/real/phantom_assassin/20260725_160212_s7.png new file mode 100644 index 0000000..9e26bb9 Binary files /dev/null and b/templates/real/phantom_assassin/20260725_160212_s7.png differ diff --git a/templates/real/queenofpain/20260725_210215_s10.png b/templates/real/queenofpain/20260725_210215_s10.png new file mode 100644 index 0000000..e400259 Binary files /dev/null and b/templates/real/queenofpain/20260725_210215_s10.png differ diff --git a/templates/real/rubick/20260725_160212_s10.png b/templates/real/rubick/20260725_160212_s10.png new file mode 100644 index 0000000..a05f812 Binary files /dev/null and b/templates/real/rubick/20260725_160212_s10.png differ diff --git a/templates/real/shadow_shaman/20260725_200018_s3.png b/templates/real/shadow_shaman/20260725_200018_s3.png new file mode 100644 index 0000000..c270cb6 Binary files /dev/null and b/templates/real/shadow_shaman/20260725_200018_s3.png differ diff --git a/templates/real/silencer/20260725_160212_s5.png b/templates/real/silencer/20260725_160212_s5.png new file mode 100644 index 0000000..6af32c3 Binary files /dev/null and b/templates/real/silencer/20260725_160212_s5.png differ diff --git a/templates/real/skeleton_king/20260725_150302_s3.png b/templates/real/skeleton_king/20260725_150302_s3.png new file mode 100644 index 0000000..8775afd Binary files /dev/null and b/templates/real/skeleton_king/20260725_150302_s3.png differ diff --git a/templates/real/skywrath_mage/20260725_160212_s6.png b/templates/real/skywrath_mage/20260725_160212_s6.png new file mode 100644 index 0000000..f7683cd Binary files /dev/null and b/templates/real/skywrath_mage/20260725_160212_s6.png differ diff --git a/templates/real/sniper/20260725_150302_s4.png b/templates/real/sniper/20260725_150302_s4.png new file mode 100644 index 0000000..aba43fb Binary files /dev/null and b/templates/real/sniper/20260725_150302_s4.png differ diff --git a/templates/real/sven/20260725_150302_s5.png b/templates/real/sven/20260725_150302_s5.png new file mode 100644 index 0000000..5f3101c Binary files /dev/null and b/templates/real/sven/20260725_150302_s5.png differ diff --git a/templates/real/tidehunter/20260725_150711_s7.png b/templates/real/tidehunter/20260725_150711_s7.png new file mode 100644 index 0000000..fcb0eb0 Binary files /dev/null and b/templates/real/tidehunter/20260725_150711_s7.png differ diff --git a/templates/real/undying/20260725_151116_s1.png b/templates/real/undying/20260725_151116_s1.png new file mode 100644 index 0000000..bab95ec Binary files /dev/null and b/templates/real/undying/20260725_151116_s1.png differ diff --git a/templates/real/vengefulspirit/20260725_150302_s5.png b/templates/real/vengefulspirit/20260725_150302_s5.png new file mode 100644 index 0000000..6be133d Binary files /dev/null and b/templates/real/vengefulspirit/20260725_150302_s5.png differ diff --git a/templates/real/venomancer/20260725_210215_s2.png b/templates/real/venomancer/20260725_210215_s2.png new file mode 100644 index 0000000..5a91175 Binary files /dev/null and b/templates/real/venomancer/20260725_210215_s2.png differ diff --git a/templates/real/viper/20260725_150302_s3.png b/templates/real/viper/20260725_150302_s3.png new file mode 100644 index 0000000..73198cc Binary files /dev/null and b/templates/real/viper/20260725_150302_s3.png differ diff --git a/templates/real/warlock/20260725_150302_s2.png b/templates/real/warlock/20260725_150302_s2.png new file mode 100644 index 0000000..ed37d95 Binary files /dev/null and b/templates/real/warlock/20260725_150302_s2.png differ diff --git a/templates/real/weaver/20260725_210215_s3.png b/templates/real/weaver/20260725_210215_s3.png new file mode 100644 index 0000000..911549c Binary files /dev/null and b/templates/real/weaver/20260725_210215_s3.png differ diff --git a/templates/real/witch_doctor/20260725_150246_s3.png b/templates/real/witch_doctor/20260725_150246_s3.png new file mode 100644 index 0000000..6b25fe8 Binary files /dev/null and b/templates/real/witch_doctor/20260725_150246_s3.png differ diff --git a/templates/real/zuus/20260725_150302_s7.png b/templates/real/zuus/20260725_150302_s7.png new file mode 100644 index 0000000..fbca4f2 Binary files /dev/null and b/templates/real/zuus/20260725_150302_s7.png differ diff --git a/templates/roles/hard_support.png b/templates/roles/hard_support.png new file mode 100644 index 0000000..927c467 Binary files /dev/null and b/templates/roles/hard_support.png differ diff --git a/templates/roles/mid.png b/templates/roles/mid.png new file mode 100644 index 0000000..c8a5d7c Binary files /dev/null and b/templates/roles/mid.png differ diff --git a/templates/roles/off.png b/templates/roles/off.png new file mode 100644 index 0000000..4a3fd65 Binary files /dev/null and b/templates/roles/off.png differ diff --git a/templates/roles/safe.png b/templates/roles/safe.png new file mode 100644 index 0000000..82c4661 Binary files /dev/null and b/templates/roles/safe.png differ diff --git a/templates/roles/soft_support.png b/templates/roles/soft_support.png new file mode 100644 index 0000000..a7d98d2 Binary files /dev/null and b/templates/roles/soft_support.png differ