12.2 图像 digest 与误差度量
《游戏引擎实现原理》卷二《渲染引擎》 用 sha256 给每一帧封版,12.1 也靠它复现了卷二基线。但 sha256 只在一个前提下成立:这条路径上不存在规范留给实现的自由度。本节要把这个前提说清楚,然后给出自由度存在时该用什么。
digest 是全等判据,只在「全等是合理期望」时适用
sha256 是最强的判据:任意一位不同都报错,零假阳性、零假阴性,代价还只是 O(n)。它强,是因为它只回答一个问题——两份数据是否逐位相同。
整数类路径上这个问题问得对。实测(/opt/homebrew/bin/python3 sim/cg_shade_12_2_metrics.py):
A. digest 的适用边界
整数类重跑 sha256 稳定 : True 复现卷二基线 = True
浮点镜像 sha256 与基线 : 相同 = False (但 MAE 0.4963 属噪声地板)
但把同一个场景的 CPU 浮点镜像(12.1 那个)拿来比,sha256 是 651803f74eec1450…,与基线的 0d997343ca547ed5… 完全不同——digest 判它「不等」,而它的 MAE 只有 0.4963,正落在 12.1 量出的噪声地板上。
这不是 digest 错了,是问题问错了。digest 回答「是否逐位相同」,我们想问「是否一致」。当容差不为零时,这两个问题的答案本来就不同:前者是布尔量,后者是度量。 拿布尔量去承担度量的职责,得到的就是「正确的实现被判错」这一种失败模式。
三类不能用 digest 的场景,按发生频率排:
- 宿主不同(GPU 与 CPU、或两个不同驱动)——12.3 整节都在处理它。
- 浮点运算顺序不同——编译器是否启用 FMA、并行归约的树形结构、
pow的 ULP 差异。 - 感知等价但字节不同——换一次重采样、换一个编码器、有损压缩。
第 3 类最隐蔽:digest 会把两张人眼看不出区别的图判成「不同」,却对「差多少」一言不发。 而「差多少」恰恰是渲染回归测试每天要问的问题。
四个标量的定义与各自的盲区
先把量定义清楚。设两幅 8 位图逐通道展开成 N = 3·W·H 个样本,第 i 个样本的差记 d_i = |a_i − b_i|:
| 量 | 定义 | 对什么敏感 |
|---|---|---|
| MAE | (1/N)·Σ d_i | 平均幅度 |
| RMSE | √((1/N)·Σ d_i²) | 大幅误差(平方加权) |
| PSNR(教科书) | 10·log10(255² / MSE) | 由 RMSE 推出,对分布敏感 |
| PSNR(本卷) | 20·log10(255 / MAE) | 由 MAE 推出,与 MAE 同构 |
| 最大通道差 | max d_i | 最坏的那一个样本 |
盲区用一个最小例子就能量出来:两张 256 像素的图,一张整幅偏 +1,一张只有 4 个像素偏 +64。
B. MAE 的盲区(两张 256 像素图)
整幅 +1 MAE 1.0 本卷 PSNR 48.13 dB RMSE 1.0 RMSE-PSNR 48.13 dB 最大通道差 1
4px +64 MAE 1.0 本卷 PSNR 48.13 dB RMSE 8.0 RMSE-PSNR 30.07 dB 最大通道差 64
两行的 MAE 都是 1.0,本卷 PSNR 都是 48.13 dB——一个数字都不差。 但第一张是「整幅暗一格」,第二张是「4 个像素上多了 64 格」。人眼对后者的敏感度高得多:前者可能被当成伽马设置差一点,后者是明晃晃的四个亮点。
分得开它们的是 RMSE(1.0 对 8.0,差 8 倍)与最大通道差(1 对 64)。再看 RMSE-PSNR 那一列:48.13 dB 对 30.07 dB,差了 18 dB。教科书口径能区分,本卷口径不能。
这里要坦白本卷的一个取舍。本卷的 psnr 是从 MAE 推出的(20·log10(255/MAE)),好处是换语言、换平台都能复算出同一位小数;代价是它不提供任何 MAE 之外的信息,是 MAE 的单调变换。12.1 的校准扫描里「PSNR 线比 MAE 线松 8 倍」就是这个取舍的直接后果。所以本卷报告误差时不用单一标量,固定给一组:MAE + RMSE + 最大通道差 + 差值直方图。12.1 那张 [1-1] 38647 / [2-2] 4752 / … 就是直方图——它一眼就能看出「差 1 的占九成、大差只有 3 个像素」,这是任何单一数字都给不出的形状。
S 层的度量与合成对照(sim/cg_shade_12_2_metrics.py 的核心几行):
# snippet: sim/cg_shade_12_2_metrics.py(节选:RMSE / 教科书 PSNR / 合成剪影)
def rmse(a, b):
d = np.asarray(a, dtype=np.int64) - np.asarray(b, dtype=np.int64)
return float(np.sqrt(np.mean(d.astype(np.float64) ** 2)))
def rmse_psnr(a, b):
"""教科书 PSNR:20*log10(255 / RMSE),对误差分布敏感(与 cgshade.psnr 不同)。"""
r = rmse(a, b)
return float("inf") if r == 0.0 else 20.0 * math.log10(255.0 / r)
# 方向二:合成剪影平移——覆盖不一致但颜色误差极小
s = 16
e1 = np.zeros((s, s), dtype=np.uint8)
e2 = np.zeros((s, s), dtype=np.uint8)
e1[:, 8:] = 128
e2[:, 7] = 4
e2[:, 8:] = 128
cov1, cov2 = e1 != 0, e2 != 0
覆盖一致性与颜色误差是两条独立判据
渲染比对里最容易犯的错,是把「几何对不对」和「颜色对不对」压成一个分数。它们必须分两栏,因为两个方向各自都能被造出反例:
C. 覆盖一致性与颜色误差是两条独立判据
方向一 逐顶点vs逐像素 : 覆盖 0.0‰ MAE 3.5764 PSNR 37.06 dB ← 覆盖一致、颜色有误差
方向二 剪影平移 : 覆盖 62.5‰ MAE 0.25 ← 覆盖不一致、颜色误差极小
方向一是真实渲染:逐顶点着色与逐像素着色,同几何、同 albedo、同光照,只改着色频率。覆盖差异 0.0‰——几何被完全钉死;而 MAE 3.5764、RMSE 6.1748、最大通道差 48。这是「覆盖一致,颜色不一致」。
方向二是合成的最坏情形:16×16 的剪影整体平移一列,新覆盖的那一列颜色贴着背景(4 对 0,只差 4 格)。覆盖差异 62.5‰(16 个像素),而 MAE 只有 0.25、最大通道差 4。这是「覆盖不一致,颜色几乎一致」。
两个方向的数字是反向的:方向一的颜色差是方向二的 14 倍,方向二的覆盖差是方向一的无穷倍(0 → 62.5‰)。如果只报一个综合分数,两个信号会互相抵消——方向一会被稀释成「覆盖完美,颜色略差」,方向二会被稀释成「颜色几乎没差」。而方向二才是更危险的那一类:几何真的错了,但颜色统计几乎不动。 任何只看颜色的判据都会漏掉整类几何错误。反过来同理:覆盖一致不代表着色一致(方向一)。
所以本卷的报告格式固定为两栏:覆盖归属差异(‰) 与 颜色误差(MAE / RMSE / 最大通道差 / 直方图)。12.1 的跨宿主判据、12.3 的三类归因,都是在这两栏上做文章。
P 层:度量必须能换一份实现复算
度量换成 TypeScript 重写一遍,并把三条结论各自钉成断言(core/test/cg_shade_12_2_metrics.test.ts):
// file: lantern/core/src/lantern/cg_shade_12_metrics.ts
// tier: P
// verify: node --test 'core/test/cg_shade_12_2_metrics.test.ts'
// 12.2 的误差度量:把「一组标量」定义清楚,并显式标出各自的盲区。
//
// 三条纪律:
// 1. 没有单一标量能替代全部 —— 「整幅偏 1」与「4 个像素偏 64」的 MAE 都是 1.0。
// 2. 分布敏感的是 RMSE 与最大通道差;psnrFromMae 是 MAE 的单调变换,不提供独立信息。
// 3. 覆盖归属差异与颜色误差是两条独立判据,必须分两栏报告。
//
// 累加用 double:8 位差的平方最大 65025,乘 76800×3 个样本仍在 2^53 内,无精度损失;
// 只有最后转 dB 时用一次 Math.log10。
/** 逐通道误差统计:MAE / RMSE / 最大通道差 / 差值直方图。 */
export interface ErrorStats {
readonly samples: number;
readonly mae: number;
readonly rmse: number;
readonly maxChannelDiff: number;
readonly hist: ReadonlyMap<number, number>;
}
/** 一次扫完四个标量与直方图。a、b 是等长的 8 位通道序列。 */
export function errorStats(a: ArrayLike<number>, b: ArrayLike<number>): ErrorStats {
if (a.length !== b.length) throw new Error(`长度不一致: ${a.length} vs ${b.length}`);
let sumAbs = 0;
let sumSq = 0;
let maxDiff = 0;
const hist = new Map<number, number>();
for (let i = 0; i < a.length; i += 1) {
const d = Math.abs(a[i] - b[i]);
sumAbs += d;
sumSq += d * d;
if (d > maxDiff) maxDiff = d;
hist.set(d, (hist.get(d) ?? 0) + 1);
}
return {
samples: a.length,
mae: sumAbs / a.length,
rmse: Math.sqrt(sumSq / a.length),
maxChannelDiff: maxDiff,
hist,
};
}
/** 本卷口径:由 MAE 推出。与 MAE 互为单调变换,故不提供独立信息,只为可读性保留。 */
export function psnrFromMae(mae: number): number {
return mae === 0 ? Infinity : 20 * Math.log10(255 / mae);
}
/** 教科书口径:由 RMSE 推出,对误差分布敏感。 */
export function psnrFromRmse(rmse: number): number {
return rmse === 0 ? Infinity : 20 * Math.log10(255 / rmse);
}
/** 非背景即覆盖。背景色取自配置的 backgroundQ8,与 S 层同一口径。
*
* `channels` 必须显式对齐数据:渲染缓冲是 3 通道,而合成的单通道掩码图是 1。
* 写死 3 会在单通道图上把每 3 个像素并成一个,算出的覆盖差异毫无意义。
*/
export function coveredMask(buf: ArrayLike<number>, bg: readonly number[], channels = 3): Uint8Array {
const m = new Uint8Array(buf.length / channels);
for (let p = 0; p < m.length; p += 1) {
const i = p * channels;
let isBg = true;
for (let c = 0; c < channels; c += 1) if (buf[i + c] !== bg[c]) { isBg = false; break; }
m[p] = isBg ? 0 : 1;
}
return m;
}
/** 覆盖归属差异(‰):两幅覆盖掩码的异或像素数除以总像素数。 */
export function coverageDiffPermille(a: Uint8Array, b: Uint8Array): number {
if (a.length !== b.length) throw new Error(`掩码长度不一致: ${a.length} vs ${b.length}`);
let diff = 0;
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) diff += 1;
return (diff * 1000) / a.length;
}
/** 报告格式固定为这一组:颜色四项 + 覆盖一栏。不许只挑一个数上报。 */
export interface Comparison {
readonly coveragePermille: number;
readonly mae: number;
readonly rmse: number;
readonly psnrMaeDb: number;
readonly psnrRmseDb: number;
readonly maxChannelDiff: number;
}
export function compare(
a: ArrayLike<number>, b: ArrayLike<number>, bg: readonly number[], channels = 3,
): Comparison {
const e = errorStats(a, b);
return {
coveragePermille: coverageDiffPermille(coveredMask(a, bg, channels), coveredMask(b, bg, channels)),
mae: e.mae,
rmse: e.rmse,
psnrMaeDb: psnrFromMae(e.mae),
psnrRmseDb: psnrFromRmse(e.rmse),
maxChannelDiff: e.maxChannelDiff,
};
}
✔ ① MAE 的盲区:整幅 +1 与 4 像素 +64 的 MAE 相同,RMSE 与最大通道差分得开
✔ ② 覆盖与颜色独立:两个方向的数字必须反向
✔ ③ digest 的边界:整数类够用,浮点实现不够用
tests 3 / pass 3 / fail 0
第二条测试里有一个反向断言:real.coveragePermille < synth.coveragePermille && real.mae > synth.mae——把「两个方向必须反向」本身写成断言,防止有人后来把覆盖和颜色合成一个加权分数,让这个性质悄悄消失。
coveredMask 的 channels 参数是被一次真实的错算逼出来的:合成的剪影图是单通道的,而渲染缓冲是 3 通道。第一版写死了 3,于是在 16×16 的单通道图上把每 3 个像素并成一个,算出 58.82‰ 而不是 62.5‰。这个错值看起来完全「合理」——它是个介于 0 和 100 之间的千分比,不报错、不越界,只是错的。这正是本节反复强调的事:一个错误判据最危险的样子,不是崩掉,而是安静地给出一个像样的数字。
本节的口径
- digest 是全等判据。整数类够用(重跑稳定、复现基线
0d997343…);有实现自由度时不够用(浮点镜像 sha256 不同,但 MAE 0.4963 属噪声地板)。 - 没有单一标量能替代全部。MAE 1.0 既可能是「整幅暗一格」,也可能是「4 个像素多 64 格」;分得开的是 RMSE 与最大通道差。
- 本卷 PSNR 由 MAE 推出,不提供独立信息;需要分布敏感的口径时用 RMSE 版。
- 覆盖归属差异与颜色误差是两条独立判据,分两栏报告,两个方向都要有测试覆盖。
阅读导航:上一节:12.1 浮点的容差口径 · 下一节:12.3 CPU 与 GPU 的比对 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。