7.1 线性空间与 sRGB

把 sRGB 传递函数固化成整数查找表:解码表 256 项一次生成后冻结,编码用整数二分在边界表上反查,Python 与 TypeScript 读同一份整数故逐位一致。实测 8 位往返残差 0 灰度级、线性往返最大误差 292 Q16.16,并给出线性段与幂段的分段边界。

7.1 线性空间与 sRGB

站内的 HDR 管线工程 讲的是整条色彩管理的工程口径——色彩空间怎么选、HDR 元数据怎么传、显示端的 tone curve 怎么串。本章和它分工不同:那篇回答「管线怎么搭」,本章回答传递函数本身能不能写成整数、能不能在两个宿主上给出逐位相同的结果。这一节先把最底下那块砖钉死:sRGB 传递函数。

着色为什么必须在线性空间做,一句话:光照的加性与乘性只在线性光里成立。两束光叠加,物理上是辐射度相加;一盏灯的强度翻倍,物理上是辐射度乘系数。而 sRGB 是显示编码:它把线性光做了约 2.2 次幂的压缩,好让 8 位量化在人眼敏感的暗部更细。如果直接拿 sRGB 的 8 位值去算光照、去插值、去混合,加性就不成立了——这就是「伽马错误」,画面会显得发灰、暗部糊成一团。

伽马编码的由来值得说清:早期 CRT 的电子束电流与栅压近似成幂律关系,显示亮度约等于输入电压的 2.2 次幂。为了在「信号 → 亮度」这条链上端到端线性,信号端预先做 1/2.2 次幂的编码。CRT 早已退场,但这套编码被 sRGB 标准继承下来,理由是它恰好匹配人眼的亮度感知:暗部的量化台阶更细,同样 8 位能换来更少的可见色带。

sRGB 传递函数的分段定义

IEC 61966-2-1 的 sRGB 不是一条纯幂函数,而是两段拼接:靠近零的地方用线性段避免 x^2.4 在 0 附近导数为 0 带来的数值问题,其余部分用幂段。

解码(EOTF,sRGB → 线性),记 c = v/255:

  • c <= 0.04045:lin = c / 12.92
  • 否则:lin = ((c + 0.055) / 1.055) ^ 2.4

编码(OETF,线性 → sRGB):

  • lin <= 0.0031308:s = 12.92 * lin
  • 否则:s = 1.055 * lin ^ (1/2.4) - 0.055

两段在 c = 0.04045 处连续且斜率连续,这是标准刻意选的拼接点。注意这条曲线不是自己发明的:任何声称「sRGB 就是 2.2 次幂」的实现都会在暗部偏掉,本节一律按上式的分段定义做。

整数化:冻结查找表 + 边界二分

幂运算 x^2.4 没有便宜的整数算法,本卷也不用浮点 pow。做法是把解码方向固化成一张表:

  1. 用浮点算 256 个采样点 D[v] = round(65536 * EOTF(v/255)),一次性生成后冻结成整数常量(config/cg_shade_07_srgb_lut.json);
  2. 编码方向的边界表由解码表用纯整数算术导出:B[v-1] = (D[v-1] + D[v]) >> 1,非负故 >>1 与 //2 同义;
  3. encode(x) = 在 B 上做整数二分,返回 B 中 <= x 的元素个数。

这样运行期只剩查表、移位和整数二分,没有任何浮点。冻结表的几处关键采样:

vD[v]说明
00黑必须精确到 0
10199线性段最后一点
11219幂段第一点
12814147中灰
18832957线性 0.5 附近的 sRGB 值
25464953幂段最后一点
25565536白必须精确到 1.0

为什么编码要落在边界的中点而不是端点?因为「最近邻」要求:x 落在 D[v-1] 与 D[v] 之间时,取距离更近的那个。中点正是等距线。这条选择直接决定了下面的往返不变量。

往返误差实测

两个方向各测一次往返。

往返一(8 位 sRGB 值 → 线性 → 回到 8 位):对全部 256 个 v 检查 encode(D[v]) == v。实测 0 / 256 不一致——残差恰好 0 灰度级。这不是巧合:D 严格递增,B[v-1] 与 B[v] 把 D[v] 夹在中间,二分的计数自然落回 v。

往返二(任意 Q16.16 线性值 → 8 位 sRGB → 线性):对 [0, 1.0] 的全部 65537 个 Q16.16 取值,量 |D[encode(x)] - x|:

解码表               : 256 项  严格单调 True
步长                 : 线性段 19 … 幂段最大 583 Q16.16
往返一 sRGB->lin->sRGB: 不一致 0 / 256
往返二 lin->sRGB->lin : 最大 292 Q16.16(1.1362 灰度级)  均值 92.3187
  精确命中原值       : 256 / 65537
整数编码 vs 浮点编码  : 偏差 101 / 65537(sRGB 域最大 0.5258 灰度级)

读法:最大误差 292 Q16.16 出现在最亮那一档——D[254] = 64953 到 D[255] = 65536 的间隔是 583,中点距两端各 292。换句话说,往返误差恒 ≤ 半个局部步长,绝不会跨到相邻档。均值 92.3 Q16.16 来自各段步长不均:线性段只有 19,幂段最亮处 583。

