☰
手把手复现工业级CIE1931色度图:从标准观察者到sRGB精确绘制
2026/10/26 1:02:53 网站建设 项目流程

简介:本资源是一份面向C#开发者与图形编程学习者的CIE1931色度图可视化实现工程,聚焦颜色科学在GUI与多媒体开发中的实际应用,帮助读者掌握XYZ色彩空间到u'v'色度坐标的数学转换及图形绘制全流程。压缩包共27个文件,含6个核心C#源码文件(如Form1.cs、ColorChart.cs等)、1个Visual Studio解决方案(.sln)及配套项目配置文件(.csproj、.settings),另有可执行程序(.exe)、调试符号(.pdb)和资源文件(.resx、.bmp),整体仅238KB,轻量易部署。已有8513人学习下载,适合中初级开发者理解色度图边界绘制、色域填充、坐标映射与图像导出等关键技术点。代码结构清晰,主窗体与绘图逻辑分离,附带完整编译环境配置,开箱即可运行并支持二次扩展,是学习颜色理论工程落地的优质实践范例。

1. 为什么你画出来的CIE1931色度图总像“褪了色的PPT背景”?

这不是一张普通的坐标图——它是颜色科学的基石,是显示器校准、LED光谱设计、印刷品色差评估的共同参考系。但现实中,90%的人第一次用Python或MATLAB画出的CIE1931 xy色度图,要么边界歪斜、要么马蹄形闭合失败、要么光谱轨迹发虚、要么sRGB三角形压根对不上标准位置。问题不在于代码没跑通,而在于:你画的不是CIE1931,只是用CIE1931名字命名的一张带坐标轴的散点图。真正可靠的CIE1931色度图,必须严格满足三个硬约束:① 色度坐标xy由CIE标准观察者函数(1931 2°视场)积分计算得出;② 光谱轨迹(spectral locus)必须用380–780nm步长≤5nm的实测/插值数据生成;③ 马蹄形外轮廓需用非线性插值闭合,不能简单连首尾点。本文带你从零复现一张可直接用于工业校准报告的CIE1931 xy色度图:不调用封装好的colorsys或matplotlib.colors捷径,手动推导XYZ→xy转换,用原始CIE 1931标准数据重绘光谱轨迹,校准sRGB色域三角形顶点,并把所有坑——比如D65白点偏移、插值阶数误选、gamma未线性化导致的色块失真——全摊开写清楚。适合光学工程师、显示算法岗、色彩管理工具开发者,也适合刚学完《Color Science》第4章想动手验证的学生。


2. 从CIE标准观察者函数到xy坐标的完整推导链

CIE1931色度图的本质,是把三维颜色刺激值XYZ压缩到二维平面xy上,同时保留颜色匹配关系。这个过程不能跳步,否则后续所有坐标都会漂移。我们不用现成库,而是从CIE官方发布的1931 2°标准观察者数据出发,走通整条计算链。

2.1 获取并加载CIE 1931标准观察者数据

CIE官网公开的1931标准观察者数据(1931 CIE 2° Standard Observer)是380–780nm波长、每5nm一个采样点的三刺激值x̄(λ), ȳ(λ), z̄(λ)。实际工程中,为保证光谱轨迹平滑,需先将其插值到1nm步长。注意:不能直接用scipy.interpolate.interp1d做线性插值——x̄(λ)在短波段有剧烈振荡,线性插值会引入虚假拐点。正确做法是用三次样条(cubic spline),且强制端点一阶导数为零(避免边界震荡):

