dsh-vision-diff
两张截图进去,出来一句机器可读的结论和一张能给人看的对照页:同一屏两次渲染到底变没变、变在哪。
Pixel-level screenshot diff for DeepSeek Harness. Two local PNGs in; a
PIXEL_CHANGED=true|false verdict, difference regions and a self-contained HTML
comparison page out. Everything runs locally — no model call, no network, no
upload.
它解决什么
截图类插件已经会「看懂内容」:识图、OCR、元素定位、多模态问答。但视觉回归最刚需的那一步没人做——回答「这两张图一样吗」,而且要能用脚本判断,不是让人再看一眼。
所以这个插件的输出以一行字符串开头:
PIXEL_CHANGED=false
一致就是 false,不一致就是 true。grep 一下就能当断言用,后面才是差异比例、差异区域、证据数字和对照页。
它不做内容识别。 它不做「这个按钮是什么」这种判断。它只回答变化,因为那一步是可以做成确定性的,而"看懂"那一步不行。
装
# npm 版 CLI 管理的 profile
dsh plugin --profile <name> add <插件目录>
# 桌面版:desktop profile 由 Electron app 独占,npm 版 CLI 会被拒绝——
# error: profile "desktop" is managed exclusively by the Electron application
# 用它自带的那个 CLI 才行(在 app 的 resources/runtime/cli/bin/dsh 下):
"<app>/resources/runtime/cli/bin/dsh" plugin --profile desktop add <插件目录>
装完重启一次 app(host 半区的改动必须重启进程才会加载;Node 按 URL 缓存 ES 模块,关开插件换不掉已经加载的代码)。重启后对话视图里会出现「视觉差异」Tab,agent 也多了一个 vision_diff 工具。
用
让 agent 调
vision_diff(before: "shots/home-dark.png", after: "shots/home-light.png")
命令行直接看产物
每次比对会在 $DSH_HOME/vision-diff/runs/<id>/ 留一份完整证据,双击 index.html 就能看,不需要服务器。
参数
| 参数 | 必填 | 说明 |
|---|---|---|
before |
✅ | 第一张 PNG 的路径(绝对路径,或相对 cwd) |
after |
✅ | 第二张 PNG 的路径 |
ignore |
忽略区,见下 | |
threshold |
通道差必须大于它才算变化。默认 0。截图有压缩噪声时用 2~8 |
|
blockSize |
判断两个差异像素是否相连的粒度,默认 16。只影响区域如何分块,不影响区域边界 |
|
regionLimit |
最多返回几个差异区域,默认 20,按差异像素数从大到小取 |
|
cwd |
解析相对路径用的目录。不填则用插件配置的工作目录 | |
label |
这次比对的说明,会写进报告和对照页 |
忽略区
每次都会变的部分——时钟、光标、随机广告、动画帧——用忽略区排除掉,否则日报式的比对会天天报"有变化"。
以下写法都接受:
{ "x": 0, "y": 0, "w": 100, "h": 40 }
{ "x1": 0, "y1": 0, "x2": 100, "y2": 40 }
{ "left": 0, "top": 0, "right": 100, "bottom": 40 }
[0, 0, 100, 40]
{ "regions": [ { "x": 0, "y": 0, "w": 100, "h": 40 } ] }
{ "x": 0, "y": 0, "w": 0.1, "h": 0.05, "unit": "fraction" }
unit: "fraction" 时按图片宽高的比例解释,所以同一份配置能跨分辨率复用。
宽度或高度取负值表示反方向(人拖框拖反了就是这么写的)。超出图像的部分会被裁剪而不是报错。
解析不了的忽略区不会被静默忽略——它进 ignoreRejected,并在结论、对照页和 Tab 上都标出来,因为一个失效的忽略区会让差异数字虚高,看起来像被测页面出了问题。
输出
{
"pixelChanged": true,
"verdict": "PIXEL_CHANGED=true",
"width": 1920, "height": 1080,
"afterWidth": 1920, "afterHeight": 1080,
"dimensionChanged": false,
"totalPixels": 2073600,
"comparedPixels": 2073600,
"ignoredPixels": 0,
"diffPixels": 1483,
"diffPercent": 0.071527,
"maxChannelDelta": 255,
"meanAbsError": 0.4123,
"boundingBox": { "x": 240, "y": 96, "w": 120, "h": 18 },
"regions": [ { "x": 240, "y": 96, "w": 120, "h": 18, "pixels": 1483, "blocks": 9 } ],
"regionsTruncated": false,
"threshold": 0,
"blockSize": 16,
"ignore": [],
"ignoreRejected": []
}
几个刻意的取舍:
diffPercent保留 6 位小数。 1920×1080 里一个像素是 0.0000482%,四舍五入到两位就成了0.00,紧挨着diffPixels: 1读起来像"没变化"。- 比对范围是 before 的完整面积。 尺寸不一样时,没有对应像素的部分计为已变化,同时
dimensionChanged置真。反过来——只比重叠区——会让"侧边栏没了"这种渲染事故报出PIXEL_CHANGED=false。 regions的边界是精确像素包围盒。 网格只用来判断连通性(一个视觉变化算一个区域),不用来决定矩形——否则一个像素的变化会被描述成 16×16 的"区域"。meanAbsError是每通道平均绝对误差。 四个通道都计入。用"每像素最大通道差"去平均会让单通道的变化显得大四倍。
支持的图片
刻意划定的范围,而不是碰巧能跑:非交错的 PNG,颜色类型 0/2/3/4/6(灰度、RGB、调色板、灰度+alpha、RGBA),位深 8 或 16(16 位取高字节)。
不支持的会明确报出来,不会近似解码:
- Adam7 交错 → 报错并提示另存为非交错
- 位深 1/2/4(每字节多像素)→ 报错并提示另存为 8 位
- 非 PNG 文件 → 报错说明文件头不匹配
- CRC 校验失败 → 报错说明文件已损坏
这一条比看上去重要:这个插件的全部价值就是它的数字可信,所以一个把不支持的布局猜着解出来、给出一个看似合理但错误的像素数的解码器,比一个直接拒绝的解码器糟得多。
隐私与成本
全部本地。 不调用模型、不联网、不上传图片。截图里可能是别人的私有页面、客户后台、未发布的产品——这也是它做成插件而不是服务的理由。
零依赖。 host 半区只 import node: 内置模块和同目录兄弟模块。PNG 编解码是自己写的(src/png.js),因为它必须存在,而引一个解码器库就破坏了这条铁律。
数据落在哪
| 路径 | 内容 |
|---|---|
$DSH_HOME/vision-diff/runs/<id>/index.html |
自包含对照页,可归档、可转发 |
$DSH_HOME/vision-diff/runs/<id>/report.json |
原始结论 |
$DSH_HOME/vision-diff/runs/<id>/diff.png |
差异图:红色=变化,蓝色=已忽略 |
$DSH_HOME/vision-diff/runs/<id>/before.png、after.png |
输入副本(keepInputs: false 可不留) |
$DSH_HOME/vision-diff/state.json |
历史索引与最近一次结论 |
HTTP 面(挂在 webServer 的 /dsh-vision-diff 前缀下):
GET /dsh-vision-diff 统计、最近一次、历史、配置
GET /dsh-vision-diff/view/<id> 自包含对照页
GET /dsh-vision-diff/asset/<id>/<file> 原始 PNG
POST /dsh-vision-diff/clear 清空历史索引
容错姿态
这个插件任何一处坏掉都不该拖垮 harness,所以:
- 读文件优先走
ctx.fs,让读取经过 harness 自己的文件系统抽象、遵守它的策略。只有宿主根本没有 fs 服务时才退回node:fs——出错不会静默退回,因为那等于绕过一个安全边界。 - 像素上限取
ctx.attachments.imageLimits.maxImagePixels(宿主给的话)与配置的较小值。超限的图在解码前就被拒绝,不是解完再发现占了 4GB。 webServer.register失败只是没有对照页和 Tab,vision_diff工具照常工作——一个变了形状的 web 契约不该让用户连比对都做不了。- 落盘是尽力而为:state.json 写不进去不影响这次比对的结果。
- 历史滚动:超出
historyKeep(默认 30)的旧记录,连同它的产物目录一起清掉,不会无声堆积。 - 每次注册都可撤销(
ctx.effect),因为 dsh 能在运行期卸载插件。
配置
apply(ctx, config) 的 config 全部可覆盖:
| key | 默认 | 说明 |
|---|---|---|
dataDir |
$DSH_HOME/vision-diff |
产物与索引落在哪 |
baseDir |
process.cwd() |
解析相对路径的基准目录 |
maxBytes |
64 MB |
单个输入文件上限 |
maxPixels |
4000 万(取与宿主图像策略的较小值) |
单张图上限 |
threshold / blockSize / regionLimit |
0 / 16 / 20 |
这三个的默认值,可被单次调用覆盖 |
historyKeep |
30 |
保留多少次记录 |
keepInputs |
true |
是否在产物目录里留下输入副本(对照页永远内含两边,不受此项影响) |
开发
node --test # 60 个测试
node scripts/build.mjs
测试用的 PNG 是手工构造的,不是编码器自己产出的。 encodePng 只写 filter 0,所以"编码再解码"这种往返测试永远测不到 filter 1–4。测试里有一份独立的 PNG 构造器(自己的分块、自己的 CRC32、自己的正向 filter),解码器的所有滤镜都是对着这份独立实现验证的。
test/host.test.mjs 用桩 Context 驱动真实的 apply():真的把文件写进临时目录、真的走工具执行路径、真的调 HTTP handler,不是这些逻辑的复刻。
和现有实现的关系
没有参考任何现有实现的代码。 这条设想的证据来源是 ankye/dsh-client-vision——它做的是内容识别(识图、多模态问答),回答的是"这屏是什么";本插件回答的是"这两屏一样吗"。两者不重叠,我也没有去看它的实现。
- PNG 编解码是按 PNG 规范手写的(
node:zlib提供 DEFLATE,其余是分块解析、五种行过滤器、CRC32)。 - 差异算法是自己写的逐像素比较 + 连通块聚类,没有用 pixelmatch 之类的库——引依赖会破坏零依赖构建。
- 工程骨架(零依赖构建、host/client 双半区、cordis.patch.yml、README、测试)沿用
dsh-warden的既有结构,那是同一个作者自己的脚手架。
本插件是独立仓库,不是任何仓库的 fork。
License
MIT
No comments yet. Be the first to write one.