最后一行是「整数编码是不是对的」的度量:与浮点参考 round(OETF(x)*255) 相比,65537 个取值里只有 101 个选到了不同的整数档,且 sRGB 域上的偏差最大 0.5258 灰度级——不超过半个档。这说明冻结表的精度与直接浮点算等价。

可运行产物:建表 + 往返度量

下面的脚本一次性建表、落盘、并量出上面每一个数字。它只依赖标准库,不重写任何定点数学。

#!/usr/bin/env python3
# file: lantern/sim/cg_shade_07_1_srgb.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_07_1_srgb.py
"""7.1 线性空间与 sRGB:把 sRGB 传递函数固化成整数查找表。

着色必须在**线性光**空间做。sRGB 是显示编码,把它当线性光参与光照与混合就是伽马错误。
本节不重复工程结论,而是把它变成**可断言的整数**:解码表 256 项一次性生成后冻结,
编码用整数二分在边界表上反查,两侧读同一份整数故逐位一致。

产物:config/cg_shade_07_srgb_lut.json、out/cg_shade_07_1_srgb.json、
      out/cg_shade_07_1_roundtrip_hist.csv。
"""

import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ONE = 65536


def srgb_to_linear_f(v):
    """IEC 61966-2-1 的 sRGB EOTF(浮点参考,仅用于一次性建表)。"""
    c = v / 255.0
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4


def linear_to_srgb_f(x):
    """sRGB OETF 的浮点参考,用于度量整数编码与浮点编码的偏差。x in [0, 1]。"""
    return 12.92 * x if x <= 0.0031308 else 1.055 * x ** (1.0 / 2.4) - 0.055


def build_tables():
    """返回 (D, B):解码表 256 项 + 编码边界表 255 项,全部整数。"""
    d = [int(srgb_to_linear_f(v) * ONE + 0.5) for v in range(256)]
    d[0], d[255] = 0, ONE                                   # 端点必须精确
    b = [(d[v - 1] + d[v]) >> 1 for v in range(1, 256)]     # 非负,`>>1` 与 `//2` 同义
    return d, b


def encode(x, b):
    """整数二分:返回 B 中 `<= x` 的元素个数,即最接近的 8 位 sRGB 值。"""
    lo, hi = 0, 255
    while lo < hi:
        mid = (lo + hi + 1) >> 1
        if b[mid - 1] <= x:
            lo = mid
        else:
            hi = mid - 1
    return lo


def main():
    d, b = build_tables()
    mono = all(d[v] < d[v + 1] for v in range(255))
    gaps = [d[v + 1] - d[v] for v in range(255)]

    # 往返一:8 位 sRGB 值 -> 线性 -> 回到 8 位(要求逐值精确)
    rt_bad = [v for v in range(256) if encode(d[v], b) != v]

    # 往返二:任意 Q16.16 线性值 -> 8 位 sRGB -> 线性
    errs = [abs(d[encode(x, b)] - x) for x in range(ONE + 1)]
    hist = {}
    for e in errs:
        hist[e] = hist.get(e, 0) + 1

    # 与浮点编码参考的偏差(整数编码是不是「对的」)
    fp_bad = sum(1 for x in range(ONE + 1)
                 if encode(x, b) != int(linear_to_srgb_f(x / ONE) * 255.0 + 0.5))
    srgb_dev = max(abs(encode(x, b) - linear_to_srgb_f(x / ONE) * 255.0) for x in range(ONE + 1))

    out, cfg = os.path.join(ROOT, "out"), os.path.join(ROOT, "config")
    os.makedirs(out, exist_ok=True)
    os.makedirs(cfg, exist_ok=True)
    with open(os.path.join(cfg, "cg_shade_07_srgb_lut.json"), "w", encoding="utf-8") as fh:
        json.dump({"srgb_to_linear_q16": d}, fh, ensure_ascii=False)
        fh.write("\n")
    with open(os.path.join(out, "cg_shade_07_1_roundtrip_hist.csv"), "w", encoding="utf-8") as fh:
        fh.write("abs_err_q16,count\n")
        for e in sorted(hist):
            fh.write(f"{e},{hist[e]}\n")

    stats = {
        "table_len": len(d), "monotonic_strict": mono,
        "min_gap_q16": min(gaps), "max_gap_q16": max(gaps),
        "roundtrip_srgb_mismatch": len(rt_bad),
        "roundtrip_linear_max_err_q16": max(errs),
        "roundtrip_linear_mean_err_q16": round(sum(errs) / len(errs), 4),
        "roundtrip_linear_max_err_gray": round(max(errs) / ONE * 255.0, 4),
        "roundtrip_linear_exact_count": hist.get(0, 0), "roundtrip_linear_total": ONE + 1,
        "encode_vs_float_mismatch": fp_bad,
        "encode_srgb_domain_max_err_gray": round(srgb_dev, 6),
    }
    with open(os.path.join(out, "cg_shade_07_1_srgb.json"), "w", encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)

    print(f"解码表               : {len(d)} 项  严格单调 {mono}")
    print(f"步长                 : 线性段 {min(gaps)} … 幂段最大 {max(gaps)} Q16.16")
    print(f"往返一 sRGB->lin->sRGB: 不一致 {len(rt_bad)} / 256")
    print(f"往返二 lin->sRGB->lin : 最大 {max(errs)} Q16.16"
          f"({max(errs) / ONE * 255.0:.4f} 灰度级)  均值 {sum(errs) / len(errs):.4f}")
    print(f"  精确命中原值       : {hist.get(0, 0)} / {ONE + 1}")
    print(f"整数编码 vs 浮点编码  : 偏差 {fp_bad} / {ONE + 1}(sRGB 域最大 {srgb_dev:.4f} 灰度级)")
    return 0


