// 两张图的数值对拍:PSNR / SSIM / 平均绝对差 / 最大绝对差(均为 0-255 尺度)。 // 用法:node tools/diff.mjs [diff-out.png] // // 判读: // mean|diff| ≈ 0 且 max|diff| = 0 → 逐像素相同 // mean|diff| 极小 但 max|diff| 很大 → 只有零星像素变了(多半是高对比边缘的抗锯齿抖动) // mean|diff| 明显变大 → 整体变化(构图/内容/色调) // // 注意:ffmpeg 对很小的数值会用科学计数法(YAVG=4.96721e-05), // 解析时必须带上指数,否则会把 0.00005 误读成 4.97——这个坑踩过一次。 import { spawn } from "node:child_process"; const [a, b, outPng] = process.argv.slice(2); if (!a || !b) { console.error("usage: node tools/diff.mjs [diff-out.png]"); process.exit(2); } function ff(args) { return new Promise((resolve) => { const p = spawn("ffmpeg", ["-hide_banner", "-loglevel", "info", ...args], { stdio: ["ignore", "pipe", "pipe"] }); let err = ""; p.stderr.on("data", (d) => (err += d)); p.stdout.on("data", () => {}); p.on("close", () => resolve(err)); }); } const NUM = "([-+0-9.eE]+)"; const num = (text, re) => { const m = text.match(re); return m ? Number(m[1]) : NaN; }; const psnr = await ff(["-i", a, "-i", b, "-lavfi", "psnr", "-f", "null", "-"]); const ssim = await ff(["-i", a, "-i", b, "-lavfi", "ssim", "-f", "null", "-"]); const stats = await ff([ "-i", a, "-i", b, "-lavfi", "blend=all_mode=difference,format=gray,signalstats,metadata=print", "-f", "null", "-", ]); const frames = [...stats.matchAll(/frame:(\d+)/g)].length; const yavg = num(stats, new RegExp(`YAVG=${NUM}`)); const ymax = num(stats, new RegExp(`YMAX=${NUM}`)); const inf = /average:inf/.test(psnr); console.log( `psnr=${inf ? "∞" : num(psnr, new RegExp(`average:${NUM}`))} dB ` + `ssim=${num(ssim, new RegExp(`All:${NUM}`))} ` + `mean|diff|=${yavg}/255 max|diff|=${ymax}` + (frames > 1 ? ` [${frames} 帧,取首帧]` : ""), ); if (outPng) { await ff(["-y", "-i", a, "-i", b, "-lavfi", "blend=all_mode=difference", "-frames:v", "1", outPng]); console.log(`diff image -> ${outPng}`); }