7.3 色彩管理的整数化

把 sRGB 接进卷二着色链路并量出代价:8 位量化误差最大 218 Q16.16、均值 40.10,往返不变量残差 0 灰度级;线性域取中点与 sRGB 域取中点最多差 61 灰度级;端到端「线性着色 + 编码」与「直接在 sRGB 域着色」MAE 15.57、PSNR 24.28 dB。

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 纹理坐标与寻址模式 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配