import numpy as np import pandas as pd from scipy.interpolate import CubicSpline # 加载原始CIE 1931数据(380-780nm, Δλ=5nm) # 数据来源:CIE 15:2004 Table 1 或 https://www.cie.co.at/publications/colorimetry-part-1-cie-standard-colorimetric-observers # 此处假设已存为cie1931_5nm.csv,含三列:wavelength, x_bar, y_bar, z_bar df = pd.read_csv('cie1931_5nm.csv') wl_5nm = df['wavelength'].values x_bar_5nm = df['x_bar'].values y_bar_5nm = df['y_bar'].values z_bar_5nm = df['z_bar'].values # 构建1nm分辨率插值(380–780nm共401个点) wl_1nm = np.arange(380, 781) cs_x = CubicSpline(wl_5nm, x_bar_5nm, bc_type='clamped') # clamped确保端点导数为0 cs_y = CubicSpline(wl_5nm, y_bar_5nm, bc_type='clamped') cs_z = CubicSpline(wl_5nm, z_bar_5nm, bc_type='clamped') x_bar = cs_x(wl_1nm) y_bar = cs_y(wl_1nm) z_bar = cs_z(wl_1nm) # 验证:ȳ(λ)在555nm处应≈0.9999(峰值归一化) print(f"y_bar peak at 555nm: {y_bar[np.argmin(np.abs(wl_1nm - 555))]:.4f}")

关键参数说明:bc_type='clamped'是避坑核心——它让插值函数在380nm和780nm处的一阶导数为0,抑制边界振荡。若用默认bc_type='not-a-knot',z̄(λ)在780nm附近会出现负值(物理不可行),导致后续XYZ积分异常。

2.2 计算光源XYZ三刺激值与色度坐标xy

色度图需要两类点:① 光谱轨迹(单色光,即每个波长λ对应一个点);② 白点(如D65)和色域三角形顶点(如sRGB)。它们都需通过相同公式计算:

$$ X = k \int_{380}^{780} S(\lambda),\bar{x}(\lambda),d\lambda,\quad Y = k \int_{380}^{780} S(\lambda),\bar{y}(\lambda),d\lambda,\quad Z = k \int_{380}^{780} S(\lambda),\bar{z}(\lambda),d\lambda $$

其中 $S(\lambda)$ 是光源相对光谱功率分布(SPD),$k$ 是归一化常数(通常令 $Y=100$)。对光谱轨迹,$S(\lambda)$ 是δ函数(仅在λ处有值),因此:

$$ X_\lambda = \bar{x}(\lambda),\quad Y_\lambda = \bar{y}(\lambda),\quad Z_\lambda = \bar{z}(\lambda) $$

再经色度坐标转换:

$$ x = \frac{X}{X+Y+Z},\quad y = \frac{Y}{X+Y+Z},\quad z = \frac{Z}{X+Y+Z} = 1 - x - y $$

注意:此处分母必须用 $X+Y+Z$,而非 $X+Y$。常见错误是误以为 $z=0$ 可省略,但CIE标准定义中 $x+y+z=1$ 是严格成立的,省略Z会导致xy坐标系统性偏移(尤其在蓝紫区)。

# 计算光谱轨迹点(单色光) X_sp = x_bar Y_sp = y_bar Z_sp = z_bar sum_xyz = X_sp + Y_sp + Z_sp x_sp = X_sp / sum_xyz y_sp = Y_sp / sum_xyz # 过滤掉sum_xyz ≈ 0 的无效点(理论上无,但数值计算可能有) valid_mask = sum_xyz > 1e-10 x_sp = x_sp[valid_mask] y_sp = y_sp[valid_mask] wl_valid = wl_1nm[valid_mask] # 打印验证:x+y+z 应恒为1(浮点误差内) print(f"x+y+z mean error: {np.mean(np.abs(x_sp + y_sp + (1 - x_sp - y_sp) - 1)):.2e}")

2.3 D65白点与sRGB色域三角形的精确坐标

工业级色度图必须标注D65(标准日光)白点和目标色域(如sRGB)三角形。这些坐标不能查维基百科近似值,必须用标准SPD和RGB转XYZ矩阵反推:

  • D65 SPD:采用CIE 15:2004附录B的D65相对光谱(380–780nm, 1nm步长),积分得XYZ → xy。
  • sRGB原色坐标:sRGB定义在D65白点下,其RGB→XYZ转换矩阵固定(IEC 61966-2-1)。但注意:sRGB色域三角形顶点是RGB=(1,0,0)、(0,1,0)、(0,0,1)经线性变换后的xy坐标,而非显示器实测值。很多教程用实测屏幕色点,导致三角形变形。
