diff --git a/coloraide/algebra.py b/coloraide/algebra.py index 803a4de41..cc3bf65aa 100644 --- a/coloraide/algebra.py +++ b/coloraide/algebra.py @@ -3771,7 +3771,7 @@ def reshape(array: ArrayLike | float, new_shape: int | Shape) -> float | Array: m = [] # type: Array with ArrayBuilder(m, new_shape) as build: # Create an iterator to traverse the data - for data in flatiter(array) if len(current_shape) > 1 else iter(array): # type: ignore[arg-type] + for data in flatiter(array) if len(current_shape) > 1 else iter(array): next(build).append(data) return m diff --git a/coloraide/channels.py b/coloraide/channels.py index df56c0a22..c8c5a99b4 100644 --- a/coloraide/channels.py +++ b/coloraide/channels.py @@ -66,7 +66,7 @@ def __new__( limit = float # If a tuple of min/max is provided, create a function to clamp to the range elif isinstance(limit, tuple): - limit = lambda x, l=limit: float(alg.clamp(x, l[0], l[1])) # type: ignore[misc] + limit = lambda x, l=limit: float(alg.clamp(x, l[0], l[1])) obj.limit = limit obj.nans = nans diff --git a/coloraide/distance/delta_e_helmlab.py b/coloraide/distance/delta_e_helmlab.py new file mode 100644 index 000000000..1a61e309b --- /dev/null +++ b/coloraide/distance/delta_e_helmlab.py @@ -0,0 +1,56 @@ +""" +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.""" + + l1, a1, b1 = ( + color.convert('helmlab') if color.space() != 'helmlab' else color.clone().normalize(nans=False) + )[:-1] + l2, a2, b2 = ( + sample.convert('helmlab') if color.space() != 'helmlab' 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..fed0a7696 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 import Helmlab 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(), + Helmlab(), + 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..deb9f34f7 --- /dev/null +++ b/coloraide/spaces/helmgen.py @@ -0,0 +1,265 @@ +""" +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.02 +S = math.sqrt(ALPHA / 3) +S3 = S ** 3 + +# L-gated hue enrichment parameters +ENR_AMP = 0.055 +ENR_CENTER = 264.5 * math.pi / 180 # radians +ENR_SIGMA = 0.7 +ENR_LLO = 0.37 +ENR_LHI = 1.0 + + +M1 = [ + [0.8154374735648701, 0.3603221491264266, -0.12432703417946676], + [0.03298391207546648, 0.9292940788255503, 0.03614494665290377], + [0.048184113668356454, 0.26427748135788043, 0.6336388271114471] +] + +M1_INV = [ + [1.2326502723725545, -0.555741473217925, 0.2735612008453706], + [-0.04076658787624679, 1.1122097325799585, -0.07144314470371178], + [-0.07673215022426162, -0.4216188546558441, 1.5871810048801054] +] + +M2 = [ + [0.21186668013760682, 0.7989440040850104, -0.004099375589489282], + [2.4672018828033475, -2.9877348024830788, 0.520532919679731], + [-0.11390787868068575, 1.3932982808117473, -1.279390402131062] +] + +M2_INV = [ + [0.9933334327571627, 0.32599327253052285, 0.12945085631713896], + [0.9933334327571626, -0.08708353111074632, -0.03861361743004954], + [0.9933334327571621, -0.12386097008215027, -0.8351991365871065] +] + +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.05525966165868908, + 0.10574901531227408, 0.16055853320726027, 0.2140596489299375, 0.2678623050881122, + 0.3220435246104499, 0.3739052098520243, 0.43020997780918835, 0.4835465162128873, + 0.5399824670411352, 0.5956710081330342, 0.6542161666450477, 0.7115380216519989, + 0.7702762412711669, 0.8293313467712836, 0.889406386197059, 0.9462829573474727, + 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 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: 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.4, 0.4, flags=FLG_MIRROR_PERCENT), + Channel("b", -0.4, 0.4, flags=FLG_MIRROR_PERCENT) + ) + WHITE = WHITES['2deg']['ASTM-E308-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..1e7830fc2 --- /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.4), + 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.py b/coloraide/spaces/helmlab.py new file mode 100644 index 000000000..2ec556007 --- /dev/null +++ b/coloraide/spaces/helmlab.py @@ -0,0 +1,719 @@ +""" +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, Matrix +import math + +M1 = [ + [0.81840289330183746, 0.08005102323815776, -0.10505896761953251], + [-0.35330027413303422, 1.23447787661602915, 0.06850908620354954], + [0.20414114797390431, 0.08792315399532628, 1.02364268432262162] +] +M1_INV = [ + [1.1547881089435612, -0.08372379844799464, 0.12412210766119304], + [0.34491820613561897, 0.7889318231486007, -0.0174008449469931], + [-0.2599208404017181, -0.05106655149417681, 0.9536447850150028] +] +M2 = [ + [-0.37639312009077064, 0.50367215938028764, 0.51771555738739816], + [1.95296207992420956, -2.70016149427275431, 0.77433082832347488], + [0.10951701684618401, 1.67563492782820456, -1.40220219678260549] +] +M2_INV = [ + [1.0669264036262356, 0.6746871306679701, 0.7665053192472215], + [1.210362206755443, 0.20195807165403118, 0.5584114097226083], + [1.5297164637196528, 0.2940358541882354, 0.01400585324622852] +] + +GAMMA = [0.3497727371065888, 0.37932825726759606, 0.38739011180469285] + +# Enrichment parameters +HUE_COS1 = 0.2272386806411513 +HUE_COS2 = -0.02030388016936713 +HUE_COS3 = 0.13699674400656703 +HUE_COS4 = -0.06984004410611587 + +HUE_SIN1 = -0.4982906853757664 +HUE_SIN2 = 0.06941123529313283 +HUE_SIN3 = -0.059315132064903936 +HUE_SIN4 = -0.03004084108809561 + +HK_WEIGHT = 0.27875547096784253 +HK_POWER = 0.783392147824688 +HK_HUE_MOD = -0.555547503615361 +HK_SIN1 = 0.3637841358076795 +HK_COS2 = 0.42635072127486245 +HK_SIN2 = 1.1921516218553347 + +L_CORR_P1 = 0.11009459559369714 +L_CORR_P2 = -0.7539015155035271 +L_CORR_P3 = 0.681864030837032 +LH_COS1 = -0.06909882004578412 +LH_SIN1 = -0.12452116100746123 + +LP_DARK = 0.4380514859379541 +LP_DARK_HCOS = -0.13798793931285594 +LLP_DARK_HSIN = 0.05536433924970384 + +CS_COS1 = -0.5308078723216728 +CS_SIN1 = 0.24475286824122588 +CS_COS2 = -0.20064170780917295 +CS_SIN2 = 0.682115993329092 +CS_COS3 = -0.3180908895578546 +CS_SIN3 = 0.44652960690594 +CS_COS4 = 0.16676746557930713 +CS_SIN4 = 0.18153628708613942 + +CP_COS1 = -0.5 +CP_SIN1 = -0.37498974039425825 +CP_COS2 = -0.017438442891277176 +CP_SIN2 = 0.3528951682861936 + +LC1 = -0.6379164387840804 +LC2 = -0.7654466891650402 + +HLC_COS1 = 0.17567138321579084 +HLC_SIN1 = 0.7489714131085529 +HLC_COS2 = 0.4896211181827266 +HLC_SIN2 = -0.1813254937669269 + +HL_COS1 = 0.07985298470568437 +HL_SIN1 = 0.1468346774317649 +HL_COS2 = -0.046069758090729176 +HL_SIN2 = -0.01038125738174609 + +# Rigid rotation `φ = -28.2°` +PHI = -28.2 * math.pi / 180 +ROT_COS = math.cos(PHI) +ROT_SIN = math.sin(PHI) + +NC = [ + [0.0701671885, 0.1116124105, 0.0689442897], + [0.0918589388, 0.1262252954, 0.0817092493], + [0.108041173, 0.1355608768, 0.0904725501], + [0.1214792834, 0.1425433017, 0.0973716304], + [0.1332012896, 0.1481609555, 0.1031558581], + [0.1437214617, 0.1528768854, 0.1081854732], + [0.1533399934, 0.1569478755, 0.1126643818], + [0.1622499047, 0.1605321691, 0.1167204203], + [0.170583876, 0.1637346525, 0.1204395925], + [0.1784376972, 0.1666286695, 0.1238828367], + [0.2109084629, 0.1774570993, 0.1375541202], + [0.2381338548, 0.1853068792, 0.1484084559], + [0.2619611155, 0.1913775553, 0.15751445], + [0.2833489453, 0.1962489715, 0.16540493], + [0.3028714947, 0.2002497482, 0.1723893335], + [0.3209053433, 0.203585903, 0.1786656105], + [0.3377136205, 0.2063959912, 0.1843692044], + [0.3534889773, 0.2087781604, 0.1895975096], + [0.3683776268, 0.210804769, 0.19442329], + [0.3824937504, 0.2125308831, 0.1989025843], + [0.3959286094, 0.2139995051, 0.2030796306], + [0.4087565634, 0.2152449438, 0.2069900744], + [0.4210391914, 0.2162950695, 0.2106631392], + [0.4328282001, 0.2171728742, 0.2141231397], + [0.4441675287, 0.2178975788, 0.2173905669], + [0.4550949082, 0.2184854383, 0.2204828827], + [0.4656430368, 0.2189503405, 0.2234151148], + [0.4758404832, 0.2193042569, 0.2262003089], + [0.4857123902, 0.2195575909, 0.2288498787], + [0.5025718919, 0.2197875365, 0.2332676545], + [0.5185743332, 0.2197696834, 0.2373342194], + [0.5338091419, 0.2195393568, 0.2410893771], + [0.5483513164, 0.2191256492, 0.2445662836], + [0.5622645198, 0.2185528317, 0.2477929185], + [0.5756033647, 0.2178413785, 0.2507931616], + [0.5884151332, 0.217008727, 0.2535875958], + [0.6007410953, 0.216069853, 0.2561941175], + [0.6126175356, 0.2150377119, 0.2586284081], + [0.6240765624, 0.213923583, 0.2609043034], + [0.6351467554, 0.2127373424, 0.2630340862], + [0.6458536872, 0.2114876806, 0.2650287219], + [0.6562203507, 0.2101822785, 0.2668980491], + [0.6662675094, 0.2088279513, 0.2686509353], + [0.6760139896, 0.2074307678, 0.2702954066], + [0.6854769239, 0.2059961478, 0.2718387548], + [0.6946719568, 0.2045289459, 0.2732876272], + [0.7036134195, 0.2030335203, 0.2746481035], + [0.7123144783, 0.2015137924, 0.2759257596], + [0.7207872623, 0.1999732969, 0.2771257238], + [0.729042974, 0.1984152256, 0.278252724], + [0.7370919841, 0.1968424647, 0.2793111286], + [0.7449439155, 0.1952576272, 0.2803049829], + [0.7526077154, 0.1936630814, 0.28123804], + [0.7600917192, 0.1920609757, 0.282113788], + [0.7674037071, 0.1904532603, 0.2829354744], + [0.7745509538, 0.1888417065, 0.2837061273], + [0.7815402724, 0.1872279238, 0.2844285742], + [0.7883780542, 0.1856133748, 0.2851054592], + [0.7950703034, 0.1839993891, 0.2857392572], + [0.8016226688, 0.1823871748, 0.2863322885], + [0.808040472, 0.1807778298, 0.2868867299], + [0.8143287329, 0.1791723512, 0.2874046262], + [0.8204921924, 0.177571644, 0.2878878999], + [0.8265353335, 0.1759765292, 0.2883383599], + [0.8324624, 0.1743877505, 0.28875771], + [0.8382774134, 0.1728059814, 0.2891475562], + [0.8439841889, 0.1712318303, 0.289509413], + [0.8495863491, 0.1696658466, 0.2898447102], + [0.8550873372, 0.1681085247, 0.2901547982], + [0.8604904293, 0.1665603091, 0.2904409531], + [0.8657987443, 0.1650215982, 0.2907043819], + [0.8710152551, 0.163492748, 0.2909462262], + [0.8761427967, 0.1619740753, 0.291167567], + [0.8811840756, 0.160465861, 0.291369428], + [0.8861416769, 0.1589683532, 0.291552779], + [0.891018072, 0.1574817691, 0.2917185395], + [0.8958156252, 0.156006298, 0.2918675814], + [0.9005365998, 0.1545421035, 0.2920007319], + [0.9051831638, 0.1530893251, 0.2921187758], + [0.9097573954, 0.1516480806, 0.2922224585], + [0.9142612879, 0.1502184673, 0.2923124875], + [0.9186967543, 0.1488005641, 0.2923895353], + [0.9230656318, 0.1473944327, 0.2924542404], + [0.9273696854, 0.146000119, 0.2925072102], + [0.931610612, 0.1446176544, 0.2925490218], + [0.9357900439, 0.1432470571, 0.2925802241], + [0.9399095519, 0.1418883329, 0.2926013395], + [0.9439706486, 0.1405414763, 0.2926128645], + [0.947974791, 0.1392064715, 0.292615272], + [0.9519233835, 0.1378832933, 0.2926090118], + [0.9558177802, 0.1365719079, 0.2925945124], + [0.9596592875, 0.1352722734, 0.2925721814], + [0.9634491661, 0.1339843408, 0.2925424074], + [0.9671886335, 0.1327080543, 0.2925055601], + [0.9708788655, 0.1314433523, 0.292461992], + [0.9745209985, 0.1301901677, 0.2924120388], + [0.9781161309, 0.1289484282, 0.2923560202], + [0.9816653252, 0.1277180573, 0.2922942412], + [0.9851696092, 0.1264989744, 0.2922269923], + [0.9886299777, 0.125291095, 0.2921545503], + [0.992047394, 0.1240943317, 0.2920771794], + [0.9954227907, 0.1229085938, 0.291995131], + [0.9987570719, 0.1217337884, 0.2919086452], + [1.0020511135, 0.12056982, 0.2918179508], + [1.0053057649, 0.1194165911, 0.2917232659], + [1.0085218497, 0.1182740027, 0.2916247986], + [1.0117001671, 0.117141954, 0.2915227473], + [1.0148414926, 0.1160203429, 0.2914173013], + [1.0179465792, 0.1149090665, 0.291308641], + [1.0210161579, 0.1138080207, 0.2911969387], + [1.0240509389, 0.1127171007, 0.2910823585], + [1.0270516121, 0.1116362014, 0.290965057], + [1.030018848, 0.1105652169, 0.2908451837], + [1.0329532988, 0.1095040413, 0.2907228811], + [1.0358555982, 0.1084525685, 0.2905982852], + [1.038726363, 0.1074106923, 0.2904715257], + [1.0415661932, 0.1063783066, 0.2903427262], + [1.0443756726, 0.1053553055, 0.2902120047], + [1.0471553695, 0.1043415833, 0.2900794738], + [1.0499058376, 0.1033370348, 0.2899452409], + [1.0526276156, 0.1023415552, 0.2898094084], + [1.0553212288, 0.10135504, 0.2896720738], + [1.0579871888, 0.1003773855, 0.2895333303], + [1.0606259941, 0.0994084886, 0.2893932667], + [1.063238131, 0.0984482467, 0.2892519677], + [1.0658240733, 0.0974965581, 0.289109514], + [1.0683842833, 0.0965533218, 0.2889659825], + [1.0709192119, 0.0956184377, 0.2888214468], + [1.0734292992, 0.0946918064, 0.2886759766], + [1.0759149743, 0.0937733296, 0.2885296386], + [1.0783766565, 0.0928629096, 0.2883824965], + [1.0808147549, 0.0919604499, 0.2882346105], + [1.083229669, 0.0910658548, 0.2880860385], + [1.085621789, 0.0901790298, 0.2879368353], + [1.0879914961, 0.089299881, 0.2877870532], + [1.0903391629, 0.088428316, 0.287636742], + [1.0926651532, 0.0875642429, 0.287485949], + [1.0949698228, 0.0867075714, 0.2873347195], + [1.0972535196, 0.0858582116, 0.2871830963], + [1.0995165836, 0.0850160753, 0.2870311201], + [1.1017593473, 0.0841810749, 0.28687883], + [1.1039821361, 0.0833531239, 0.2867262627], + [1.1061852682, 0.0825321371, 0.2865734533], + [1.1083690551, 0.0817180302, 0.2864204353], + [1.1105338013, 0.08091072, 0.2862672401], + [1.1126798053, 0.0801101242, 0.2861138979], + [1.114807359, 0.0793161618, 0.2859604373], + [1.1169167484, 0.0785287526, 0.2858068852], + [1.1190082533, 0.0777478178, 0.2856532673], + [1.121082148, 0.0769732793, 0.2854996079], + [1.1231387012, 0.0762050602, 0.2853459302], + [1.1251781759, 0.0754430846, 0.2851922558], + [1.1272008301, 0.0746872777, 0.2850386054], + [1.1292069165, 0.0739375655, 0.2848849985], + [1.1311966828, 0.0731938752, 0.2847314536], + [1.1331703719, 0.072456135, 0.2845779881], + [1.1351282218, 0.071724274, 0.2844246183], + [1.137070466, 0.0709982224, 0.2842713598], + [1.1389973336, 0.0702779112, 0.2841182271], + [1.1409090491, 0.0695632725, 0.2839652339], + [1.1428058328, 0.0688542394, 0.2838123933], + [1.144687901, 0.0681507457, 0.2836597171], + [1.1465554658, 0.0674527265, 0.2835072169], + [1.1484087353, 0.0667601174, 0.2833549031], + [1.150247914, 0.0660728553, 0.2832027858], + [1.1520732025, 0.0653908778, 0.2830508742], + [1.1538847977, 0.0647141233, 0.282899177], + [1.1556828931, 0.0640425314, 0.2827477022], + [1.1574676785, 0.0633760421, 0.2825964572], + [1.1592393405, 0.0627145968, 0.2824454489], + [1.1609980624, 0.0620581373, 0.2822946837], + [1.1627440242, 0.0614066064, 0.2821441674], + [1.1644774028, 0.0607599478, 0.2819939055], + [1.1661983719, 0.0601181059, 0.2818439028], + [1.1679071026, 0.0594810259, 0.2816941638], + [1.1696037626, 0.0588486539, 0.2815446924], + [1.1712885169, 0.0582209366, 0.2813954925], + [1.172961528, 0.0575978217, 0.2812465671], + [1.1746229553, 0.0569792574, 0.2810979191], + [1.1762729557, 0.0563651929, 0.2809495511], + [1.1779116836, 0.0557555779, 0.2808014652], + [1.1795392907, 0.055150363, 0.2806536633], + [1.1811559263, 0.0545494994, 0.2805061468], + [1.1827617373, 0.053952939, 0.2803589171], + [1.1843568683, 0.0533606346, 0.280211975], + [1.1859414613, 0.0527725394, 0.2800653212], + [1.1875156564, 0.0521886073, 0.2799189562], + [1.1890795914, 0.0516087932, 0.2797728801], + [1.1906334017, 0.0510330522, 0.2796270928], + [1.192177221, 0.0504613403, 0.279481594], + [1.1937111805, 0.0498936141, 0.2793363832], + [1.1952354097, 0.0493298308, 0.2791914596], + [1.196750036, 0.0487699482, 0.2790468224], + [1.198255185, 0.0482139247, 0.2789024704], + [1.1997509801, 0.0476617194, 0.2787584023], + [1.2012375432, 0.0471132917, 0.2786146166], + [1.2027149942, 0.046568602, 0.2784711117], + [1.2041834512, 0.0460276108, 0.2783278857], + [1.2056430308, 0.0454902796, 0.2781849368], + [1.2070938476, 0.0449565701, 0.2780422628], + [1.2085360148, 0.0444264447, 0.2778998615], + [1.2099696438, 0.0438998664, 0.2777577306], + [1.2113948445, 0.0433767985, 0.2776158674], + [1.2128117251, 0.0428572051, 0.2774742695], + [1.2142203925, 0.0423410505, 0.2773329341], + [1.215620952, 0.0418282998, 0.2771918583], + [1.2170135073, 0.0413189184, 0.2770510392], + [1.2183981608, 0.0408128722, 0.2769104738], + [1.2197750136, 0.0403101276, 0.276770159], + [1.2211441653, 0.0398106515, 0.2766300914], + [1.222505714, 0.0393144112, 0.2764902679], + [1.2238597567, 0.0388213746, 0.2763506849], + [1.2252063891, 0.0383315098, 0.2762113391], + [1.2265457055, 0.0378447856, 0.2760722269], + [1.227877799, 0.037361171, 0.2759333446], + [1.2292027615, 0.0368806356, 0.2757946886], + [1.2305206838, 0.0364031493, 0.2756562552], + [1.2318316552, 0.0359286825, 0.2755180405], + [1.2331357643, 0.0354572061, 0.2753800406], + [1.2344330983, 0.034988691, 0.2752422517], + [1.2357237432, 0.034523109, 0.2751046698], + [1.2370077841, 0.034060432, 0.2749672908], + [1.2382853051, 0.0336006323, 0.2748301106], + [1.239556389, 0.0331436826, 0.2746931252], + [1.2408211178, 0.0326895561, 0.2745563304], + [1.2420795723, 0.032238226, 0.2744197219], + [1.2433318325, 0.0317896664, 0.2742832956], + [1.2445779773, 0.0313438512, 0.2741470472], + [1.2458180847, 0.0309007551, 0.2740109724], + [1.2470522318, 0.0304603528, 0.2738750668], + [1.2482804947, 0.0300226196, 0.273739326], + [1.2495029486, 0.029587531, 0.2736037457], + [1.250719668, 0.0291550627, 0.2734683213], + [1.2519307263, 0.028725191, 0.2733330486], + [1.2531361962, 0.0282978924, 0.2731979229], + [1.2543361495, 0.0278731436, 0.2730629399], + [1.2555306571, 0.0274509217, 0.2729280949], + [1.2567197892, 0.0270312041, 0.2727933834], + [1.2579036153, 0.0266139686, 0.2726588008], + [1.2590822039, 0.0261991931, 0.2725243427], + [1.2602556229, 0.0257868559, 0.2723900043], + [1.2614239394, 0.0253769355, 0.272255781], + [1.2625872197, 0.0249694109, 0.2721216683], + [1.2637455295, 0.0245642611, 0.2719876615], + [1.2648989336, 0.0241614655, 0.271853756], + [1.2660474965, 0.0237610037, 0.2717199469], + [1.2671912814, 0.0233628558, 0.2715862298], + [1.2683303514, 0.0229670018, 0.2714525999], + [1.2694647687, 0.0225734223, 0.2713190525], + [1.2705945947, 0.0221820978, 0.2711855828], + [1.2717198903, 0.0217930094, 0.2710521863], + [1.272840716, 0.0214061383, 0.2709188581], + [1.2739571312, 0.0210214658, 0.2707855935], + [1.2750691951, 0.0206389735, 0.2706523878] +] + + +def neutral_error(l: float, nc: Matrix) -> Vector: + """Neutral correction error.""" + + n = len(nc) + + if l <= 0: + return [0.0, 0.0] + + if l < nc[0][0]: + t = l / nc[0][0] + return [nc[0][1] * t, nc[0][2] * t] + + if l >= nc[n - 1][0]: + return nc[n - 1][1:] + + lo, hi = 0, n - 1 + while (hi - lo) > 1: + mid = (lo + hi) >> 1 + if nc[mid][0] <= l: + lo = mid + else: + hi = mid + + t = (l - nc[lo][0]) / (nc[lo + 1][0] - nc[lo][0]) + return [ + nc[lo][1] + t * (nc[lo + 1][1] - nc[lo][1]), + nc[lo][2] + t * (nc[lo + 1][2] - nc[lo][2]) + ] + + +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) + g = coeff * l * (1 - l) ** 2 + 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 - l + 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 10: Neutral correction (LUT) + a_err, b_err = neutral_error(l, NC) + a -= a_err + b -= b_err + + # 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 10: neutral correction + a_err, b_err = neutral_error(l, NC) + a += a_err + b += b_err + + # 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 Helmlab(Lab): + """Helmlab class.""" + + BASE = "xyz-d65" + NAME = "helmlab" + SERIALIZE = ("--helmlab",) + CHANNELS = ( + Channel("l", 0.0, 1.0893533344347792), + Channel("a", -1.0, 1.0, flags=FLG_MIRROR_PERCENT), + Channel("b", -1.0, 1.0, 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..41e7c2847 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 @@ -74,12 +75,14 @@ Florian Formatter Fortran GMA +GenSpace Golub Gosset Gossett Grau HCT HDR +HLC HLG HPE HPLuv @@ -94,6 +97,9 @@ HUSL HWB HWBish Hellwig +Helmgen +Helmgenlch +Helmlab Hermann Horner HyAB @@ -147,6 +153,8 @@ MINDE MacAdam Machado Matcher +MetricSpace +Minkowski Miquel Mixbox MkDocs @@ -362,6 +370,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 38d1c5ec5..91bc36251 100644 --- a/docs/src/markdown/about/changelog.md +++ b/docs/src/markdown/about/changelog.md @@ -5,6 +5,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..82de51098 --- /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, ~0.9287] +> `a` | [-0.4, 0.4] +> `b` | [-0.4, 0.4] +> +> ^\*^ 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: MetricSpace (72-parameter enriched pipeline for perceptual distance) +and GenSpace (generation-optimized pipeline for gradients and palettes). MetricSpace achieves STRESS 23.30 on COMBVD +(3,813 color pairs) - a 20.1% improvement over CIEDE2000. GenSpace + arc-length reparameterization produces perfectly +uniform gradients (CV ≈ 0% on any color pair). + +Helmgen is the GenSpace 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 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 --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.56358, 0.28763, 0.18093]) +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..2498add9c --- /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.168] +> `c` | [0, 0.4] +> `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: MetricSpace (72-parameter enriched pipeline for perceptual distance) +and GenSpace (generation-optimized pipeline for gradients and palettes). MetricSpace achieves STRESS 23.30 on COMBVD +(3,813 color pairs) - a 20.1% improvement over CIEDE2000. GenSpace + arc-length reparameterization produces perfectly +uniform gradients (CV ≈ 0% on any color pair). + +Helmgenlch is the GenSpace in polar form 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.60503, 0.22053, 8.5153]) +Color("helmgenlch", [0.81194, 0.11553, 20.862]).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.md b/docs/src/markdown/colors/helmlab.md new file mode 100644 index 000000000..198619171 --- /dev/null +++ b/docs/src/markdown/colors/helmlab.md @@ -0,0 +1,71 @@ +# Helmlab + +> [!failure] The Helmlab color space is not registered in `Color` by default + +/// html | div.info-container +> [!info | inline | end] Properties +> **Name:** `helmlab` +> +> **White Point:** D65 / 2˚ (Variant from ASTM-E308) +> +> **Coordinates:** +> +> Name | Range^\*^ +> ---- | ----- +> `l` | [0, ~1.143] +> `a` | [-1, 1] +> `b` | [-1, 1] +> +> ^\*^ Space is not bound to the range and is only used as a reference to define percentage inputs/outputs. + +![Helmlab](../images/helmlab-3d.png) +//// figure-caption +The sRGB gamut represented within the Helmlab color space. +//// + +Helmlab is a family of purpose-built color spaces: MetricSpace (72-parameter enriched pipeline for perceptual distance) +and GenSpace (generation-optimized pipeline for gradients and palettes). MetricSpace achieves STRESS 23.30 on COMBVD +(3,813 color pairs) - a 20.1% improvement over CIEDE2000. GenSpace + arc-length reparameterization produces perfectly +uniform gradients (CV ≈ 0% on any color pair). + +Helmlab is the MetricSpace 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 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`: + +```css-color +color(--helmlab l a b / a) // Color function +``` + +The string representation of the color object and the default string output use the +`#!css-color color(--helmlab l a b / a)` form. + +```py play +Color("helmlab", [0.61752, 0.58275, -0.20187]) +Color("helmlab", [0.87013, 0.49555, 0.29624]).to_string() +``` + +## Registering + +```py +from coloraide import Color as Base +from coloraide.spaces.helmlab import Helmlab + +class Color(Base): ... + +Color.register(Helmlab()) +``` diff --git a/docs/src/markdown/colors/index.md b/docs/src/markdown/colors/index.md index 65ce4b9df..1346a80b4 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 + + 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(Helmlab) 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 "./helmlab/" _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.md) | `helmlab` [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..1e4c0b078 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..b2cc81279 Binary files /dev/null and b/docs/src/markdown/images/helmgenlch-3d.png differ diff --git a/docs/src/markdown/images/helmlab-3d.png b/docs/src/markdown/images/helmlab-3d.png new file mode 100644 index 000000000..a60a57639 Binary files /dev/null and b/docs/src/markdown/images/helmlab-3d.png differ diff --git a/docs/src/zensical.yml b/docs/src/zensical.yml index 94a3c76f4..87f70b094 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: colors/helmlab.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..31ffe6fe8 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.2818), + ('red', 'yellow', 0.3395), + ('red', 'green', 0.3559), + ('red', 'blue', 0.3239), + ('red', 'indigo', 0.3024), + ('red', 'violet', 0.2801), + ('red', 'white', 0.3214), + ('red', 'black', 0.3355), + ('red', 'gray', 0.2939), + ('red', 'red', 0), + ('orange', 'red', 0.2818), + ('yellow', 'red', 0.3395), + ('green', 'red', 0.3559), + ('blue', 'red', 0.3239), + ('indigo', 'red', 0.3024), + ('violet', 'red', 0.2801), + ('white', 'red', 0.3214), + ('black', 'red', 0.3355), + ('gray', 'red', 0.2939) + ] + ) + 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..f2a03ea83 --- /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.56358 0.28763 0.18093)'), + ('orange', 'color(--helmgen 0.75771 0.07633 0.24769)'), + ('yellow', 'color(--helmgen 0.96486 -0.0828 0.31422)'), + ('green', 'color(--helmgen 0.44109 -0.17567 0.18227)'), + ('blue', 'color(--helmgen 0.36553 -0.04097 -0.4854)'), + ('indigo', 'color(--helmgen 0.23764 0.11887 -0.2513)'), + ('violet', 'color(--helmgen 0.72132 0.19702 -0.16488)'), + ('white', 'color(--helmgen 1 0 0)'), + ('gray', 'color(--helmgen 0.53175 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.00074 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.2 -0.2 / 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.4 0.4)'), + ('color(--helmgen -100% -100% -100%)', 'color(--helmgen -1 -0.4 -0.4)') + ] + + @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..1e2ce0605 --- /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.56358 0.3398 32.171)'), + ('orange', 'color(--helmgenlch 0.75771 0.25919 72.872)'), + ('yellow', 'color(--helmgenlch 0.96486 0.32495 104.76)'), + ('green', 'color(--helmgenlch 0.44109 0.25314 133.94)'), + ('blue', 'color(--helmgenlch 0.36553 0.48713 265.18)'), + ('indigo', 'color(--helmgenlch 0.23764 0.278 295.32)'), + ('violet', 'color(--helmgenlch 0.72132 0.2569 320.07)'), + ('white', 'color(--helmgenlch 1 0 0)'), + ('gray', 'color(--helmgenlch 0.53175 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.2 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.4 360)'), + ('color(--helmgenlch -100% -100% -360)', 'color(--helmgenlch -1 -0.4 -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.py b/tests/test_helmlab.py new file mode 100644 index 000000000..edeea71b4 --- /dev/null +++ b/tests/test_helmlab.py @@ -0,0 +1,122 @@ +"""Test Helmlab.""" +import unittest +from . import util +from coloraide.everything import ColorAll as Color, NaN +import pytest + + +class TestHelmlab(util.ColorAssertsPyTest): + """Test Helmlab.""" + + COLORS = [ + ('red', 'color(--helmlab 0.61752 0.58275 -0.20187)'), + ('orange', 'color(--helmlab 0.87013 0.49555 0.29624)'), + ('yellow', 'color(--helmlab 0.9855 0.16195 0.50526)'), + ('green', 'color(--helmlab 0.50464 -0.11043 0.55474)'), + ('blue', 'color(--helmlab 0.47312 -0.15706 -0.4441)'), + ('indigo', 'color(--helmlab 0.35922 -0.01904 -0.3189)'), + ('violet', 'color(--helmlab 0.8309 0.09765 -0.37128)'), + ('white', 'color(--helmlab 1.0894 0 0)'), + ('gray', 'color(--helmlab 0.66349 0.00001 0)'), + ('black', 'color(--helmlab 0 0 0)'), + ('color(srgb 1.5 1.5 1.5)', 'color(--helmlab 1.3392 -0.02214 0.00215)'), + ('color(srgb 1e-3 1e-3 1e-3)', 'color(--helmlab 0.02807 0.02767 -0.00283)'), + # Test color + ('color(--helmlab 0.5 0.1 -0.1)', 'color(--helmlab 0.5 0.1 -0.1)'), + ('color(--helmlab 0.5 0.1 -0.1 / 0.5)', 'color(--helmlab 0.5 0.1 -0.1 / 0.5)'), + ('color(--helmlab 50% 50% -50% / 50%)', 'color(--helmlab 0.54468 0.5 -0.5 / 0.5)'), + ('color(--helmlab none none none / none)', 'color(--helmlab none none none / none)'), + # Test percent ranges + ('color(--helmlab 0% 0% 0%)', 'color(--helmlab 0 0 0)'), + ('color(--helmlab 100% 100% 100%)', 'color(--helmlab 1.0894 1 1)'), + ('color(--helmlab -100% -100% -100%)', 'color(--helmlab -1.0894 -1 -1)') + ] + + @pytest.mark.parametrize('color1,color2', COLORS) + def test_colors(self, color1, color2): + """Test colors.""" + + self.assertColorEqual(Color(color1).convert('helmlab'), Color(color2)) + + +class TestHelmlabSerialize(util.ColorAssertsPyTest): + """Test Helmlab serialization.""" + + COLORS = [ + # Test color + ('color(--helmlab 0.1 0.75 -0.1 / 0.5)', {}, 'color(--helmlab 0.1 0.75 -0.1 / 0.5)'), + # Test alpha + ('color(--helmlab 0.1 0.75 -0.1)', {'alpha': True}, 'color(--helmlab 0.1 0.75 -0.1 / 1)'), + ('color(--helmlab 0.1 0.75 -0.1 / 0.5)', {'alpha': False}, 'color(--helmlab 0.1 0.75 -0.1)'), + # Test None + ('color(--helmlab none 0.75 -0.1)', {}, 'color(--helmlab 0 0.75 -0.1)'), + ('color(--helmlab none 0.75 -0.1)', {'none': True}, 'color(--helmlab none 0.75 -0.1)'), + # Test Fit (not bound) + ('color(--helmlab 1.2 0.75 -0.1)', {}, 'color(--helmlab 1.2 0.75 -0.1)'), + ('color(--helmlab 1.2 0.75 -0.1)', {'fit': False}, 'color(--helmlab 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 TestHelmlabPoperties(util.ColorAsserts, unittest.TestCase): + """Test Helmlab.""" + + def test_l(self): + """Test `l`.""" + + c = Color('color(--helmlab -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 -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 -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 -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 -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', [0.3, 0, 0]).is_achromatic(), True) + self.assertEqual(Color('helmlab', [0.3, 0.0000001, 0]).is_achromatic(), True) + self.assertEqual(Color('helmlab', [NaN, 0.0000001, 0]).is_achromatic(), True) + self.assertEqual(Color('helmlab', [0, NaN, NaN]).is_achromatic(), True) + self.assertEqual(Color('helmlab', [0, NaN, NaN]).is_achromatic(), True) + self.assertEqual(Color('helmlab', [0, 0.1, -0.2]).is_achromatic(), False) + self.assertEqual(Color('helmlab', [NaN, 0, -0.1]).is_achromatic(), False) + self.assertEqual(Color('helmlab', [0.3, NaN, 0]).is_achromatic(), True) + self.assertEqual(Color('helmlab', [NaN, NaN, 0]).is_achromatic(), True) diff --git a/tools/gen_3d_models.py b/tools/gen_3d_models.py index 79df3c017..04203aa28 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': {'title': TEMPLATE.format('Helmlab'), 'filename': 'helmlab-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 d24d65626..f4b70c510 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: colors/helmlab.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