moon_icc

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

    icc
    color
    colour
    profile
    cmm
    colorspace
    Download zip
    Author
    Version
    0.3.2
    License
    Apache-2.0
    Last updated
    2 hours ago
    Downloads
    48

    #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"))

    // 建边界:CMYK 用 9 层采样 = 6561 个设备值 → A2B → Lab
    let boundary = target.gamut_boundary(1, 9)
    println(boundary.samples()) // 6561
    println(boundary.coverage()) // 0.83...(CMYK 色域不填满 Lab 的所有方向)

    // 判一个颜色(sRGB 纯绿)
    let lab = @moon_icc.xyz_to_lab(source.device_to_pcs(1, [ 0.0, 1.0, 0.0 ]))
    println(boundary.contains(lab)) // false —— SWOP CMYK 覆盖不了
    println(boundary.signed_distance(lab)) // 18.70...(越界了多少,Lab 单位)

    为什么不用「往返 ΔE」判定(本项目第一版的做法,已被证伪):

    往返误差里混着颜色映射误差——同一个 profile 的 A2B 与 B2A 是两张独立的表, CMYK 还受 GCR 与总墨量限制,二者并不严格互逆。实测 1296 个 CMYK 设备值样本 (按定义全部在色域内):往返误差平均 6.78 ΔE、最大 19.73,用当时的自适应 容差 5.59 判定会误判 696/1296 = 53.7%。改用几何边界后,同一类样本 (边界 9 层、验证 7 层共 2401 个)误判 0。

    两者是不同的量,不要混用:

    指标含义用途
    GamutBoundary::contains / signed_distance到设备色域边界的几何位置色域判定
    Profile::round_trip_delta_eA2B 与 B2A 的互逆程度设备特性/打样质量的映射误差指标

    命令行(第 4 个参数是边界采样层数,默认 9):

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

    sectors 16 samples 6561 coverage 0.94140625 gamut 0 0 0 0 boundary=out dist=8.59 mapping_de=12.88 ← 纸到不了 L*=0 gamut 1 255 255 255 boundary=out dist=5.69 mapping_de=3.42 ← 超出纸白边界 gamut 3 0 255 0 boundary=out dist=18.70 mapping_de=55.83 ← SWOP 覆盖不了 sRGB 纯绿

    注意 boundary 与 mapping_de 刻意并列输出:前者是几何判定,后者是映射误差。 两者的结论可以相反——中性灰 34(gamut 10)映射误差 3.47 却在色域内(距边界 5.70), 而 sRGB 纯白映射误差只有 3.42 却超出纸白边界 5.69。判据有效性的证据见 MUTATION.md。

    #命令行示例

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

    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 moon run cmd/main --target wasm-gc -- named testdata/named_lab.icc moon run cmd/main --target wasm-gc -- conformance

    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 / ncl2)✅ 已完成并验证
    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 级)
    色域判断(几何色域边界:球面直方图)✅ 已完成并验证(设备值样本 0 误判;被证伪的往返 ΔE 法在同类样本上误判 53.7%)
    abst / link(抽象 profile 与设备链接)✅ 已完成并验证(合成样本 + 解析式判据)
    命名色(nmcl 类 / ncl2 tag:色名 → PCS 查表)✅ 已完成并验证(对 lcms2 逐条 u16 对拍 5/5)

    #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; 若误再加一次适应,白点会明显偏离。

    #命名色(nmcl 类、ncl2 tag)

    命名色 profile 的必需行为是「色名 → PCS」查表。本库解析 ncl2 (namedColor2Type,ICC.1:2010-12 §10.14)的全部字段:

    字段处理
    vendorFlag原样读出(NamedColor2::vendor_flag())
    count条目数;上界检查用除法,避免 count × 步长 在 Int32 上溢出后绕过检查
    nDeviceCoords每条附带的设备坐标个数,规范允许 0..8,超出报 BadTagData
    prefix / suffix32 字节 ASCII,去尾部 0x00
    rootName每条 32 字节 ASCII
    PCS3 × uInt16Number(不是 s15Fixed16Number):Lab 按 v4 0xFFFF / v2 0xFF00 解码,XYZ 按 u1Fixed15(1.0 = 0x8000)
    设备坐标nDeviceCoords × u16

    完整色名 = prefix + rootName + suffix,查表走完整名:PANTONE Red C 命中, 裸根名 Red 不命中——否则同根名、不同后缀的条目(PANTONE Red C / PANTONE Red U) 无法区分。

    具体输入输出(moon run cmd/main --target wasm-gc -- named testdata/named_lab.icc):

    class nmcl pcs Lab version 4 vendor_flag 0 n_device_coords 3 prefix PANTONE suffix C count 5 named 0 PANTONE Red C lab=54.0002 80.8016 69.8988 device=255 0 0 named 3 PANTONE Gray C lab=50.0008 0 0 device=128 128 128 lookup PANTONE Red C -> 54.0002 80.8016 69.8988 lookup Red -> 未命中

    同一组色名在 PCS 为 XYZ 的 testdata/named_xyz.icc 上经 D50 换算出同一组 Lab (差异 < 0.1,来自 u16 量化)——这是跨编码空间的判据:把 XYZ 当 Lab 直读,Gray 会 得到 (0.1776, 0.1842, 0.1519) 而不是 (50, 0, 0),差两个数量级。

    夹具由 tools/gen_named_profile.py 确定性生成;第三方对拍用 lcms2 cmsNamedColorInfo,5 条 × 两份 profile 的 PCS u16 与设备坐标逐条相同。

    #不作为目标(明确边界)

    本库的终点是「设备↔PCS 数值色彩变换 + 色名→PCS 查表」这条链路(见当前进度与 CONFORMANCE.md 的覆盖矩阵)。下列各项不属于本项目目标, 不是"尚未实现的待办";写在此处以免误用。

    • MPE 多处理元素变换(mped):MPE 是把 curve / matrix / lut 等元素串成链的 容器。本库已实现其全部 constituent 元素的解码与求值(curv、para、矩阵、 mft1/mft2、mAB/mBA),但不提供任意元素链容器。
    • 黑点补偿(BPC):非 ICC.1 强制要求,属 CMM 扩展选项;与 lcms2 默认行为一致。
    • 感知 / 饱和度意图的色域映射算法:映射结果由 profile 内 A2B0/A2B2 承载, 本库忠实执行 LUT,不自造映射(与 lcms2 同)。
    • iccMAX(ICC.2):架构不同(多 PCS、光谱、材质),需要独立设计。
    • 色貌模型(CAM):超出 ICC PCS 范围;PCS 已隐含观察条件。
    • 图像容器读写(PNG / JPEG / TIFF):本库只处理色彩数值,不碰图像容器; 4 通道(CMYK)的求值本身已可用。
    • 不完全色适应(AdaptationState != 1):与 lcms2 默认(AdaptationState = 1) 保持一致;绝对比色只做媒体白点缩放,chad 不参与。

    #已实现但判据覆盖不完整(诚实清单)

    以下是已实现的能力,但缺少真实样本,因此判据强度弱于其他部分:

    • PCS 为 XYZ 的 LUT 型 profile:v4(mAB /mBA )已用合成样本 testdata/xyz_lut.icc 验证(26 样本双向对拍 + u1Fixed15 编码常数判据); v2 的 mft1/mft2 + XYZ PCS 走同一段解码代码,但没有真实样本、未建立判据。
    • abst(PCS→PCS)与 link(设备→设备):用恒等与解析式合成样本验证 (生成时经 lcms2 复核可读),但没有真实世界的该类 profile。
    • 色域判定的外部 oracle:lcms2 自带的 GDB API 实测不可用 (cmsGDBCheckPoint 对全部查询返回 out),改用独立第二实现 (lcms2 取 Lab + Python 重写球面直方图)交叉验证,一致率 91.7%; 没有与 CMM 内部算法逐位对拍的 oracle。

    • 命名色(nmcl 类):ncl2 全字段解析与查表已交付,第三方对拍用 lcms2 cmsNamedColorInfo(PCS u16 与设备坐标逐条相同);但夹具是合成样本 (testdata/named_lab.icc / named_xyz.icc),本机没有真实世界的 nmcl 类 profile。

    PCS 为其他取值(既非 Lab 也非 XYZ )时显式报错,不静默给出错误颜色。

    #一致性与标准覆盖

    目标规范、设备类覆盖矩阵、各类必需行为、不作为目标清单与已知边界,见 CONFORMANCE.md。可用命令自查:

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

    覆盖矩阵只有两种状态:本次实现(属于本项目终点,已交付并有判据)与 不作为目标(附标准依据)。不使用"暂不覆盖/后续版本"这类含义不明的说法。 证据强度在表内注明:多数设备类用真实 profile 验证;abst/link 因本机没有 该类真实 profile,用合成样本 + 解析式判据(生成时经 lcms2 复核可读)。 必需 tag 的检查是可执行的,不是文档里的一句话。

    与 mooncakes 上现有色彩包的对照实验(同一份 profile、可复现命令)见 COMPARISON.md。

    #开发回顾

    按《验收自查指南》§3-J 记录,便于评审当场核对「为什么这样设计」。

    #关键架构决策

    1. 纯 MoonBit 重写,不用 extern 封装 lcms2:lcms2 需要 C 工具链、无法编到 wasm-gc, 且是黑盒——封装它等于把「实现」这一层让出去。代价是必须自己啃规范(ICC.1:2010-12 §7–§10)。
    2. 解析层与变换层分离:reader.mbt(大端读取 + 边界防护)→ header.mbt/tag.mbt (结构)→ transform.mbt/clut.mbt/v4lut.mbt(求值)。外部输入一律先过边界检查, 畸形输入返回 IccError 六种结构化错误,不 panic。
    3. PCS 固定 D50,不重复做色适应:profile 里的矩阵列已适应到 D50(见「变换顺序与白点」), 再加一次会让白点偏离 17.3 ΔE。
    4. 色域判定用几何边界而非往返误差:往返法把「映射误差」与「真实越界」混为一谈, 1296 个设备值样本误判 53.7%;改用只由设备值构成的球面直方图边界后为 0。

    #AI 参与环节与边界

    • AI 参与:规范条文的检索与交叉核对、代码与测试骨架的初稿、变异测试的构造、 文档措辞的收敛。
    • 人工负责:架构选型与范围声明(哪些属于项目终点)、每条判据的取舍、 与 lcms2 对拍结果的判断、全部对外材料的最终定稿。
    • 验证纪律:所有产出都先过本地门禁(fmt --check / check --deny-warn / 双后端测试) 与第三方对拍(lcms2、Python 独立实现)才入库;判据本身用变异测试检验判别力 (见 MUTATION.md)。命名色的第一版曾因「生成器与解析器犯同一个错」自洽通过, 是 lcms2 逐条对拍抓出来的——这条已写进 MUTATION.md。

    #参考来源与许可

    • 规范:ICC.1:2010-12(ISO 15076-1:2010),公开标准文本。
    • 参考实现:Little-CMS(lcms2,MIT)——只用于行为对拍与规范解读,未移植任何源码; 其测试 profile testbed/test4.icc 作为测试夹具引入,来源与许可见 testdata/README.md。
    • 数据与夹具:testdata/ 下第三方 profile 的来源、文件内 cprt/desc 原文、 MIT 许可全文均记录在 testdata/README.md;本仓库合成的 profile 由 tools/gen_*.py 确定性生成(生成器路径与输入即脚本本身)。
    • 本项目许可:Apache-2.0(根目录 LICENSE 全文)。

    #设计要点

    #外部不可信输入

    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 # 104 项测试 moon test --target js # 同一套测试,js 后端 moon test --target native # 同一套测试,native 后端(本机 Windows 缺 MSVC,以 CI 为准) 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) python tools/gen_abst_link_profiles.py # 重新生成 abst / link 合成样本(生成时用 lcms2 复核) python tools/compare_gamut.py # 色域边界的独立对拍(lcms2 取 Lab + Python 重写,一致率 91.7%) # 判据有效性(刻意植入错误看测试是否变红)见 MUTATION.md moon run cmd/main -- conformance # 打印设备类覆盖矩阵(标准版本与必需行为)

    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、PCS 黑与原色 out、覆盖率、映射误差独立性)
    抽象 profile(abst)与 device link(link)abstract_link_wbtest.mbt(恒等抽象不改变 PCS;device link 等于闭式 C=1-R, M=1-G, Y=1-B, K=0)
    命令行示例与端到端可用性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 打印机,几何边界):
      • 设备值样本(边界 9 层、验证 7 层,2401 个)误判 0; 旧往返 ΔE 法在 1296 个同类样本上误判 696(53.7%),平均映射误差 6.78、最大 19.73;
      • 纸白与「设备能产生的最深黑」判 in;PCS 纯黑(L*=0)与 sRGB 纯绿/纯蓝判 out;
      • 边界采样覆盖率 0.94(9 层采样;CMYK 色域不填满 Lab 的所有方向);
      • 面向不同设备:3 通道(显示器)与 4 通道(CMYK)设备都能建边界;同一颜色结论随设备不同 (sRGB 纯红:显示器界内、CMYK 界外)。采样密度需匹配设备——3 通道要 17³ 样本、 4 通道要 9⁴ 样本才达到 0 误判(9 层建 3 通道边界时实测 3/343 误判)。
    • 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 规范 ICC.1:2010-12(ISO 15076-1:2010) 从零编写,profile 目标版本 4.3 并向下兼容 v2.x;不包含任何第三方代码。规范版本口径与 CONFORMANCE.md、申报书一致。

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

    项目语言许可证参考范围
    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)会被拒绝,不静默回退到默认意图。

    ConformanceReport

    pub struct ConformanceReport {
    device_class : String
    version : String
    required : Array[String]
    missing : Array[String]
    transform_supported : Bool
    note : String
    lut_count : Int
    support_level : String
    }

    一个 profile 的一致性报告。

    ConformanceReport::device_class

    fn ConformanceReport::device_class(self : ConformanceReport) -> String

    设备类。

    ConformanceReport::lut_count

    fn ConformanceReport::lut_count(self : ConformanceReport) -> Int

    profile 里 LUT tag 的实有张数(A2B0-2 / B2A0-2)。

    ConformanceReport::missing

    fn ConformanceReport::missing(self : ConformanceReport) -> Array[String]

    缺失的必需 tag。

    ConformanceReport::note

    fn ConformanceReport::note(self : ConformanceReport) -> String

    该类在本库中的边界说明。

    ConformanceReport::ok

    fn ConformanceReport::ok(self : ConformanceReport) -> Bool

    该 profile 是否满足其设备类的必需 tag(共同必需 + 至少一个完整候选 set)。

    ConformanceReport::support_level

    fn ConformanceReport::support_level(self : ConformanceReport) -> String

    本库对该 profile 设备类的状态(本次实现 / 不作为目标)。

    ConformanceReport::transform_supported

    fn ConformanceReport::transform_supported(self : ConformanceReport) -> Bool

    是否支持对该 profile 做变换(按设备类判定)。

    ConformanceReport::version

    fn ConformanceReport::version(self : ConformanceReport) -> String

    规范版本字符串。

    DestinationStage

    type DestinationStage

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

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

    GamutBoundary

    pub struct GamutBoundary {
    sectors : Int
    radii : Array[Double]
    filled : Array[Bool]
    samples : Int
    steps : Int
    }

    色域边界描述符(Gamut Boundary Descriptor)。

    为什么不能再用「往返 ΔE」

    上一版把「PCS → 设备 → PCS 的往返误差」当色域判据,并用中性轴基线 自适应阈值。这个判据混进了颜色映射误差:同一个 profile 的 A2B 与 B2A 是两张独立的表,CMYK 还受 GCR 与总墨量限制,二者并不严格互逆。

    实测(1296 个 CMYK 设备值网格样本,按定义全部在色域内): 往返误差平均 6.78 ΔE、最大 19.73,用当时的容差 5.59 判定会把 696/1296 = 53.7% 的色域内颜色误判成越界。

    本实现的判据

    只用设备值产生的颜色建立边界:以 Lab(L*=50, a*=0, b*=0) 为中心, 把方向切成扇区,每个扇区记录样本中的最大半径 r_max(球面直方图)。 判定一个颜色是否在色域内,就是看它的半径是否不超过所在扇区的 r_max。

    这条判据与映射误差完全无关:边界点全部来自设备本身, A2B/B2A 是否互逆不影响结果。扇区离散化带来的误差用相邻扇区插值削弱。

    GamutBoundary::contains

    fn GamutBoundary::contains(self : GamutBoundary, lab : Lab) -> Bool

    该颜色是否在色域内:半径不超过所在扇区(含 3×3 邻域)的最大半径。

    空扇区(没有采样到的方向)保守地判为不在色域内, 并可用 GamutBoundary::coverage 检查采样充分性。

    GamutBoundary::coverage

    fn GamutBoundary::coverage(self : GamutBoundary) -> Double

    采样覆盖率:采到样本的扇区占比。用来判断采样层数是否足够 (覆盖率过低说明边界有洞,判定会保守地偏严)。

    GamutBoundary::samples

    fn GamutBoundary::samples(self : GamutBoundary) -> Int

    建边界时使用的样本数。

    GamutBoundary::sectors

    fn GamutBoundary::sectors(self : GamutBoundary) -> Int

    每轴扇区数。

    GamutBoundary::signed_distance

    fn GamutBoundary::signed_distance(self : GamutBoundary, lab : Lab) -> Double

    到色域边界的带符号距离(Lab 单位,即 ΔE76 量级)。

    负值表示在色域内、离边界还有多远;正值表示越界了多少。 这比「in/out」二元判定更有用:可以区分「刚好在边界上」与「远在界外」。

    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 线性化 → 矩阵乘。

    NamedColor2

    pub struct NamedColor2 {
    vendor_flag : Int
    prefix : String
    suffix : String
    device_coords_count : Int
    pcs_space : String
    major_version : Int
    colors : Array[NamedColorEntry]
    }

    解析后的 ncl2 tag。

    NamedColor2::device_coords_count

    fn NamedColor2::device_coords_count(self : NamedColor2) -> Int

    每条命名色附带的设备坐标个数(nDeviceCoords)。

    NamedColor2::entry_at

    fn NamedColor2::entry_at(self : NamedColor2, index : Int) -> NamedColorEntry?

    按序号取条目;越界返回 None。

    NamedColor2::length

    fn NamedColor2::length(self : NamedColor2) -> Int

    命名色条数。

    NamedColor2::lookup

    fn NamedColor2::lookup(self : NamedColor2, full_name : String) -> NamedColorEntry?

    按完整色名(prefix + 根名 + suffix)查表,返回第一条匹配项。

    匹配是 ASCII 精确比较(区分大小写):ncl2 的色名按规范是 ASCII 且以固定 长度存储,规范未定义大小写折叠规则,因此这里不做折叠,避免自造语义。 同根名重复出现时取文件中靠前的一条(与线性查表语义一致)。

    NamedColor2::lookup_lab

    fn NamedColor2::lookup_lab(self : NamedColor2, full_name : String) -> (Double, Double, Double)?

    按完整色名查表并返回 PCS 的 Lab 表示(L 0..100、a/b -128..127)。

    PCS 为 Lab 时原样返回;为 XYZ 时经 xyz_to_lab 转换。 pcs_space 未知(空串)或既非 Lab 也非 XYZ 时返回 None—— 不猜一个空间出来,避免给出错误颜色。

    NamedColor2::major_version

    fn NamedColor2::major_version(self : NamedColor2) -> Int

    解码时使用的主版本号(决定 Lab 的 16 位上限)。

    NamedColor2::pcs_space

    fn NamedColor2::pcs_space(self : NamedColor2) -> String

    profile 头部声明的 PCS(Lab / XYZ ;空串表示解析时未提供)。

    NamedColor2::prefix

    fn NamedColor2::prefix(self : NamedColor2) -> String

    色名前缀(prefix)。

    NamedColor2::suffix

    fn NamedColor2::suffix(self : NamedColor2) -> String

    色名后缀(suffix)。

    NamedColor2::vendor_flag

    fn NamedColor2::vendor_flag(self : NamedColor2) -> Int

    厂商自定义标志(vendorFlag)。

    NamedColorEntry

    pub struct NamedColorEntry {
    root_name : String
    full_name : String
    pcs : XyzNumber
    device_coords : Array[Int]
    }

    一条命名色记录。

    pcs 已按 profile 的 PCS 字段与版本解码:PCS 为 Lab 时是 L(0..100)/a(-128..127)/b(-128..127),为 XYZ 时是 XYZ 三刺激值。

    NamedColorEntry::device_coords

    fn NamedColorEntry::device_coords(self : NamedColorEntry) -> Array[Int]

    附带的设备坐标(可能为空)。

    NamedColorEntry::full_name

    fn NamedColorEntry::full_name(self : NamedColorEntry) -> String

    完整色名:prefix + root_name + suffix。

    NamedColorEntry::pcs

    解码后的 PCS 三分量。

    NamedColorEntry::root_name

    fn NamedColorEntry::root_name(self : NamedColorEntry) -> String

    根名(tag 里的 32 字节名字段,不含前缀与后缀)。

    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::apply_abstract

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

    抽象 profile:PCS → PCS。

    输入按 profile 的 color_space 编码(Lab 或 XYZ ), 输出按 pcs 编码;两者的 v2/v4 Lab 编码差异由 profile 版本决定。
    fn Profile::apply_device_link(self : Profile, intent : Int, inputs : Array[Double]) -> Array[Double] raise IccError

    device link profile:源设备值 → 目标设备值。

    不经过 PCS,因此返回的数组就是目标设备的通道值; 通道数由 A2B 表的输出通道数决定(如 RGB → CMYK 得 4 个值)。

    Profile::b2a

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

    取出 PCS → 设备的 LUT。

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

    Profile::conformance

    fn Profile::conformance(self : Profile) -> ConformanceReport

    对一个 profile 做一致性检查。

    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_boundary

    fn Profile::gamut_boundary(self : Profile, intent : Int, steps : Int) -> GamutBoundary raise IccError

    把设备空间的采样网格映射成 PCS Lab 点,建出色域边界。

    steps 是每轴的层数:CMYK 4 通道时 steps=6 得到 1296 个样本, steps=9 得到 6561 个。矩阵/TRC 型 profile 按 3 通道处理。

    采样值取 i/(steps-1),覆盖 0..1 全量程。

    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_abstract

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

    该 profile 是否是抽象 profile。
    fn Profile::is_device_link(self : Profile) -> Bool

    该 profile 是否是 device link profile。

    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::named_colors

    fn Profile::named_colors(self : Profile) -> NamedColor2 raise IccError

    读取该 profile 的命名色表(ncl2 tag)。

    命名色 profile(设备类 nmcl)的必需行为就是这张表:色名 → PCS 查表。 缺少 ncl2 的 profile 会报 BadTagData,不静默返回空表。

    Profile::neutral_mapping_error

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

    中性轴上的映射误差基线:若干中性灰往返 ΔE76 的中位数。

    本机 Agfa SWOP CMYK profile 实测约 4.5 ΔE——这全部是映射误差, 与色域无关。上一版曾把它当色域判定的容差基准,实测证明那条路走不通 (见 gamut.mbt 顶部的量化说明)。

    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。

    这个量衡量的是「同一个 profile 的 A2B 与 B2A 有多不互逆」—— 它们本就是两张独立的表,CMYK 还受 GCR 与总墨量限制。

    它不用于色域判定(那是 gamut.mbt 的球面边界描述符的职责)。 之所以保留这个 API:它是打样链与设备特性的一手指标,也用于在 README 中量化「映射误差」与「真实越界」的区别。

    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

    由三刺激值构造。

    class_required_sets

    fn class_required_sets(device_class : String) -> Array[Array[String]]

    该设备类的完整候选 set:每个 set 内的 tag 必须全部存在, 只要有一个 set 成立即视为满足(规范里"matrix/TRC 型 或 LUT 型"的语义)。

    这是本实现的一处明确简化:只检查 tag 是否存在,不检查 LUT 张数是否 覆盖全部意图(v4 输出类规范上要求 A2B0-2 与 B2A0-2 共 6 张; 张数不足会在 ConformanceReport 的备注里给出提示)。

    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 倍。

    conformance_matrix

    fn conformance_matrix() -> Array[(String, String, String)]

    覆盖矩阵的一行:设备类 + 支持情况 + 边界说明,供 CLI 与文档使用。

    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。

    gamut_sample_points

    fn gamut_sample_points(steps : Int, channels : Int) -> Array[Double]

    采样网格的笛卡尔展开(3/4 通道通用)。

    返回一维数组,每 channels 个值为一个设备值组合。

    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_named_color2

    fn parse_named_color2(data : Bytes, entry : IccTagEntry) -> NamedColor2 raise IccError

    解析 ncl2 tag(不提供 profile 的 PCS 与版本:PCS 记为空串、按 v4 解码)。

    一般通过 Profile::named_colors 调用,那里会带上头部的 PCS 与主版本, 使 lookup_lab 能把 PCS 正确解释成 Lab。

    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_steps

    fn parse_steps(text : String) -> Int?

    解析采样层数参数:仅接受 2..12 的十进制整数。

    上限 12 是为了让 4 通道的笛卡尔积不至于爆炸(12^4 = 20736 个样本)。

    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"。

    required_tags_for_class

    fn required_tags_for_class(device_class : String) -> Array[String]

    该设备类的必需 tag(依据 ICC.1:2010 表 17–23,取各类的核心必需项)。

    说明:规范里部分 tag 允许"二选一"(例如输入类可以是 matrix/TRC, 也可以是 A2B 系列)。这里把「共同必需」与「至少一项」分开列出: 共同必需的放在返回值里,二选一的用 class_alternative_tags 给出。

    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 数据体的公共前置:返回 (类型签名, 数据体起始偏移)。

    target_specification

    let target_specification : String

    目标规范版本:ICC.1:2010-12(profile version 4.3),并向下兼容 ICC.1:2001-04(v2.x)的既有 profile。

    明确排除:iccMAX(ICC.2,完全不同的架构)、profile v5(不存在该版本号)。

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

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io