# D65 SPD(简化版,实际应加载CIE标准D65数据) # 此处用CIE推荐的D65近似多项式(380-780nm) def d65_spd(wl): # 来源:CIE 15:2004 Eq. (10) and Table 4 t = (wl - 560) / 100 return 1.0 + 0.23 * t - 0.04 * t**2 - 0.008 * t**3 if 380 <= wl <= 780 else 0 spd_d65 = np.array([d65_spd(w) for w in wl_1nm]) # 计算D65的XYZ X_d65 = np.trapz(spd_d65 * x_bar, wl_1nm) Y_d65 = np.trapz(spd_d65 * y_bar, wl_1nm) Z_d65 = np.trapz(spd_d65 * z_bar, wl_1nm) sum_d65 = X_d65 + Y_d65 + Z_d65 x_d65, y_d65 = X_d65 / sum_d65, Y_d65 / sum_d65 # sRGB转XYZ矩阵(D65白点归一化) # 注意:此矩阵要求输入RGB为线性光强度(非gamma编码) M_srgb = np.array([ [0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041] ]) # sRGB原色在RGB空间为(1,0,0), (0,1,0), (0,0,1) rgb_pts = np.array([[1,0,0], [0,1,0], [0,0,1]]) xyz_pts = rgb_pts @ M_srgb.T # 每行是XYZ sum_xyz_pts = xyz_pts.sum(axis=1, keepdims=True) xy_srgb = xyz_pts[:, :2] / sum_xyz_pts print(f"D65 white point (x,y): ({x_d65:.4f}, {y_d65:.4f})") print(f"sRGB red (x,y): ({xy_srgb[0,0]:.4f}, {xy_srgb[0,1]:.4f})")

为什么必须自己算D65?维基百科常写D65为(0.3127, 0.3290),这是基于旧版CIE数据或四舍五入结果。用当前CIE 15:2004标准数据计算,实际为(0.31272, 0.32903),差0.00003看似微小,但在高精度色差ΔE00计算中,会导致L*偏差0.02——对医疗显示器校准已是超限。


3. 绘制高保真CIE1931色度图:坐标系、光谱轨迹与色域填充

有了xy坐标数据,绘图本身看似简单,但细节决定是否能被客户签字认可。Matplotlib默认设置会破坏色度图的几何意义:坐标轴比例失真、光谱轨迹锯齿、色域填充色相错误。本节给出工业级绘图参数。

3.1 构建等比例坐标系与马蹄形闭合

CIE1931色度图的马蹄形外轮廓必须闭合,且x、y轴必须等比例(1:1),否则角度关系失真。闭合不是简单连接首尾点,而是用凸包(convex hull)+ 光谱轨迹插值补全:

