7.3 色彩管理的整数化
站内的 HDR 管线工程 关心的是色彩管理在工程上怎么落地——转换矩阵、ICC、显示端协商。本节把镜头收窄到误差:8 位量化到底丢了多少、在错误的色彩空间里插值会差多远、以及把 sRGB 传递函数接进卷二着色链路之后,画面与「不接」差多少。前三节给了工具,这一节把它们串成一条可断言的整数链路,并逐项量出误差。
先把链路摆清楚。卷二前六章的着色在线性空间做:法线插值、Lambert 求值、BRDF 累加,全部在线性辐射度上进行。最后一步才把它经 sRGB 传递函数编码成 8 位输出。这一节的四组测量,正是围绕「这一步编码」和「有人跳过了这一步」展开。
8 位量化误差的分布
第一件事:线性辐射度存成 8 位,会引入多大误差?做法是把《烛影山谷》覆盖像素的每个通道取出来,经 sRGB 编码到 8 位、再解码回线性,量 |decode(encode(x)) - x|。
实测(131193 个通道值):
- 最大误差 218 Q16.16(0.8482 灰度级),均值 40.10 Q16.16
- 精确命中原值的只有 747 / 131193(0.57%)
这个分布是步长不均匀的直接后果:暗部(线性段)步长只有 19 Q16.16,误差极小;最亮处(幂段顶端)步长 583,误差可以接近 292。场景的大部分像素落在暗部与中调,所以均值只有 40。
对照实验很说明问题:如果不用 sRGB、直接把线性值均匀量化到 8 位(步长恒为 65536/255 ≈ 257),最大误差 128 Q16.16(0.498 灰度级),但均值升到 65.28——比 sRGB 高了六成。
这组对比正是 sRGB 存在的理由:均匀线性量化把同样的精度平摊到全量程,而 sRGB 把精度倾斜给暗部。代价是亮部步长变大(218 > 128),收益是感知误差的整体下降(40.10 < 65.28)。两者是同一个权衡的两面,不是一个「更对」。
线性插值的误差界
第二件事:在 8 位码值之间做线性插值,误差有多大?这里有两个完全不同的问题,别混。
其一是表内插值。解码表在相邻两项之间是一条弦,真曲线是凸的,弦永远在曲线之上。实测最大偏差 0.7836 Q16.16(0.003 灰度级),出现在第 210 段。这个数小到可以忽略——说明 256 点的表密到不需要插值:对 8 位输出而言,查表本身就是精确的。
其二是在错误的色彩空间里插值,这才是真问题。两个端点在线性域取中点,和在 sRGB 域取中点,结果不是一回事。把全部 256×255/2 个端点对遍历一遍:
- 最大偏差 61 灰度级,出现在最差对
(0, 255)——纯黑与纯白 - 纯黑与纯白的中点:线性域是 sRGB 188,sRGB 域是 127
差 61 档。这就是「渐变在暗部发灰、在亮部发死」的量化解释:把两端 8 位值直接平均(128),得到的是算术中点,而不是感知中点。正确做法是先解码到线性、取中点、再编码回去(188)。纹理过滤、顶点色插值、透明度混合——所有「取中间值」的操作都踩在这条线上。
往返不变量
第三件事是一条不变量,它保证编码解码是可逆的:对全部 256 个 8 位 sRGB 值,decode(encode(v)) 的残差必须是 0 灰度级。实测 0 / 256 不一致。
这条不变量不是自动成立的,它依赖 7.1 里那个设计选择:编码边界取在解码值的中点。如果边界取在端点(B[v-1] = D[v-1]),那么 x 恰好等于 D[v] 时会落到 v-1 档,往返立刻分叉。所以「取中点」不是美学偏好,是可逆性的前提。
端到端:接上 sRGB 前后差多远
最后一件事,把 sRGB 接进卷二着色链路,看「接」与「不接」差多少。两条路径喂同一份规格、同一套几何、同一个 Lambert 公式,唯一差别在在哪着色:
- 线性路径(正确):albedo 先解码到线性,在线性域算光照,最后经 sRGB 编码输出
- 直接路径(伽马错误):拿 albedo 的 8 位值当线性值直接算光照,结果直接当 8 位输出
canvas : 320x240 覆盖 43731
sRGB 量化误差 : max 218 Q16.16(0.8482 灰度级) 均值 40.1011 精确 747/131193
直接线性量化(对照) : max 128(0.498 灰度级) 均值 65.2801
线性插值最大偏差 : 0.7836 Q16.16(0.003 灰度级) 第 210 段
插值空间误差 : 最大 61 灰度级 最差对 [0, 255] 黑白中点 线性 188 vs sRGB 127
往返不变量 : decode(encode(v)) 不一致 0 / 256
端到端 线性 vs 直接 : 差异 43730 像素(999.9771‰) MAE 15.5724 PSNR 24.28 dB 最大通道差 63
线性 sha256 : 3d50635f1e0d1dce4141550f25660cca8091ddb29902c9973c9eb559f0b31ad7
直接 sha256 : 9fb909113cc4ccc7eaef252773331ff60a11267f50ec90a0cdda8708ab5b9058
43730 / 43731 个覆盖像素都不同(999.9771‰),MAE 15.57,PSNR 只有 24.28 dB,最大通道差 63。两个 8 位缓冲的 sha256 完全不同——伽马错误不是「略有偏差」,而是几乎每个像素都错,且错得肉眼可见。这解释了为什么「在线性空间着色」是硬要求而不是优化。
可运行产物:四组测量 + 端到端对照
下面的脚本把四组测量与端到端对照全部落盘。它复用 render_shade.build_scene_shaded,只改着色回调,不重写任何定点数学。
#!/usr/bin/env python3
# file: lantern/sim/cg_shade_07_3_quantize.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_07_3_quantize.py
"""7.3 色彩管理的整数化:量化误差、插值误差界、往返不变量与端到端对照。
四件事,全部整数、全部可断言:
1. 8 位量化误差分布:把《烛影山谷》的线性辐射度经 sRGB 编码到 8 位再解码回来,
量出残差分布;并与「直接把线性值量化到 8 位」对照。
2. 插值误差界:解码表相邻两项之间是弦,真曲线是凸的,量出最大偏差;
再看「线性域取中点」与「sRGB 域取中点」差多少灰度级。
3. 往返不变量:decode(encode(v)) 对全部 256 个 8 位值逐位精确(残差 0 灰度级)。
4. 端到端对照:同一份规格下「线性着色 + sRGB 编码」与「直接在 sRGB 域着色」的差异。
产物:out/cg_shade_07_3_quant.csv / .json、out/cg_shade_07_covered.bin、
out/cg_shade_07_3_linear.png / _naive.png / _diff.png。
"""
import hashlib
import json
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
import cgshade # noqa: E402
import render_shade # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ONE = 65536
def make_shader(spec, d, naive):
"""naive=False:线性着色(正确)。naive=True:直接在 8 位 sRGB 域着色(伽马错误)。"""
dlight, ambient = spec["directionalLight"], spec["ambientQ16"]
dir_q16, intensity = tuple(dlight["dirQ16"]), dlight["intensityQ16"]
def shade(normal, albedo):
ndl = max(0, cglib.v_dot(normal, dir_q16))
k = ambient + cglib.fmul(ndl, intensity) # 不 clamp:与 7.2 的 HDR 辐射度同源
if naive:
return tuple((albedo[i] * k) >> 16 for i in range(3))
return tuple((int(d[albedo[i]]) * k) >> 16 for i in range(3))
return shade
def main():
spec = cglib.load_spec()
width, height = spec["width"], spec["height"]
with open(os.path.join(ROOT, "config", "cg_shade_07_srgb_lut.json"), encoding="utf-8") as fh:
d = np.array(json.load(fh)["srgb_to_linear_q16"], dtype=np.int64)
b = (d[1:] + d[:-1]) >> 1
tris, _total, _dropped = render_shade.build_scene_shaded(spec)
lin, _dep, covered, _cull = cgshade.rasterize_shaded(
tris, width, height, (0, 0, 0), make_shader(spec, d, naive=False))
naive, _d2, _c2, _cu2 = cgshade.rasterize_shaded(
tris, width, height, (0, 0, 0), make_shader(spec, d, naive=True))
lin_c = lin[covered].reshape(-1)
srgb8 = np.searchsorted(b, np.minimum(lin_c, ONE), side="right").astype(np.int64)
err_srgb = np.abs(d[srgb8] - lin_c)
lin_direct8 = (lin_c * 255 + ONE // 2) // ONE
err_direct = np.abs((lin_direct8 * ONE + 127) // 255 - lin_c)
# 插值误差界:每段取真曲线中点,与弦中点比较(幂段凸,弦在上)
devs = []
for v in range(255):
mid = (v + 0.5) / 255.0
true_mid = mid / 12.92 if mid <= 0.04045 else ((mid + 0.055) / 1.055) ** 2.4
devs.append((int(d[v]) + int(d[v + 1])) / 2.0 - true_mid * ONE)
max_dev, max_dev_seg = max(devs), int(np.argmax(devs))
# 插值空间误差:两个端点在**线性域**取中点 vs 在 **sRGB 域**取中点
ii, jj = np.meshgrid(np.arange(256), np.arange(256), indexing="ij")
aa, bb = ii[ii < jj], jj[ii < jj]
space_dev = np.abs(np.searchsorted(b, (d[aa] + d[bb]) >> 1, side="right") - (aa + bb) // 2)
space_max = int(space_dev.max())
worst = [int(aa[int(np.argmax(space_dev))]), int(bb[int(np.argmax(space_dev))])]
# 往返不变量 + 端到端对照
rt_bad = sum(1 for v in range(256) if int(np.searchsorted(b, int(d[v]), side="right")) != v)
out_lin = np.searchsorted(b, np.minimum(lin, ONE), side="right").astype(np.int64)
out_naive = np.minimum(naive, 255)
diff = (out_lin != out_naive).any(axis=2)
chan = np.abs(out_lin - out_naive)
out = os.path.join(ROOT, "out")
os.makedirs(out, exist_ok=True)
cglib.save_png(os.path.join(out, "cg_shade_07_3_linear.png"), out_lin)
cglib.save_png(os.path.join(out, "cg_shade_07_3_naive.png"), out_naive)
dimg = np.zeros((height, width, 3), dtype=np.int64)
dimg[diff] = (255, 90, 40)
cglib.save_png(os.path.join(out, "cg_shade_07_3_diff.png"), dimg)
with open(os.path.join(out, "cg_shade_07_covered.bin"), "wb") as fh:
fh.write(covered.astype(np.uint8).tobytes())
eq = np.bincount(err_srgb, minlength=int(err_srgb.max()) + 1)
cglib.write_csv(os.path.join(out, "cg_shade_07_3_quant.csv"), ["abs_err_q16", "count"],
[(i, int(n)) for i, n in enumerate(eq) if n])
stats = {
"canvas": [width, height], "covered_pixels": int(covered.sum()),
"quant_srgb": {"max_err_q16": int(err_srgb.max()),
"mean_err_q16": round(float(err_srgb.mean()), 4),
"max_err_gray": round(float(err_srgb.max()) / ONE * 255.0, 4),
"exact_count": int((err_srgb == 0).sum()), "total": int(err_srgb.size)},
"quant_linear_direct": {"max_err_q16": int(err_direct.max()),
"mean_err_q16": round(float(err_direct.mean()), 4),
"max_err_gray": round(float(err_direct.max()) / ONE * 255.0, 4)},
"interp": {"max_dev_q16": round(max_dev, 4), "max_dev_segment": max_dev_seg,
"max_dev_gray": round(max_dev / ONE * 255.0, 4),
"space_max_gray": space_max, "space_worst_pair": worst,
"black_white_linear_mid_gray": int(np.searchsorted(b, (int(d[0]) + int(d[255])) >> 1,
side="right")),
"black_white_srgb_mid_gray": (0 + 255) // 2},
"roundtrip_srgb_mismatch": rt_bad,
"end_to_end": {"linear_sha256": hashlib.sha256(out_lin.astype(np.uint8).tobytes()).hexdigest(),
"naive_sha256": hashlib.sha256(out_naive.astype(np.uint8).tobytes()).hexdigest(),
"diff_pixels": int(diff.sum()),
"diff_permille": round(float(diff.sum()) * 1000.0 / covered.sum(), 4),
"mae": round(cgshade.mae(out_lin[covered], out_naive[covered]), 4),
"psnr_db": round(cgshade.psnr(out_lin[covered], out_naive[covered]), 2),
"max_channel_diff": int(chan[covered].max())},
}
with open(os.path.join(out, "cg_shade_07_3_quant.json"), "w", encoding="utf-8") as fh:
json.dump(stats, fh, ensure_ascii=False, indent=2)
q, qd, ip, e2e = stats["quant_srgb"], stats["quant_linear_direct"], stats["interp"], stats["end_to_end"]
print(f"canvas : {width}x{height} 覆盖 {stats['covered_pixels']}")
print(f"sRGB 量化误差 : max {q['max_err_q16']} Q16.16({q['max_err_gray']} 灰度级)"
f" 均值 {q['mean_err_q16']} 精确 {q['exact_count']}/{q['total']}")
print(f"直接线性量化(对照) : max {qd['max_err_q16']}({qd['max_err_gray']} 灰度级)"
f" 均值 {qd['mean_err_q16']}")
print(f"线性插值最大偏差 : {ip['max_dev_q16']} Q16.16({ip['max_dev_gray']} 灰度级)"
f" 第 {ip['max_dev_segment']} 段")
print(f"插值空间误差 : 最大 {ip['space_max_gray']} 灰度级 最差对 {ip['space_worst_pair']}"
f" 黑白中点 线性 {ip['black_white_linear_mid_gray']} vs sRGB {ip['black_white_srgb_mid_gray']}")
print(f"往返不变量 : decode(encode(v)) 不一致 {rt_bad} / 256")
print(f"端到端 线性 vs 直接 : 差异 {e2e['diff_pixels']} 像素({e2e['diff_permille']}‰)"
f" MAE {e2e['mae']} PSNR {e2e['psnr_db']} dB 最大通道差 {e2e['max_channel_diff']}")
print(f"线性 sha256 : {e2e['linear_sha256']}")
print(f"直接 sha256 : {e2e['naive_sha256']}")
return 0
if __name__ == "__main__":
sys.exit(main())
产物写进 out/:量化误差直方图 cg_shade_07_3_quant.csv、含全部数字的 cg_shade_07_3_quant.json、覆盖掩码 cg_shade_07_covered.bin(供 P 层限定统计范围),以及线性 / 直接两张成片与一张分歧热图。
跨语言断言:整幅缓冲逐字节相同
P 层读 S 层的 HDR 缓冲与覆盖掩码,用整数内核复现「线性辐射度 → 8 位 sRGB」的整幅缓冲,断言 sha256 逐字节相同,并复算量化误差上界与往返不变量。
// file: lantern/core/test/cg_shade_07_3_quantize.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_07_3_quantize.test.ts'
// 7.3 的跨语言断言:读 S 层的 HDR 辐射度与覆盖掩码,用 P 层整数内核复现
// 「线性辐射度 -> 8 位 sRGB」的整幅缓冲(sha256 逐字节相同)、8 位量化误差上界、
// 以及往返不变量,并含一条负例(编码换成直接线性量化即分叉)。
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { ONE, SRGB_TO_LINEAR_Q16, linearToSrgb8, srgbDecode, srgbEncode } from "../src/lantern/cg_shade_07_color.ts";
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..", "..");
const stats = JSON.parse(readFileSync(join(root, "out", "cg_shade_07_3_quant.json"), "utf-8"));
const width = stats.canvas[0];
const height = stats.canvas[1];
const n = width * height * 3;
const raw = readFileSync(join(root, "out", "cg_shade_07_hdr.bin"));
const hdr = new Int32Array(raw.buffer, raw.byteOffset, n);
const covRaw = readFileSync(join(root, "out", "cg_shade_07_covered.bin"));
const covered = new Uint8Array(covRaw.buffer, covRaw.byteOffset, width * height);
test("线性辐射度 -> 8 位 sRGB 的整幅缓冲与 S 层逐字节相同", () => {
const out = new Uint8Array(n);
for (let i = 0; i < n; i += 1) out[i] = linearToSrgb8(hdr[i]);
const dig = createHash("sha256").update(Buffer.from(out)).digest("hex");
assert.equal(dig, stats.end_to_end.linear_sha256);
});
test("8 位量化误差上界与 S 层一致(覆盖像素内)", () => {
let maxErr = 0;
for (let p = 0; p < width * height; p += 1) {
if (covered[p] === 0) continue;
for (let c = 0; c < 3; c += 1) {
const x = hdr[p * 3 + c];
maxErr = Math.max(maxErr, Math.abs(srgbDecode(srgbEncode(x)) - x));
}
}
assert.equal(maxErr, stats.quant_srgb.max_err_q16); // 218
});
test("往返不变量:decode(encode(v)) 残差 0 灰度级", () => {
for (let v = 0; v < 256; v += 1) assert.equal(srgbEncode(srgbDecode(v)), v);
assert.equal(stats.roundtrip_srgb_mismatch, 0);
assert.equal(SRGB_TO_LINEAR_Q16[0], 0);
assert.equal(SRGB_TO_LINEAR_Q16[255], ONE);
});
test("负例:直接把线性值量化到 8 位,摘要必然分叉", () => {
const out = new Uint8Array(n);
for (let i = 0; i < n; i += 1) {
const x = hdr[i] > ONE ? ONE : hdr[i];
out[i] = Math.floor((x * 255 + ONE / 2) / ONE);
}
const dig = createHash("sha256").update(Buffer.from(out)).digest("hex");
assert.notEqual(dig, stats.end_to_end.linear_sha256);
});
四条断言全绿:整幅缓冲 sha256 相同、量化误差上界 218 与 S 层一致、往返残差 0、以及一条负例——把编码换成「直接线性量化到 8 位」,摘要立刻分叉。到这里,卷二的颜色进出与亮度压缩都是整数、可断言的了;下一章转向纹理——过滤核与 mip 链同样要在两个宿主上逐位对齐。
阅读导航:上一节:7.2 色调映射 · 下一节:8.1 纹理坐标与寻址模式 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。