Files
pqAutomationApp/app/tests/color_accuracy.py
2026-07-07 18:59:44 +08:00

453 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
色准ΔE2000 / 标准色)相关纯算法。
按 CIE / CIEDE2000 官方定义实现。
标准色度来源:
- 彩色块(ColorChecker): ColorChecker N Ohta 光谱积分 (CIE1931 2°, D65)
已与 Calman 彩色块 Target Y 验证一致(总误差 1.34)。
- 灰阶: linear信号值即线性域目标相对亮度已验证对齐 Calman。
- 100% 原色/混合色: 由目标色域三原色定义科学推导,按色域分组,
随信号格式通过 set_active_gamut() 切换。默认 sRGB。
统一规则:
Target xy = 标准色度
Target Y = white_lv × Yr / 100
Yr 一律是"线性域相对亮度因子"(0~100),与 xy 同源。
"""
import math
import numpy as np
from app.solid_color_scale import solid_color_rgb as _rgb
D65_X = 0.3127
D65_Y = 0.3290
# ======================================================================
# 彩色块标准 (x, y, Yr)
# 来源:直接抄自 Calman Target xyX-Rite 厂商标称值),零偏差对齐
# Yr = Calman Target Y / White Target Y(649.5768) × 100
# ======================================================================
_COLOR_PATCH_XYY = {
"dark skin": (0.4063, 0.3645, 9.91),
"light skin": (0.3780, 0.3562, 35.54),
"blue sky": (0.2489, 0.2653, 19.04),
"foliage": (0.3416, 0.4319, 13.13),
"blue flower": (0.2686, 0.2528, 23.78),
"bluish green": (0.2614, 0.3594, 42.42),
"orange": (0.5146, 0.4095, 28.59),
"purplish blue": (0.2147, 0.1891, 11.72),
"moderate red": (0.4641, 0.3122, 18.61),
"purple": (0.2882, 0.2164, 6.49),
"yellow green": (0.3774, 0.4955, 43.66),
"orange yellow": (0.4749, 0.4427, 42.91),
"blue": (0.1883, 0.1349, 6.04),
"green": (0.3049, 0.4948, 23.29),
"red": (0.5474, 0.3187, 11.57),
"yellow": (0.4477, 0.4759, 59.77),
"magenta": (0.3738, 0.2440, 18.96),
"cyan": (0.2080, 0.2688, 19.71),
}
# ======================================================================
# 100% 原色/混合色:由色域定义科学推导 (x, y, Yr),按色域分组
# 来源:各色域三原色 + 白点,线性 RGB -> XYZ -> xyY (白场Y=100)
# 注意sRGB 与 BT.709 共用同一组三原色,数值一致(做别名)
# ======================================================================
_PRIMARY_XYY_BY_GAMUT = {
"sRGB": {
"100% Red": (0.6400, 0.3300, 21.2639),
"100% Green": (0.3000, 0.6000, 71.5169),
"100% Blue": (0.1500, 0.0600, 7.2192),
"100% Cyan": (0.2246, 0.3287, 78.7361),
"100% Magenta": (0.3209, 0.1542, 28.4831),
"100% Yellow": (0.4193, 0.5053, 92.7808),
},
"DCI-P3": {
"100% Red": (0.6800, 0.3200, 20.9492),
"100% Green": (0.2650, 0.6900, 72.1595),
"100% Blue": (0.1500, 0.0600, 6.8913),
"100% Cyan": (0.2048, 0.3602, 79.0508),
"100% Magenta": (0.3424, 0.1544, 27.8405),
"100% Yellow": (0.4248, 0.5476, 93.1087),
},
"BT.2020": {
"100% Red": (0.7080, 0.2920, 26.2700),
"100% Green": (0.1700, 0.7970, 67.7998),
"100% Blue": (0.1310, 0.0460, 5.9302),
"100% Cyan": (0.1465, 0.3446, 73.7300),
"100% Magenta": (0.3682, 0.1471, 32.2002),
"100% Yellow": (0.4465, 0.5374, 94.0698),
},
}
# BT.709 共用 sRGB 三原色,做别名指向,避免重复维护
_PRIMARY_XYY_BY_GAMUT["BT.709"] = _PRIMARY_XYY_BY_GAMUT["sRGB"]
# 当前激活色域(默认 sRGB随信号格式切换调用 set_active_gamut() 修改
_ACTIVE_GAMUT = "sRGB"
def set_active_gamut(gamut_name):
"""
切换当前色域(随信号格式选择调用)。
支持: 'sRGB' / 'BT.709' / 'DCI-P3' / 'BT.2020'
"""
global _ACTIVE_GAMUT
if gamut_name not in _PRIMARY_XYY_BY_GAMUT:
raise ValueError(
f"未知色域: {gamut_name},可选: {list(_PRIMARY_XYY_BY_GAMUT)}"
)
_ACTIVE_GAMUT = gamut_name
def get_active_gamut():
"""返回当前激活的色域名。"""
return _ACTIVE_GAMUT
def _current_primaries():
"""返回当前色域的原色表。"""
return _PRIMARY_XYY_BY_GAMUT[_ACTIVE_GAMUT]
# ======================================================================
# 测试流程用名 -> 标准来源
# 彩色块: 映射到 _COLOR_PATCH_XYY 的键
# 灰阶 : 映射到 linear 信号值Yr = 信号值×100
# 100%原色: 走当前色域的 _PRIMARY_XYY_BY_GAMUT
# ======================================================================
_PATCH_NAME_MAP = {
"Dark Skin": "dark skin",
"Light Skin": "light skin",
"Blue Sky": "blue sky",
"Foliage": "foliage",
"Blue Flower": "blue flower",
"Bluish Green": "bluish green",
"Orange": "orange",
"Purplish Blue": "purplish blue",
"Moderate Red": "moderate red",
"Purple": "purple",
"Yellow Green": "yellow green",
"Orange Yellow": "orange yellow",
"Blue (Legacy)": "blue",
"Green (Legacy)": "green",
"Red (Legacy)": "red",
"Yellow (Legacy)": "yellow",
"Magenta (Legacy)": "magenta",
"Cyan (Legacy)": "cyan",
}
# 灰阶:信号值(线性域目标相对亮度),色度恒为 D65 白点
_GRAYSCALE_SIGNAL = {
"White": 1.00,
"Gray 80": 0.80,
"Gray 65": 0.65,
"Gray 50": 0.50,
"Gray 35": 0.35,
"Black": 0.00,
}
_SDR_COLOR_PATTERNS = [
("White", *_rgb(255, 255, 255)),
("Gray 80", *_rgb(230, 230, 230)),
("Gray 65", *_rgb(209, 209, 209)),
("Gray 50", *_rgb(186, 186, 186)),
("Gray 35", *_rgb(158, 158, 158)),
("Dark Skin", *_rgb(115, 82, 66)),
("Light Skin", *_rgb(194, 150, 130)),
("Blue Sky", *_rgb(94, 122, 156)),
("Foliage", *_rgb(89, 107, 66)),
("Blue Flower", *_rgb(130, 128, 176)),
("Bluish Green", *_rgb(99, 189, 168)),
("Orange", *_rgb(217, 120, 41)),
("Purplish Blue", *_rgb(74, 92, 163)),
("Moderate Red", *_rgb(194, 84, 97)),
("Purple", *_rgb(92, 61, 107)),
("Yellow Green", *_rgb(158, 186, 64)),
("Orange Yellow", *_rgb(230, 161, 46)),
("Blue (Legacy)", *_rgb(51, 61, 150)),
("Green (Legacy)", *_rgb(71, 148, 71)),
("Red (Legacy)", *_rgb(176, 48, 59)),
("Yellow (Legacy)", *_rgb(237, 199, 33)),
("Magenta (Legacy)", *_rgb(186, 84, 145)),
("Cyan (Legacy)", *_rgb(0, 133, 163)),
("100% Red", *_rgb(255, 0, 0)),
("100% Green", *_rgb(0, 255, 0)),
("100% Blue", *_rgb(0, 0, 255)),
("100% Cyan", *_rgb(0, 255, 255)),
("100% Magenta", *_rgb(255, 0, 255)),
("100% Yellow", *_rgb(255, 255, 0)),
]
# ----------------------------------------------------------------------
# 标准 xy / Yr 解析(统一入口)
# ----------------------------------------------------------------------
def _resolve_reference_xy(name):
"""返回该色块的标准参考 xy。未知则回退 D65。"""
if name in _GRAYSCALE_SIGNAL:
return (D65_X, D65_Y)
if name in _PATCH_NAME_MAP:
x, y, _ = _COLOR_PATCH_XYY[_PATCH_NAME_MAP[name]]
return (x, y)
primaries = _current_primaries()
if name in primaries:
x, y, _ = primaries[name]
return (x, y)
return (D65_X, D65_Y)
def _resolve_reference_yr(name):
"""
返回该色块的标准相对亮度因子 Yr (0~100)。
灰阶: 信号值×100 (linear)。彩色/原色: 光谱或色域 Yr。
无法确定返回 None。
"""
if name in _GRAYSCALE_SIGNAL:
return _GRAYSCALE_SIGNAL[name] * 100.0
if name in _PATCH_NAME_MAP:
return _COLOR_PATCH_XYY[_PATCH_NAME_MAP[name]][2]
primaries = _current_primaries()
if name in primaries:
return primaries[name][2]
return None
def get_target_xyY(name, white_lv):
"""
返回该色块的完整 target (x, y, Y)。
Target Y = white_lv × Yr / 100 (统一规则)。
Yr 不可知时 Y 返回 None由调用方按实测处理。
"""
x, y = _resolve_reference_xy(name)
Yr = _resolve_reference_yr(name)
if Yr is None or white_lv is None or white_lv <= 0:
return (x, y, None)
return (x, y, round(white_lv * Yr / 100.0, 4))
# ----------------------------------------------------------------------
# 兼容旧接口
# ----------------------------------------------------------------------
def get_accuracy_reference_y(name, white_lv):
"""
返回图表/表格用的参考亮度比例White=100 缩放)。
现基于标准 Yr 返回真实比例;无标准 Yr 时回退 100。
"""
Yr = _resolve_reference_yr(name)
if Yr is None or white_lv is None or white_lv <= 0:
return 100.0
return round(Yr, 4)
# ----------------------------------------------------------------------
# xyY -> XYZ -> LabCIE 官方定义)
# ----------------------------------------------------------------------
def _xyY_to_XYZ(x, y, Y):
"""xyY 转 XYZ。y 为 0 时返回全 0避免除零。"""
if y <= 0:
return 0.0, 0.0, 0.0
X = (x / y) * Y
Z = ((1.0 - x - y) / y) * Y
return X, Y, Z
def _XYZ_to_lab(X, Y, Z, Xn, Yn, Zn):
"""
XYZ 转 CIE L*a*b*。
Xn, Yn, Zn 为参考白点的绝对 XYZYn 通常为白场亮度 white_lv
"""
delta = 6.0 / 29.0
def f(t):
if t > delta ** 3:
return t ** (1.0 / 3.0)
return t / (3.0 * delta ** 2) + 4.0 / 29.0
xr = X / Xn if Xn != 0 else 0.0
yr = Y / Yn if Yn != 0 else 0.0
zr = Z / Zn if Zn != 0 else 0.0
fx, fy, fz = f(xr), f(yr), f(zr)
L = 116.0 * fy - 16.0
a = 500.0 * (fx - fy)
b = 200.0 * (fy - fz)
return L, a, b
def _xyY_to_lab(x, y, Y, white_x=D65_X, white_y=D65_Y, white_Y=None):
"""
xyY 直接转 Lab。
白点默认 D65 色度white_Y 为白场绝对亮度(用于 L 的归一化基准)。
若 white_Y 为 None则退化为以 Y=1 归一化(仅相对比较时可用)。
"""
if white_Y is None or white_Y <= 0:
white_Y = 1.0
X, Y3, Z = _xyY_to_XYZ(x, y, Y)
Xn, Yn, Zn = _xyY_to_XYZ(white_x, white_y, white_Y)
return _XYZ_to_lab(X, Y3, Z, Xn, Yn, Zn)
# ----------------------------------------------------------------------
# CIEDE2000官方公式
# ----------------------------------------------------------------------
def _delta_e_2000_from_lab(L1, a1, b1, L2, a2, b2, kL=1.0, kC=1.0, kH=1.0):
C1 = math.sqrt(a1 ** 2 + b1 ** 2)
C2 = math.sqrt(a2 ** 2 + b2 ** 2)
C_bar = (C1 + C2) / 2.0
G = 0.5 * (1 - math.sqrt(C_bar ** 7 / (C_bar ** 7 + 25 ** 7)))
a1_prime = a1 * (1 + G)
a2_prime = a2 * (1 + G)
C1_prime = math.sqrt(a1_prime ** 2 + b1 ** 2)
C2_prime = math.sqrt(a2_prime ** 2 + b2 ** 2)
def calc_hue(a_prime, b):
if a_prime == 0 and b == 0:
return 0.0
h = math.degrees(math.atan2(b, a_prime))
if h < 0:
h += 360.0
return h
h1_prime = calc_hue(a1_prime, b1)
h2_prime = calc_hue(a2_prime, b2)
# ΔL', ΔC'
delta_L_prime = L2 - L1
delta_C_prime = C2_prime - C1_prime
# Δh'(官方三分支)
if C1_prime * C2_prime == 0:
delta_h_prime = 0.0
else:
dh = h2_prime - h1_prime
if abs(dh) <= 180:
delta_h_prime = dh
elif dh > 180:
delta_h_prime = dh - 360.0
else:
delta_h_prime = dh + 360.0
delta_H_prime = (
2.0
* math.sqrt(C1_prime * C2_prime)
* math.sin(math.radians(delta_h_prime / 2.0))
)
# 平均值
L_bar_prime = (L1 + L2) / 2.0
C_bar_prime = (C1_prime + C2_prime) / 2.0
# H_bar'(官方分支)
if C1_prime * C2_prime == 0:
H_bar_prime = h1_prime + h2_prime
else:
dh_abs = abs(h1_prime - h2_prime)
if dh_abs <= 180:
H_bar_prime = (h1_prime + h2_prime) / 2.0
elif (h1_prime + h2_prime) < 360:
H_bar_prime = (h1_prime + h2_prime + 360.0) / 2.0
else:
H_bar_prime = (h1_prime + h2_prime - 360.0) / 2.0
T = (
1
- 0.17 * math.cos(math.radians(H_bar_prime - 30))
+ 0.24 * math.cos(math.radians(2 * H_bar_prime))
+ 0.32 * math.cos(math.radians(3 * H_bar_prime + 6))
- 0.20 * math.cos(math.radians(4 * H_bar_prime - 63))
)
delta_theta = 30 * math.exp(-(((H_bar_prime - 275) / 25.0) ** 2))
R_C = 2 * math.sqrt(C_bar_prime ** 7 / (C_bar_prime ** 7 + 25 ** 7))
R_T = -R_C * math.sin(math.radians(2 * delta_theta))
S_L = 1 + (0.015 * (L_bar_prime - 50) ** 2) / math.sqrt(
20 + (L_bar_prime - 50) ** 2
)
S_C = 1 + 0.045 * C_bar_prime
S_H = 1 + 0.015 * C_bar_prime * T
return math.sqrt(
(delta_L_prime / (kL * S_L)) ** 2
+ (delta_C_prime / (kC * S_C)) ** 2
+ (delta_H_prime / (kH * S_H)) ** 2
+ R_T * (delta_C_prime / (kC * S_C)) * (delta_H_prime / (kH * S_H))
)
def calculate_delta_e_2000(
measured_x,
measured_y,
measured_lv,
standard_x,
standard_y,
standard_lv=None,
white_lv=None,
):
"""
计算 ΔE 2000 色差。
Args:
measured_x, measured_y: 测量的 xy 坐标
measured_lv: 测量的亮度cd/m²
standard_x, standard_y: 标准的 xy 坐标
standard_lv: 标准亮度cd/m²默认与 measured_lv 相同
white_lv: 白场亮度cd/m²作为 Lab 的 L 归一化基准;
默认取 measured_lv仅当不传时退化为相对比较
Returns:
float: ΔE 2000 色差值
"""
if standard_lv is None:
standard_lv = measured_lv
if white_lv is None:
white_lv = measured_lv
L1, a1, b1 = _xyY_to_lab(measured_x, measured_y, measured_lv, white_Y=white_lv)
L2, a2, b2 = _xyY_to_lab(standard_x, standard_y, standard_lv, white_Y=white_lv)
return _delta_e_2000_from_lab(L1, a1, b1, L2, a2, b2)
def calculate_accuracy_delta_e_2000(
patch_name, measured_x, measured_y, measured_lv, white_lv
):
"""
色准测试专用 ΔE2000
标准 xy 来自光谱积分/色域定义(随当前色域);
目标 Y 取实测 Y同亮度下比较色度差异
L 的归一化基准使用白场亮度 white_lv。
"""
standard_x, standard_y = _resolve_reference_xy(patch_name)
return calculate_delta_e_2000(
measured_x,
measured_y,
measured_lv,
standard_x,
standard_y,
standard_lv=measured_lv,
white_lv=white_lv,
)
def calculate_color_accuracy(measured, standard):
"""计算色差简化版xy 欧氏距离 × 1000"""
delta_E = {}
for color in measured.keys():
dx = measured[color][0] - standard[color][0]
dy = measured[color][1] - standard[color][1]
delta_E[color] = np.sqrt(dx * dx + dy * dy) * 1000
return delta_E
def get_accuracy_color_standards(test_type=None):
"""返回色准标准patch 名称 -> 参考 xy随当前色域"""
del test_type
return {name: _resolve_reference_xy(name) for name, _, _, _ in _SDR_COLOR_PATTERNS}