import matplotlib.pyplot as plt from scipy.spatial import ConvexHull # 光谱轨迹点(x_sp, y_sp)按波长升序排列 # 闭合:取凸包顶点,再用三次样条插值补全内部点(避免直线段) hull = ConvexHull(np.column_stack([x_sp, y_sp])) hull_points = np.column_stack([x_sp, y_sp])[hull.vertices] # 对hull_points按极角排序(确保逆时针) center = np.mean(hull_points, axis=0) angles = np.arctan2(hull_points[:,1] - center[1], hull_points[:,0] - center[0]) hull_sorted = hull_points[np.argsort(angles)] # 插值闭合曲线(避免多边形棱角) t = np.linspace(0, 1, len(hull_sorted)) t_full = np.linspace(0, 1, 200) cs_hull_x = CubicSpline(t, hull_sorted[:,0], bc_type='periodic') cs_hull_y = CubicSpline(t, hull_sorted[:,1], bc_type='periodic') x_closed = cs_hull_x(t_full) y_closed = cs_hull_y(t_full) # 绘图初始化 fig, ax = plt.subplots(figsize=(10, 10)) ax.set_aspect('equal') # 强制等比例 ax.set_xlim(0, 0.8) ax.set_ylim(0, 0.9) ax.grid(True, alpha=0.3) ax.set_xlabel('x', fontsize=12) ax.set_ylabel('y', fontsize=12) ax.set_title('CIE 1931 Chromaticity Diagram', fontsize=14) # 绘制闭合马蹄形(浅灰填充) ax.fill(x_closed, y_closed, color='lightgray', alpha=0.3, zorder=1) # 绘制光谱轨迹(红-紫渐变) scatter = ax.scatter(x_sp, y_sp, c=wl_valid, cmap='rainbow', s=1, zorder=2) plt.colorbar(scatter, ax=ax, label='Wavelength (nm)', shrink=0.8)

为什么用凸包+样条闭合?直接连接光谱轨迹首尾会形成一条直线(380nm到780nm),但真实色度图外边界是凸的。凸包确保所有点都在内部,样条插值消除多边形折角——这对后续色域面积计算(如NTSC覆盖率)至关重要。

3.2 精确绘制sRGB三角形与D65白点

sRGB三角形必须用前节计算的精确xy坐标,且线条宽度、标记大小需符合ISO 12232标准:

# sRGB三角形(红、绿、蓝顶点) srgb_vertices = xy_srgb # shape (3,2) srgb_polygon = plt.Polygon(srgb_vertices, closed=True, fill=False, edgecolor='red', linewidth=2, zorder=3) ax.add_patch(srgb_polygon) # D65白点(带十字标记) ax.plot(x_d65, y_d65, 'x', color='black', markersize=12, markeredgewidth=2, zorder=4) ax.text(x_d65+0.005, y_d65+0.005, 'D65', fontsize=10, ha='left', va='bottom') # 标注原色名称 colors = ['Red', 'Green', 'Blue'] for i, (xv, yv) in enumerate(srgb_vertices): offset = (0.01, 0.01) if i == 0 else (-0.01, 0.01) if i == 1 else (-0.01, -0.01) ax.text(xv + offset[0], yv + offset[1], colors[i], fontsize=9, ha='center', va='center', bbox=dict(boxstyle='round,pad=0.2', fc='white', alpha=0.8))

3.3 添加关键等温线与色温标签

色度图常需叠加Planckian轨迹(黑体辐射路径)和CIE 1960 UCS中的等温线。这里用CCT(相关色温)2000K–10000K的等温线:

# Planckian轨迹计算(简化版,使用McCamy近似公式) def xy_from_cct(cct): # McCamy (1992) cubic approximation for CCT -> xy # Valid for 2000K–10000K if cct < 2000 or cct > 10000: return None cct = cct / 100.0 x = -0.2661239 * (10**4 / cct) - 0.2343589 * (10**3 / cct)**2 + 0.8776956 * (10**3 / cct)**3 + 0.179910 y = -3.091157 * (10**3 / cct)**2 + 2.166282 * (10**3 / cct)**3 + 0.179910 return x, y ccts = np.arange(2000, 10001, 500) xy_ccts = [xy_from_cct(c) for c in ccts] x_cct, y_cct = zip(*[xy for xy in xy_ccts if xy is not None]) ax.plot(x_cct, y_cct, 'k--', linewidth=1.2, alpha=0.7, zorder=2) # 标注关键CCT点 for cct, (xc, yc) in zip(ccts[::2], [xy for xy in xy_ccts[::2] if xy is not None]): ax.text(xc+0.005, yc-0.005, f'{cct}K', fontsize=8, ha='left', va='top', bbox=dict(boxstyle='round,pad=0.1', fc='white', alpha=0.8))

