Sign in

    moon_icc

    纯 MoonBit 实现的 ICC 色彩管理引擎:解析 ICC profile 并执行真实色空间变换

    icc
    color
    colour
    profile
    cmm
    colorspace
    Download zip
    Author
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    6 hours ago
    Downloads
    8

    Dependencies

    #moon_icc

    纯 MoonBit 实现的 ICC 色彩管理引擎:解析 ICC profile 并执行真实色空间变换,不依赖任何 C 库或系统色彩管理系统。

    CI

    演示预览

    ▶ 演示视频(60 秒,1280×720) — 七个场景依次运行 parse / transform / link / lut 子命令, 覆盖 matrix/TRC 变换、跨类型链接、v2 的 mft2、v4 的 mAB/mBA、 以及 PCS 为 XYZ 的 LUT。视频里的每一行都是当场跑出来的,不是摆拍; 可用 python tools/make_demo_video.py 重新生成。

    #这个库解决什么问题

    MoonBit 生态里已有图像处理与渲染栈(图像编解码、2D 光栅化、PDF 读写、排版引擎),但没有色彩管理:它们可以解出像素、画出图形、排出页面,却无法回答一个基础问题——这些 RGB 数值在目标设备上到底是什么颜色。

    具体缺口:

    已有缺什么
    色彩空间数值转换(sRGB↔HSL↔XYZ)按 ICC profile 做设备相关变换
    能解析 ICC profile 元数据的库用 profile 做实际色变换
    图像编解码图像进入/离开色彩工作流时的色彩管理环节

    moon_icc 补的是中间那一层:读取 ICC profile,并真的按它做变换—— 从单个 profile 的色空间变换(设备编码值 ↔ PCS XYZ ↔ Lab), 到源设备到目标设备的完整色彩管理链路。

    #安装

    moon add qqq66668/moon_icc

    在 moon.pkg 中导入:

    import { "qqq66668/moon_icc", }

    #用法

    #解析 ICC profile

    let data = @fs.read_file_to_bytes("sRGB Color Space Profile.icc")
    let header = @moon_icc.parse_header(data)
    println(header.device_class) // "mntr"
    println(header.color_space) // "RGB "
    println(header.pcs) // "XYZ "
    println(header.date_time.to_iso_string()) // "1998-02-09T06:49:00"

    let tags = @moon_icc.parse_tag_table(data, header)
    for tag in tags {
    println("\{tag.signature} 偏移=\{tag.offset} 长度=\{tag.size}")
    }

    #变换到 PCS(XYZ / Lab)

    let data = @fs.read_file_to_bytes("sRGB Color Space Profile.icc")
    let profile = @moon_icc.Profile::parse(data)
    let transform = profile.matrix_trc()

    // sRGB 中灰 (0.5, 0.5, 0.5) → PCS XYZ
    let xyz = transform.to_pcs(0.5, 0.5, 0.5)
    // xyz = (0.20640003241890215, 0.21403939749252843, 0.17660683523992873)

    // 再转到 Lab
    let lab = @moon_icc.xyz_to_lab(xyz)
    println(lab.l) // 53.585...

    #源 profile → 目标 profile(多 profile 链接)

    let src = @moon_icc.Profile::parse(@fs.read_file_to_bytes("sRGB Color Space Profile.icc"))
    let dst = @moon_icc.Profile::parse(@fs.read_file_to_bytes("display.icc"))

    // 媒体相对比色意图
    let link = @moon_icc.ColorTransform::new(src, dst)
    let (r, g, b) = link.transform(0.5, 0.5, 0.5)

    #跨类型链接(matrix/TRC ↔ LUT)

    两侧各自可以是 matrix/TRC 型或 LUT 型,四种组合都支持——中间统一用 PCS XYZ 衔接。因此 sRGB → CMYK 打印机这样的跨类型链接可用:

    let rgb = @moon_icc.Profile::parse(@fs.read_file_to_bytes("sRGB Color Space Profile.icc"))
    let cmyk = @moon_icc.Profile::parse(@fs.read_file_to_bytes("printer.icc"))
    let link = @moon_icc.ColorTransform::new(rgb, cmyk)

    // 源 3 通道 → 目标 4 通道,用通道数无关的 transform_values
    let out = link.transform_values([ 0.5, 0.5, 0.5 ]) // 4 个 CMYK 分量

    source_channels() / destination_channels() 给出两侧的通道数, 由 profile 的色空间决定(RGB 为 3、CMYK 为 4)。

    命令行等价形式:

    moon run cmd/main --target wasm-gc -- link testdata/srgb.icc testdata/display.icc moon run cmd/main --target wasm-gc -- link testdata/srgb.icc testdata/cmyk_printer.icc

    输出首两行是两侧通道数,随后是 0..255 的灰阶映射(量化到 8 位), 每行 link <输入> <输出通道...>,可直接与 lcms2 之类的参考实现比对:

    src_channels 3 dst_channels 3 link 0 0 0 0 link 1 9 9 9 ...

    CMYK 目标时输出 4 个分量:

    src_channels 3 dst_channels 4 link 0 0 0 0 255 link 1 1 2 1 255 ...

    #软打样(soft proofing)

    在一台显示器上预看「这张图印在目标设备上会是什么样」。链路是

    源设备值 → PCS → 打样设备值 → PCS → 显示设备值 A2B B2A A2B B2A

    中间那次往返是关键:它把超出打样设备色域的颜色按该设备的实际能力裁掉。

    let chain = @moon_icc.ColorTransform::proofing(
    @moon_icc.Profile::parse(@fs.read_file_to_bytes("sRGB Color Space Profile.icc")),
    @moon_icc.Profile::parse(@fs.read_file_to_bytes("printer.icc")), // 打样:CMYK 印刷机
    @moon_icc.Profile::parse(@fs.read_file_to_bytes("display.icc")), // 显示设备
    1, // 源/显示侧意图:媒体相对比色
    1, // 打样意图:也可用 3(绝对比色)
    )
    let (proof_values, shown) = chain.evaluate([ 0.5, 0.5, 0.5 ])
    // proof_values = 印这张图会用到的 CMYK 四个分量
    // shown = 屏幕上应显示的 RGB

    命令行:

    moon run cmd/main --target wasm-gc -- proof testdata/srgb.icc testdata/cmyk_printer.icc testdata/display.icc

    输出每行 proof <灰阶> | <打样设备值> | <显示设备值>:

    proof_intent 1 proof_channels 4 display_channels 3 proof 0 | 0 0 0 255 | 41 41 40 proof 51 | 13 17 16 224 | 61 60 59

    打样链与 lcms2 的 cmsCreateProofingTransform 交叉核对通过: 相对/相对 7/16 完全一致、相对/绝对打样 9/16,两者最大偏差均为 1 级 (金标生成器见 tools/gen_proof_golden.py)。

    #色域判断(gamut check)

    判据:把颜色送进目标设备再送回来,看往返误差多大。

    let source = @moon_icc.Profile::parse(@fs.read_file_to_bytes("sRGB Color Space Profile.icc"))
    let target = @moon_icc.Profile::parse(@fs.read_file_to_bytes("printer.icc"))

    // 源设备值 → PCS XYZ(统一入口:matrix/TRC 型与 LUT 型 profile 都适用)
    let xyz = source.device_to_pcs(1, [ 0.0, 1.0, 0.0 ]) // sRGB 纯绿
    let de = target.round_trip_delta_e(1, xyz) // 往返 ΔE76
    let inside = target.in_gamut(1, xyz) // 按自适应容差判定
    println(de) // 55.83...
    println(inside) // false —— SWOP CMYK 覆盖不了 sRGB 纯绿

    阈值按 profile 自适应:取中性灰色的往返误差中位数(色域判定基线)再乘 1.25。 原因是 CMYK 受 GCR 与总墨量限制,同一 profile 的 A2B 与 B2A 不严格互逆, 中性灰本就有几个 ΔE 的底噪(本机 Agfa SWOP 约 4.5);用固定阈值会把 明明在色域内的灰阶误判成超色域。

    命令行:

    moon run cmd/main --target wasm-gc -- gamut testdata/srgb.icc testdata/cmyk_printer.icc

    tolerance 5.591375629781349 gamut 0 0 0 0 de=12.88 out ← 纸张到不了 L*=0 gamut 1 255 255 255 de=3.42 in ← 纸白可表达 gamut 3 0 255 0 de=55.83 out ← SWOP CMYK 覆盖不了 sRGB 纯绿 gamut 12 68 68 68 de=5.19 in ← 中性灰在色域内

    #命令行示例

    仓库自带一个可运行的示例程序:

    moon run cmd/main --target wasm-gc -- parse testdata/srgb.icc moon run cmd/main --target wasm-gc -- transform testdata/srgb.icc moon run cmd/main --target wasm-gc -- link testdata/srgb.icc testdata/display.icc moon run cmd/main --target wasm-gc -- link testdata/srgb.icc testdata/cmyk_printer.icc moon run cmd/main --target wasm-gc -- link testdata/srgb.icc testdata/cmyk_printer.icc saturation moon run cmd/main --target wasm-gc -- link testdata/srgb.icc testdata/cmyk_printer.icc absolute moon run cmd/main --target wasm-gc -- proof testdata/srgb.icc testdata/cmyk_printer.icc testdata/display.icc moon run cmd/main --target wasm-gc -- gamut testdata/srgb.icc testdata/cmyk_printer.icc moon run cmd/main --target wasm-gc -- lut testdata/cmyk_printer.icc moon run cmd/main --target wasm-gc -- lut testdata/test4.icc

    parse 输出 profile 的头部与 tag 表:

    size 3144 version 2.1 class mntr colorspace RGB pcs XYZ datetime 1998-02-09T06:49:00 intent 0 illuminant 0.964202880859375 1 0.8249053955078125 tagcount 17 cprt 336 51 desc 388 108 wtpt 496 20 ...

    transform 输出固定样本点的变换结果(含中间调,便于对拍):

    sample 0.25 0.049063689759460294 0.050879655743278275 0.041981500061132944 ... sample 0.5 0.20640003241890215 0.21403939749252843 0.17660683523992873 ... sample 1 0.9642791748046875 0.999969482421875 0.8250885009765625 ...

    #当前进度

    功能状态
    ICC profile 二进制解析(头部 + tag 表,含边界校验)✅ 已完成并验证
    tag 数据解析(XYZ / curv / para)✅ 已完成并验证
    matrix/TRC 类型 profile → PCS XYZ 变换✅ 已完成并验证
    XYZ ↔ Lab 转换与 CIE76 色差✅ 已完成并验证
    逆变换(PCS XYZ → 设备 RGB)与矩阵求逆✅ 已完成并验证
    多 profile 链接(源设备 → PCS → 目标设备)✅ 已完成并验证
    媒体相对比色意图✅ 已完成并验证
    CLUT 型 profile 变换(mft1 / mft2,CMYK 打印机等)✅ 已完成并验证
    跨类型链接(matrix/TRC ↔ LUT 任意组合)✅ 已完成并验证
    渲染意图:感知 / 媒体相对比色 / 饱和度✅ 已完成并验证
    渲染意图:绝对比色(PCS 白点缩放)✅ 已完成并验证(对 lcms2 197/200 完全一致、最大 1 级)
    mAB / mBA(ICC v4 LUT)✅ 已完成并验证
    PCS 为 XYZ 的 LUT 型 profile✅ 已完成并验证(v4 样本;v2 路径无样本,见「明确不做」)
    软打样链(源 → 打样设备往返 → 显示器)✅ 已完成并验证(对 lcms2 proofing 最大偏差 1 级)
    色域判断(往返 ΔE76 + 自适应容差)✅ 已完成并验证

    #CLUT 型 profile 变换

    大量真实世界的 profile(印刷、CMYK、扫描仪)不是矩阵/TRC 型,而是把整个 变换存在一张多维查找表里。本库支持 ICC v2 的两种 LUT 类型:

    类型名称表值位深
    mft1lut8Type8 位(表固定 256 项)
    mft2lut16Type16 位(表项数在文件里显式给出)

    处理顺序:输入表 → 矩阵(仅 3 输入通道时)→ CLUT(多线性插值)→ 输出表。

    用法:

    let profile = @moon_icc.Profile::parse(@fs.read_file_to_bytes("printer.icc"))
    // 设备值按 profile 输入色空间的通道顺序给出,0..1
    let lab = profile.device_to_lab(1, [ 0.0, 0.0, 0.0, 0.5 ]) // CMYK 50% 黑

    命令行:

    moon run cmd/main --target wasm-gc -- lut testdata/cmyk_printer.icc

    #实现中容易错的三处

    (一)mft2 的字段顺序:矩阵在前,表项数在后。

    矩阵在偏移 12..47(9 × s15Fixed16),输入/输出表项数在 48..51, 表数据从 52 开始。本项目最初按"表项数在 12..15、矩阵在 16..51"实现, 结果与该 tag 的声明长度差 2052 字节;改用正确顺序后 52 + 4×256×2 + 9⁴×3×2 + 3×2×2 = 41478,与声明长度精确相等, 且 12..47 处读到标准单位矩阵、48..51 处读到 256 与 2。

    (二)ICC v2 的 Lab 编码上限是 0xFF00,不是 0xFFFF。

    版本位深L 满量程a/b 满量程
    v2160xFF00 = 652800xFF00
    v280xFF = 2550xFF
    v4160xFFFF = 655350xFFFF

    因此解码要同时知道位深与版本。另有一个陷阱:不能在折算之后把结果夹到 1.0——v2 的 profile 会合法地存到 0xFFFF(表示略高于 100 的 L)。 本项目最初正是这样写的,网格节点判据立刻抓到:期望 100.387561,实得 100。

    (三)意图到 tag 的映射:A2B0/A2B1/A2B2 是三个不同的表。

    意图编号设备→PCSPCS→设备
    感知0A2B0B2A0
    媒体相对比色1A2B1B2A1
    饱和度2A2B2B2A2

    本机 CMYK profile 的 A2B0 与 A2B1 逐字节比较有 26539 处差异,全在 CLUT 区。 若把相对比色误接到 A2B0,暗端会差约 5 个 L 单位。

    (四)输入表常常不是恒等,不能假定"节点索引/网格数"就落在节点上。

    实测该 profile 通道 0 在 v=0.75 处表值为 49151,而恒等应为 49344。 因此网格节点判据的输入必须反解输入表得到(见 tools/gen_clut_golden.py),否则测到的是节点之间的插值结果, 节点判据就失效了。

    #ICC v4 的 LUT(mAB / mBA )

    ICC v4 把 LUT 换成了两种新类型,分段更灵活:

    类型名称方向
    mAB lutAToBType设备 → PCS
    mBA lutBToATypePCS → 设备

    每一段都可以缺省(偏移为 0),处理顺序由方向决定:

    mAB:A 曲线 → CLUT → M 曲线 → 矩阵 → B 曲线 mBA:B 曲线 → 矩阵 → M 曲线 → CLUT → A 曲线

    两处与 mft1/mft2 差别很大、照直觉写就会错的地方:

    (一)矩阵段没有类型签名头。 偏移直接指向 9 个 s15Fixed16 矩阵项,紧跟 3 个 s15Fixed16 偏移项,共 48 字节。 而曲线段是有类型签名的——两者不一致。本项目初期按"先跳 8 字节类型头" 读矩阵,结果把 M 段的 para 签名当成了矩阵偏移项(读出的"偏移"约 28769.44,正是 'para' 四个字节被当成定点数的结果),与参考实现的最大偏差 达到 10 级。

    (二)内嵌曲线段没有长度字段。 类型签名之后的 4 字节是保留位, 不是长度;段长只能按类型从内容推算,再补齐到 4 字节边界:

    类型内容长度
    curv 项数 0恒等8 + 4
    curv 项数 1单个 gamma8 + 4 + 2
    curv 项数 n>1查找表8 + 4 + 2n
    para函数类型 + 参数8 + 4 + 4×(1/3/4/5/7)

    对拍样本的曲线段保留位恰好全为 0;若当成长度读,会原地打转或读错位。

    (三)两个方向的插值方式不同。 参考实现的 3 输入 CLUT 默认用 四面体插值,但在 PCS 为 Lab 的输出 LUT 上会改回三线性 (lcms2 cmsio1.c 的 _cmsReadOutputLUT,源码注释称此处为 "controversial stuff")。本库照此实现,并用实测确认了它是对的:

    插值选择与 lcms2 的一致率(40 样本)
    mAB 四面体 + mBA 三线性(本库)见下表
    两者对调2 项测试变红,A2B0 差 3 级、B2A0 差 4 级

    (四)逐级夹紧。 参考实现的 16 位定点管线在每一级之后把值限制在 [0, 65535]。mBA 的 M 曲线在超色域输入下会算出负值,不夹紧会与参考实现 差出数十级。

    #实测(testdata/test4.icc,40 个样本,8 位域)

    tag方向完全一致差 1 级最大偏差
    A2B0设备 → PCS40/4000 级
    A2B1设备 → PCS40/4000 级
    B2A0PCS → 设备38/4021 级
    B2A1PCS → 设备39/4011 级

    两张 A2B 表逐位一致:金标取 lcms2 的 NOOPTIMIZE 精确路径 (默认路径会把管线重采样成近似 CLUT,在合成 XYZ 样本上实测会让 b 通道差 4 级)。

    判据还包含一层不依赖插值的节点检查:A2B0 的曲线与矩阵全为恒等, 因此其输出必须精确等于 CLUT 节点值;节点值由测试直接读原始字节得到 (用独立分析得出的偏移),不经过库的解析代码。

    #关于 B2A 的对拍口径

    PCS → 设备方向不能用 LAB 模式输入直接对拍:实测参考实现的 8 位 Lab 输入格式器会给送进 CLUT 的坐标加一个偏移(用一个"CLUT 恒等、曲线恒等"的 探针 profile 反解坐标,L8=1 时坐标是 0.0353 而非 0.0039,即近黑处抬高约 8/255)。因此金标改用 RGB → RGB 组合变换(内部先 A2B 再 B2A), 避开该格式器,否则会把参考实现的行为记成本库的误差。

    #PCS 为 XYZ 的 LUT 型 profile

    LUT 型 profile 的 PCS 既可以是 Lab ,也可以是 XYZ 。两者的编码不同:

    PCS编码1.0 对应上限
    Lab (v2)16 位线性—0xFF00(L=100)
    Lab (v4)16 位线性—0xFFFF(L=100)
    XYZ (v2 与 v4 相同)u1Fixed15Number0x80000xFFFF = 1.99997

    即 XYZ 的 PCS 值是 原始值 / 32768(8 位表值是 原始值 / 128), 与 profile 版本无关——这一点与 Lab 恰好相反,也是实现时最容易搞错的地方。 若误按满量程 0xFFFF 解释,所有颜色坐标会整体缩小一半。

    本库把两个方向统一到 PCS XYZ 作为中间表示:A2B 输出按上表解码, B2A 输入按上表编码,因此 Lab 与 XYZ PCS 的 profile 可以互相链接。

    #样本是合成的

    本机(Windows 驱动目录)与参考实现的测试套件里,带 LUT 的 profile 全是 Lab PCS, 因此 XYZ PCS 没有现成样本可建立判据。仓库里的 testdata/xyz_lut.icc 由 tools/gen_xyz_lut_profile.py 合成:设备白点映射到 PCS 白点 D50, A2B0/B2A0 都是恒等曲线 + 2×2×2 线性 CLUT,且能被参考实现读取与变换 (生成脚本会当场用 lcms2 复核)。线性斜坡让期望值有闭式解:

    A2B0:XYZ = 设备值 × D50 B2A0:设备值 = XYZ / 1.99997… 组合:设备输出 = 设备输入 × D50 / 1.99997…

    实测(8 位域,26 个样本):

    方向与 lcms2 完全一致最大偏差与解析期望的最大偏差
    设备 → PCS Lab26/260 级—
    组合(设备 → PCS → 设备)26/260 级1 级(量化)

    另外测试直接读文件的 CLUT 原始值,要求解码结果恰为 原始值/32768—— 这条判据不依赖插值,只看编码常数。

    #与 lcms2 的交叉核对(重要)

    端到端链接结果与 lcms2(业界标准 CMM,经 Pillow 的 ImageCms 调用) 用 4096 个随机彩色样本交叉核对。判据是三条,均可证伪:

    1. 完全一致率 ≥ 98%(实测 98.68%);
    2. 最大偏差 ≤ 3 级(8 位量化);
    3. 偏差 ≥2 级的通道,其 lcms2 输出必须 ≤ 8——把成因钉在极暗区。

    为什么不是零容差?追查后的结论是差异来自 lcms2 一侧的两个已知特性:

    (一)lcms2 默认走 CLUT 近似。 它链接两个 profile 时会构建 3D CLUT 并插值(速度优先)。同一批样本实测:

    lcms2 flags与本库完全一致最大偏差
    NONE(默认)3942/40964 级
    NOOPTIMIZE4042/40963 级
    FORCE_CLUT3370/409611 级

    强制走 CLUT 会让一致率与最大偏差同时显著恶化——这直接证明默认路径的差异 来自 lcms2 自身的近似。金标因此取 NOOPTIMIZE 这条精确通路。

    (二)8 位量化边界与 16 位流水线分辨率。 lcms2 在 16 位域上传递中间值。 实测偏差分布:

    偏差例数目标线性值范围lcms2 输出
    1 级391.9e-4 .. 9.5e-1(全范围)7 .. 248
    2 级96.7e-5 .. 1.8e-4(极暗)2 .. 6
    3 级68.7e-5 .. 1.2e-4(极暗)3 .. 4

    即:±1 级是全范围的量化边界翻转;≥2 级只出现在极暗区——那里目标线性值 仅约 1e-4,在 16 位域只有 6~8 个量化级,lcms2 的输出本身就有数级不确定度, 而本库全程双精度。

    独立证据的另一条腿:本库与"按 ICC 规范逐字转写的独立双精度实现" (tools/diff_oracle.py)在全部样本上逐位一致,因此上面是交叉核对, 本库的正确性不单独依赖 lcms2。

    复现全部数字:

    python tools/gen_golden.py # 生成链接金标(lcms2 NOOPTIMIZE) python tools/gen_clut_golden.py # 生成 CLUT 金标(网格节点 + lcms2) python tools/gen_cross_link_golden.py # 生成跨类型链接金标 python tools/gen_intent_golden.py # 生成四种渲染意图金标 python tools/gen_v4lut_golden.py # 生成 ICC v4 LUT(mAB/mBA)金标 python tools/gen_xyz_lut_profile.py # 合成 PCS 为 XYZ 的 LUT profile(并用 lcms2 复核) python tools/gen_xyz_golden.py # 生成合成 XYZ profile 的金标 python tools/lcms2_flags_check.py # 复现 flags 对比表 python tools/lcms2_deviation_report.py # 复现链接偏差分布表 python tools/lcms2_intent_check.py # 复现"哪些 profile 能区分意图" python tools/diff_oracle.py wasm-gc # 独立 Python 规范实现差分 python tools/make_demo_video.py # 重新生成演示视频

    #渲染意图

    四种意图经 ColorTransform::with_intent 指定,命令行用意图名:

    moon run cmd/main --target wasm-gc -- link testdata/srgb.icc testdata/cmyk_printer.icc saturation

    编号名称命令行对 LUT 型 profile 的效果
    0感知perceptual用 A2B0/B2A0
    1媒体相对比色(默认)relative用 A2B1/B2A1
    2饱和度saturation用 A2B2/B2A2
    3绝对比色absolute用 A2B1/B2A1,外加 PCS 白点缩放

    非法名称会被拒绝并给出可选值,不静默回退到默认意图。

    #matrix/TRC 型 profile 四种意图等价

    这类 profile 只有一组矩阵与曲线,没有按意图分开的数据。这不是本库的省略: 实测 lcms2 对 sRGB → 显示器 这组 profile 对,四种意图给出逐位相同的结果 (复现:python tools/lcms2_intent_check.py)。

    #含多张 LUT 的 profile 意图确实可区分

    以本机 CMYK 打印机 profile 为例,以媒体相对比色为基准,感知差 35 级、 饱和度差 255 级、绝对差 20 级。四种意图都与 lcms2 交叉核对, 200 个随机 RGB 样本、8 位域下最大偏差 ≤ 2 级、一致率 ≥ 60%。

    #绝对比色的 PCS 白点缩放

    绝对比色不做白点适应:源的白点在目标上仍按绝对值呈现。在 D50 相对 PCS 上 这等价于在两个阶段之间乘 diag(源媒体白点 / 目标媒体白点)。

    白点按 lcms2 的 _cmsReadMediaWhitePoint(src/cmsio1.c:64)取:

    • 没有 wtpt tag → D50;
    • v2 且设备类为 mntr → D50(老显示器 profile 的 wtpt 记的是器件白 D65, 而它的矩阵列已经适应到 D50。用 D65 会让 Z 分量多乘约 1.32 倍);
    • 其余取 wtpt 原值(CMYK 打印机记的是纸张白,显著低于 D50)。

    本库第一版正是在源侧乘了标签里的 D65,导致与 lcms2 的偏差 从 20 级恶化到 107 级;按上面的取法修正后,200 样本197/200 完全一致、 最大偏差 1 级(intent_wbtest.mbt)。这条教训同时写进了 Profile::media_white_point 的文档。

    对 sRGB → CMYK 打印机这组 profile:源侧白点按 D50、目标侧为 (0.8617, 0.8933, 0.7431),缩放 ≈ (1.119, 1.119, 1.110)。

    #变换顺序与白点(实现要点)

    两处最容易错、且只用端点测试发现不了的地方:

    #1. TRC 必须先于矩阵

    linear = TRC(encoded) # 先线性化 XYZ = M · linear # 再乘矩阵

    顺序颠倒时,白点 (1,1,1) 与黑点 (0,0,0) 仍然完全正确—— 只有中间调会错。因此测试必须包含中间调样本; 本项目实测:翻转顺序后白点与 D50 的色差达到 17.3,中间调用例全部变红。

    #2. PCS 是 D50,但不能自己再做一次色适应

    sRGB 的原生白点是 D65,而 ICC 的 PCS 固定为 D50。 关键在于:sRGB profile 里的 rXYZ/gXYZ/bXYZ 矩阵列 已经是从 D65 经 Bradford 适应到 D50 的结果。

    所以实现不再做任何色适应。实测 (1,1,1) 经变换得到 (0.96428, 0.99997, 0.82509),与 D50 (0.9642, 1.0, 0.8249) 的色差 < 0.1; 若误再加一次适应,白点会明显偏离。

    #明确不做

    以下内容不在本项目范围内,写在此处以免误用:

    • 绝对比色的白点缩放:编号 3 当前与媒体相对比色(编号 1)相同。本项目按标准形式实现过白点缩放,但实测与 lcms2 的偏差从 20 级恶化到 107 级,说明该形式与参考实现的语义不符;在无法确定参考语义前不发布猜测实现。详见上文「渲染意图」。
    • 感知 / 饱和度意图的 gamut mapping:本库通过选取 A2B0/A2B2 表来体现意图,与 lcms2 在这两种意图下交叉核对通过;但不自行做色域映射(映射规则由 profile 内的 LUT 承载)。
    • PCS 为 XYZ 的 LUT 型 profile:已实现并通过验证,但只覆盖了 v4(mAB /mBA )样本—— 本机与参考实现的测试套件里带 LUT 的 profile 全是 Lab PCS,XYZ PCS 的样本是合成的 (testdata/xyz_lut.icc)。v2 的 mft1/mft2 + XYZ PCS 组合走同一段解码代码, 但没有真实样本,未建立判据。另:PCS 为其他取值时会显式报错,不静默给出错误颜色。
    • 黑点补偿(BPC):不做。
    • 图像文件读写(PNG / JPEG / TIFF):本库只处理色彩数值,不碰图像容器。4 通道(CMYK)的求值本身已可用。
    • 色貌模型(CAM):不做。ICC 的 PCS 已隐含观察条件,超出 PCS 的色貌预测(CIECAM02 等)不在本库范围。
    • 黑点补偿(BPC):不做。软打样链与色域判断已完成;BPC 是相对比色之上的可选项, 其黑点检测依赖 lcms2 那样的迭代反查,本库尚未实现,测试中也不声称支持。

    #设计要点

    #外部不可信输入

    ICC profile 是外部文件,因此解析层不信任任何字段:

    • 每一处读取都做边界检查,越界返回结构化错误而不是 panic;
    • 头部声明的 size 若大于实际字节数会被拒绝(此后所有 tag 偏移都不可信);
    • 每个 tag 的 offset + size 必须落在文件范围内;
    • tag 条目数必须能装进剩余空间,且乘法做了溢出检查。

    错误类型是 IccError,含 Truncated / BadSignature / SizeMismatch / TagOutOfBounds / BadTagTable / OutOfRange 六种,均带定位信息。

    #大端序与定点数

    ICC 规范(ISO 15076-1 / ICC.1)全程使用大端序,且大量使用 16.16 定点数(s15Fixed16Number)。定点转换集中在 reader.mbt,避免各处重复实现而产生精度偏差。

    实测教训:用错误的字节序读 sRGB Color Space Profile.icm,会把 3144 字节读成 1208745984,且 acsp 签名读成乱码。

    #测试与验证

    moon test --target wasm-gc # 85 项测试 moon test --target js # 同一套测试,js 后端 moon test --target native # 同一套测试,native 后端 python tools/diff_oracle.py wasm-gc # 外部 oracle 差分 python tools/diff_oracle.py js # 同上,js 后端 python tools/verify_publish.py # 在全新工程里实拉已发布包并跑真实变换 python tools/check_readme_examples.py # 把本文件的示例代码逐块实跑并核对其中公布的数值 python tools/gen_proof_golden.py # 重新生成软打样金标(需要 MSYS2 gcc 编译 lcms2)

    check_readme_examples.py 与 verify_publish.py 不放进 CI 的 push 门禁: 它们从 mooncakes.io 拉取已发布版本,而 push 门禁跑的是当前源码—— 若改了示例但尚未发布,二者会不一致而产生误报。这两个脚本用于发布前后的人工验证。

    #核心功能 → 判据对照

    核心功能覆盖它的判据
    profile 解析(头部 + tag 表 + 边界校验)header_wbtest.mbt(合成字节流,含畸形输入);tools/diff_oracle.py 对 5 个真实 profile 逐字段比对
    matrix/TRC 变换与逆变换transform_wbtest.mbt;link_golden_wbtest.mbt(4096 彩色样本对 lcms2);差分脚本逐位比对
    LUT 变换(mft1/mft2)clut_wbtest.mbt(625 个网格节点 + 300 个 lcms2 CMYK 样本);tools/gen_clut_golden.py 反解输入表定位节点
    LUT 变换(mAB /mBA ,v4)v4lut_wbtest.mbt(节点判据读原始字节 + 40 样本对 lcms2);tools/diff_oracle.py 独立结构对拍
    PCS 为 XYZ 的 LUTxyz_lut_wbtest.mbt(u1Fixed15 编码常数判据 + 26 样本双向对拍 + 解析期望式)
    多 profile 链接(四种跨类型组合)cross_link_wbtest.mbt + cross_link_golden_wbtest.mbt(sRGB ↔ CMYK 各 300 样本)
    渲染意图(感知 / 相对 / 饱和度 / 绝对)intent_wbtest.mbt + intent_golden_wbtest.mbt(200 样本 × 四意图);绝对比色另断言「与相对比色必须不同」以防缩放失效
    软打样链proofing_wbtest.mbt(自洽性 + 物理正确性)+ proof_golden_wbtest.mbt(lcms2 cmsCreateProofingTransform 金标,最大偏差 1 级)
    色域判断proofing_wbtest.mbt(中性轴基线、灰/白 in、黑与原色 out、判定与 ΔE 自洽)
    命令行示例与端到端可用性cmd/main 四子命令;tools/verify_publish.py 在仓库之外的空工程里 moon add 实拉并运行

    判据分四层,独立性递增:

    1. 合成字节流:精确构造每个字段,验证解析逻辑本身(含畸形输入);
    2. 真实 profile:Windows 系统自带的 3 个真实 profile(matrix/TRC 型 sRGB、 真实显示器、CMYK/LUT 打印机),外加 Little-CMS 测试套件里的 ICC v4 profile test4.icc(覆盖 mAB / mBA ,来源与许可见 testdata/README.md);
    3. 外部 oracle:七个来源——
      • tools/diff_oracle.py:Python struct 独立按规范解析并独立重做 matrix/TRC 变换,与本库逐字段、逐样本比对;并对 v4 的 mAB /mBA 做独立结构对拍(网格、精度、各段曲线条数、矩阵项数);
      • link_golden_wbtest.mbt:lcms2 生成的 4096 个彩色样本金标(matrix/TRC → matrix/TRC);
      • clut_golden_wbtest.mbt:625 个 CLUT 网格节点 + 300 个 lcms2 CMYK 样本;
      • cross_link_golden_wbtest.mbt:lcms2 生成的跨类型链接金标(sRGB ↔ CMYK 各 300 个随机样本);
      • intent_golden_wbtest.mbt:lcms2 生成的四种渲染意图金标(200 个随机 RGB 样本,四意图共用输入);
      • v4lut_golden_wbtest.mbt:lcms2 生成的 v4 LUT 金标(A2B0/A2B1/B2A0/B2A1 × 40 样本);
      • xyz_lut_golden_wbtest.mbt:lcms2 生成的合成 XYZ-PCS profile 金标(RGB→LAB 与 RGB→RGB 各 26 样本);
      • proof_golden_wbtest.mbt:C 版 lcms2 生成的软打样金标。Pillow 不暴露 proofing 接口,因此 tools/gen_proof_golden.py 用 MSYS2 gcc 编译 _research 下的 Little-CMS 源码,调用 cmsCreateProofingTransform(SOFTPROOFING | NOOPTIMIZE);
    4. 网格节点:CLUT 节点值不依赖插值细节,任何正确实现在节点上都必须 返回存储值。这一层单独钉住解析、索引步长、Lab 解码; v4lut_wbtest.mbt 里还有一条同类的 v4 节点判据。

    实测结果:

    • matrix/TRC 差分:3 个 profile × 15 个样本 × 3 个分量,最大偏差 0.0(逐位一致), wasm-gc 与 js 双后端相同。容差因此设为 1e-15。
    • 与 lcms2 交叉核对(matrix/TRC → matrix/TRC):一致率 98.68%,判据见上文。
    • CLUT 网格节点:625 个节点与独立解析的偏差 < 1e-6(金标精度上限)。
    • CLUT 对 lcms2:最大偏差恰为 1 级(8 位),无 2 级及以上; L 通道平均有符号差 −0.11 级,即无系统性偏移。
    • 跨类型链接对 lcms2(各 300 个随机样本,8 位域):
      • sRGB → CMYK:完全一致 297/300(99%),最大偏差 ≤ 2 级;
      • CMYK → sRGB:完全一致 173、差 1 级 125、差 2 级 2,最大偏差 2 级。
    • 渲染意图对 lcms2(200 个随机样本,8 位域):感知 / 相对比色 / 饱和度 三种意图最大偏差 ≤ 2 级、一致率 ≥ 60%;绝对比色 197/200 完全一致、 最大偏差 1 级。
    • 软打样对 lcms2 cmsCreateProofingTransform(16 个灰阶样本,8 位域): 相对/相对 7/16 完全一致、相对/绝对打样 9/16,两者最大偏差均为 1 级。
    • 色域判断(sRGB → CMYK 打印机):中性轴基线 4.47 ΔE,自适应容差 5.59; 灰阶与纸白判 in,纯黑(ΔE 12.88)与 sRGB 原色(12.48–103.12)判 out。
    • ICC v4 LUT 对 lcms2(test4.icc,40 个样本,8 位域):A2B0 与 A2B1 均 40/40 完全一致(偏差 0),B2A0 38/40、B2A1 39/40,最大偏差 1 级。
    • PCS 为 XYZ 的 LUT(合成 xyz_lut.icc,26 个样本,8 位域):设备→PCS Lab 与 组合变换均 26/26 完全一致(偏差 0)。
    • v4 结构差分(tools/diff_oracle.py):Python 独立解析的网格、精度、曲线条数与 矩阵项数,与库的输出全部吻合(含合成 XYZ profile 的 A3/M0/B3 结构)。

    判据验证(主动植入 bug 确认判据是活的,不是空转):

    植入的错误单元测试反应
    renderingIntent 读取位置错位3 项变红,外部差分 12 处不一致
    TRC 与矩阵顺序颠倒5 项变红(白点偏离 D50 达 17.3 色差)
    矩阵求逆漏掉转置5 项变红(lcms2 一致率跌到 0%)
    CLUT 维度步长顺序写反4 项变红(节点偏差 91 个 L 单位,lcms2 差 93 级)
    LUT 侧 PCS 不做 Lab↔XYZ 转换2 项变红(偏差 255 级,即完全错)
    mAB/mBA 矩阵段按"8 字节类型头 + 矩阵"读5 项变红(偏差 215–255 级)
    取消曲线段 4 字节对齐1 项变红(合成 tag 判据报出 ..cu 非法曲线类型)
    交换两个方向的插值方式2 项变红(A2B0 差 3 级、B2A0 差 4 级)
    mBA 阶段顺序颠倒(矩阵与 M 曲线互换)1 项变红(偏差 255 级)
    忽略矩阵的偏移项1 项变红(偏差 255 级)
    XYZ PCS 解码误按 u1Fixed16(数字 32768→65535)3 项变红(节点判据报出 2 倍偏差,端到端差 61–64 级)
    XYZ PCS 编码(逆方向)误按 u1Fixed161 项变红(组合变换差 127 级)
    缺省的曲线段返回空数组而非透传3 项变红(合成 profile 的最大输入处整体输出 0)

    矩阵求逆那条是开发中真实出现过的 bug:逆矩阵写成了转置,与 lcms2 在 8 位域上 最大差 173/255。灰阶样本完全测不出它(灰阶只走矩阵的对角路径), 是 4096 个彩色样本把它抓出来的——这正是金标必须用彩色随机样本的原因。

    #参考与移植说明

    本项目为原创实现,按 ICC 规范(ISO 15076-1:2005 / ICC.1:2022,即 ICC Profile Format Specification)从零编写,不包含任何第三方代码。

    实现过程中参考以下开源项目理解算法与行为(仅作对照,未复制代码):

    项目语言许可证参考范围
    Little-CMS (lcms2)CMITICC 规范的工程化解读、tag 处理策略

    lcms2 采用 MIT 许可证,与本项目 Apache-2.0 兼容。本项目未链接、未编译、未复制其代码; 仓库内只包含它的测试用 profile testdata/test4.icc(用于建立 ICC v4 LUT 判据), 其来源、许可与 MIT 声明全文见 testdata/README.md。

    #许可证

    Apache-2.0,见 LICENSE。

    IccError

    pub suberror IccError {
    Truncated(expected~ : Int, offset~ : Int, actual~ : Int)
    BadSignature(found~ : String)
    SizeMismatch(declared~ : Int, actual~ : Int)
    TagOutOfBounds(tag~ : String, offset~ : Int, size~ : Int)
    BadTagTable(count~ : Int, offset~ : Int)
    BadTagData(tag~ : String, reason~ : String)
    OutOfRange(what~ : String, value~ : Int)
    }

    解析过程中可能出现的错误。

    BigEndianReader

    pub struct BigEndianReader {
    data : Bytes
    pos : Int
    }

    大端序读取器:持有一份字节数据与当前游标,所有读取都做边界检查。

    游标是可变的,读取失败时游标不动,便于调用方决定是否继续。

    BigEndianReader::new

    fn BigEndianReader::new(data : Bytes) -> BigEndianReader

    用给定字节创建读取器,游标从 0 开始。

    BigEndianReader::position

    fn BigEndianReader::position(self : BigEndianReader) -> Int

    当前游标位置。

    BigEndianReader::read_bytes

    fn BigEndianReader::read_bytes(self : BigEndianReader, n : Int) -> Bytes raise IccError

    从当前位置复制 n 字节。越界返回错误。

    BigEndianReader::read_s15f16

    fn BigEndianReader::read_s15f16(self : BigEndianReader) -> Double raise IccError

    读取 s15Fixed16Number:有符号 16.16 定点数。

    BigEndianReader::read_s32

    fn BigEndianReader::read_s32(self : BigEndianReader) -> Int raise IccError

    读取 4 字节大端有符号整数。

    BigEndianReader::read_signature

    fn BigEndianReader::read_signature(self : BigEndianReader) -> String raise IccError

    读取 4 字节 ASCII 签名(如 acsp、mntr、XYZ )。

    签名来自外部不可信输入,可能含 NUL 等不可打印字节。这类字节若原样进入 错误消息,会让日志与测试驱动无法解析(本项目真实踩到过:一个畸形的内嵌 曲线签名 \0\0cu 导致测试结果的 JSON 解析失败)。因此这里把 可打印 ASCII 之外的字节统一渲染成 .——合法签名全部是可打印 ASCII, 该替换不影响任何查表语义。

    BigEndianReader::read_u16

    fn BigEndianReader::read_u16(self : BigEndianReader) -> Int raise IccError

    读取 2 字节大端无符号整数。

    BigEndianReader::read_u16f16

    fn BigEndianReader::read_u16f16(self : BigEndianReader) -> Double raise IccError

    读取 u16Fixed16Number:无符号 16.16 定点数。

    BigEndianReader::read_u32

    fn BigEndianReader::read_u32(self : BigEndianReader) -> Int raise IccError

    读取 4 字节大端无符号整数。

    返回 Int:ICC 中 32 位字段(偏移、长度、tag 数量)都在 Int 可表示范围内。 若最高位被置位导致解释为负数,说明数据畸形,这里显式拒绝。

    BigEndianReader::read_u64

    fn BigEndianReader::read_u64(self : BigEndianReader) -> Int raise IccError

    读取 8 字节大端无符号整数(ICC 的 dateTimeNumber 等场景用不到, 但 profile 里 u64 字段偶有出现)。超过 2^53 会丢失精度,此处显式拒绝。

    BigEndianReader::read_u8

    fn BigEndianReader::read_u8(self : BigEndianReader) -> Int raise IccError

    读取 1 字节无符号整数。

    BigEndianReader::read_u8f8

    fn BigEndianReader::read_u8f8(self : BigEndianReader) -> Double raise IccError

    读取 u8Fixed8Number:无符号 8.8 定点数。

    BigEndianReader::remaining

    fn BigEndianReader::remaining(self : BigEndianReader) -> Int

    剩余可读字节数。

    BigEndianReader::seek

    fn BigEndianReader::seek(self : BigEndianReader, offset : Int) -> Unit raise IccError

    把游标移动到指定绝对位置。越界返回错误。

    BigEndianReader::skip

    fn BigEndianReader::skip(self : BigEndianReader, n : Int) -> Unit raise IccError

    跳过若干字节。越界返回错误。

    Clut

    pub struct Clut {
    input_channels : Int
    output_channels : Int
    grid_points : Int
    bit_depth : Int
    matrix : Array[Double]
    input_tables : Array[Double]
    input_entries : Int
    clut : Array[Double]
    output_tables : Array[Double]
    output_entries : Int
    }

    一个已解析的 CLUT 型 tag。

    Clut::evaluate

    fn Clut::evaluate(self : Clut, inputs : Array[Double]) -> Array[Double]

    求值:把输入值(0..1)变换为输出值(0..1)。

    顺序:输入表 → 矩阵(仅 3 输入通道时)→ CLUT 多线性插值 → 输出表。

    ColorTransform

    pub struct ColorTransform {
    source : SourceStage
    destination : DestinationStage
    intent : Int
    pcs_scale : XyzNumber
    }

    端到端色彩管理:源设备 RGB → PCS → 目标设备 RGB。

    使用媒体相对比色意图。这是 matrix/TRC 型 profile 唯一 有明确数学定义、且不依赖 profile 内 LUT 的意图:

    • 感知与饱和度意图需要 gamut mapping,通常由 profile 内的 LUT(A2B0/A2B1 等)承载;matrix/TRC 型 profile 没有这些 LUT, 参考实现(lcms2)在此情形下也会退化为相对比色;
    • 绝对比色需要源媒体的绝对白点亮度,超出 ICC profile 本身携带的信息。

    白点处理

    两个 profile 的矩阵已经各自完成到 D50 的色适应, 因此这里在 PCS 中直接衔接,不再做额外的白点适应。

    ColorTransform::destination_channels

    fn ColorTransform::destination_channels(self : ColorTransform) -> Int

    目标侧通道数。

    ColorTransform::intent

    fn ColorTransform::intent(self : ColorTransform) -> Int

    该变换使用的渲染意图编号。

    ColorTransform::new

    fn ColorTransform::new(source : Profile, destination : Profile) -> ColorTransform raise IccError

    构造源→目标的端到端变换,使用媒体相对比色(编号 1)。

    两侧各自可以是 matrix/TRC 型或 LUT 型,四种组合都支持: 中间统一用 PCS XYZ 衔接。因此 sRGB → CMYK 打印机这样的跨类型链接可用。

    ColorTransform::proofing

    fn ColorTransform::proofing(source : Profile, proof : Profile, display : Profile, intent : Int, proof_intent : Int) -> ProofTransform raise IccError

    构造软打样链。

    参数含义
    source图像所在设备(如 sRGB 相机/显示器)
    proof要模拟的目标设备(如 CMYK 印刷机)
    display观察者实际使用的显示设备
    intent源与显示侧意图(通常 1 = 媒体相对比色)
    proof_intent打样 profile 两个方向的意图(通常 1,或 3 = 绝对比色)

    ColorTransform::source_channels

    fn ColorTransform::source_channels(self : ColorTransform) -> Int

    源侧通道数(目标色空间的通道数由 profile 决定,如 CMYK 为 4)。

    ColorTransform::transform

    fn ColorTransform::transform(self : ColorTransform, r : Double, g : Double, b : Double) -> (Double, Double, Double)

    端到端变换求值(3 通道便捷形式)。

    仅用于源与目标都是 3 通道的情形(如 sRGB → 显示器)。跨类型或 CMYK 场景请用 transform_values。

    ColorTransform::transform_values

    fn ColorTransform::transform_values(self : ColorTransform, inputs : Array[Double]) -> Array[Double]

    端到端变换求值(通用形式,支持任意通道数)。

    输入按源 profile 色空间的通道顺序给出,输出按目标 profile 色空间 的通道顺序。例如 sRGB → CMYK 时输入 3 个值、输出 4 个值。

    中间在 PCS XYZ 上乘 pcs_scale:只有绝对比色(编号 3)不是 (1,1,1), 其余意图这步是恒等变换。

    ColorTransform::with_intent

    fn ColorTransform::with_intent(source : Profile, destination : Profile, intent : Int) -> ColorTransform raise IccError

    构造源→目标的端到端变换,指定渲染意图。

    编号意图对 LUT 型 profile 的效果
    0感知用 A2B0/B2A0
    1媒体相对比色用 A2B1/B2A1
    2饱和度用 A2B2/B2A2
    3绝对比色用 A2B1/B2A1,外加 PCS 白点缩放

    matrix/TRC 型 profile 四种意图等价:这类 profile 只有一组矩阵与曲线, 没有按意图分开的数据。这不是本库的省略——实测 lcms2 对 sRGB → 显示器 这组 profile 对,四种意图给出逐位相同的结果 (见 tools/lcms2_intent_check.py)。

    反之对含多个 LUT 的 profile(如本机 CMYK 打印机),意图确实可区分: 实测以媒体相对比色为基准,感知差 35 级、饱和度差 255 级。

    绝对比色(编号 3)的 PCS 缩放

    绝对比色不做白点适应:它要求源的白点在目标上仍按绝对值呈现。 在 ICC 的 D50 相对 PCS 上,这等价于在两个阶段之间乘一个对角缩放

    scale = diag(源媒体白点 / 目标媒体白点)

    与 lcms2 的 ComputeConversion(src/cmscnvrt.c:352,AdaptationState = 1 即默认的 V4 行为)逐项一致;白点按 Profile::media_white_point 取 (v2 的 mntr 类按 D50 处理,这是 lcms2 的既定行为)。

    以本机测试 profile 为例:srgb.icc 是 v2 mntr → 白点按 D50; cmyk_printer.icc 是 v2 prtr、wtpt = (0.8617, 0.8933, 0.7431) → 缩放 ≈ (1.119, 1.119, 1.110)。所以这条链上只有目标侧贡献差异, 源侧缩放在 lcms2 语义下恰好是 1。

    上一版曾在源侧乘 wtpt/D50(用了 sRGB 标签里的 D65 而不是 D50), Z 分量被多乘约 1.32 倍,这才导致「偏差反而从 20 级恶化到 107 级」。

    非法编号(负数或大于 3)会被拒绝,不静默回退到默认意图。

    DestinationStage

    type DestinationStage

    目标侧(PCS → 设备)的两种实现。

    LUT 分支存的是 B2A 表:输入 PCS,输出设备值。

    IccDateTime

    pub struct IccDateTime {
    year : Int
    month : Int
    day : Int
    hour : Int
    minute : Int
    second : Int
    }

    profile 的日期时间字段(6 个 u16)。

    IccDateTime::to_iso_string

    fn IccDateTime::to_iso_string(self : IccDateTime) -> String

    用 ISO 8601 风格格式化日期时间。

    IccHeader

    pub struct IccHeader {
    size : Int
    cmm_type : Int
    major_version : Int
    minor_version : Int
    device_class : String
    color_space : String
    pcs : String
    date_time : IccDateTime
    platform : String
    flags : Int
    manufacturer : Int
    model : Int
    attributes : Int
    rendering_intent : Int
    illuminant : XyzNumber
    creator : Int
    profile_id : Bytes
    }

    解析后的 profile 头部。

    IccHeader::is_display

    fn IccHeader::is_display(self : IccHeader) -> Bool

    设备类别为显示器时返回 true。

    IccTagEntry

    pub struct IccTagEntry {
    signature : String
    offset : Int
    size : Int
    }

    tag 表中的一个条目。

    InverseCurve

    type InverseCurve

    逆变换专用的曲线表示。

    curv 表型曲线是等间距采样的(表项 i 对应输入 i/(n-1)), 因此求逆不该"先重采样成一张新表再插值"——那会在极暗区 (曲线陡、输出接近 0)引入可观的二次误差。 正确做法是在表值域上直接反查:找到第一个表值 ≥ target 的索引, 在该索引与前一索引之间按表值线性插值得到输入。

    实测:早期用"重采样 1025 点 + 插值"的做法,在 sRGB→显示器 profile 的 i=1(最暗一级)上输出 4/255,而 lcms2 与纯数学参考实现均为 9/255。

    InverseCurve::evaluate

    fn InverseCurve::evaluate(self : InverseCurve, y : Double) -> Double

    对逆曲线求值。

    InverseMatrixTrc

    pub struct InverseMatrixTrc {
    inverse_matrix : Matrix3
    inv_r : InverseCurve
    inv_g : InverseCurve
    inv_b : InverseCurve
    }

    逆变换:PCS XYZ → 设备编码值。

    MatrixTrcTransform 持有的是"正变换"上下文(矩阵与正向曲线), 逆变换需要矩阵的逆与曲线的反函数。为避免每次调用都重新求逆, 这里单独构造一个逆变换上下文。

    InverseMatrixTrc::from_pcs

    fn InverseMatrixTrc::from_pcs(self : InverseMatrixTrc, xyz : XyzNumber) -> (Double, Double, Double)

    逆变换求值:XYZ → 设备编码值。

    步骤:矩阵求逆得到线性值 → 逐通道套反曲线 → 夹到 [0,1]。

    Lab

    pub struct Lab {
    l : Double
    a : Double
    b : Double
    }

    Lab 值。l 取 0..100,a/b 为有符号值。

    Lab::new

    fn Lab::new(l : Double, a : Double, b : Double) -> Lab

    由分量构造 Lab。

    LutTable

    pub enum LutTable {
    LutV2(Clut)
    LutV4(V4Lut)
    }

    LUT 型 tag 的统一入口。

    v2 的 mft1/mft2 与 v4 的 mAB /mBA 数据布局差别很大, 但对外语义相同:"一组归一化输入 → 一组归一化输出"。 统一成一个枚举后,链接逻辑(transform.mbt)不必区分二者。

    LutTable::bit_depth

    fn LutTable::bit_depth(self : LutTable) -> Int

    表值位深(clut_output_to_lab / clut_output_to_xyz 需要)。

    v2 的 mft1/mft2 由类型决定(8 / 16 位); v4 的 mAB /mBA 由 CLUT 段的精度字节决定(1 字节 → 8 位,2 字节 → 16 位), 因为这个位深同时决定 XYZ PCS 的折算系数(8 位满量程是 128,16 位是 32768)。

    LutTable::evaluate

    fn LutTable::evaluate(self : LutTable, inputs : Array[Double]) -> Array[Double]

    求值。

    LutTable::grid_text

    fn LutTable::grid_text(self : LutTable) -> String

    网格点数描述。

    mft1/mft2 每一维的网格点数相同,只报一个值; mAB /mBA 每一维可以不同,逐维列出。

    LutTable::input_channels

    fn LutTable::input_channels(self : LutTable) -> Int

    输入通道数。

    LutTable::output_channels

    fn LutTable::output_channels(self : LutTable) -> Int

    输出通道数。

    LutTable::pcs_is_v2

    fn LutTable::pcs_is_v2(self : LutTable, profile_is_v2 : Bool) -> Bool

    该表给出的 PCS Lab 是否按 v2 编码 解释。

    mft1/mft2 用 profile 版本判定(v2 的上限是 0xFF00); mAB /mBA 是 v4 独有的类型,一律是 v4 口径。

    LutTable::type_name

    fn LutTable::type_name(self : LutTable) -> String

    LUT 的类型签名(诊断输出用)。

    Matrix3

    pub struct Matrix3 {
    col0 : XyzNumber
    col1 : XyzNumber
    col2 : XyzNumber
    }

    3×3 矩阵,按列存储(ICC 中 rXYZ/gXYZ/bXYZ 各是一列)。

    Matrix3::determinant

    fn Matrix3::determinant(self : Matrix3) -> Double

    矩阵行列式。零行列式意味着不可逆。

    Matrix3::inverse

    fn Matrix3::inverse(self : Matrix3) -> Matrix3?

    矩阵求逆(伴随矩阵除以行列式)。

    行列式为零时返回 None——调用方须显式处理,不能假定可逆。 ICC profile 的矩阵理论上总是可逆的(三原色线性无关), 但 profile 是外部不可信输入,因此必须检查。

    Matrix3::mul

    fn Matrix3::mul(self : Matrix3, v : XyzNumber) -> XyzNumber

    矩阵乘以列向量。

    Matrix3::new

    fn Matrix3::new(col0 : XyzNumber, col1 : XyzNumber, col2 : XyzNumber) -> Matrix3

    用三列构造矩阵。

    MatrixTrcTransform

    pub struct MatrixTrcTransform {
    matrix : Matrix3
    trc_r : ToneCurve
    trc_g : ToneCurve
    trc_b : ToneCurve
    white_point : XyzNumber
    }

    matrix/TRC 型 profile 的变换上下文。

    由 Profile::matrix_trc 构造,持有三列矩阵与三条通道曲线。

    MatrixTrcTransform::inverse

    构造逆变换上下文。矩阵不可逆时报错。

    MatrixTrcTransform::to_pcs

    fn MatrixTrcTransform::to_pcs(self : MatrixTrcTransform, r : Double, g : Double, b : Double) -> XyzNumber

    把编码值(0..1)变换为 PCS XYZ。

    步骤:逐通道 TRC 线性化 → 矩阵乘。

    Profile

    pub struct Profile {
    header : IccHeader
    tags : Array[IccTagEntry]
    data : Bytes
    }

    一个已解析的 ICC profile:头部 + tag 表,可按签名查找 tag。

    Profile::a2b

    fn Profile::a2b(self : Profile, intent : Int) -> LutTable raise IccError

    取出设备 → PCS 的 LUT。

    若指定意图的 tag 不存在,退回 A2B0;都没有则报错。

    支持 v2 的 mft1/mft2 与 v4 的 mAB (见 v4lut.mbt)。

    Profile::b2a

    fn Profile::b2a(self : Profile, intent : Int) -> LutTable raise IccError

    取出 PCS → 设备的 LUT。

    支持 v2 的 mft1/mft2 与 v4 的 mBA (见 v4lut.mbt)。

    Profile::device_to_lab

    fn Profile::device_to_lab(self : Profile, intent : Int, inputs : Array[Double]) -> Lab raise IccError

    设备值(0..1,按 profile 输入色空间的通道顺序)→ PCS Lab。

    PCS 为 XYZ 的 LUT 型 profile 会先求出 PCS XYZ 再转 Lab (这一步是精确的坐标换算,不引入近似)。

    Profile::device_to_pcs

    fn Profile::device_to_pcs(self : Profile, intent : Int, inputs : Array[Double]) -> XyzNumber raise IccError

    单 profile 的设备值 → PCS XYZ(统一入口)。

    与 Profile::device_to_xyz 的区别:那个只处理 LUT 型 profile, 这里按 profile 的实际类型自动选择 matrix/TRC 或 LUT 路径, 因此打样链、色域判断这类「只涉及一个 profile」的场景可以直接用。

    Profile::device_to_xyz

    fn Profile::device_to_xyz(self : Profile, intent : Int, inputs : Array[Double]) -> XyzNumber raise IccError

    设备值(0..1,按 profile 输入色空间的通道顺序)→ PCS XYZ。

    LUT 型 profile 的 PCS 可以是 Lab 或 XYZ ,两者的编码不同: Lab 按版本折算(v2 上限 0xFF00),XYZ 一律是 u1Fixed15Number (1.0 = 0x8000)。见 clut_output_to_lab 与 clut_output_to_xyz。

    Profile::find_tag

    fn Profile::find_tag(self : Profile, signature : String) -> IccTagEntry?

    按签名查找第一个匹配的 tag。

    Profile::gamut_tolerance

    fn Profile::gamut_tolerance(self : Profile, intent : Int) -> Double raise IccError

    该 profile 的色域判定容差(ΔE76)。

    取「中性轴基线 × 1.25」,下限 2.0。1.25 的含义是:允许比中性轴底噪再高 25% 的偏差——中性色本身不判超色域,而明显被设备边界裁掉的颜色 (实测 ΔE 从 12 起步)会被判出来。

    这是启发式判据,不是 ICC 规范里的定义;需要别的口径时可以直接用 round_trip_delta_e 自己定阈值。

    Profile::has_lut

    fn Profile::has_lut(self : Profile) -> Bool

    该 profile 是否具备 LUT 变换所需的 A2B tag。

    Profile::has_matrix_trc

    fn Profile::has_matrix_trc(self : Profile) -> Bool

    该 profile 是否具备 matrix/TRC 变换所需的全部 tag。

    用于在构造链接时决定走哪条路径,而不必靠捕获异常来试探。

    Profile::in_gamut

    fn Profile::in_gamut(self : Profile, intent : Int, xyz : XyzNumber) -> Bool raise IccError

    色域判断:往返误差不超过该 profile 的自适应容差即认为在色域内。

    判据的两个已知性质(都有测试钉住):
    • 中性灰与纸白判为「在色域内」;
    • 纯黑与高饱和 RGB 原色判为「超色域」——这是物理事实:纸上最深的黑 达不到 L*=0,SWOP 类 CMYK 也覆盖不了 sRGB 的原色。

    Profile::is_v2

    fn Profile::is_v2(self : Profile) -> Bool

    该 profile 是否使用 ICC v2 的 PCS 编码。

    ICC 的版本从 2 直接跳到 4,没有 v3。因此主版本号 <= 2 即视为 v2。 这个判断影响 Lab 的解码范围(见 clut_output_to_lab)。

    Profile::matrix_trc

    fn Profile::matrix_trc(self : Profile) -> MatrixTrcTransform raise IccError

    构造 matrix/TRC 变换。

    需要 rXYZ/gXYZ/bXYZ/rTRC/gTRC/bTRC 六个 tag。 LUT 型 profile(打印机 CMYK 等)没有这些 tag,会报 BadTagData—— 这是有意的显式失败,而不是静默给出错误颜色。

    Profile::media_white_point

    fn Profile::media_white_point(self : Profile) -> XyzNumber raise IccError

    媒体白点——绝对比色的缩放基准。

    语义对齐 lcms2 的 _cmsReadMediaWhitePoint(src/cmsio1.c:64):

    • 没有 wtpt tag → D50;
    • v2 且设备类为 mntr → D50:这类老显示器 profile 的 wtpt 记的是 器件白(D65),而它的矩阵列已经适应到 D50。拿 D65 当媒体白点会让 绝对比色的 Z 分量多乘约 1.32 倍——这正是本库上一版绝对比色 「偏差反而从 20 级恶化到 107 级」的根因;
    • 其余取 wtpt 原值(例如 CMYK 打印机记的是纸张白,显著低于 D50)。

    绝对比色的缩放是 diag(源白点 / 目标白点),见 ColorTransform。

    Profile::neutral_round_trip_baseline

    fn Profile::neutral_round_trip_baseline(self : Profile, intent : Int) -> Double raise IccError

    中性轴往返基线:若干中性灰的往返 ΔE76 中位数。

    为什么需要它:同一个 profile 的 A2B 与 B2A 是两张独立的表,互相不是 严格的逆——CMYK 打印机受 GCR 与总墨量限制,中性灰往返本就有几个 ΔE 的 底噪(本机 Agfa SWOP profile 实测约 4.5)。若用固定的绝对阈值, 会把明明在色域内的灰阶判成超色域。

    因此色域判断以这台设备自己的中性轴底噪为基线,见 Profile::gamut_tolerance。

    Profile::parse

    fn Profile::parse(data : Bytes) -> Profile raise IccError

    解析完整 profile(头部 + tag 表)。

    Profile::pcs_to_device

    fn Profile::pcs_to_device(self : Profile, intent : Int, xyz : XyzNumber) -> Array[Double] raise IccError

    单 profile 的 PCS XYZ → 设备值(统一入口)。

    Profile::round_trip_delta_e

    fn Profile::round_trip_delta_e(self : Profile, intent : Int, xyz : XyzNumber) -> Double raise IccError

    色域往返误差:PCS → 设备 → PCS 的 ΔE76。

    这是「该颜色能否被这台设备表达」的直接度量: 色域内的颜色往返几乎无损(只剩下 8 位 CLUT 的量化误差), 被色域边界裁掉的颜色往返后会明显偏离。

    用 ΔE76 而不是 ΔE2000:本库只实现了 CIE76,且这一判据比较的是 「同一颜色往返前后」的差异,ΔE76 与 ΔE2000 在这里单调同向。

    Profile::xyz_to_device

    fn Profile::xyz_to_device(self : Profile, intent : Int, xyz : XyzNumber) -> Array[Double] raise IccError

    PCS XYZ → 设备值(device_to_xyz 的逆方向)。

    ProofTransform

    pub struct ProofTransform {
    source : SourceStage
    proof_dest : DestinationStage
    proof_source : SourceStage
    display : DestinationStage
    source_scale : XyzNumber
    display_scale : XyzNumber
    intent : Int
    proof_intent : Int
    }

    软打样链。

    源设备值 --A2B--> PCS --B2A--> 打样设备值 --A2B--> PCS --B2A--> 显示设备值

    打样 profile 的两个方向都用 proof_intent;源与显示用 intent。 绝对比色会在两个 PCS 交界处引入白点缩放(见 ColorTransform 的说明), 缩放取相邻两侧的媒体白点之比。

    ProofTransform::display_channels

    fn ProofTransform::display_channels(self : ProofTransform) -> Int

    显示设备值通道数。

    ProofTransform::evaluate

    fn ProofTransform::evaluate(self : ProofTransform, inputs : Array[Double]) -> (Array[Double], Array[Double])

    打样链求值:返回 (打样设备值, 显示设备值)。

    两个结果都有用:前者是「这张图印在目标设备上会用哪些墨量」, 后者是「屏幕上应该显示成什么」。

    ProofTransform::intent

    fn ProofTransform::intent(self : ProofTransform) -> Int

    源/显示侧使用的意图编号。

    ProofTransform::proof_channels

    fn ProofTransform::proof_channels(self : ProofTransform) -> Int

    打样设备值 → 设备值通道数(如 CMYK 为 4)。

    ProofTransform::proof_intent

    fn ProofTransform::proof_intent(self : ProofTransform) -> Int

    打样 profile 使用的意图编号。

    ProofTransform::transform_values

    fn ProofTransform::transform_values(self : ProofTransform, inputs : Array[Double]) -> Array[Double]

    打样链求值(只取显示设备值)。

    SourceStage

    type SourceStage

    源侧(设备 → PCS)的两种实现。

    LUT 分支存的是 A2B 表:输入设备值,输出 PCS。

    ToneCurve

    pub enum ToneCurve {
    Gamma(Double)
    Table(Array[Double])
    Parametric(Array[Double])
    }

    一维曲线(TRC)。

    ICC 用两种方式表示:curv 类型(gamma 值或查找表)与 para 类型(参数方程)。 本库统一求值为 evaluate。

    ToneCurve::evaluate

    fn ToneCurve::evaluate(self : ToneCurve, x : Double) -> Double

    对曲线求值。

    • Gamma(g):x^g;
    • Table(t):在表项间线性插值,表长 1 时视为常量;
    • Parametric(p):按 ICC 定义的 5 种函数求值。

    V4Lut

    pub struct V4Lut {
    kind : V4LutKind
    input_channels : Int
    output_channels : Int
    a_curves : Array[ToneCurve]
    m_curves : Array[ToneCurve]
    b_curves : Array[ToneCurve]
    matrix : Array[Double]
    offsets : Array[Double]
    grid : Array[Int]
    precision : Int
    clut : Array[Double]
    }

    一个已解析的 mAB / mBA LUT。

    V4Lut::evaluate

    fn V4Lut::evaluate(self : V4Lut, inputs : Array[Double]) -> Array[Double]

    对 mAB/mBA 的 LUT 求值。

    输入按该方向的入口通道数给出(mAB 为设备值,mBA 为 PCS Lab 编码), 输出按出口通道数给出,全部归一化到 [0,1]。

    V4LutKind

    pub enum V4LutKind {
    AToB
    BToA
    }

    v4 LUT 的方向。

    XyzNumber

    pub struct XyzNumber {
    x : Double
    y : Double
    z : Double
    }

    三刺激值(XYZ 或 Lab 的三分量)。

    XyzNumber::new

    fn XyzNumber::new(x : Double, y : Double, z : Double) -> XyzNumber

    由三刺激值构造。

    clut_output_to_lab

    fn clut_output_to_lab(values : Array[Double], bit_depth : Int, is_v2 : Bool) -> Lab

    把 CLUT 的输出(按位深满量程归一化到 [0,1])解码为 PCS Lab。

    ICC v2 与 v4 的 Lab 编码范围不同,这是实现 LUT 时最容易出错的地方:

    版本编码L 满量程a/b 满量程
    v216 位0xFF00 = 652800xFF00
    v28 位0xFF = 2550xFF
    v416 位0xFFFF = 655350xFFFF

    因此需要同时知道位深与版本:8 位表值本身就是满量程, 不需要乘回 65535;16 位表值要先乘回 65535 再按版本的上限折算。

    实测依据:本机 CMYK 打印机 profile(v2.0)的 A2B1 首个 CLUT 节点为 (65533, 33024, 33022)。按 v2 解码得 Lab ≈ (100.39, 1.00, 0.99), 与 lcms2 在 K=0 处的 (100.00, 0.00, 0.00) 吻合;按 v4 解码得 (100.00, 0.50, 0.49),a/b 偏差明显更大。

    clut_output_to_xyz

    fn clut_output_to_xyz(values : Array[Double], bit_depth : Int) -> XyzNumber

    把 CLUT 的输出(归一化到 [0,1])解码为 PCS XYZ。

    XYZ 的 PCS 编码是 u1Fixed15Number:1.0 = 0x8000,上限约 1.99997 (0xFFFF 表示 1 + 32767/32768),且 v2 与 v4 相同—— 这一点与 Lab 不同(Lab 的 v2/v4 上限分别是 0xFF00 与 0xFFFF)。

    因此 16 位表值要乘 65535/32768,8 位表值要乘 255/128。 实测依据:用一个合成的 XYZ-PCS LUT profile(testdata/xyz_lut.icc, 节点值 (i,j,k)×32768)与 lcms2 对拍,输出恰为 device/1.99997…, 逐点偏差 0 级;若误按满量程 0xFFFF 解释,结果会整体差 2 倍。

    d50_white

    let d50_white : XyzNumber

    PCS 白点 D50 的 XYZ 值。

    依据 ICC 规范,PCS 照明体固定为 D50: X = 0.9642、Y = 1.0、Z = 0.8249。

    delta_e76

    fn delta_e76(a : Lab, b : Lab) -> Double

    CIE76 色差 ΔE*ab。

    header_size

    let header_size : Int

    头部固定长度。

    icc_signature

    let icc_signature : String

    acsp 文件签名。

    intent_to_a2b_tag

    fn intent_to_a2b_tag(intent : Int) -> String

    按渲染意图选取 设备 → PCS 的 LUT tag 签名。

    ICC 的编号约定:

    意图编号tag
    感知0A2B0
    媒体相对比色1A2B1
    饱和度2A2B2
    绝对比色3无独立 tag,用 A2B1 再加白点调整

    实测依据:本机 CMYK 打印机 profile 的 A2B0 与 A2B1 内容不同 (逐字节比较有 26539 处差异,全部落在 CLUT 区)。用 lcms2 以 intent=1 变换时只有 A2B1 的节点值与之一致——若误用 A2B0,暗端会差约 5 个 L 单位。

    intent_to_b2a_tag

    fn intent_to_b2a_tag(intent : Int) -> String

    按渲染意图选取 PCS → 设备 的 LUT tag 签名。

    lab_to_clut_input

    fn lab_to_clut_input(lab : Lab, bit_depth : Int, is_v2 : Bool) -> Array[Double]

    把 Lab 编码到 CLUT 输入域(clut_output_to_lab 的逆)。

    lab_to_xyz

    fn lab_to_xyz(lab : Lab) -> XyzNumber

    Lab → XYZ,以 D50 为参考白点。

    parse_a2b_table

    fn parse_a2b_table(data : Bytes, entry : IccTagEntry) -> LutTable raise IccError

    解析设备 → PCS 方向的 LUT tag。

    parse_b2a_table

    fn parse_b2a_table(data : Bytes, entry : IccTagEntry) -> LutTable raise IccError

    解析PCS → 设备 方向的 LUT tag。

    parse_clut_tag

    fn parse_clut_tag(data : Bytes, entry : IccTagEntry) -> Clut raise IccError

    解析 mft1 或 mft2 tag。其他类型报错。

    parse_curv_tag

    fn parse_curv_tag(data : Bytes, entry : IccTagEntry) -> ToneCurve raise IccError

    解析 curv 类型 tag。

    数据体:u32 表项数 N,随后 N 个 u16(0..65535 映射到 [0,1])。 N == 0 表示恒等曲线;N == 1 表示 y = x^(值/256)。

    parse_header

    fn parse_header(data : Bytes) -> IccHeader raise IccError

    解析 128 字节头部。

    会校验 acsp 签名;size 与实际字节数不一致时报 SizeMismatch (某些第三方 profile 会留空洞,故仅对"声明值小于实际值"严格报错, 见下方注释)。

    parse_para_tag

    fn parse_para_tag(data : Bytes, entry : IccTagEntry) -> ToneCurve raise IccError

    解析 para 类型 tag。

    数据体:u16 函数类型 (0..4),u16 保留位,随后 s15Fixed16 参数。 参数个数由函数类型决定:0→1 个, 1→3 个, 2→4 个, 3→5 个, 4→7 个。

    parse_tag_table

    fn parse_tag_table(data : Bytes, header : IccHeader) -> Array[IccTagEntry] raise IccError

    解析 tag 表。

    每个条目的 offset + size 必须落在 profile 数据范围内,否则报 TagOutOfBounds。返回的条目顺序保持文件中的原始顺序。

    parse_tone_curve

    fn parse_tone_curve(data : Bytes, entry : IccTagEntry) -> ToneCurve raise IccError

    按 tag 类型自动分派解析曲线(curv 或 para)。

    parse_v4_lut

    fn parse_v4_lut(data : Bytes, entry : IccTagEntry, kind : V4LutKind) -> V4Lut raise IccError

    解析 mAB / mBA tag。

    parse_xyz_tag

    fn parse_xyz_tag(data : Bytes, entry : IccTagEntry) -> XyzNumber raise IccError

    解析 XYZ 类型 tag,返回三刺激值。

    数据体为 3 个 s15Fixed16Number。用于 rXYZ/gXYZ/bXYZ/wtpt/bkpt。

    rendering_intent_name

    fn rendering_intent_name(intent : Int) -> String

    渲染意图的可读名称。未知值返回 "unknown"。

    s15f16_to_double

    fn s15f16_to_double(raw : Int) -> Double

    把 s15Fixed16Number 的原始 32 位整数转为浮点。

    单独暴露是为了让测试能直接验证 16.16 定点语义, 而不必构造完整 profile。

    tag_entry_size

    let tag_entry_size : Int

    每个 tag 表项固定长度。

    tag_type_and_data

    fn tag_type_and_data(data : Bytes, entry : IccTagEntry) -> (String, Int) raise IccError

    解析 tag 数据体的公共前置:返回 (类型签名, 数据体起始偏移)。

    u16f16_to_double

    fn u16f16_to_double(raw : Int) -> Double

    把 u16Fixed16Number 的原始 32 位整数转为浮点。

    xyz_to_clut_input

    fn xyz_to_clut_input(xyz : XyzNumber, bit_depth : Int) -> Array[Double]

    把 PCS XYZ 编码到 CLUT 输入域(clut_output_to_xyz 的逆)。

    xyz_to_lab

    fn xyz_to_lab(xyz : XyzNumber) -> Lab

    XYZ → Lab,以 D50 为参考白点。