if __name__ == "__main__":
    sys.exit(main())

产物写进 config/ 与 out/:冻结表 cg_shade_07_srgb_lut.json(256 个整数)、统计 cg_shade_07_1_srgb.json、以及往返误差直方图 cg_shade_07_1_roundtrip_hist.csv(误差 0…292 Q16.16 的计数)。注意脚本里编码与解码都用整数:encode 只有移位与比较,build_tables 里的浮点只在建表那一刻出现一次。

跨语言断言:同一份表,逐位一致

P 层不重新建表,而是读同一份冻结表,再复算解码、编码与往返。这样「逐位一致」的对象就只是那几十行整数代码,而不是两门语言的浮点库。

// file: lantern/core/test/cg_shade_07_1_srgb.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_07_1_srgb.test.ts'
// 7.1 的跨语言断言:TypeScript 侧的 sRGB 整数编码必须与 Python 侧
// sim/cg_shade_07_1_srgb.py 给出**逐位相同**的解码表、往返残差与首处分歧,
// 并含一条负例(解码表被扰动后往返必然分叉)。

import assert from "node:assert/strict";
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, srgbDecode, srgbEncode } from "../src/lantern/cg_shade_07_color.ts";

const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..", "..");
const lut = JSON.parse(readFileSync(join(root, "config", "cg_shade_07_srgb_lut.json"), "utf-8"));
const stats = JSON.parse(readFileSync(join(root, "out", "cg_shade_07_1_srgb.json"), "utf-8"));

/** 用给定解码表重建边界表并编码(供负例使用)。 */
function encodeWith(table: readonly number[], x: number): number {
  const b = table.slice(1).map((hi, i) => (table[i] + hi) >> 1);
  let lo = 0;
  let hi = 255;
  while (lo < hi) {
    const mid = (lo + hi + 1) >> 1;
    if (b[mid - 1] <= x) lo = mid;
    else hi = mid - 1;
  }
  return lo;
}

test("P 层解码表与 S 层冻结表逐位一致", () => {
  assert.equal(SRGB_TO_LINEAR_Q16.length, 256);
  for (let v = 0; v < 256; v += 1) {
    assert.equal(SRGB_TO_LINEAR_Q16[v], lut.srgb_to_linear_q16[v], `v=${v}`);
  }
  assert.equal(stats.table_len, 256);
  assert.equal(stats.monotonic_strict, true);
});

test("往返一:encode(decode(v)) == v(残差 0 灰度级)", () => {
  for (let v = 0; v < 256; v += 1) assert.equal(srgbEncode(srgbDecode(v)), v, `v=${v}`);
  assert.equal(stats.roundtrip_srgb_mismatch, 0);
});

test("往返二:lin->sRGB->lin 最大/均值误差与 S 层一致", () => {
  let maxErr = 0;
  let sum = 0;
  for (let x = 0; x <= ONE; x += 1) {
    const e = Math.abs(srgbDecode(srgbEncode(x)) - x);
    maxErr = Math.max(maxErr, e);
    sum += e;
  }
  assert.equal(maxErr, stats.roundtrip_linear_max_err_q16); // 292
  assert.equal(Math.round((sum / (ONE + 1)) * 1e4) / 1e4, stats.roundtrip_linear_mean_err_q16);
});

test("负例:解码表被扰动后往返必然分叉", () => {
  const bad = [...SRGB_TO_LINEAR_Q16];
  bad[200] = bad[201] + 500; // 破坏严格单调
  const mono = bad.every((v, i) => i === 0 || v > bad[i - 1]);
  assert.equal(mono, false);
  const broken = Array.from({ length: 256 }, (_, v) => encodeWith(bad, bad[v]) !== v)
    .filter(Boolean).length;
  assert.ok(broken > 0, "扰动后应有值往返分叉");
});

四条断言全绿:解码表与 S 层逐位相同、encode(decode(v)) == v、往返最大/均值误差与 S 层 JSON 相等、以及一条负例——把解码表第 200 项抬到破坏严格单调后,往返立刻分叉。负例是这套判据的保险:如果连故意写错的表都验得过,判据就不值得信。

到这里,颜色进出的那扇门是整数、可断言的了。下一节把镜头对准亮度:同一份 HDR 辐射度经过三条色调映射曲线,输出会差多远。

阅读导航:上一节:6.3 能量守恒的验证 · 下一节:7.2 色调映射 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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