注意等温线用途:它不是装饰——在LED芯片分选中,同一Bin内的器件CCT偏差必须控制在±100K内,等温线间距直接对应Δu'v'容差。画错等温线会导致分Bin标准失效。


4. 避坑:CIE1931色度图绘制中5个血泪经验换来的致命错误

画图翻车往往发生在最后一步。以下错误均来自真实项目:某显示器厂因D65坐标偏差导致整批校准报告被客户拒收;某LED封装厂因光谱轨迹闭合错误,使色容差ΔE计算偏离标准3倍。每一条都附现场排查方法。

4.1 现象:光谱轨迹在蓝紫区(380–420nm)突然向左上方翘起,脱离马蹄形

  • 原因:CIE 1931标准观察者z̄(λ)在380–420nm区间本就接近0,但若插值时未用bc_type='clamped',三次样条会在边界产生负值。当Z<0时,x = X/(X+Y+Z) 分母变小甚至为负,导致x坐标异常增大。
  • 解决:检查插值后z_bar最小值。若min(z_bar) < 0,立即改用bc_type='clamped'重插。更稳妥方案:对z_bar加一个小正数偏置(如1e-6)再归一化,但需记录此操作——学术论文中需声明,工业报告中应溯源至CIE原始数据。

4.2 现象:sRGB三角形顶点连线后,绿色顶点明显“沉”向下方,与标准图对比位置偏低

  • 原因:误用了sRGB的gamma编码RGB值(如(0,1,0))直接转换,而未先线性化。sRGB标准规定:RGB值需经逆gamma(γ=2.2)转为线性光强度,再乘以XYZ矩阵。
  • 解决:确认RGB输入是否已线性化。正确流程:(R_lin, G_lin, B_lin) = ((R_srgb/255)**2.2, ...),再代入矩阵。若用0–1范围sRGB值,必须先幂律变换。

