Sign in

    moon_icc

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

    icc
    color
    colour
    profile
    cmm
    colorspace
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    14 hours ago
    Downloads
    3

    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 ...
    ...
    ### 命令行示例 仓库自带一个可运行的示例程序:
    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 -- 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 任意组合) | ✅ 已完成并验证 | | 渲染意图:感知 / 媒体相对比色 / 饱和度 | ✅ 已完成并验证 | | 渲染意图:绝对比色 | **已知限制**,见下文(与相对比色相同路径) | | `mAB` / `mBA`(ICC v4 LUT) | ✅ 已完成并验证 | | PCS 为 `XYZ ` 的 LUT 型 profile | ✅ 已完成并验证(v4 样本;v2 路径无样本,见「明确不做」) | ## CLUT 型 profile 变换 大量真实世界的 profile(印刷、CMYK、扫描仪)不是矩阵/TRC 型,而是把整个 变换存在一张多维查找表里。本库支持 ICC v2 的两种 LUT 类型: | 类型 | 名称 | 表值位深 | |---|---|---| | `mft1` | lut8Type | 8 位(表固定 256 项) | | `mft2` | lut16Type | 16 位(表项数在文件里显式给出) | 处理顺序:**输入表 → 矩阵(仅 3 输入通道时)→ CLUT(多线性插值)→ 输出表**。 用法: ```moonbit 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同 1(已知限制,见下)

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

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

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

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

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

    #绝对比色是已知限制

    绝对比色需要在 PCS 上做白点缩放。本项目按标准形式实现过 「源侧乘 wtpt/D50、目标侧乘 D50/wtpt」,实测与 lcms2 的偏差 从 20 级恶化到 107 级,说明该形式与参考实现的实际语义不符。

    由于无法从可用材料确定参考实现的绝对比色语义,本库不实现它: 编号 3 走与相对比色相同的路径(与 lcms2 的差异 ≤ 20 级),并在测试中 显式断言「编号 3 与编号 1 逐点相同」。发布一个会把结果改坏的猜测实现, 比承认这项限制更糟。

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

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

    #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)的求值本身已可用。
    • 软打样、色貌模型:不做。

    #设计要点

    #外部不可信输入

    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 # 78 项测试 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 后端

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

    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 样本);
    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%;绝对比色见上文「已知限制」。
    • 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
    }

    端到端色彩管理:源设备 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::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 个值。

    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绝对比色同 1(已知限制,见下)

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

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

    绝对比色(编号 3)为什么与 1 相同

    绝对比色需要在 PCS 上做白点缩放。本项目尝试过 「源侧乘 wtpt/D50、目标侧乘 D50/wtpt」这一标准形式,实测与 lcms2 的 偏差从 20 级恶化到 107 级,说明该形式与参考实现的实际语义不符。

    由于无法从可用材料确定参考实现的绝对比色语义,本库不实现它, 而是让编号 3 走与相对比色相同的路径(与 lcms2 的差异 ≤ 20 级), 并在 README 的「明确不做」中如实记录。发布一个会把结果改坏的猜测实现, 比承认这项限制更糟。

    非法编号(负数或大于 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_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::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::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::parse

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

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

    Profile::xyz_to_device

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

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

    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 为参考白点。