1. 先想清楚:这套色度工具到底在转什么
1.1 从一次"两个数对不上"的现场说起
去年帮一个做显示模组的团队看产线数据,同一批白画面,两台分光设备给出的色度坐标一个是 (0.3127, 0.3290),另一个是 (0.3155, 0.3308)。产线判定一台合格一台不合格,两边工程师互相怀疑对方仪器坏了。我把两组数丢进色度计算里跑了一遍:第一组换算到 CIE 1976 u'v' 是 (0.1978, 0.4683),第二组是 (0.1994, 0.4700),Δu'v' 大概 0.0024,属于仪器间正常的台间差范围;但关键在于,第二组的 Duv 是 +0.0075,比第一组的 +0.0032 明显偏绿,肉眼在暗室里确实能看出一点点绿。所以结论不是"谁坏了",而是判定阈值定错了——他们拿 xy 上的绝对距离当判据,而 xy 这个平面在绿区被拉得很扁,同样 0.002 的坐标差,在绿区和在蓝区代表的视觉差异完全不是一个量级。
这就是 CieChromaticity 这类色度工具套件存在的意义。它要解决的核心问题就三件事:把不同来源的颜色数据统一到同一个可比的数学空间里、把冷冰冰的坐标翻译成人能判断的物理量(色温、Duv、主波长、色纯度、色域覆盖率)、在转换过程中不引入新的误差。听起来像纯粹的坐标变换,实际上每一步都有坑:矩阵用错、传递函数顺序反了、光谱积分步长选错、白点没对齐、CCT 在远离黑体轨迹的地方硬算出一个数——这些错误在数值上都很"正常",不会报错,只会悄悄让你的结论偏离。
这套工具适合谁?做显示屏、灯具、摄像头模组、印刷打样的工程师要天天用;做 UI 主题配色、图像算法、游戏渲染的技术美术也会用到其中的一部分(主要是 RGB↔XYZ↔Lab 和 ΔE);还有一种人最需要它——手上有一堆测量数据但不确定自己算得对不对的人。下面我按"设计思路—核心概念—实操流程—排错"四段来讲,中间该给的公式、参数、判据一个不落。
1.2 模块怎么分:要一条链,不是一堆散函数
我见过不少团队把色度计算写成一个大杂烩脚本,谁用谁改,改到最后没人知道哪个版本是对的。CieChromaticity 这类套件的合理拆法是按数据形态分层,而不是按功能名堆砌:
| 层 | 职责 | 典型输入输出 |
|---|---|---|
| 数据接入层 | 读光谱、读坐标、读图像、做单位归一 | 原始文件 → 统一结构体 |
| 空间转换层 | RGB / XYZ / xyY / Lab / Luv / uv / u'v' 互转 | 三刺激值 ↔ 各空间 |
| 色度分析层 | xy、u'v'、主波长、色纯度、色域三角 | XYZ → 派生指标 |
| 色温层 | CCT、Duv、黑体轨迹、日光轨迹 | xy → 色温参数 |
| 差异度量层 | ΔE76 / ΔE94 / ΔE00、Δu'v' | 两色 → 一个数 |
| 报告层 | 格式化、阈值判定、批量汇总 | 指标 → 表格/图表 |
这个分法的好处是每一层都可以单独做单元测试。空间转换层用"往返误差"验(XYZ→xyY→XYZ 应回到原值,float64 下相对误差能压到 1e-12 量级);色温层用标准光源的已知值验(A 光源应为 2856K、D65 应为 6504K 附近);差异度量层用标准色对验(CIEDE2000 有一批公开的测试数据对)。
反过来,如果你把所有东西塞进一个函数,最大的问题是错误会跨层传播且无法定位。比如最终 CCT 偏了 200K,到底是 xy 算错了、还是白点没适配、还是 CCT 算法本身在边缘区域精度掉了?分层之后,打印中间量就能立刻定位。
1.3 为什么把 XYZ 当中枢,而不是 RGB 直连 RGB
新手最容易问的问题:既然我要把 sRGB 转成 Adobe RGB,为什么不直接乘一个 3×3 矩阵,非要绕一圈 XYZ?
能绕,但你会丢掉三样东西。第一是白点信息。RGB→RGB 的矩阵是把两个色彩空间的"白"强行对等,前提是两者的白点已经通过色适应对齐过;如果源是 D65 白、目标是 D50 白,你必须先做一次色适应,而色适应需要在与人类视觉更接近的空间里做(Bradford 用的是经过锐化的锥体响应空间),这个空间与 XYZ 是一一对应的。第二是可加性。XYZ 是线性空间,两个光源的光谱功率相加,XYZ 直接相加;RGB 是非线性的编码值,相加没有任何物理意义。做多光源混光、做 LED 配比计算,只能在 XYZ 或更上游的光谱层面做。第三是可扩展性。CIE 标准观察者本质上定义了一组配色函数,任何"物理光"都能投到 XYZ 上;而 RGB 只是某一块三角形区域内的表示,超出色域的部分在 RGB 里根本没有坐标。
所以工具套件的内部惯例是:入口处一律先转成 XYZ(或直接保留光谱),所有核心计算在 XYZ 及以上完成,只在最后一步落到目标 RGB 空间。这条规矩看着啰嗦,但它是唯一能保证"多路径结果一致"的做法——不管你是从光谱进来、从 RGB 进来、还是从 Lab 进来,走到 XYZ 之后就是同一条流水线。
2. 核心概念与关键参数拆解
2.1 xy 只是三刺激值的一次归一化投影
三刺激值 X、Y、Z 里,Y 被定义成亮度(明视觉下的光度量),X 和 Z 只承担色度信息。色度坐标做的事很简单:把"有多亮"这个维度除掉,只留"什么颜色"。
$$x = \frac{X}{X+Y+Z},\quad y = \frac{Y}{X+Y+Z},\quad z = 1-x-y$$
因为 x+y+z 恒等于 1,z 是冗余的,所以平面只需要两个数。这一步除法带来两个后果,都得记牢。
后果一:亮度信息被彻底丢弃。一个 (0.3127, 0.3290) 的点,可能是很暗的灰,也可能是刺眼的白,xy 完全看不出来。所以报告里如果只有 xy 没有 Y(或亮度、或照度),这份数据是不完整的——产线上经常出现"色度合格但亮度不够"被判成"颜色不对"的扯皮,根源就在这。
后果二:这个平面严重不均匀。著名的麦克亚当椭圆说明了问题:在 xy 平面上画"人眼刚好能分辨差异"的等感区域,蓝区是细长的窄椭圆,绿区是宽扁的大椭圆,面积能差十几倍。这意味着在 xy 上算欧氏距离是错的。要做差异判定,得换到 CIE 1976 u'v':
$$u' = \frac{4X}{X+15Y+3Z},\quad v' = \frac{9Y}{X+15Y+3Z}$$
用 xy 表达更省事:
$$u' = \frac{4x}{-2x+12y+3},\quad v' = \frac{9y}{-2x+12y+3}$$
u'v' 平面经过投影拉伸,各方向的等感尺度大致拉平了,工程上常拿 Δu'v' 在 0.002~0.003 这个量级当"肉眼可辨"的粗门槛,具体阈值还得看行业规范、观察条件和样本大小,别拿这个数当铁律。另外还有一个 CIE 1960 uv(注意没有撇),v 与 v' 的关系是 v' = 1.5v,这个空间现在是算 CCT 和 Duv 的标准工作平面,很多老代码把两者混用,结果 Duv 差 1.5 倍,是个高频事故点。
提醒一句:任何需要"比较两个颜色差多少"的场景,先确认你在哪个平面里比较。xy 平面只适合画图展示,不适合做判定。
2.2 CCT 与 Duv:色温必须成对出现
相关色温 CCT 的定义是:在某个均匀色度空间里,与目标色度点距离最近的黑体辐射轨迹上的那一点的温度。注意几个关键词——"最近"、"黑体轨迹"、"在那个空间里"。换一个空间,"最近"的点就不一样,CCT 就会变。CIE 规定的标准做法是在 CIE 1960 uv 平面里找最近点,这也是为什么工具里一定要有 uv 而不只是 u'v'。
计算 CCT 有两条路。快的路是 McCamy 近似:
$$n = \frac{x - 0.3320}{0.1858 - y},\quad CCT = 449n^3 + 3525n^2 + 6823.3n + 5520.33$$
三行代码搞定,在 2856K~6504K 区间内误差通常在几 K 量级,做实时预览、做交互式调色足够用。但它在两端会迅速劣化,低于 2500K 或高于 10000K 之后误差能到几十甚至上百 K,而且它只给你 CCT,给不出 Duv。
稳的路是 Robertson 迭代法或者后来的 Ohno 方法。Robertson 的思路很聪明:预先在黑体轨迹上取 31 条等温线,每条线记一个 (u, v) 和一个斜率,构成一张表(对应倒数色温 0~600 mired)。来一个待测点,算它到每条等温线的有符号距离:
$$d_i = \frac{v - v_i - t_i(u - u_i)}{\sqrt{1 + t_i^2}}$$
找到符号发生变化的那一对相邻等温线,线性插值就能得到倒数色温和 Duv。这个方法在整个范围内都稳,而且 Duv 顺手就出来了。Ohno 的方法在此基础上做了更细的三角/抛物线拟合,精度更高,代价是要处理更多的边界情况。
Duv 是必须一起报的量。它表示目标点相对黑体轨迹的偏离方向和大小,单位就是 uv 平面上的距离,正号表示偏绿(轨迹上方),负号表示偏品红(轨迹下方)。同一句"6500K",Duv = +0.006 和 Duv = -0.004 在视觉上是两种完全不同的白,前者发绿后者发粉。灯具行业对此有明确的分档要求(比如某些应用要求 |Duv| ≤ 0.003 甚至更严),显示行业虽然不强制,但做白平衡校准时不知道 Duv 基本等于闭着眼调。
还有一个边界条件必须处理:CCT 只在黑体轨迹附近有意义。当 |Duv| 大到 0.05 以上(比如一个饱和的红色或蓝色),"最近的色温"在数学上还能算出来,但物理上没有对应物,报出去就是误导。工具里应该设一个门限,超了就直接输出"无有效 CCT",而不是硬给一个数。
2.3 主波长与色纯度:把坐标翻译成人话
CCT 只对"接近白"的颜色有效,那饱和色怎么办?用主波长(dominant wavelength)和色纯度(purity / excitation purity)。
做法是几何的:在 xy 平面上,从参考白点向待测点连一条射线,延长出去与光谱轨迹(把 380~780nm 单色光的 xy 点连成的马蹄形曲线)相交,交点的波长就是主波长。纯度则是长度比:
$$purity = \frac{d(\text{白点},\ \text{待测点})}{d(\text{白点},\ \text{交点})}$$
纯度为 100% 就是光谱色本身,0% 就是白点。这个指标在 LED 分光分色、滤光片、油墨配色里用得极多,因为它直接对应"这个颜色看起来像多少纳米的单色光"。
麻烦在于紫色边界。马蹄形的开口处是那条"紫线"(连接 380nm 和 780nm 的直线),从白点出发的射线如果打在这条线上,就没有对应的单色波长。这时候标准做法是反向延长,与对侧的光谱轨迹相交,得到补色主波长,报告里加个负号或者标注 "c" 以示区分。这一点很多自研代码都漏了,遇到品红类颜色直接给出一个莫名其妙的波长值。
实现上不必真的求射线与曲线交点。更稳的做法是把光谱轨迹上每个点相对白点的极角算出来,得到一个角度数组,然后对待测点的极角做二分/插值,找到角度相邻的两个轨迹点,再做线段求交。这样既避开了曲线拟合,也天然处理了跨越 0° 和 360° 的环绕问题。
2.4 参数与常量速查表
实操中最容易抄错的就是这些常量。下表是我自己那份工具里固化的值,全部是标准定义,可以直接对照。
| 项目 | 数值 | 备注 |
|---|---|---|
| sRGB 白点 D65 | x=0.3127, y=0.3290 | 精确值常用 0.31271 / 0.32902 |
| D50 | x=0.3457, y=0.3585 | 印刷常用参考白 |
| A 光源 | x=0.4476, y=0.4074 | 约 2856K,钨丝灯模拟 |
| E 光源 | x=1/3, y=1/3 | 等能白,只用于理论 |
| 第二辐射常数 c2 | 1.4388×10⁻² m·K | 与旧值 1.4380 差约 0.05%,会导致 CCT 偏差 |
| sRGB 传递函数转折点 | 0.04045(非线性侧) | 线性侧 0.0031308 |
| 光谱积分范围 | 360~830nm | 建议不低于 380~780 |
| 窄带光源步长 | 1nm | 5nm 会漏掉峰值,误差可达数个百分点 |
关于 c2 这个值,值得多说一句:早期的黑体轨迹表和后来的表会出现细微差异,根源就是第二辐射常数的取值改过。如果你的数据和别人的对不上、而且差异呈现"色温越高差得越多"的规律,先查这个常数,再查是不是用了不同的色匹配函数版本(1931 的 2° 观察者和 1964 的 10° 观察者,在大视场测量时差异很明显,10° 观察者的色度坐标能差 0.002 以上)。
3. 实操:从原始数据到可交付报告
3.1 环境准备与第一次跑通
这类计算不需要什么重型依赖,numpy 加标准库就够,关键是全程用 float64。我用 float32 试过一次光谱积分,470 个点累加误差不大,但后面紧接着做 3×3 矩阵连乘、再做除法归一化,误差会被放大,最后在蓝区的小 Z 值上直接炸开。
目录结构建议这样分,跟前面说的分层对应:
chroma/ io/ spectra.py coords.py image.py space/ xyz.py rgb.py lab.py luv.py chroma/ xy.py uv.py dominant.py gamut.py temp/ planck.py cct.py duv.py metric/ deltae.py report/ table.py一个最小示例,从三刺激值走到 CCT 和 Duv:
import numpy as np def xyz_to_xy(X, Y, Z): s = X + Y + Z if s <= 1e-9: raise ValueError("三刺激值和过小,色度坐标不稳定") return X / s, Y / s def xy_to_uv60(x, y): d = -2.0 * x + 12.0 * y + 3.0 return 4.0 * x / d, 6.0 * y / d def xy_to_upvp(x, y): d = -2.0 * x + 12.0 * y + 3.0 return 4.0 * x / d, 9.0 * y / d注意xy_to_uv60的分母,当 x、y 使得 -2x+12y+3 接近 0 时会爆掉——这个分母在可见色域内不会为零,但如果你从外部读进来一批脏数据(比如误把 Y 当成 y 传进来),就会立刻暴露出来。所以入口处加个合法性校验是划算的。
3.2 RGB 路径:矩阵对了,传递函数错了照样翻车
从 RGB 转到 XYZ,标准流程是先解码非线性,再乘矩阵。顺序反了,结果是"看起来像那么回事但永远差一截"。
以 sRGB 为例,解码:
def srgb_to_linear(c): c = np.asarray(c, dtype=np.float64) return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)然后乘矩阵(D65 白点下):
$$M_{sRGB} = \begin{bmatrix} 0.4124564 & 0.3575761 & 0.1804375 \ 0.2126729 & 0.7151522 & 0.0721750 \ 0.0193339 & 0.1191920 & 0.9503041 \end{bmatrix}$$
这三个错误我见得太多了,几乎每个新人的代码里都会中一个:
错误一:把矩阵乘在 gamma 编码值上。症状是暗部整体偏亮、饱和度偏低,算出来的白点勉强对,但中间调的色度全偏。验证办法很简单——输入 (255,255,255),输出必须精确落在设计白点上;输入 (255,0,0),输出应该落在红原色的 xy 上。如果白点对但原色不对,基本就是顺序问题。
错误二:矩阵和白点不匹配。拿 sRGB 的矩阵去处理 Display P3 的数据,白点都是 D65 所以看上去"白是白的",但红绿原色坐标差了 0.02 以上,色域覆盖率的计算会整体偏移。这类错误在跨设备流程里特别隐蔽,因为单看白色永远是对的。规矩是:矩阵必须和"原色坐标 + 白点"绑定生成,不要抄现成的数。给定三个原色坐标和白点,用标准方法反解出矩阵,代码里留一行注释写清楚原色来源。
错误三:8 位量化后的往返。如果你把 float 结果再转回 8 位存盘,然后再读回来算,误差会明显变大,暗部尤其明显(sRGB 在暗部的量化台阶对应的线性值变化很陡)。做精度验证时一定要在全 float 链路上做,别拿存过盘的数据当参照。
3.3 光谱路径:CMF 加权积分的三个细节
有光谱就别用 RGB,这是基本原则。光谱转 XYZ 的公式:
$$X = k\sum_\lambda S(\lambda),\bar{x}(\lambda),\Delta\lambda,\quad Y = k\sum_\lambda S(\lambda),\bar{y}(\lambda),\Delta\lambda,\quad Z = k\sum_\lambda S(\lambda),\bar{z}(\lambda),\Delta\lambda$$
归一化系数 k 取决于测量对象:
- 自发光体(屏、灯):k 通常取 683 或者直接取 1,只要保证 Y 与你的光度单位一致即可,报色度时 k 不影响 xy。
- 反射样品:S(λ) = R(λ)·I(λ),R 是反射率、I 是照明光谱,此时 k = 100 / Σ I(λ)·ȳ(λ)·Δλ,这样理想漫反射体的 Y 正好是 100。
细节一:色匹配函数的插值方式。标准表通常是 1nm 或 5nm 间隔,如果你的光谱仪是 0.5nm 或 3nm 输出,就得重采样。别用线性插值去处理 5nm 的 CMF——ȳ 曲线在 555nm 附近曲率很大,线性插值会系统性地低估峰值。用三次样条或者 Sprague 插值,误差能降一个量级。我一般统一把 CMF 和样品光谱都插到 1nm 网格再积分,代码简单且不会出错。
细节二:积分步长要跟光源带宽匹配。宽谱(白光 LED、荧光灯)用 5nm 完全够;窄带(单色 LED、激光、带窄带荧光粉的样品)必须 1nm 甚至更细,否则峰值被漏掉,色度点会朝白点方向偏移,饱和度被低估。判断方法:把同一个样品用 5nm 和 1nm 各算一遍,如果 Δu'v' 超过 0.001,就说明你的数据必须用细步长。
细节三:仪器带宽修正。分光设备有光谱带宽(比如 5nm 的狭缝),测窄带光源时会把峰值削平、展宽。严格的流程要做带宽修正(用仪器带宽函数做反卷积),粗糙一点的做法是记录带宽并在报告里注明。这个不做的话,同一支单色 LED 在两台不同带宽的仪器上测出来的主波长能差 1~2nm,谁都说不清谁对。
3.4 白点与色适应:什么时候必须做,什么时候不能做
色适应这块是重灾区中的重灾区。核心原则一句话:色适应是"模拟人眼在不同照明下的适应",只有在比较两个不同照明条件下的颜色时才需要;测量一个自发光的绝对色度时,绝对不能做。
Bradford 变换的做法是先把 XYZ 投到经过锐化的锥体空间:
$$M_{BFD} = \begin{bmatrix} 0.8951 & 0.2664 & -0.1614 \ -0.7502 & 1.7135 & 0.0367 \ 0.0389 & -0.0685 & 1.0296 \end{bmatrix}$$
在新空间里对三个通道分别做比例缩放,把源白点拉到目标白点,再乘逆矩阵回来。CAT02、CAT16 的思路一样,只是矩阵不同。
什么时候必须做:把 D65 下的 sRGB 图像送到 D50 的印刷参考环境做打样比对;把 A 光源下测的样品色度换算到 D65 下做色差评估。什么时候不能做:测量显示屏的白场色度、测量灯具的输出色度——这些要给的是"它本身发出的光是什么色度",你一做适应,数据就变成了"适应之后看起来是什么色度",物理意义完全变了。
还有一个常见错误链:先做色适应到某个白点,再算 CCT。这等于把数据强行拽到黑体轨迹附近,算出来的 CCT 当然"很漂亮",但那是你自己造出来的,不是被测物真实的。我在一个项目里见过这种数据,一批明显偏绿的模组被算成 Duv 都在 ±0.001 以内,后来发现是管线里默认开了色适应,关掉之后立刻现出原形。
3.5 批量处理与结果落盘
单点算对了不代表批量就对。批量场景下有三件事必须做。
第一,输入做严格校验。每个字段检查量纲和范围:色度坐标 x、y 应该在 0~1 之间且和接近 1;亮度值不能为负;波长数组必须单调递增且覆盖整个积分范围;光谱不能全是 0。校验失败的行单独落到"待人工确认"文件,别让脏数据静默地流进计算,最后输出一堆看起来很有道理的错误数字。
第二,中间量全部留痕。报告至少要包含:原始 XYZ、xy、u'v'、CCT、Duv、主波长、色纯度、亮度,以及"用了哪套 CMF、哪个白点、哪个算法版本"。这看起来冗余,但出问题的时候能救命——我在排查一次批量偏差时,就是靠报告里的"算法版本"字段发现某台机器上的包是旧的,CCT 走的是 McCamy 近似,而其他机器走的是 Robertson。
第三,判定阈值分档。不要用一个阈值卡死所有颜色区域。绿色区可以用 Δu'v',蓝色区因为人眼敏感度不同,阈值应该更严一些。更专业的做法是按颜色区域查对应的分档表,或者直接在 u'v' 平面上按区域做椭圆判定。具体数值我这里不给死数,因为不同产品规范差别很大,但方向一定要对:判定用 u'v',展示用 xy。
色域覆盖率的计算也顺带提一句。常见做法是把待测色域和目标色域都投到某个平面求多边形交集面积比。这里有个选择:在 xy 上算还是 u'v' 上算。两者差异不小,因为 xy 非均匀,绿区面积被压缩,覆盖率的数值会偏高。行业里两种都有,所以报数的时候一定要写清楚"在哪个平面、算的是面积比还是体积比(加亮度维度)"。我个人的习惯是主报 u'v',同时附上 xy 的值,免得跟别人的数据对不上时又扯半个月。
4. 常见问题与排查技巧实录
4.1 症状—原因—处置速查表
这张表是从实际踩坑记录里整理出来的,按"你看到什么"来查,比按原理查快得多。
| 症状 | 最可能原因 | 处置 |
|---|---|---|
| 白点算出来总是差 0.001~0.003 | 白点常量版本不一致(0.3127 与 0.31271 混用)或 CMF 版本不同 | 全链路统一常量,报告里注明 |
| 暗部颜色坐标乱跳 | 三刺激值和接近 0,除法不稳定 | 加 Y 或和值下限,低于门限标记为无效 |
| CCT 全部集中在 6500K 附近 | 管线里默认开了色适应 | 关掉,重算 |
| Duv 数值总是偏大 1.5 倍 | uv 与 u'v' 混用 | 检查 v 是 6y/d 还是 9y/d |
| 窄带光源饱和度偏低 | 积分步长太粗,峰值被漏 | 改 1nm,复测对比 Δu'v' |
| 品红类颜色主波长异常 | 未处理紫线,未用补色波长 | 补上反向延长逻辑,负值标注 |
| 同一批数据两次运行结果不同 | 多线程下共享缓存或随机种子 | 关并行复现,锁版本 |
| 色域覆盖率比竞品高出一截 | 在 xy 上算面积比 | 统一换到 u'v',注明平面 |
| ΔE 很小的两个颜色肉眼差异明显 | 只算了 ΔE76,没算 ΔE00 | 加 ΔE00,暗部/低饱和差异会显现 |
| 光谱数据算出的 Y 和亮度计差很多 | 归一化系数 k 用错(自发光 vs 反射) | 分清对象,反射样品用 100/ΣI·ȳ·Δλ |
4.2 数值稳定性:那些"看起来对其实已经错了"的情况
色度计算最阴险的地方在于——错误不会报错,只会给你一个煞有介事的数字。
最典型的是深蓝区域。一个非常饱和的蓝色,X 和 Y 都极小,只有 Z 大。此时 X+Y+Z 虽然还是正数,但 x = X/(X+Y+Z) 的分子分母都很小,任何一点测量噪声都会被放大成大比例的坐标漂移。处理办法不是提高精度就完事,而是设物理意义上的有效性门限:Y 低于某个值(比如相对满量程的 0.1%)时,色度坐标一律标记为不可信,只报亮度。这跟"算不出来"是两回事,是"算出来也不该信"。
第二个是黑体轨迹上的除法。Robertson 方法里如果待测点恰好落在某条等温线上,有符号距离 d_i 为零,插值的分母可能出现 0/0。代码里必须检查符号变化的两条线是否"严格异号",用d[i] * d[i+1] < 0而不是<= 0,并且对全零情况做兜底。
第三个是矩阵求逆的精度。从原色坐标反解 RGB→XYZ 矩阵时,如果三个原色接近共线(某些宽色域空间的红绿原色在 xy 上夹角很小),矩阵条件数会变大。这种情况先算条件数,超过阈值就换成显式公式解或者提高精度重算,别硬求逆。
第四个是 ΔE00 的色相角处理。公式里有个 h' 的差值判断,必须处理 360° 环绕;还有那个 25⁷ 的常数项,用来在低彩度区抑制色相差的影响,抄错一个指数级常数,结果会整体偏移但看不出来。建议直接拿公开的标准测试色对对一遍,误差应该在小数点后四位。我在一次代码合并后发现 ΔE00 比标准值系统性偏大 0.01,查了两个小时,最后发现是有人"优化"时把 25⁷ 改成了 25⁶。
4.3 几条踩坑换来的实操心得
先画图,再看数。我现在的习惯是:拿到任何一批新数据,第一件事是把所有色度点画到马蹄图上,把黑体轨迹和日光轨迹一起叠上去。很多问题在图上一眼就能看出来——点全部挤在轨迹上方说明系统性偏绿,点沿一条斜线分布说明某个通道的增益不对,点分成了两簇说明有两批不同工艺的样品混在一起了。等你把这些数字一个个算完再回头看,可能已经过去半天。
永远不要单独报一个 CCT。报 CCT 必须同时给 Duv,报 xy 必须同时给 Y,报主波长必须说明白点是谁。这是我给自己定的硬规矩,因为缺任何一个,对方都没法复现你的结论。我在一份对外报告里漏了白点说明,对方的复算结果和我的主波长差了 3nm,来回解释了三轮邮件,加一行字就能避免。
用"已知答案"建回归测试。我的测试集里有这么几组:sRGB 纯白 →(0.3127, 0.3290);A 光源 → 2856K 附近且 |Duv| < 0.002;等能白 → CCT 计算应给出无效或极不稳定;某几个标准色对 → ΔE00 等于公开值。每次改代码,这几组先跑一遍,跑不过就别往下走。这个习惯帮我拦下过好几次"看起来只是重构"的改动。
版本管理要管到常量级别。CMF 表、白点常量、黑体轨迹表、等温线表,这些东西一旦变更,所有历史数据的可比性就断了。我的做法是把它们放在独立的constants/目录下,文件名带版本号,报告里记录实际加载的文件名和哈希。听着有点过度,但当你需要解释"为什么上个月的数据和这个月的不一样"时,这一行哈希能省掉一整天的争论。
最后,遇到"两个数就是差一点",先怀疑测量再怀疑算法。色度计算的数学部分其实非常确定,公式就那些,验证起来也不难。真正的不确定性来自测量环节:仪器的台间差、测量几何条件(0/45 还是积分球)、样品温度、甚至预热时间。我有一次追了两天的"算法偏差",最后发现是分光计没预热够,冷机状态下的读数在蓝区偏了 0.0015。所以现在的流程是:先做仪器自检和重复性测试,确认测量端稳定了,再动算法。这个顺序反了,就是在给噪声找数学解释,永远找不到头。