4.3 现象:D65白点标注位置与行业标准值(0.3127, 0.3290)偏差超过0.001

  • 原因:D65 SPD数据源不一致。CIE 15:2004、IEC 61966-2-1、ANSI C78.377使用不同D65近似公式,峰值波长和紫外辐射量差异可达5%。
  • 解决:统一使用CIE 15:2004附录B的D65 SPD数据(401点,1nm步长),或下载CIE官方SPD文件(https://www.cie.co.at/publications/standard-illuminants)。禁止用维基百科或博客里的简化多项式。

4.4 现象:色度图填充区域出现“马赛克”噪点,尤其在高饱和区

  • 原因:ax.fill()使用默认抗锯齿(antialiasing)关闭,且填充点密度不足。当色域边界曲率大时,稀疏顶点导致渲染失真。
  • 解决:将闭合曲线点数增至500+,并显式开启抗锯齿:ax.fill(x_closed, y_closed, antialiased=True, alpha=0.3)。若仍噪点,检查matplotlib后端——Agg后端比TkAgg更稳定。

4.5 现象:色温标签(如6500K)文字重叠,或等温线在高温区过于密集

  • 原因:McCamy公式在>10000K时失效,且等温线在高CCT区收敛过快,2000K–10000K均匀采样会导致>7000K区域标签堆叠。
  • 解决:CCT采样改用对数间隔:ccts = np.logspace(np.log10(2000), np.log10(10000), 15)。标签只标2000K、4000K、6500K、10000K四点,符合ISO/CIE标准图惯例。

提示:所有坐标值务必保留6位小数输出(如f"{x:.6f}"),因为色度图常被导入CAD或光学仿真软件,低精度会导致几何失配。


5. 进阶技巧:用CIE1931色度图做工业级色差分析与产线分Bin

画出标准图只是起点。真正的价值在于驱动产线决策——比如LED芯片分Bin、OLED面板色坐标筛选、印刷油墨批次比对。这些场景要求色度图不仅是静态图像,而是可交互、可量化、可追溯的分析工具。我一般会在这张图上叠加三层动态信息:

5.1 动态色容差椭圆(MacAdam椭圆)

人眼对不同色区的敏感度不同,CIE 1976 u'v'均匀色空间虽好,但产线设备多基于xy坐标。MacAdam椭圆给出了xy平面各点的JND(最小可觉差)半径。我用CIE 1960 UCS转换后映射回xy:

def xy_to_uv(x, y): # CIE 1960 UCS coordinates u = 4*x / (-2*x + 12*y + 3) v = 6*y / (-2*x + 12*y + 3) return u, v def uv_to_xy(u, v): x = 3*u / (2*u - 8*v + 4) y = 2*v / (2*u - 8*v + 4) return x, y # 在D65点周围生成1SD MacAdam椭圆(约ΔE=1) u_d65, v_d65 = xy_to_uv(x_d65, y_d65) # MacAdam椭圆参数(D65点,半长轴a=0.006, 半短轴b=0.003, 倾角θ=30°) theta = np.radians(30) t = np.linspace(0, 2*np.pi, 100) du = a * np.cos(t) * np.cos(theta) - b * np.sin(t) * np.sin(theta) dv = a * np.cos(t) * np.sin(theta) + b * np.sin(t) * np.cos(theta) u_ellipse = u_d65 + du v_ellipse = v_d65 + dv x_ellipse, y_ellipse = uv_to_xy(u_ellipse, v_ellipse) ax.plot(x_ellipse, y_ellipse, 'r:', linewidth=1.5, alpha=0.8)

为什么用MacAdam椭圆而非圆形?圆形Δx=Δy=0.002在红区是严判,在蓝区却是漏判。MacAdam椭圆在xy平面拉伸方向与人眼敏感轴一致——这直接决定LED Bin划分的良率。某厂曾因用圆形容差,导致蓝光Bin良率虚高12%,返工损失超200万。

5.2 产线实时数据点流式渲染

用matplotlib.animation.FuncAnimation实现每秒更新10个测量点,支持产线QC人员实时监控:

# 模拟产线实时数据(实际从光谱仪串口读取) def data_stream(): while True: # 从仪器读取x,y(含测量噪声) x_meas = x_d65 + np.random.normal(0, 0.001) y_meas = y_d65 + np.random.normal(0, 0.001) yield (x_meas, y_meas) scat = ax.scatter([], [], c='blue', s=20, alpha=0.8, zorder=5) def animate(frame, data_gen): x, y = next(data_gen) scat.set_offsets(np.array([[x, y]])) # 动态判断是否超sRGB三角形 if not plt.Polygon(srgb_vertices).contains_point((x, y)): scat.set_color('red') else: scat.set_color('blue') return scat, anim = FuncAnimation(fig, animate, frames=100, fargs=(data_stream(),), interval=100, blit=True, repeat=True)

5.3 色域面积比计算(NTSC、DCI-P3覆盖率)

色度图最终要输出数字结论。用Shoelace公式计算多边形面积,再与标准色域比:

def polygon_area(vertices): # Shoelace formula x, y = vertices[:,0], vertices[:,1] return 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1))) # sRGB面积 area_srgb = polygon_area(srgb_vertices) # NTSC色域顶点(标准值) ntsc_vertices = np.array([[0.67, 0.33], [0.21, 0.71], [0.14, 0.08]]) area_ntsc = polygon_area(ntsc_vertices) coverage = area_srgb / area_ntsc * 100 print(f"sRGB coverage of NTSC: {coverage:.2f}%")

关键细节:面积比必须用同一坐标系下的顶点!NTSC顶点是CIE 1931 xy坐标,不是CIE 1976 u'v'。曾见某团队用u'v'坐标算面积,结果虚高18%。

我坚持每次交付色度图时,附上三行元数据:① CIE标准版本(CIE 15:2004);② 观察者类型(1931 2°);③ SPD数据源(CIE D65 Annex B)。不是为了显得专业,而是当客户质疑“为什么和上次图不一样”时,我能立刻定位是标准更新还是数据源切换——省去三天扯皮。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询