玩《暗黑破坏神IV》时,自动统计你打了多少次各类副本和活动,不用手动记账。
只读屏幕像素——不读游戏内存、不注入 DLL、不装键盘钩子、不模拟输入。
| 活动 | 怎么判断"打完了" | 自动 |
|---|---|---|
| 梦魇地下城 | 顶部居中横幅 梦魇地下城完成 |
✅ |
| 深坑 | 顶部居中横幅 地下城完成 |
✅ |
| 炼狱魔潮 | 波次计数器 波次:10/10 |
✅ |
| 库拉斯特地下城 | 右侧目标面板 区域通关 |
✅ |
| 秘语之树 | 中央奖励面板 使用此宝匣获取你的奖励。 |
✅ |
| 巢穴首领 | 目标行从 击败<首领>… 变成 打开<首领>的秘宝 |
✅ |
| 地狱狂潮 | 小地图左侧的畸变余烬 / 灾祸之心数值 | ✅ 读数 |
| 想记的任何东西 | 自己加条目,+1 / −1 / 直接填数字 |
手动 |
- 到 Releases 下载
D4Tracker.exe(或从源码跑,见下) - 双击运行,点「开始」
- 进游戏正常玩 —— 打完一把它会自己记账
窗口里能实时看到每类模板的当前匹配分数和横幅区域预览,所以万一识别不准, 你一眼就能看出是哪块区域的问题。
参数:游戏用窗口化(全屏) / 无边框窗口,关闭 HDR,字体大小保持默认。 独占全屏下抓不到画面(这是 Windows 的限制,不是工具的问题)。
D4 没有战斗日志、没有开放 API(暴雪论坛从 2023 年至今的请求帖没有任何回应); 唯一的结构化数据源是 Overwolf GEP,但要引入整个 Overwolf 平台。
暴雪蓝贴点名 TurboHUD4(读内存)可永久封号,判据是是否 "modify / automate / interfere"。屏幕捕获 + 图像识别不碰进程、不注入、不模拟输入,是同类工具 (d4lf、Diablo4Companion)长期公开使用且未被处理的做法。
技术上:PrintWindow(PW_RENDERFULLCONTENT) 让窗口自己渲染一份到我们的 DC(游戏被
别的窗口挡住也能抓到),再对固定的 UI 区域做文字掩膜 + IoU 模板匹配;地狱狂潮那
两个数字则跳过检测模型直接送识别,并用滑动窗口投票压噪声。
跑在 2560×1440、i7 + 16 核的机器上:
| 状态 | CPU | 内存 |
|---|---|---|
| 游戏在前台、3fps 持续抓帧 | 20.6% 单核(总算力 1.3%) | 259 MB |
| 游戏不在前台(自动跳过抓帧) | 2.3% 单核 | 213 MB |
完全离线可用:代码里没有任何网络调用,运行时不产生任何 TCP 连接, OCR 模型和模板都打进 exe 里了。
py -3.13 -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
.\run-counter.cmd # 主界面
.\run-sampler.cmd # 样本采集器(想加新副本类型时用)打包成 exe:
.venv\Scripts\python.exe tools\make_icon.py
.venv\Scripts\python.exe tools\build_exe.py # 单文件夹版
.venv\Scripts\python.exe tools\build_exe.py --onefile # 单文件版自检(不需要游戏、不需要样本):
.venv\Scripts\python.exe -m d4tracker --selftestrun_tracker.py 打包入口(也是 exe 实际执行的脚本,带全局异常兜底)
run-counter.cmd 源码方式启动计数器
run-sampler.cmd 源码方式启动样本采集器
d4tracker/
capture.py 窗口查找 + 抓帧(PrintWindow 主路径 / BitBlt 回退)
hotkeys.py 全局热键(Win32 RegisterHotKey,无键盘钩子)
imageio.py 非 ASCII 路径安全的图像读写
paths.py 源码 / 打包两种形态下的只读资源与可写数据目录
sampler.py 样本采集器:环形缓冲 + 热键落盘
detect.py 完成信号识别(固定 ROI 比对 + 条带滑动搜索)
counter.py 状态机:逐帧分数 -> 事件(上升沿 + 释放间隔 + 冷却)
values.py 数值读取(地狱狂潮余烬/灾祸之心):多变体 + 窗口投票
store.py 计数存储(SQLite:events 事件表 + readings 数值时间线)
kinds.py 目标活动登记表 + 样本期望映射
monitor.py 实时监视 + 主界面
config/ 热键 → 标签映射(采集器用)
templates/ 6 个信号模板 + templates.json(只读资源,进 exe)
assets/ 图标(只读资源,进 exe)
data/ counts.db、selftest.log、crash.log(可写数据,不进 exe)
dist/ 打包产物
tools/
snapshot.py 抓帧通道快速自检
contact.py 样本联络表(每组抽 9 帧拼图 + 帧间变化点)
crop.py 按区域裁剪并纵排多帧(读原生分辨率文案)
ocr_probe.py RapidOCR 探针,把区域文字打出来
find_signal.py 全屏 OCR 找瞬现文案(不预设区域)
train.py 从样本裁出模板
validate.py 全样本分离度与阈值标定
replay.py 状态机回放(按期望类型校验)
status.py 诊断:数据库里记了什么 + 各模板此刻的分数
bench.py 分段计时:抓帧 / 匹配 / 数值读取各占多少
make_icon.py 生成多尺寸 ico
build_exe.py PyInstaller 打包
两种产物可以装在同一个目录、共用同一份 data/counts.db(否则各记各的,计数会分裂):
| 产物 | 体积 | 启动 | 用途 |
|---|---|---|---|
D4Tracker.exe + 支撑目录 |
5.3 MB + 314 MB | 约 1 秒 | 日常用(推荐) |
D4Tracker-portable.exe |
130.7 MB 单文件 | 约 3 秒 | 拷到别的机器 |
EXE 在 Releases 页面下载。 打包命令见上面的「从源码运行」。
-
三段可拖拽:活动计数 / 识别状态 / 日志,中间的分界条能拖,窗口大小和分界线 位置都记在
data/ui.ini里。 -
每行都有
−和+:放在一起就是为了防误触。−的语义是"删掉该项目最近一条 记录",不是减一个负数 —— 计数是数出来的,往回退就该少一条,这样任何一行的数都能 追到具体时刻。 -
双击「今日」格直接填数字:多了补记录、少了删最新的几条,用来把已有进度补进来。
-
自定义条目(不自动抓取,自己填):可新增 / 重命名 / 删除。改名会连同历史记录 一起改,否则旧计数会"消失";删除会连同历史一起清掉。
-
悬浮窗:点工具条上的「悬浮窗」开启,之后最小化主窗口就会在屏幕上留一个 半透明小窗,只显示各项目的今日 / 总计次数。拖动可移位置,右键隐藏,位置记在
data/ui.ini里。恢复主窗口它就自动收起。两个细节值得说明:半透明是画出来的(背景带 alpha、文字保持不透明),不是整体 降透明度 —— 后者会把字一起淡化,贴在游戏画面上读不清;窗口还用了
WA_ShowWithoutActivating,显示时不会把游戏切到后台(否则反而会让抓帧跳过)。悬浮窗默认从屏幕捕获里排除(
SetWindowDisplayAffinity),这样它压在某块识别 区域上也不会干扰模板匹配 —— 代价是在 OBS / 截图里同样看不见它。想边直播边显示 计数,设环境变量D4TRACKER_OVERLAY_CAPTURABLE=1关掉这个排除。 -
设置存
data/ui.ini而不是注册表:便携、看得见、删掉就重置。
各类活动的"完成"表现不在同一处,所以模板各自带 ROI(由样本实测得到,按参考 分辨率 2560x1440 归一化存储):
| 活动 | 信号文案 | 位置 | 模板 |
|---|---|---|---|
| 梦魇地下城 | 梦魇地下城完成 |
顶部居中横幅 (1080,45) | nightmare_complete |
| 深坑 | 地下城完成 |
顶部居中横幅 (1080,45) | dungeon_complete |
| 炼狱魔潮 | 波次:10/10 |
上方波次计数 (1898,45) | hordes_complete |
| 库拉斯特地下城 | 区域通关 |
右侧目标面板 (2330,603) | kurast_complete |
| 秘语之树 | 使用此宝匣获取你的奖励。 |
中央奖励面板 (1540,685) | tree_turnin |
| 巢穴首领 | 打开<首领>的秘宝 |
右侧第一条目标行(条带搜索) | lair_complete |
击杀后右侧目标行会这样演变(实测 220211 那组)::
#000 击败齐尔领主的折磨回响
#008 (无文字) <- 过渡
#010 打开齐尔领主的秘宝 <- 完成信号,停留约 4~6 帧
#016 (无文字)
#020 使用血腥祭坛召唤齐尔领主的折磨回响 <- 新一轮
首领名长度不同,整串的起止位置就不同,固定 ROI 一定对不齐。所以用了新的模板类型:
在 y 484~528 那条目标行内滑动搜索不变的尾巴 的秘宝。
两个必须注意的点:
- 条带只盖第一条目标行。下面常驻的加成说明
提升小队成员从秘宝中获得额外宝物的几率里也有"秘宝"二字,靠 y 分隔干净。 - 搜索的评分必须在最佳位置上算真正的 IoU。第一版只算召回(模板像素被覆盖的比例), 结果在密集文字处随便对上一部分就能拿 0.58~0.70,几乎所有巢穴首领样本都被误报; 把并集也计进来才分得开(分离度 0.631)。
这两个不是事件而是数值:随拾取增长、去祭坛时被消耗。所以机制不同 —— 周期性读数字,
只在数值变化时落库,于是 readings 表本身就是一条变化时间线,"今日峰值"和
"今日累计获得"(只累加正增量,消耗导致的下降不计)都从它算出来。
位置:小地图左侧那块面板,上面是倒计时 地狱狂潮:48:30,下面两个圆形图标
(左=火焰=畸变余烬,右=恶魔=灾祸之心),数字在图标正下方,字形只有约 12x23 像素。
三个坑:
- 检测模型根本读不到它。 RapidOCR 完整流程里
min_height默认 30,23 像素高的 字形被高度过滤器丢掉;把min_height调到 6 也没用(实测 6/10/30 结果一样)。 直接调text_recognizer就对了,而且只要几毫秒。 - 固定阈值切分在亮场景下失效。 实测亮场景里阈值 150 会把整块面板算成前景
(包围盒变成整个 ROI),读出
57、26这种垃圾。改用局部 Otsu。 - 单帧读数仍然不能信。 局部 Otsu 让余烬稳了,但灾祸之心的
7会有一半概率 退化成1(装饰字体的笔画被二值化切断)。
最终用多变体 + 时间窗口投票:每帧用三种切分各读一次,结果投进滑动窗口取众数,
并要求众数明显领先第二名。实测三个样本六个槽位全部给出正确众数,优势很大
(正确值 2839 票,第二名最多 8 票)。代价是两槽约 3050ms,和抓帧一个量级,
所以真机约 1Hz 读一次即可(余烬变化很慢)。
1. 右上角不是信号区。 同一块区域实测出现过 地狱狂潮:48:23(狂潮倒计时)、
层数 4/3(地下城进度)、角色C…火焰抗性(角色面板)、众灵给了你力量(祭坛)。
它是通用信息区。
2. 已完成地下城 是通用标记,不能当作炼狱魔潮的信号。 第一版拿它做
hordes_complete 的模板,回放时发现它在深坑样本里同样出现(同一位置、同样文案),
把深坑算成了魔潮(误报到 0.69~0.74)。改用 波次:10/10 —— 波次计数器只有魔潮有。
- 顶部横幅是接近纯白的字,阈值 190 能取到;
- 秘语之树奖励面板的字只有 120~171,用 190 一个像素都取不到;
- 若改用 70,暗场景下整片黑底都会被当成文字(实测暗色像素占 ROI 的 86%)。
所以阈值由建模板时对该 ROI 跑 Otsu 定下并固定存进模板。Otsu 对这种"黑底 + 灰字" 的双峰分布很准;固定不变是为了让同一面板在不同帧上得到一致的掩膜(自适应阈值会让 IoU 没法比)。各模板实测阈值 61~125,文字占比 7.7%~15.2% —— 是文字该有的密度。
tools/validate.py 的正确口径:对每个模板,负样本 = 所有"期望类型不是它"的目录,
包含其它类型的正例。第一版只把「反例/手动抓帧/测试样本」当负样本,于是完全看不见
"正例但类型认错"这类最隐蔽的错误。
| 模板 | 正例最高 | 负例最高 | 分离度 |
|---|---|---|---|
| 梦魇地下城 | 1.000 | 0.265 | 0.735 |
| 深坑 | 1.000 | 0.363 | 0.637 |
| 炼狱魔潮 | 1.000 | 0.158 | 0.842 |
| 库拉斯特地下城 | 1.000 | 0.225 | 0.775 |
| 秘语之树 | 1.000 | 0.159 | 0.841 |
| 巢穴首领(条带搜索) | 1.000 | 0.369 | 0.631 |
tools/replay.py 按"期望类型"逐组校验:23/33 通过,零误报(既无类型认错,也无
重复计数)。10 组"缺事件"全部是样本里根本没有信号:巢穴首领 7 组只有 1 组抓到了
打开…的秘宝 那一刻(它只停留 4~6 帧),另有梦魇 2 组、深坑 1 组、秘语之树 1 组是
采集时序错过。这是采集问题,不是检测问题。
横幅/面板停留 1.34.7 秒,3fps 下就是 414 帧连续命中。必须只算一次。
- 连续命中
confirm_frames帧才算"出现"(滤单帧噪点); - 消失的判定要更迟钝:必须连续
release_gap秒低于阈值,才认为这次结束了 —— 否则淡出时分数在阈值附近抖动,会被拆成两次事件; - 再加一道 per-kind 冷却兜底(真副本不可能 20 秒内完成两次)。
梦魇 210543 的横幅连续 14 帧在阈值之上,状态机在第 2 帧确认并触发一次,之后进入
active 状态不再重复。
Python 3.13(.venv 已就绪,909 MB),依赖:PySide6 / opencv-python-headless /
numpy / mss / rapidocr-onnxruntime。版本都锁在 requirements.txt 里,环境删了也能重建:
py -3.13 -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt `
-i https://pypi.tuna.tsinghua.edu.cn/simple
.venv只对开发有用(采集样本、重新标定模板、重打 exe)。发布出去的 exe 是 自包含的,删掉.venv不影响它运行。
两个必须记住的坑:
- 选
opencv-python-headless而不是opencv-python:后者自带一套 Qt 插件会和 PySide6 打架。但 RapidOCR 依赖opencv-python,装完会把完整版拖回来, 两者又装在同一个cv2目录下 —— 卸载任一个都会把共用的文件删掉,留下 "pip 说已安装、import cv2却失败"的状态。修复方式是pip install --force-reinstall --no-deps opencv-python-headless。 cv2.imread/cv2.imwrite在 Windows 上都不支持非 ASCII 路径:读返回None, 写静默失败(返回False不抛异常)。本项目标签全是中文,所以一律走d4tracker/imageio.py。
完全离线可用。 代码里没有任何网络调用(grep 过 requests/urllib/socket/http,
零命中),运行期间进程没有任何 TCP 连接(实测确认)。OCR 模型(13.4 MB,3 个
.onnx)和 6 个模板都打进包里,SQLite 是本地的。唯一需要联网的是开发侧的
pip install 和打包,跟运行时无关。
实测(2560x1440 全屏抓帧,16 核机器):
| 状态 | CPU | 内存 |
|---|---|---|
| 游戏在前台、3fps 持续抓帧(GUI) | 20.6% 单核(16 核总算力 1.3%) | 259 MB |
| 游戏不在前台(自动跳过抓帧) | 2.3% 单核 | 213 MB |
| 打包后 exe 刚启动未监视 | ≈0 | 133 MB |
分段计时(tools/bench.py,可随时复测):
| 阶段 | 均值 | 备注 |
|---|---|---|
| 抓帧 | ~29 ms | 绝对大头,PrintWindow/BitBlt 的固有成本 |
| 模板匹配 | ~2 ms | 可忽略 |
| 数值读取 | 中位 14 ms | 每 6 帧一次(0.5Hz),变化很慢够用 |
- 抓帧去掉两次整帧拷贝(59ms → 29ms)。原来每帧
string_at拷一份 14.7MB、ascontiguousarray再拷一份,而实际只用到 6 个小 ROI。改成np.ctypeslib.as_array把 GDI 缓冲映射成零拷贝视图,只在切 ROI 时拷贝那一小块(grab_window(copy=False), 默认仍是True供外部调用方安全持有)。 - 数值读取限制长宽比。识别模型按高度归一化,一个 80×5 的包围盒归一化后宽度会撑到 768,实测能跑到 300ms+ —— 那些"尖刺"就是它。数字的长宽比本来就稳定,直接滤掉 比例异常的候选。另外把识别输入长边限制在 256。
单核占用从优化前的 42% 降到 20.6%,差不多砍半。
窗口右上角「停止」即可(停止识别不影响已记录的计数);游戏不在前台时本来就会跳过抓帧。
嫌开销还想再低可以 --fps 2,但横幅只停留 4~6 帧,2fps 下抓到的帧数偏少,不建议低于 2。
本机 D4 2560×1440 无边框全屏实测:
| 方式 | 耗时 | 结果 |
|---|---|---|
PrintWindow(PW_RENDERFULLCONTENT) |
~40 ms | 正常画面,窗口被遮挡也能抓到 |
BitBlt(mss / ImageGrab 走的也是这条) |
~20 ms | 游戏被遮挡时返回一片平色(通道 std = 0) |
PrintWindow 不带 RENDERFULLCONTENT |
— | 全屏均匀灰,D3D 窗口的典型结果 |
因此:
- 不能用 mss / PIL.ImageGrab 做主路径,独占全屏下会黑屏,被遮挡时会抓到遮挡物;
grab_window(method="auto")在游戏处于前台时先用 BitBlt(省一半), 拿到平色就回退 PrintWindow;不在前台直接走 PrintWindow。
性能上限约 25 fps(PrintWindow 每帧 40ms,这是硬成本)。所以检测不能靠高帧率 轮询 —— 结算面板这类 UI 会停留数秒,2~3 fps 足够,靠"连续 N 帧稳定"去抖动。
1. 先看数据库里到底记了什么
.venv\Scripts\python.exe tools\status.py它列出最近的事件(时间 / 分数 / 连续帧数 / 自动还是手动)、各类计数、数值读数; 游戏开着的话还会抓一帧,打印每个模板此刻的分数和它查的 ROI —— 一眼能看出 是识别没触发,还是压根没出现。
注意 exe 和源码运行用的是两份不同的数据库: exe 在
dist\D4Tracker\data\counts.db,源码在data\counts.db。 读错那份会以为一条记录都没有。指定路径:tools\status.py --db <路径>
2. 看自动留下的现场
工具会自动把两类"没算上"的情况连同画面一起存下来,放在 data\nearmiss\:
| 情况 | 含义 |
|---|---|
*_near_*.png |
分数到了 0.30 但没到阈值 0.55 —— 通常是位置偏了或文字变了 |
*_short_*.png |
分数过了阈值,但连续帧数不够(3fps 下需连续 2 帧 = 0.67 秒)—— 提示一闪而过 |
界面日志里也会同步写一行,例如:
秘语之树:命中但只持续 1 帧(需连续 2 帧),已忽略,图 nearmiss/0921-0123_tree_turnin_short_0.982.png
这两张图就是"事后唯一能查的现场"。没有它,只能重开一局复现 —— 秘语之树出过一次 零识别,当时没有任何现场可查,就是靠补上这个能力才定位的。每个模板最多保留 60 张, 自动滚动删除。