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。做法是把解码方向固化成一张表:
- 用浮点算 256 个采样点
D[v] = round(65536 * EOTF(v/255)),一次性生成后冻结成整数常量(config/cg_shade_07_srgb_lut.json); - 编码方向的边界表由解码表用纯整数算术导出:
B[v-1] = (D[v-1] + D[v]) >> 1,非负故>>1与//2同义; encode(x)= 在B上做整数二分,返回B中<= x的元素个数。
这样运行期只剩查表、移位和整数二分,没有任何浮点。冻结表的几处关键采样:
| v | D[v] | 说明 |
|---|---|---|
| 0 | 0 | 黑必须精确到 0 |
| 10 | 199 | 线性段最后一点 |
| 11 | 219 | 幂段第一点 |
| 128 | 14147 | 中灰 |
| 188 | 32957 | 线性 0.5 附近的 sRGB 值 |
| 254 | 64953 | 幂段最后一点 |
| 255 | 65536 | 白必须精确到 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 色调映射 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。