diff --git a/coloraide/distance/delta_e_helmlab.py b/coloraide/distance/delta_e_helmlab.py new file mode 100644 index 000000000..4c7bfc485 --- /dev/null +++ b/coloraide/distance/delta_e_helmlab.py @@ -0,0 +1,58 @@ +""" +Delta E Helmlab. + +- https://arxiv.org/abs/2602.23010 +- https://github.com/Grkmyldz148/helmlab +""" +from __future__ import annotations +import math +from . import DeltaE +from ..types import AnyColor +from typing import Any, cast + +SL = 0.0010089809904916469 +SC = 0.021678192255028452 +WC = 1.0458243890301122 +P = 0.804265429185275 +COMPRESS = 1.5903206798028005 +Q = 1.1 + + +class DEHelmlab(DeltaE): + """Delta E Helmlab class.""" + + NAME = "helmlab" + + def distance(self, color: AnyColor, sample: AnyColor, **kwargs: Any) -> float: + """Delta E Helmlab color distance formula.""" + + space = 'helmlab-metric' + + l1, a1, b1 = ( + color.convert(space) if color.space() != space else color.clone().normalize(nans=False) + )[:-1] + l2, a2, b2 = ( + sample.convert(space) if color.space() != space else sample.clone().normalize(nans=False) + )[:-1] + + dl = l1 - l2 + da = a1 - a2 + db = b1 - b2 + + # Pair-dependent weighting + lavg = (l1 + l2) * 0.5 + sl = 1.0 + SL * (lavg - 0.5) ** 2 + + c1 = math.sqrt(a1 ** 2 + b1 ** 2) + c2 = math.sqrt(a2 ** 2 + b2 ** 2) + cavg = (c1 + c2) * 0.5 + sc = 1.0 + SC * cavg + + # Weighted Minkowski distance + raw = (dl ** 2 / sl ** 2 + WC * (da ** 2 + db ** 2) / sc ** 2) ** (P / 2) + + # Monotonic compression + compressed = raw / (1.0 + COMPRESS * raw) + + # `mypy` is broken and can't figure out we are returning a float + return cast(float, compressed ** Q) diff --git a/coloraide/everything.py b/coloraide/everything.py index 73b126b95..2da1a33da 100644 --- a/coloraide/everything.py +++ b/coloraide/everything.py @@ -42,10 +42,14 @@ from .spaces.cubehelix import Cubehelix from .spaces.rec2020_oetf import Rec2020OETF from .spaces.msh import Msh +from .spaces.helmgen import Helmgen +from .spaces.helmgenlch import Helmgenlch +from .spaces.helmlab_metric import HelmlabMetric from .distance.delta_e_99o import DE99o from .distance.delta_e_cam16 import DECAM16 from .distance.delta_e_cam02 import DECAM02 from .distance.delta_e_hct import DEHCT +from .distance.delta_e_helmlab import DEHelmlab from .gamut.fit_hct_chroma import HCTChroma from .interpolate.catmull_rom import CatmullRom from .interpolate.spectral import Spectral, SpectralContinuous @@ -112,12 +116,16 @@ class ColorAll(Base): Msh(), sCAMJMh(), sUCS(), + Helmgen(), + HelmlabMetric(), + Helmgenlch(), # Delta E DE99o(), DECAM16(), DECAM02(), DEHCT(), + DEHelmlab(), # Gamut Mapping HCTChroma(), diff --git a/coloraide/spaces/helmgen.py b/coloraide/spaces/helmgen.py new file mode 100644 index 000000000..ef545e026 --- /dev/null +++ b/coloraide/spaces/helmgen.py @@ -0,0 +1,276 @@ +""" +Helmlab GenSpace: generation-optimized color space for interpolation. + +A simplified pipeline (`XYZ -> M1 -> cbrt -> M2 -> NC`) optimized for +perceptually uniform gradients, palette generation, and color-mix. +Achieves 6x better hue accuracy than Oklab with 10% better perceptual +distance prediction. + +Key differences from Helmlab (MetricSpace): + - Shared gamma = 1/3 (cube root, guarantees achromatic a=b=0) + - No enrichment stages (simpler, faster, better for generation) + - Different M1/M2 matrices (Phase1H-optimized) + +- https://arxiv.org/abs/2602.23010 +- https://github.com/Grkmyldz148/helmlab +""" +from __future__ import annotations +import math +from .lab import Lab +from ..cat import WHITES +from ..channels import Channel, FLG_MIRROR_PERCENT +from .. import algebra as alg +from ..types import Vector + +# Depressed cubic parameter +ALPHA = 0.021 +S = math.sqrt(ALPHA / 3) +S3 = S ** 3 + +# Chroma power +CP = 0.978 + +# L-gated hue enrichment parameters +ENR_AMP = 0.058 +ENR_CENTER = 264.5 * math.pi / 180 # radians +ENR_SIGMA = 0.7 +ENR_LLO = 0.37 +ENR_LHI = 1.0 + + +M1 = [ + [8.1548321559412884e-01, 3.6033406153856506e-01, -1.2434135574228214e-01], + [3.3010083527450780e-02, 9.2928650570661686e-01, 3.6121927165754429e-02], + [4.8188273564568611e-02, 2.6428415753384238e-01, 6.3349717841955344e-01] +] + +M1_INV = alg.inv(M1) + +M2 = [ + [0.21193779684470104, 0.7992121834263127, -0.00410075161564345], + [2.4672018828033475, -2.9877348024830788, 0.520532919679731], + [-0.11390787868068575, 1.3932982808117473, -1.279390402131062] +] + +M2_INV = alg.inv(M2) + +# Piecewise linear L correction (21 breakpoints, v0.11.1) +PW_L_IN = [ + 0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, + 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1.0 +] + +PW_L_OUT = [ + 0, 0.009494013522189627, 0.02564569838030986, 0.055259661658689105, 0.10574901531227408, + 0.16055853320726027, 0.21405964892993756, 0.26786230508811226, 0.3220435246104499, 0.3739052098520243, + 0.43020997780918835, 0.4835465162128873, 0.5399824670411353, 0.5956710081330342, 0.6542161666450478, + 0.7115380216519989, 0.7702762412711669, 0.8293313467712837, 0.889406386197059, 0.9462829573474728, + 1.0 +] + +PW_N = len(PW_L_IN) + + +# Depressed cubic: y ** 3 + αy = x +def depcubic_fwd(x: float) -> float: + """Depressed cubic forward.""" + t = x / (2 * S3) + y = 2 * S * math.sinh(math.asinh(t) / 3) + # Halley refinement + f = y ** 3 + ALPHA * y - x + fp = 3 * y * y + ALPHA + fpp = 6 * y + denom = 2 * fp * fp - f * fpp + if abs(denom) > 1e-30: + y -= 2 * f * fp / denom + return y + + +def depcubic_inv(y: float) -> float: + """Depressed cubic inverse.""" + + return y * y * y + ALPHA * y + + +# L - gated hue enrichment +def enrich_gate(l: float) -> float: + """Enrichment gate.""" + t = max(0, min(1, (l - ENR_LLO) / (ENR_LHI - ENR_LLO))) + return math.sin(math.pi * t) ** 2 + + +def enrich_fwd(l: float, a: float, b: float) -> Vector: + """Enrichment forward.""" + c = math.sqrt(a ** 2 + b ** 2) + if (c < 1e-12): + return [a, b] + + gate = enrich_gate(l) + if (gate < 1e-12): + return [a, b] + + h = math.atan2(b, a) + dh = h - ENR_CENTER + dh = dh - round(dh / (2 * math.pi)) * 2 * math.pi + gauss = math.exp(-0.5 * (dh / ENR_SIGMA) ** 2) + h_new = h + ENR_AMP * gate * gauss + return [c * math.cos(h_new), c * math.sin(h_new)] + + +def enrich_inv (l: float, a: float, b: float) -> Vector: + """Inverse enrichment.""" + c = math.sqrt(a ** 2 + b ** 2) + if (c < 1e-12): + return [a, b] + + gate = enrich_gate(l) + if (gate < 1e-12): + return [a, b] + + h_target = math.atan2(b, a) + sig2 = ENR_SIGMA * ENR_SIGMA + ag = ENR_AMP * gate + h = h_target + for _ in range(8): + dh = h - ENR_CENTER + dh = dh - round(dh / (2 * math.pi)) * 2 * math.pi + gauss = math.exp(-0.5 * dh * dh / sig2) + f = h + ag * gauss - h_target + fp = 1 + ag * gauss * (-dh / sig2) + fpp = ag * gauss * (-1 / sig2 + dh * dh / (sig2 * sig2)) + den = 2 * fp * fp - f * fpp + if abs(den) > 1e-30: + h -= 2 * f * fp / den + + return [c * math.cos(h), c * math.sin(h)] + + +# PW L correction +def pw_l_fwd(l: float) -> float: + """PW L correction (forward).""" + if (l <= 0 or l >= 1): + return l + + lo, hi = 0, PW_N - 1 + while (hi - lo) > 1: + mid = (lo + hi) >> 1 + if (PW_L_IN[mid] <= l): + lo = mid + else: + hi = mid + + t = (l - PW_L_IN[lo]) / (PW_L_IN[hi] - PW_L_IN[lo]) + return PW_L_OUT[lo] + t * (PW_L_OUT[hi] - PW_L_OUT[lo]) + + +def pw_l_inv(l: float) -> float: + """PW L correction (inverse).""" + if l <= PW_L_OUT[0] or l >= PW_L_OUT[PW_N - 1]: + return l + + lo, hi = 0, PW_N - 1 + while (hi - lo) > 1: + mid = (lo + hi) >> 1 + if PW_L_OUT[mid] <= l: + lo = mid + else: + hi = mid + + t = (l - PW_L_OUT[lo]) / (PW_L_OUT[hi] - PW_L_OUT[lo]) + return PW_L_IN[lo] + t * (PW_L_IN[hi] - PW_L_IN[lo]) + + +def xyz_d65_to_helmgen(xyz: Vector) -> Vector: + """Convert XYZ to Helmgen.""" + + # Stage 1: XYZ -> LMS (M1) + lms = alg.matmul_x3(M1, xyz, dims=alg.D2_D1) + c = [depcubic_fwd(max(v, 0)) for v in lms] + + # Stage 2.5: Smooth neutral blend (C∞ correction for achromatic precision) + mean = sum(c) / 3 + mx = max(c) + mn = min(c) + spread = (mx - mn) / max(abs(mean), 1e-30) + w = math.exp(-((spread / 1e-5) ** 2)) + c = [v + w * (mean - v) for v in c] + + # Stage 3: LMS_c -> Lab (M2) + l, a, b = alg.matmul_x3(M2, c, dims=alg.D2_D1) + + # Stage 3.5: Chroma power (`cp=0.978`) + chroma = math.sqrt(a ** 2 + b ** 2) + if chroma > 1e-12: + c_new = math.pow(chroma, CP) + s = c_new / chroma + a *= s + b *= s + + # Stage 4: Piecewise-linear L correction + l = pw_l_fwd(l) + + # Stage 5: L-gated hue enrichment + a, b = enrich_fwd(l, a, b) + + return [l, a, b] + + +def helmgen_to_xyz(lab: Vector) -> Vector: + """Convert Helmgen to XYZ.""" + + l, a, b = lab + + # Undo Stage 5: L-gated hue enrichment + a, b = enrich_inv(l, a, b) + + # Undo Stage 4: PW L correction + l = pw_l_inv(l) + + # Undo Stage 3.5: Chroma power inverse (`C^(1/cp)`) + chroma = math.sqrt(a ** 2 + b ** 2) + if chroma > 1e-12: + c_orig = math.pow(chroma, 1 / CP) + s = c_orig / chroma + a *= s + b *= s + + # Undo Stage 3: Lab -> LMS_c (`M2_INV`) + c = alg.matmul_x3(M2_INV, [l, a, b], dims=alg.D2_D1) + + # Undo Stage 2.5: Smooth neutral blend + mean = sum(c) / 3 + mx = max(c) + mn = min(c) + spread = (mx - mn) / max(abs(mean), 1e-30) + w = math.exp(-((spread / 1e-5) ** 2)) + c = [v + w * (mean - v) for v in c] + + # Undo Stage 2: Inverse depressed cubic (x = y ** 3 + αy) + lms = [depcubic_inv(v) for v in c] + + # Undo Stage 1: LMS -> XYZ (`M1_INV`) + return alg.matmul_x3(M1_INV, lms, dims=alg.D2_D1) + + +class Helmgen(Lab): + """Helmgen class.""" + + BASE = "xyz-d65" + NAME = "helmgen" + SERIALIZE = ("--helmgen",) + CHANNELS = ( + Channel("l", 0.0, 1.0), + Channel("a", -0.6, 0.6, flags=FLG_MIRROR_PERCENT), + Channel("b", -0.6, 0.6, flags=FLG_MIRROR_PERCENT) + ) + WHITE = WHITES['2deg']['D65'] + + def to_base(self, coords: Vector) -> Vector: + """To XYZ.""" + + return helmgen_to_xyz(coords) + + def from_base(self, coords: Vector) -> Vector: + """From XYZ.""" + + return xyz_d65_to_helmgen(coords) diff --git a/coloraide/spaces/helmgenlch.py b/coloraide/spaces/helmgenlch.py new file mode 100644 index 000000000..5dc838b1d --- /dev/null +++ b/coloraide/spaces/helmgenlch.py @@ -0,0 +1,41 @@ +""" +Helmgenlch class. + +LCh based on the Helmlab GenSpace: generation-optimized color space for interpolation. + +A simplified pipeline (`XYZ -> M1 -> cbrt -> M2 -> NC`) optimized for +perceptually uniform gradients, palette generation, and color-mix. +Achieves 6x better hue accuracy than Oklab with 10% better perceptual +distance prediction. + +Key differences from Helmlab (MetricSpace): + - Shared gamma = 1/3 (cube root, guarantees achromatic a=b=0) + - No enrichment stages (simpler, faster, better for generation) + - Different M1/M2 matrices (Phase1H-optimized) + +- https://arxiv.org/abs/2602.23010 +- https://github.com/Grkmyldz148/helmlab +""" +from __future__ import annotations +from .lch import LCh +from ..cat import WHITES +from ..channels import Channel, FLG_ANGLE + + +class Helmgenlch(LCh): + """Helmgenlch class.""" + + BASE = "helmgen" + NAME = "helmgenlch" + SERIALIZE = ("--helmgenlch",) + CHANNELS = ( + Channel("l", 0.0, 1.0), + Channel("c", 0.0, 0.65), + Channel("h", flags=FLG_ANGLE) + ) + CHANNEL_ALIASES = { + "lightness": "l", + "chroma": "c", + "hue": "h" + } + WHITE = WHITES['2deg']['ASTM-E308-D65'] diff --git a/coloraide/spaces/helmlab_metric.py b/coloraide/spaces/helmlab_metric.py new file mode 100644 index 000000000..a98a68b4a --- /dev/null +++ b/coloraide/spaces/helmlab_metric.py @@ -0,0 +1,419 @@ +""" +Helmlab MetricSpace - 13-stage perceptual color space. + +A data-driven analytical color space trained on 64,000+ individual human +color perception observations. Achieves 20.1% lower STRESS than CIEDE2000 +on the COMBVD dataset (3,813 color pairs). + +Pipeline: XYZ -> M1 -> γ -> M2 -> hue correction -> H-K -> cubic L -> dark L + -> hue-dependent chroma scale -> chroma power -> L-dependent chroma scale + -> HLC interaction -> hue-dependent lightness -> neutral correction -> rotation + +- https://arxiv.org/abs/2602.23010 +- https://github.com/Grkmyldz148/helmlab +""" +from __future__ import annotations +from .lab import Lab +from ..cat import WHITES +from ..channels import Channel, FLG_MIRROR_PERCENT +from .. import algebra as alg +from ..types import Vector +import math + +M1 = [ + [0.72129864331134985189, 0.45344826541531813024, -0.19288975751942616377], + [-0.78821186949557897616, 1.79524137675723594043, 0.08761724511817850503], + [-0.09177005999121559676, 0.45765588659459255361, 1.29220455139176770842] +] +M1_INV = alg.inv(M1) +M2 = [ + [-0.26355622180094095963, 0.41683228837031738312, 0.49267631416564028335], + [1.88975705087773215851, -3.12122320342057735232, 1.04216669210603840590], + [0.35851086179620561545, 1.76940281937903676202, -1.41206260676953720967] +] +M2_INV = alg.inv(M2) + +GAMMA = [0.47229813098762524, 0.5149184096354483, 0.5113233386366979] + +# Enrichment parameters +HUE_COS1 = -0.02833024015436984 +HUE_COS2 = 0.2189784817615645 +HUE_COS3 = 0.005506053349515315 +HUE_COS4 = -0.053592461436994296 + +HUE_SIN1 = -0.21131429516166544 +HUE_SIN2 = -0.06871898981942523 +HUE_SIN3 = -0.0641329861299175 +HUE_SIN4 = -0.00954137464208059 + +HK_WEIGHT = 0.2676231133101982 +HK_POWER = 0.8934892185255707 +HK_HUE_MOD = 0.7173169828841472 +HK_SIN1 = 0.6915224124600773 +HK_COS2 = 0.48647127559605596 +HK_SIN2 = 0.9853124591201782 + +L_CORR_P1 = 0.5385456675962418 +L_CORR_P2 = 0.12508858146241716 +L_CORR_P3 = 0.6768950256217603 +LH_COS1 = -0.4963251525324449 +LH_SIN1 = -0.09564696283240552 + +LP_DARK = -0.029053748937210654 +LP_DARK_HCOS = 1.3346761652952872 +LLP_DARK_HSIN = -0.1698908144723919 + +CS_COS1 = -0.195370576218515 +CS_COS2 = 0.08863325582067766 +CS_COS3 = 0.13789738139719568 +CS_COS4 = 0.0641970862504494 + +CS_SIN1 = 0.5330819227283227 +CS_SIN2 = 0.9365540137751136 +CS_SIN3 = 0.061650260197979936 +CS_SIN4 = -0.027401052793571013 + +CP_COS1 = -0.09900209889026965 +CP_COS2 = -0.013586499967803128 + +CP_SIN1 = 0.059635520647228726 +CP_SIN2 = 0.2253393118474472 + +LC1 = -1.5239477450767043 +LC2 = -1.751157310240011 + +HLC_COS1 = -0.43576378069144767 +HLC_COS2 = 0.47931193034584496 + +HLC_SIN1 = 1.060094063845983 +HLC_SIN2 = -0.2622579649434462 + +HL_COS1 = 0.13610794232685908 +HL_COS2 = -0.01617739641422492 + +HL_SIN1 = 0.1168702235362288 +HL_SIN2 = 0.038145638815030566 + +# Rigid rotation `φ = -28.2°` +PHI = -28.2 * math.pi / 180 +ROT_COS = math.cos(PHI) +ROT_SIN = math.sin(PHI) + + +def xyz_d65_to_helmlab(xyz: Vector) -> Vector: + """Convert XYZ to Helmlab.""" + + lms = alg.matmul_x3(M1, xyz, dims=alg.D2_D1) + cx = [alg.spow(a, b) for a, b in zip(lms, GAMMA)] + return alg.matmul_x3(M2, cx, dims=alg.D2_D1) + + +def helmlab_to_xyz(lab: Vector) -> Vector: + """Convert Helmlab to XYZ.""" + + cx = alg.matmul_x3(M2_INV, lab, dims=alg.D2_D1) + lms = [alg.spow(a, 1 / b) for a, b in zip(cx, GAMMA)] + return alg.matmul_x3(M1_INV, lms, dims=alg.D2_D1) + + +def hue_delta(h: float) -> float: + """Calculate `δ(h)` = Fourier series for hue rotation (up to 4th harmonic).""" + return ( + HUE_COS1 * math.cos(h) + HUE_SIN1 * math.sin(h) + + HUE_COS2 * math.cos(2 * h) + HUE_SIN2 * math.sin(2 * h) + + HUE_COS3 * math.cos(3 * h) + HUE_SIN3 * math.sin(3 * h) + + HUE_COS4 * math.cos(4 * h) + HUE_SIN4 * math.sin(4 * h) + ) + + +def hue_delta_deriv(h: float) -> float: + """Calculate `d/dh` of `δ(h)`, needed for Newton iteration in inverse.""" + + return ( + -HUE_COS1 * math.sin(h) + HUE_SIN1 * math.cos(h) + + -2 * HUE_COS2 * math.sin(2 * h) + 2 * HUE_SIN2 * math.cos(2 * h) + + -3 * HUE_COS3 * math.sin(3 * h) + 3 * HUE_SIN3 * math.cos(3 * h) + + -4 * HUE_COS4 * math.sin(4 * h) + 4 * HUE_SIN4 * math.cos(4 * h) + ) + + +def chroma_scale_h (h: float) -> float: + """Calculate `S(h) = exp(Fourier series up to 4th harmonic)`. Always > 0.""" + + return math.exp( + CS_COS1 * math.cos(h) + CS_SIN1 * math.sin(h) + + CS_COS2 * math.cos(2 * h) + CS_SIN2 * math.sin(2 * h) + + CS_COS3 * math.cos(3 * h) + CS_SIN3 * math.sin(3 * h) + + CS_COS4 * math.cos(4 * h) + CS_SIN4 * math.sin(4 * h) + ) + + +def l_chroma_scale(l: float) -> float: + """Calculate `T(L) = exp(polynomial)`. Always > 0. Clips exponent for extreme L.""" + + dL = l - 0.5 + return math.exp(alg.clamp(LC1 * dL + LC2 * dL * dL, -30, 30)) + + +def hlc_scale(h: float, l: float) -> float: + """Hue x Lightness chroma interaction: `exp((L-0.5) * Fourier(h))`. Always > 0.""" + + hueFactor = ( + HLC_COS1 * math.cos(h) + HLC_SIN1 * math.sin(h) + + HLC_COS2 * math.cos(2 * h) + HLC_SIN2 * math.sin(2 * h) + ) + return math.exp(alg.clamp((l - 0.5) * hueFactor, -30, 30)) + + +def hue_lightness_scale(h: float) -> float: + """Calculate `exp(Fourier(h))` - pure hue -> lightness modulation. Always > 0.""" + + return math.exp( + HL_COS1 * math.cos(h) + HL_SIN1 * math.sin(h) + + HL_COS2 * math.cos(2 * h) + HL_SIN2 * math.sin(2 * h) + ) + + +def chroma_power_h(h: float) -> float: + """Calculate `1 + Fourier(h, 2 harmonics)` - exponent for chroma power compression.""" + + return ( + 1 + CP_COS1 * math.cos(h) + CP_SIN1 * math.sin(h) + + CP_COS2 * math.cos(2 * h) + CP_SIN2 * math.sin(2 * h) + ) + + +def l_correct_fwd (l: float, h: float) -> float: + """Calculate `L1 = L_raw + p1*t + p2*t*(0.5-L) + p3*t^2 [+ t*Lh(h)], t = L*(1-L)`.""" + + t = l * (1 - l) + result = l + L_CORR_P1 * t + L_CORR_P2 * t * (0.5 - l) + L_CORR_P3 * t * t + result += t * (LH_COS1 * math.cos(h) + LH_SIN1 * math.sin(h)) + return result + + +def l_correct_inv (l1: float, h: float) -> float: + """Invert L correction via Newton iteration.""" + + lh = LH_COS1 * math.cos(h) + LH_SIN1 * math.sin(h) + l = l1 + for _ in range(15): + t = l * (1 - l) + dt = 1 - 2 * l + f = ( + l + (L_CORR_P1 + lh) * t + L_CORR_P2 * t * (0.5 - l) + + L_CORR_P3 * t * t - l1 + ) + dfdL = ( + 1 + (L_CORR_P1 + lh) * dt + + L_CORR_P2 * (dt * (0.5 - l) - t) + + L_CORR_P3 * 2 * t * dt + ) + if abs(dfdL) < 1e-10: # pragma: no cover + dfdL = 1 + + l -= f / dfdL + return l + + +def dark_l_fwd (l: float, h: float) -> float: + """ + Calculate `L_new = L * exp(g), g = L*(1-L) ** 2 * coeff(h, S)`. Targets dark region. + + v13: coefficient is hue-dependent when `lp_dark_hcos/hsin != 0`. + v16: coefficient is surround-dependent when `lp_dark_S/S2 != 0`. + """ + + coeff = LP_DARK + LP_DARK_HCOS * math.cos(h) + LLP_DARK_HSIN * math.sin(h) + oml = (1.0 - l) if l < 1 else 0.0 # clamp at `L=1`: identity for `L>1` + g = coeff * l * oml * oml + return l * math.exp(alg.clamp(g, -30, 30)) + + +def dark_l_inv (ln: float, h: float) -> float: + """ + Invert dark L compression via Newton iteration. + + v13: coefficient is hue-dependent when `lp_dark_hcos/hsin != 0`. + v16: coefficient is surround-dependent when `lp_dark_S/S2 != 0`. + """ + + coeff = LP_DARK + LP_DARK_HCOS * math.cos(h) + LLP_DARK_HSIN * math.sin(h) + l = ln + for _ in range(12): + oml = (1.0 - l) if l < 1 else 0.0 + g = coeff * l * oml * oml + eg = math.exp(alg.clamp(g, -30, 30)) + f = l * eg - ln + gp = coeff * oml * (1 - 3 * l) + fp = eg * (1 + l * gp) + if abs(fp) < 1e-10: # pragma: no cover + fp = 1 + l -= f / fp + return l + + +def xyz_d65_to_helmlab_full(xyz: Vector) -> Vector: + """Convert XYZ to Helmlab.""" + + l, a, b = xyz_d65_to_helmlab(xyz) + + # Stage 3.5: Hue correction (4-harmonic Fourier) + h = math.atan2(b, a) + c = math.sqrt(a * a + b * b) + delta = hue_delta(h) + h_new = h + delta + a = c * math.cos(h_new) + b = c * math.sin(h_new) + + + # Stage 3.7: Helmholtz-Kohlrausch correction + cr = math.sqrt(a * a + b * b) + hkBoost = HK_WEIGHT * alg.spow(cr, alg.clamp(HK_POWER, 0.01, 10)) + hr = math.atan2(b, a) + factor = ( + 1 + HK_HUE_MOD * math.cos(hr) + HK_SIN1 * math.sin(hr) + + HK_COS2 * math.cos(2 * hr) + HK_SIN2 * math.sin(2 * hr) + ) + l += hkBoost * factor + + # Stage 4: Cubic L correction (with hue modulation) + h = math.atan2(b, a) + l = l_correct_fwd(l, h) + + # Stage 4.5: Dark L compression + h = math.atan2(b, a) + l = dark_l_fwd(l, h) + + # Stage 5: Hue-dependent chroma scaling + h = math.atan2(b, a) + cs = chroma_scale_h(h) + a *= cs + b *= cs + + # Stage 5.5: Nonlinear chroma power + h = math.atan2(b, a) + c = math.sqrt(a * a + b * b) + p = chroma_power_h(h) + cn = c ** p if c > 0 else 0 + a = cn * math.cos(h) + b = cn * math.sin(h) + + # Stage 6: L-dependent chroma scaling + t = l_chroma_scale(l) + a *= t + b *= t + + # Stage 6.5: HLC interaction + h = math.atan2(b, a) + hlcs = hlc_scale(h, l) + a *= hlcs + b *= hlcs + + # Stage 8: Hue-dependent lightness scaling + h = math.atan2(b, a) + l *= hue_lightness_scale(h) + + # Stage 11: Rigid rotation `(φ = -28.2°)` + a_rot = a * ROT_COS - b * ROT_SIN + b_rot = a * ROT_SIN + b * ROT_COS + + return [l, a_rot, b_rot] + + +def helmlab_full_to_xyz(lab: Vector) -> Vector: + """Convert XYZ to Helmlab.""" + + l, a, b = lab + + # Undo Stage 11: rotation + aun = a * ROT_COS + b * ROT_SIN + bun = -a * ROT_SIN + b * ROT_COS + a = aun + b = bun + + # Undo Stage 8: hue-dependent lightness + h = math.atan2(b, a) + l /= hue_lightness_scale(h) + + # Undo Stage 6.5: HLC + h = math.atan2(b, a) + hlcs = hlc_scale(h, l) + a /= hlcs + b /= hlcs + + # Undo Stage 6: L-dependent chroma + t = l_chroma_scale(l) + a /= t + b /= t + + # Undo Stage 5.5: chroma power + h = math.atan2(b, a) + c = math.sqrt(a * a + b * b) + p = chroma_power_h(h) + co = c ** (1 / p) if c > 0 else 0 + a = co * math.cos(h) + b = co * math.sin(h) + + # Undo Stage 5: chroma scaling + h = math.atan2(b, a) + cs = chroma_scale_h(h) + a /= cs + b /= cs + + # Undo Stage 4.5: dark L + h = math.atan2(b, a) + l = dark_l_inv(l, h) + + # Undo Stage 4: cubic L + h = math.atan2(b, a) + l = l_correct_inv(l, h) + + # Undo Stage 3.7: H-K + cr = math.sqrt(a * a + b * b) + hkBoost = HK_WEIGHT * pow(cr, alg.clamp(HK_POWER, 0.01, 10)) + hr = math.atan2(b, a) + factor = ( + 1 + HK_HUE_MOD * math.cos(hr) + HK_SIN1 * math.sin(hr) + + HK_COS2 * math.cos(2 * hr) + HK_SIN2 * math.sin(2 * hr) + ) + l -= hkBoost * factor + + # Undo Stage 3.5: hue correction (Newton iteration) + h_out = math.atan2(b, a) + c = math.sqrt(a * a + b * b) + h_raw = h_out + for _ in range(8): + f = h_raw + hue_delta(h_raw) - h_out + fp = 1 + hue_delta_deriv(h_raw) + if abs(fp) < 1e-10: # pragma: no cover + fp = 1 + h_raw -= f / fp + a = c * math.cos(h_raw) + b = c * math.sin(h_raw) + + return helmlab_to_xyz([l, a, b]) + + +class HelmlabMetric(Lab): + """Helmlab class.""" + + BASE = "xyz-d65" + NAME = "helmlab-metric" + SERIALIZE = ("--helmlab-metric",) + CHANNELS = ( + Channel("l", 0.0, 1.6), + Channel("a", -1.5, 1.5, flags=FLG_MIRROR_PERCENT), + Channel("b", -1.5, 1.5, flags=FLG_MIRROR_PERCENT) + ) + WHITE = WHITES['2deg']['ASTM-E308-D65'] + + def to_base(self, coords: Vector) -> Vector: + """To XYZ.""" + + return helmlab_full_to_xyz(coords) + + def from_base(self, coords: Vector) -> Vector: + """From XYZ.""" + + return xyz_d65_to_helmlab_full(coords) diff --git a/docs/src/dictionary/en-custom.txt b/docs/src/dictionary/en-custom.txt index 0bfde2782..c0877b332 100644 --- a/docs/src/dictionary/en-custom.txt +++ b/docs/src/dictionary/en-custom.txt @@ -32,6 +32,7 @@ CMF CMFs CMY CMYK +COMBVD CSS CVD CVDs @@ -44,6 +45,7 @@ Chromaticities Chromaticity Colaboratory ColorAide +ColorBench ColorBrewer ColorHelper Colorimetry @@ -74,12 +76,14 @@ Florian Formatter Fortran GMA +GenSpace Golub Gosset Gossett Grau HCT HDR +HLC HLG HPE HPLuv @@ -94,6 +98,9 @@ HUSL HWB HWBish Hellwig +Helmgen +Helmgenlch +Helmlab Hermann Horner HyAB @@ -147,6 +154,8 @@ MINDE MacAdam Machado Matcher +MetricSpace +Minkowski Miquel Mixbox MkDocs @@ -362,6 +371,7 @@ quantizer rc reflectance reflectances +reparameterization repurpose rgb sCAM diff --git a/docs/src/markdown/.snippets/abbr.md b/docs/src/markdown/.snippets/abbr.md index 246f25026..5f3ecc548 100644 --- a/docs/src/markdown/.snippets/abbr.md +++ b/docs/src/markdown/.snippets/abbr.md @@ -3,6 +3,7 @@ *[CATs]: chromatic adaptation transform *[CCT]: correlated color temperature *[CMFs]: color matching functions +*[COMBVD]: Combined Visual-Difference Dataset *[CVD]: color vision deficiency *[CVDs]: color vision deficiency *[EOTF]: electro-optical transfer function diff --git a/docs/src/markdown/.snippets/links.md b/docs/src/markdown/.snippets/links.md index b16e1b764..15621126a 100644 --- a/docs/src/markdown/.snippets/links.md +++ b/docs/src/markdown/.snippets/links.md @@ -16,6 +16,7 @@ [dehyab]: http://markfairchild.org/PDFs/PAP40.pdf [deitp]: https://kb.portrait.com/help/ictcp-color-difference-metric [dez]: https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-25-13-15131&id=368272 +[dehelmlab]: https://arxiv.org/abs/2602.23010 [extras]: https://github.com/facelessuser/coloraide-extras [filter-effects]: https://www.w3.org/TR/filter-effects-1/ [floating-point]: https://docs.python.org/3/tutorial/floatingpoint.html#floating-point-arithmetic-issues-and-limitations diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md index 713a6f104..0473e6aa8 100644 --- a/docs/src/markdown/about/changelog.md +++ b/docs/src/markdown/about/changelog.md @@ -9,6 +9,8 @@ icon: lucide/scroll-text ## 8.8.1 +- **NEW**: Add new spaces: `helmlab`, `helmgen`, and `helmgenlch`. +- **NEW**: Add new distancing algorithm: `helmlab`. - **ENHANCE**: Minor speed improvements related to chromatic adaptation. - **FIX**: Small regression in RYB round-trip precision due to a global tolerance change. Ensure RYB uses a tolerance specific for its needs. diff --git a/docs/src/markdown/colors/helmgen.md b/docs/src/markdown/colors/helmgen.md new file mode 100644 index 000000000..3cbe9ab75 --- /dev/null +++ b/docs/src/markdown/colors/helmgen.md @@ -0,0 +1,71 @@ +# Helmgen + +> [!failure] The Helmgen color space is not registered in `Color` by default + +/// html | div.info-container +> [!info | inline | end] Properties +> **Name:** `helmgen` +> +> **White Point:** D65 / 2˚ (Variant from ASTM-E308) +> +> **Coordinates:** +> +> Name | Range^\*^ +> ---- | ----- +> `l` | [0, 1.0] +> `a` | [-0.6, 0.6] +> `b` | [-0.6, 0.6] +> +> ^\*^ Space is not bound to the range and is only used as a reference to define percentage inputs/outputs. + +![Helmgen](../images/helmgen-3d.png) +//// figure-caption +The sRGB gamut represented within the Helmgen color space. +//// + +Helmlab is a family of purpose-built color spaces, two to be exact. The first is the Helmlab Metric space which is +designed for perceptual distance measurements, claiming STRESS 23.30 on COMBVD - 20% better than CIEDE2000. The second +is the Helmgen space which is designed for gradient and palette generation (60-8 vs Oklab on ColorBench's 83 metrics, +360/360/360 gamut cusps, zero monotonicity violations). + +Helmgen is the generation-optimized space and is specifically used for interpolation, palettes, etc. It is the general +purpose color space of the Helmlab family. + +[Learn more](https://arxiv.org/abs/2602.23010). +/// + +## Channel Aliases + +Channels | Aliases +-------- | ------- +`l` | `lightness` +`a` | +`b` | + +**Inputs** + +The Helmgen space is not currently supported in the CSS spec, the parsed input and string output formats use the +`#!css-color color()` function format using the custom name `#!css-color --helmgen`: + +```css-color +color(--helmgen l a b / a) // Color function +``` + +The string representation of the color object and the default string output use the +`#!css-color color(--helmgen l a b / a)` form. + +```py play +Color("helmgen", [0.56321, 0.29471, 0.18551]) +Color("helmgen", [0.75771, 0.07633, 0.24769]).to_string() +``` + +## Registering + +```py +from coloraide import Color as Base +from coloraide.spaces.helmgen import Helmgen + +class Color(Base): ... + +Color.register(Helmgen()) +``` diff --git a/docs/src/markdown/colors/helmgenlch.md b/docs/src/markdown/colors/helmgenlch.md new file mode 100644 index 000000000..30704ea18 --- /dev/null +++ b/docs/src/markdown/colors/helmgenlch.md @@ -0,0 +1,71 @@ +# Helmgenlch + +> [!failure] The Helmgenlch color space is not registered in `Color` by default + +/// html | div.info-container +> [!info | inline | end] Properties +> **Name:** `helmgenlch` +> +> **White Point:** D65 / 2˚ (Variant from ASTM-E308) +> +> **Coordinates:** +> +> Name | Range^\*^ +> ---- | ----- +> `l` | [0, 1.0] +> `c` | [0, 0.65] +> `h` | [0, 360] +> +> ^\*^ Space is not bound to the range and is only used as a reference to define percentage inputs/outputs. + +![Helmgenlch](../images/helmgenlch-3d.png) +//// figure-caption +The sRGB gamut represented within the Helmgenlch color space. +//// + +Helmlab is a family of purpose-built color spaces, two to be exact. The first is the Helmlab Metric space which is +designed for perceptual distance measurements, claiming STRESS 23.30 on COMBVD - 20% better than CIEDE2000. The second +is the Helmgen space which is designed for gradient and palette generation (60-8 vs Oklab on ColorBench's 83 metrics, +360/360/360 gamut cusps, zero monotonicity violations). + +Helmgenlch is the polar form of Helmgen and is the generation-optimized space and is specifically used for +interpolation, palettes, etc. It is the general purpose color space of the Helmlab family. + +[Learn more](https://arxiv.org/abs/2602.23010). +/// + +## Channel Aliases + +Channels | Aliases +-------- | ------- +`l` | `lightness` +`c` | `chroma` +`hue` | `hue` + +**Inputs** + +The Helmlab space is not currently supported in the CSS spec, the parsed input and string output formats use the +`#!css-color color()` function format using the custom name `#!css-color --helmgenlch`: + +```css-color +color(--helmgenlch l a b / a) // Color function +``` + +The string representation of the color object and the default string output use the +`#!css-color color(--helmgenlch l a b / a)` form. + +```py play +Color("helmgenlch", [0.56321, 0.34823, 32.189]) +Color("helmgenlch", [0.75752, 0.26718, 72.88]).to_string() +``` + +## Registering + +```py +from coloraide import Color as Base +from coloraide.spaces.helmgenlch import Helmgenlch + +class Color(Base): ... + +Color.register(Helmgenlch()) +``` diff --git a/docs/src/markdown/colors/helmlab_metric.md b/docs/src/markdown/colors/helmlab_metric.md new file mode 100644 index 000000000..385e296f3 --- /dev/null +++ b/docs/src/markdown/colors/helmlab_metric.md @@ -0,0 +1,71 @@ +# Helmlab Metric + +> [!failure] The Helmlab Metric color space is not registered in `Color` by default + +/// html | div.info-container +> [!info | inline | end] Properties +> **Name:** `helmlab-metric` +> +> **White Point:** D65 / 2˚ (Variant from ASTM-E308) +> +> **Coordinates:** +> +> Name | Range^\*^ +> ---- | ----- +> `l` | [0, ~1.6] +> `a` | [-1.5, 1.5] +> `b` | [-1.5, 1.5] +> +> ^\*^ Space is not bound to the range and is only used as a reference to define percentage inputs/outputs. + +![Helmlab Metric](../images/helmlab-metric-3d.png) +//// figure-caption +The sRGB gamut represented within the Helmlab Metric color space. +//// + +Helmlab is a family of purpose-built color spaces, two to be exact. The first is the Helmlab Metric space which is +designed for perceptual distance measurements, claiming STRESS 23.30 on COMBVD - 20% better than CIEDE2000. The second +is the Helmgen space which is designed for gradient and palette generation (60-8 vs Oklab on ColorBench's 83 metrics, +360/360/360 gamut cusps, zero monotonicity violations). + +Helmlab Metric is the metric space and is specifically used for [color distancing](../distance.md#delta-e-helmlab) and +is not meant to be used for interpolation and palettes, and least not directly. + +[Learn more](https://arxiv.org/abs/2602.23010). +/// + +## Channel Aliases + +Channels | Aliases +-------- | ------- +`l` | `lightness` +`a` | +`b` | + +**Inputs** + +The Helmlab Metric space is not currently supported in the CSS spec, the parsed input and string output formats use the +`#!css-color color()` function format using the custom name `#!css-color --helmlab-metric`: + +```css-color +color(--helmlab-metric l a b / a) // Color function +``` + +The string representation of the color object and the default string output use the +`#!css-color color(--helmlab-metric l a b / a)` form. + +```py play +Color("helmlab-metric", [0.9207, 0.94084, -0.2063]) +Color("helmlab-metric", [1.0056, 0.80476, 0.71403]).to_string() +``` + +## Registering + +```py +from coloraide import Color as Base +from coloraide.spaces.helmlab_metric import HelmlabMetric + +class Color(Base): ... + +Color.register(HelmlabMetric()) +``` diff --git a/docs/src/markdown/colors/index.md b/docs/src/markdown/colors/index.md index 65ce4b9df..d874017bb 100644 --- a/docs/src/markdown/colors/index.md +++ b/docs/src/markdown/colors/index.md @@ -103,6 +103,10 @@ flowchart LR xyz-d65 --- hct + xyz-d65 --- helmlab-metric + + xyz-d65 --- helmgen --- helmgenlch + xyz-d65 --- sucs xyz-d65 --- scam-jmh @@ -148,6 +152,9 @@ flowchart LR display-p3-linear(Linear Display P3) hct(HCT) hellwig-jmh(Hellwig JMh) + helmgen(Helmgen) + helmgenlch(Helmgenlch) + helmlab-metric(Helmlab Metric) hpluv(HPLuv) hsi(HSI) hsl(HSL) @@ -221,6 +228,9 @@ flowchart LR click display-p3-linear "./display_p3_linear/" _self click hct "./hct/" _self click hellwig-jmh "./hellwig/" _self + click helmgen "./helmgen/" _self + click helmgenlch "./helmgenlch/" _self + click helmlab-metric "./helmlab_metric/" _self click hpluv "./hpluv/" _self click hsi "./hsi/" _self click hsl "./hsl/" _self @@ -302,6 +312,9 @@ Color Space | ID [Display P3](./display_p3.md) | `display-p3` [HCT](./hct.md) | `hct` [Hellwig JMh](./hellwig.md) | `hellwig-jmh` +[Helmgen](./helmgen.md) | `helmgen` +[Helmgenlch](./helmgenlch.md) | `helmgenlch` +[Helmlab](./helmlab_metric.md) | `helmlab-metric` [HPLuv](./hpluv.md) | `hpluv` [HSI](./hsi.md) | `hsi` [HSL](./hsl.md) | `hsl` diff --git a/docs/src/markdown/distance.md b/docs/src/markdown/distance.md index d67cb76a7..f99287073 100644 --- a/docs/src/markdown/distance.md +++ b/docs/src/markdown/distance.md @@ -203,14 +203,26 @@ Delta\ E | Symmetrical | Name Both the DIN99o color space and the ∆E algorithm must be registered to use. +### Delta E Helmlab + +> [!failure] The ∆E~helmlab~ distancing algorithm is **not** registered in `Color` by default + +Delta\ E | Symmetrical | Name | Parameters +---------------------------------------- | --------------------- | --------------- | -------------------- +[∆E~helmlab~][dehelmlab]\ (Helmlab) | :octicons-check-16: | `helmlab` | + +∆E~helmlab~ uses a special algorithm to compute distance in the [Helmlab](./colors/helmlab.md) color space. + +Both the Helmlab color space and the ∆E algorithm must be registered to use. + ```py from coloraide import Color as Base -from coloraide.distance.delta_e_99o import DE99o -from coloraide.spaces.din99o import DIN99o +from coloraide.distance.delta_e_helmlab import DEHelmlab +from coloraide.spaces.helmlab import Helmlab class Color(Base): ... -Color.register([DIN99o(), DE99o()]) +Color.register([Helmlab(), DEHelmlab()]) ``` ### Delta E CAM16 diff --git a/docs/src/markdown/images/helmgen-3d.png b/docs/src/markdown/images/helmgen-3d.png new file mode 100644 index 000000000..dc91f5164 Binary files /dev/null and b/docs/src/markdown/images/helmgen-3d.png differ diff --git a/docs/src/markdown/images/helmgenlch-3d.png b/docs/src/markdown/images/helmgenlch-3d.png new file mode 100644 index 000000000..3070e538d Binary files /dev/null and b/docs/src/markdown/images/helmgenlch-3d.png differ diff --git a/docs/src/markdown/images/helmlab-metric-3d.png b/docs/src/markdown/images/helmlab-metric-3d.png new file mode 100644 index 000000000..8ded9dd8f Binary files /dev/null and b/docs/src/markdown/images/helmlab-metric-3d.png differ diff --git a/docs/src/zensical.yml b/docs/src/zensical.yml index 0fd03aeb8..cba30d299 100644 --- a/docs/src/zensical.yml +++ b/docs/src/zensical.yml @@ -131,6 +131,8 @@ nav: - CAM16 SCD: colors/cam16_scd.md - CAM16 LCD: colors/cam16_lcd.md - XYB: colors/xyb.md + - Helmgen: colors/helmgen.md + - Helmlab Metric: colors/helmlab-metric.md - LCh Like Spaces: - LCh D50: colors/lch.md @@ -148,6 +150,7 @@ nav: - Msh: colors/msh.md - sUCS: colors/sucs.md - sCAM: colors/scam.md + - Helmgenlch: colors/helmgenlch.md - ACES Spaces: - ACES 2065-1: colors/aces2065_1.md diff --git a/pyproject.toml b/pyproject.toml index 04cdf16a7..1d5cb18e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,6 +124,7 @@ lint.ignore = [ "N818", "PGH004", "RUF002", + "RUF003", "RUF005", "RUF012", "RUF022", diff --git a/tests/test_distance.py b/tests/test_distance.py index e90e7640d..fe3bc02c7 100644 --- a/tests/test_distance.py +++ b/tests/test_distance.py @@ -647,6 +647,42 @@ def test_delta_e_hct(self, color1, color2, value): rounding=4 ) + @pytest.mark.parametrize( + 'color1,color2,value', + [ + ('red', 'red', 0), + ('red', 'orange', 0.3429), + ('red', 'yellow', 0.3841), + ('red', 'green', 0.3872), + ('red', 'blue', 0.361), + ('red', 'indigo', 0.333), + ('red', 'violet', 0.3252), + ('red', 'white', 0.3464), + ('red', 'black', 0.3852), + ('red', 'gray', 0.3266), + ('red', 'red', 0), + ('orange', 'red', 0.3429), + ('yellow', 'red', 0.3841), + ('green', 'red', 0.3872), + ('blue', 'red', 0.361), + ('indigo', 'red', 0.333), + ('violet', 'red', 0.3252), + ('white', 'red', 0.3464), + ('black', 'red', 0.3852), + ('gray', 'red', 0.3266) + ] + ) + def test_delta_e_helmlab(self, color1, color2, value): + """Test delta e Helmlab.""" + + print('color1: ', color1) + print('color2: ', color2) + self.assertCompare( + Color(color1).delta_e(color2, method="helmlab"), + value, + rounding=4 + ) + @pytest.mark.parametrize( 'color1,color2,value', [ @@ -672,6 +708,7 @@ def test_delta_e_hct(self, color1, color2, value): ('gray', 'red', 47.5086) ] ) + def test_delta_e_cam16(self, color1, color2, value): """Test delta e CAM16 UCS.""" @@ -962,4 +999,3 @@ def test_bad_de_cam02_space(self): with self.assertRaises(ValueError): Color('red').delta_e('blue', method='cam02', space='lab') - diff --git a/tests/test_helmgen.py b/tests/test_helmgen.py new file mode 100644 index 000000000..dbf3c6dc2 --- /dev/null +++ b/tests/test_helmgen.py @@ -0,0 +1,122 @@ +"""Test Helmgen.""" +import unittest +from . import util +from coloraide.everything import ColorAll as Color, NaN +import pytest + + +class TestHelmgen(util.ColorAssertsPyTest): + """Test Helmgen.""" + + COLORS = [ + ('red', 'color(--helmgen 0.56321 0.29471 0.18551)'), + ('orange', 'color(--helmgen 0.75752 0.07865 0.25534)'), + ('yellow', 'color(--helmgen 0.96484 -0.08492 0.32224)'), + ('green', 'color(--helmgen 0.44058 -0.1813 0.18815)'), + ('blue', 'color(--helmgen 0.36491 -0.04189 -0.49352)'), + ('indigo', 'color(--helmgen 0.2367 0.1225 -0.25893)'), + ('violet', 'color(--helmgen 0.7211 0.2033 -0.16973)'), + ('white', 'color(--helmgen 1 0 0)'), + ('gray', 'color(--helmgen 0.53135 0 0)'), + ('black', 'color(--helmgen 0 0 0)'), + ('color(srgb 1.01 1.01 1.01)', 'color(--helmgen 1.0077 0 0)'), + ('color(srgb 1e-3 1e-3 1e-3)', 'color(--helmgen 0.0007 0 0)'), + # Test color + ('color(--helmgen 0.5 0.1 -0.1)', 'color(--helmgen 0.5 0.1 -0.1)'), + ('color(--helmgen 0.5 0.1 -0.1 / 0.5)', 'color(--helmgen 0.5 0.1 -0.1 / 0.5)'), + ('color(--helmgen 50% 50% -50% / 50%)', 'color(--helmgen 0.5 0.3 -0.3 / 0.5)'), + ('color(--helmgen none none none / none)', 'color(--helmgen none none none / none)'), + # Test percent ranges + ('color(--helmgen 0% 0% 0%)', 'color(--helmgen 0 0 0)'), + ('color(--helmgen 100% 100% 100%)', 'color(--helmgen 1 0.6 0.6)'), + ('color(--helmgen -100% -100% -100%)', 'color(--helmgen -1 -0.6 -0.6)') + ] + + @pytest.mark.parametrize('color1,color2', COLORS) + def test_colors(self, color1, color2): + """Test colors.""" + + self.assertColorEqual(Color(color1).convert('helmgen'), Color(color2)) + + +class TestHelmgenSerialize(util.ColorAssertsPyTest): + """Test Helmgen serialization.""" + + COLORS = [ + # Test color + ('color(--helmgen 0.1 0.75 -0.1 / 0.5)', {}, 'color(--helmgen 0.1 0.75 -0.1 / 0.5)'), + # Test alpha + ('color(--helmgen 0.1 0.75 -0.1)', {'alpha': True}, 'color(--helmgen 0.1 0.75 -0.1 / 1)'), + ('color(--helmgen 0.1 0.75 -0.1 / 0.5)', {'alpha': False}, 'color(--helmgen 0.1 0.75 -0.1)'), + # Test None + ('color(--helmgen none 0.75 -0.1)', {}, 'color(--helmgen 0 0.75 -0.1)'), + ('color(--helmgen none 0.75 -0.1)', {'none': True}, 'color(--helmgen none 0.75 -0.1)'), + # Test Fit (not bound) + ('color(--helmgen 1.2 0.75 -0.1)', {}, 'color(--helmgen 1.2 0.75 -0.1)'), + ('color(--helmgen 1.2 0.75 -0.1)', {'fit': False}, 'color(--helmgen 1.2 0.75 -0.1)') + ] + + @pytest.mark.parametrize('color1,options,color2', COLORS) + def test_colors(self, color1, options, color2): + """Test colors.""" + + self.assertEqual(Color(color1).to_string(**options), color2) + + +class TestHelmgenPoperties(util.ColorAsserts, unittest.TestCase): + """Test Helmgen.""" + + def test_l(self): + """Test `l`.""" + + c = Color('color(--helmgen -0.02 0.7 0.04)') + self.assertEqual(c['l'], -0.02) + c['l'] = 0.1 + self.assertEqual(c['l'], 0.1) + + def test_a(self): + """Test `a`.""" + + c = Color('color(--helmgen -0.02 0.7 0.04)') + self.assertEqual(c['a'], 0.7) + c['a'] = 0.2 + self.assertEqual(c['a'], 0.2) + + def test_b(self): + """Test `b`.""" + + c = Color('color(--helmgen -0.02 0.7 0.04)') + self.assertEqual(c['b'], 0.04) + c['b'] = 0.1 + self.assertEqual(c['b'], 0.1) + + def test_alpha(self): + """Test `alpha`.""" + + c = Color('color(--helmgen -0.02 0.7 0.04)') + self.assertEqual(c['alpha'], 1) + c['alpha'] = 0.5 + self.assertEqual(c['alpha'], 0.5) + + def test_labish_names(self): + """Test `labish_names`.""" + + c = Color('color(--helmgen -0.02 0.7 0.03 / 1)') + self.assertEqual(c._space.names(), ('l', 'a', 'b')) + + +class TestsAchromatic(util.ColorAsserts, unittest.TestCase): + """Test achromatic.""" + + def test_achromatic(self): + """Test when color is achromatic.""" + + self.assertEqual(Color('helmgen', [0.3, 0, 0]).is_achromatic(), True) + self.assertEqual(Color('helmgen', [0.3, 0.0000001, 0]).is_achromatic(), True) + self.assertEqual(Color('helmgen', [NaN, 0.0000001, 0]).is_achromatic(), True) + self.assertEqual(Color('helmgen', [0, NaN, NaN]).is_achromatic(), True) + self.assertEqual(Color('helmgen', [0, NaN, NaN]).is_achromatic(), True) + self.assertEqual(Color('helmgen', [0, 0.1, -0.2]).is_achromatic(), False) + self.assertEqual(Color('helmgen', [NaN, 0, -0.1]).is_achromatic(), False) + self.assertEqual(Color('helmgen', [0.3, NaN, 0]).is_achromatic(), True) + self.assertEqual(Color('helmgen', [NaN, NaN, 0]).is_achromatic(), True) diff --git a/tests/test_helmgenlch.py b/tests/test_helmgenlch.py new file mode 100644 index 000000000..213558cb3 --- /dev/null +++ b/tests/test_helmgenlch.py @@ -0,0 +1,164 @@ +"""Test Helmgenlch library.""" +import unittest +from . import util +from coloraide.everything import ColorAll as Color +from coloraide import NaN +import pytest + + +class TestHelmgenlch(util.ColorAssertsPyTest): + """Test Helmgenlch.""" + + COLORS = [ + ('red', 'color(--helmgenlch 0.56321 0.34823 32.189)'), + ('orange', 'color(--helmgenlch 0.75752 0.26718 72.88)'), + ('yellow', 'color(--helmgenlch 0.96484 0.33324 104.76)'), + ('green', 'color(--helmgenlch 0.44058 0.26129 133.94)'), + ('blue', 'color(--helmgenlch 0.36491 0.49529 265.15)'), + ('indigo', 'color(--helmgenlch 0.2367 0.28644 295.32)'), + ('violet', 'color(--helmgenlch 0.7211 0.26484 320.14)'), + ('white', 'color(--helmgenlch 1 0 0)'), + ('gray', 'color(--helmgenlch 0.53135 0 0)'), + ('black', 'color(--helmgenlch 0 0 none)'), + # Test color + ('color(--helmgenlch 1.0 0.5 270)', 'color(--helmgenlch 1 0.5 270)'), + ('color(--helmgenlch 1.0 0.5 270 / 0.5)', 'color(--helmgenlch 1 0.5 270 / 0.5)'), + ('color(--helmgenlch 50% 50% 180 / 50%)', 'color(--helmgenlch 0.5 0.325 180 / 0.5)'), + ('color(--helmgenlch none none none / none)', 'color(--helmgenlch none none none / none)'), + # Test percent ranges + ('color(--helmgenlch 0% 0% 0)', 'color(--helmgenlch 0 0 none)'), + ('color(--helmgenlch 100% 100% 360)', 'color(--helmgenlch 1 0.65 360)'), + ('color(--helmgenlch -100% -100% -360)', 'color(--helmgenlch -1 -0.65 -360)') + ] + + @pytest.mark.parametrize('color1,color2', COLORS) + def test_colors(self, color1, color2): + """Test colors.""" + + self.assertColorEqual(Color(color1).convert('helmgenlch'), Color(color2)) + + +class TestHelmgenlchSerialize(util.ColorAssertsPyTest): + """Test Helmgenlch serialization.""" + + COLORS = [ + # Test color + ('color(--helmgenlch 0.75 0.5 50 / 0.5)', {}, 'color(--helmgenlch 0.75 0.5 50 / 0.5)'), + # Test alpha + ('color(--helmgenlch 0.75 0.5 50)', {'alpha': True}, 'color(--helmgenlch 0.75 0.5 50 / 1)'), + ('color(--helmgenlch 0.75 0.5 50 / 0.5)', {'alpha': False}, 'color(--helmgenlch 0.75 0.5 50)'), + # Test None + ('color(--helmgenlch none 0.5 50)', {}, 'color(--helmgenlch 0 0.5 50)'), + ('color(--helmgenlch none 0.5 50)', {'none': True}, 'color(--helmgenlch none 0.5 50)'), + # Test Fit (not bound) + ('color(--helmgenlch 0.75 0.50 50)', {}, 'color(--helmgenlch 0.75 0.5 50)'), + ('color(--helmgenlch 0.75 0.50 50)', {'fit': False}, 'color(--helmgenlch 0.75 0.5 50)') + ] + + @pytest.mark.parametrize('color1,options,color2', COLORS) + def test_colors(self, color1, options, color2): + """Test colors.""" + + self.assertEqual(Color(color1).to_string(**options), color2) + + +class TestHelmgenlchProperties(util.ColorAsserts, unittest.TestCase): + """Test Helmgenlch.""" + + def test_lightness(self): + """Test `lightness`.""" + + c = Color('color(--helmgenlch 90% 0.5 120 / 1)') + self.assertEqual(c['lightness'], 0.9) + c['lightness'] = 0.3 + self.assertEqual(c['lightness'], 0.3) + + def test_chroma(self): + """Test `chroma`.""" + + c = Color('color(--helmgenlch 90% 0.5 120 / 1)') + self.assertEqual(c['chroma'], 0.5) + c['chroma'] = 0.2 + self.assertEqual(c['chroma'], 0.2) + + def test_hue(self): + """Test `hue`.""" + + c = Color('color(--helmgenlch 90% 0.5 120 / 1)') + self.assertEqual(c['hue'], 120) + c['hue'] = 110 + self.assertEqual(c['hue'], 110) + + def test_alpha(self): + """Test `alpha`.""" + + c = Color('color(--helmgenlch 90% 0.5 120 / 1)') + self.assertEqual(c['alpha'], 1) + c['alpha'] = 0.5 + self.assertEqual(c['alpha'], 0.5) + + +class TestNull(util.ColorAsserts, unittest.TestCase): + """Test Null cases.""" + + def test_null_input(self): + """Test null input.""" + + c = Color('helmgenlch', [0.75, 0.2, NaN], 1) + self.assertTrue(c.is_nan('hue')) + + def test_none_input(self): + """Test `none` null.""" + + c = Color('color(--helmgenlch 0.75 0 none / 1)') + self.assertTrue(c.is_nan('hue')) + + def test_near_zero_null(self): + """ + Test very near zero null. + + This is a fix up to help give more sane hues + when chroma is very close to zero. + """ + + c = Color('color(--helmgenlch 0.75 0.000000000009 120 / 1)').convert('helmgen').convert('helmgenlch') + self.assertTrue(c.is_nan('hue')) + + def test_from_helmgen(self): + """Test null from Helmgen conversion.""" + + c1 = Color('color(--helmgen 90% 0 0)') + c2 = c1.convert('helmgenlch') + self.assertColorEqual(c2, Color('color(--helmgenlch 90% 0 0)')) + self.assertTrue(c2.is_nan('hue')) + + def test_null_normalization_min_chroma(self): + """Test minimum saturation.""" + + c = Color('color(--helmgenlch 90% 0 120 / 1)').normalize() + self.assertTrue(c.is_nan('hue')) + + def test_achromatic_hue(self): + """Test that all RGB-ish colors convert to Helmgenlch with a null hue.""" + + for space in ('srgb', 'display-p3', 'rec2020', 'a98-rgb', 'prophoto-rgb'): + for x in range(0, 256): + color = Color('color({space} {num:f} {num:f} {num:f})'.format(space=space, num=x / 255)) + color2 = color.convert('helmgenlch') + self.assertTrue(color2.is_nan('hue')) + + +class TestsAchromatic(util.ColorAsserts, unittest.TestCase): + """Test achromatic.""" + + def test_achromatic(self): + """Test when color is achromatic.""" + + self.assertEqual(Color('helmgenlch', [0.3, 0, 270]).is_achromatic(), True) + self.assertEqual(Color('helmgenlch', [0.3, 0.0000001, 270]).is_achromatic(), True) + self.assertEqual(Color('helmgenlch', [NaN, 0.0000001, 270]).is_achromatic(), True) + self.assertEqual(Color('helmgenlch', [0, NaN, 270]).is_achromatic(), True) + self.assertEqual(Color('helmgenlch', [0, 0.4, 270]).is_achromatic(), False) + self.assertEqual(Color('helmgenlch', [NaN, 0.1, 270]).is_achromatic(), False) + self.assertEqual(Color('helmgenlch', [0.3, NaN, 270]).is_achromatic(), True) + self.assertEqual(Color('helmgenlch', [NaN, NaN, 270]).is_achromatic(), True) diff --git a/tests/test_helmlab_metric.py b/tests/test_helmlab_metric.py new file mode 100644 index 000000000..af15e26f3 --- /dev/null +++ b/tests/test_helmlab_metric.py @@ -0,0 +1,122 @@ +"""Test Helmlab.""" +import unittest +from . import util +from coloraide.everything import ColorAll as Color, NaN +import pytest + + +class TestHelmlaMetric(util.ColorAssertsPyTest): + """Test Helmlab.""" + + COLORS = [ + ('red', 'color(--helmlab-metric 0.9207 0.94084 -0.2063)'), + ('orange', 'color(--helmlab-metric 1.0056 0.80476 0.71403)'), + ('yellow', 'color(--helmlab-metric 0.89977 0.17785 0.87596)'), + ('green', 'color(--helmlab-metric 0.37812 -0.04603 0.55253)'), + ('blue', 'color(--helmlab-metric 0.76231 -0.12541 -0.20263)'), + ('indigo', 'color(--helmlab-metric 0.58174 0.15615 -0.23201)'), + ('violet', 'color(--helmlab-metric 1.0991 0.16562 -0.1134)'), + ('white', 'color(--helmlab-metric 1.1211 0.10164 0.20041)'), + ('gray', 'color(--helmlab-metric 0.77788 0.23579 0.16737)'), + ('black', 'color(--helmlab-metric 0 0 0)'), + ('color(srgb 1.5 1.5 1.5)', 'color(--helmlab-metric 1.5448 0.01225 0.0711)'), + ('color(srgb 1e-3 1e-3 1e-3)', 'color(--helmlab-metric 0.02104 0.01541 -0.00171)'), + # Test color + ('color(--helmlab-metric 0.5 0.1 -0.1)', 'color(--helmlab-metric 0.5 0.1 -0.1)'), + ('color(--helmlab-metric 0.5 0.1 -0.1 / 0.5)', 'color(--helmlab-metric 0.5 0.1 -0.1 / 0.5)'), + ('color(--helmlab-metric 50% 50% -50% / 50%)', 'color(--helmlab-metric 0.8 0.75 -0.75 / 0.5)'), + ('color(--helmlab-metric none none none / none)', 'color(--helmlab-metric none none none / none)'), + # Test percent ranges + ('color(--helmlab-metric 0% 0% 0%)', 'color(--helmlab-metric 0 0 0)'), + ('color(--helmlab-metric 100% 100% 100%)', 'color(--helmlab-metric 1.6 1.5 1.5)'), + ('color(--helmlab-metric -100% -100% -100%)', 'color(--helmlab-metric -1.6 -1.5 -1.5)') + ] + + @pytest.mark.parametrize('color1,color2', COLORS) + def test_colors(self, color1, color2): + """Test colors.""" + + self.assertColorEqual(Color(color1).convert('helmlab-metric'), Color(color2)) + + +class TestHelmlabMetricSerialize(util.ColorAssertsPyTest): + """Test Helmlab serialization.""" + + COLORS = [ + # Test color + ('color(--helmlab-metric 0.1 0.75 -0.1 / 0.5)', {}, 'color(--helmlab-metric 0.1 0.75 -0.1 / 0.5)'), + # Test alpha + ('color(--helmlab-metric 0.1 0.75 -0.1)', {'alpha': True}, 'color(--helmlab-metric 0.1 0.75 -0.1 / 1)'), + ('color(--helmlab-metric 0.1 0.75 -0.1 / 0.5)', {'alpha': False}, 'color(--helmlab-metric 0.1 0.75 -0.1)'), + # Test None + ('color(--helmlab-metric none 0.75 -0.1)', {}, 'color(--helmlab-metric 0 0.75 -0.1)'), + ('color(--helmlab-metric none 0.75 -0.1)', {'none': True}, 'color(--helmlab-metric none 0.75 -0.1)'), + # Test Fit (not bound) + ('color(--helmlab-metric 1.2 0.75 -0.1)', {}, 'color(--helmlab-metric 1.2 0.75 -0.1)'), + ('color(--helmlab-metric 1.2 0.75 -0.1)', {'fit': False}, 'color(--helmlab-metric 1.2 0.75 -0.1)') + ] + + @pytest.mark.parametrize('color1,options,color2', COLORS) + def test_colors(self, color1, options, color2): + """Test colors.""" + + self.assertEqual(Color(color1).to_string(**options), color2) + + +class TestHelmlabMetricPoperties(util.ColorAsserts, unittest.TestCase): + """Test Helmlab.""" + + def test_l(self): + """Test `l`.""" + + c = Color('color(--helmlab-metric -0.02 0.7 0.04)') + self.assertEqual(c['l'], -0.02) + c['l'] = 0.1 + self.assertEqual(c['l'], 0.1) + + def test_a(self): + """Test `a`.""" + + c = Color('color(--helmlab-metric -0.02 0.7 0.04)') + self.assertEqual(c['a'], 0.7) + c['a'] = 0.2 + self.assertEqual(c['a'], 0.2) + + def test_b(self): + """Test `b`.""" + + c = Color('color(--helmlab-metric -0.02 0.7 0.04)') + self.assertEqual(c['b'], 0.04) + c['b'] = 0.1 + self.assertEqual(c['b'], 0.1) + + def test_alpha(self): + """Test `alpha`.""" + + c = Color('color(--helmlab-metric -0.02 0.7 0.04)') + self.assertEqual(c['alpha'], 1) + c['alpha'] = 0.5 + self.assertEqual(c['alpha'], 0.5) + + def test_labish_names(self): + """Test `labish_names`.""" + + c = Color('color(--helmlab-metric -0.02 0.7 0.03 / 1)') + self.assertEqual(c._space.names(), ('l', 'a', 'b')) + + +class TestsAchromatic(util.ColorAsserts, unittest.TestCase): + """Test achromatic.""" + + def test_achromatic(self): + """Test when color is achromatic.""" + + self.assertEqual(Color('helmlab-metric', [0.3, 0, 0]).is_achromatic(), True) + self.assertEqual(Color('helmlab-metric', [0.3, 0.0000001, 0]).is_achromatic(), True) + self.assertEqual(Color('helmlab-metric', [NaN, 0.0000001, 0]).is_achromatic(), True) + self.assertEqual(Color('helmlab-metric', [0, NaN, NaN]).is_achromatic(), True) + self.assertEqual(Color('helmlab-metric', [0, NaN, NaN]).is_achromatic(), True) + self.assertEqual(Color('helmlab-metric', [0, 0.1, -0.2]).is_achromatic(), False) + self.assertEqual(Color('helmlab-metric', [NaN, 0, -0.1]).is_achromatic(), False) + self.assertEqual(Color('helmlab-metric', [0.3, NaN, 0]).is_achromatic(), True) + self.assertEqual(Color('helmlab-metric', [NaN, NaN, 0]).is_achromatic(), True) diff --git a/tools/gen_3d_models.py b/tools/gen_3d_models.py index 79df3c017..1960aa244 100644 --- a/tools/gen_3d_models.py +++ b/tools/gen_3d_models.py @@ -49,6 +49,10 @@ def plot_model(name, title, filename, gamut='srgb', space=None, elev=45, azim=-6 'hct': {'title': TEMPLATE.format('HCT'), 'filename': 'hct-3d.png'}, 'hellwig-hk-jmh': {'title': TEMPLATE.format('Hellwig H-K JMh'), 'filename': 'hellwig-hk-jmh-3d.png'}, 'hellwig-jmh': {'title': TEMPLATE.format('Hellwig JMh'), 'filename': 'hellwig-jmh-3d.png'}, + 'helmgen': {'title': TEMPLATE.format('Helmgen'), 'filename': 'helmgen-3d.png'}, + 'helmgenlch': {'title': TEMPLATE.format('Helmgenlch'), 'filename': 'helmgenlch-3d.png'}, + 'helmlab-metric': {'title': TEMPLATE.format('Helmlab Metric'), 'filename': 'helmlab-metric-3d.png'}, + 'helmlch': {'title': TEMPLATE.format('Helmlch'), 'filename': 'helmlch-3d.png'}, 'hpluv': {'title': 'HPLuv Color Space', 'filename': 'hpluv-3d.png', 'gamut': 'hpluv'}, 'hsi': {'title': 'HSI Color Space', 'filename': 'hsi-3d.png'}, 'hsl': {'title': 'HSL Color Space', 'filename': 'hsl-3d.png'}, diff --git a/zensical.yml b/zensical.yml index cf9403f25..7db4cb46d 100644 --- a/zensical.yml +++ b/zensical.yml @@ -131,6 +131,8 @@ nav: - CAM16 SCD: colors/cam16_scd.md - CAM16 LCD: colors/cam16_lcd.md - XYB: colors/xyb.md + - Helmgen: colors/helmgen.md + - Helmlab Metric: colors/helmlab-metric.md - LCh Like Spaces: - LCh D50: colors/lch.md @@ -148,6 +150,7 @@ nav: - Msh: colors/msh.md - sUCS: colors/sucs.md - sCAM: colors/scam.md + - Helmgenlch: colors/helmgenlch.md - ACES Spaces: - ACES 2065-1: colors/aces2065_1.md