From 3845f3ad47a6f36bfa3e09b736d62b4e28417720 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 3 Mar 2026 13:30:20 -0700 Subject: [PATCH 1/2] Add checking if color is in visible spectrum and fitting to it --- coloraide/color.py | 7 +- coloraide/gamut/__init__.py | 4 + coloraide/gamut/pointer.py | 9 +- coloraide/gamut/visible_spectrum.py | 84 ++++++++++++- docs/src/markdown/about/changelog.md | 1 + docs/src/markdown/examples/3d_models.html | 139 +++++++++++++++++++--- tests/test_gamut.py | 74 ++++++++++++ tools/gamut_3d_plotly.py | 138 ++++++++++++++++++--- 8 files changed, 416 insertions(+), 40 deletions(-) diff --git a/coloraide/color.py b/coloraide/color.py index e824b26bb..0fcb43989 100644 --- a/coloraide/color.py +++ b/coloraide/color.py @@ -1026,7 +1026,7 @@ def fit( # Handle special gamut requests if space in gamut.SPECIAL_GAMUTS: - return cast(Self, gamut.SPECIAL_GAMUTS[space]['fit'](self)) + return cast(Self, gamut.SPECIAL_GAMUTS[space]['fit'](self, **kwargs)) # If within gamut, just normalize hue range by calling clip. if self.in_gamut(space, tolerance=0): @@ -1049,7 +1049,7 @@ def fit( mapping.fit(self, target, **kwargs) return self - def in_gamut(self, space: str | None = None, *, tolerance: float = util.DEF_FIT_TOLERANCE) -> bool: + def in_gamut(self, space: str | None = None, *, tolerance: float | None = None) -> bool: """Check if current color is in gamut.""" if space is None: @@ -1059,6 +1059,9 @@ def in_gamut(self, space: str | None = None, *, tolerance: float = util.DEF_FIT_ if space in gamut.SPECIAL_GAMUTS: return cast(bool, gamut.SPECIAL_GAMUTS[space]['check'](self, tolerance=tolerance)) + if tolerance is None: + tolerance = util.DEF_FIT_TOLERANCE + # Check if gamut is in the provided space c = self.convert(space, norm=False) if space is not None and space != self.space() else self diff --git a/coloraide/gamut/__init__.py b/coloraide/gamut/__init__.py index 29aaa6213..0918fd769 100644 --- a/coloraide/gamut/__init__.py +++ b/coloraide/gamut/__init__.py @@ -29,6 +29,10 @@ 'macadam-limits': { 'check': visible_spectrum.in_macadam_limits, 'fit': visible_spectrum.fit_macadam_limits + }, + 'visible-spectrum': { + 'check': visible_spectrum.in_visible_spectrum, + 'fit': visible_spectrum.fit_visible_spectrum } } # type: dict[str, dict[str, Callable[..., Any]]] diff --git a/coloraide/gamut/pointer.py b/coloraide/gamut/pointer.py index 720cba8c1..747b06273 100644 --- a/coloraide/gamut/pointer.py +++ b/coloraide/gamut/pointer.py @@ -11,7 +11,7 @@ from .. import algebra as alg from .. import util from ..types import Vector, Matrix, AnyColor, VectorLike # noqa: F401 -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: #pragma: no cover from ..color import Color @@ -144,7 +144,7 @@ def get_chroma_limit(l: float, h: float) -> float: return alg.lerp(alg.lerp(row1[li], row1[li + 1], lf), alg.lerp(row2[li], row2[li + 1], lf), hf) -def fit_pointer_gamut(color: AnyColor) -> AnyColor: +def fit_pointer_gamut(color: AnyColor, **kwargs: Any) -> AnyColor: """Fit a color to the Pointer gamut.""" # Convert to CIE LCh with the SC illuminant @@ -162,7 +162,7 @@ def fit_pointer_gamut(color: AnyColor) -> AnyColor: return from_lch_sc(color, [new_l, new_c, h]) if adjusted else color -def in_pointer_gamut(color: Color, tolerance: float) -> bool: +def in_pointer_gamut(color: Color, tolerance: float | None = None) -> bool: """ See if color is within the pointer gamut. @@ -172,6 +172,9 @@ def in_pointer_gamut(color: Color, tolerance: float) -> bool: color's chroma does not exceed the limit. """ + if tolerance is None: + tolerance = util.DEF_FIT_TOLERANCE + # Convert to CIE LCh with the SC illuminant l, c, h = to_lch_sc(color) diff --git a/coloraide/gamut/visible_spectrum.py b/coloraide/gamut/visible_spectrum.py index a45255ed3..7c606cc87 100644 --- a/coloraide/gamut/visible_spectrum.py +++ b/coloraide/gamut/visible_spectrum.py @@ -1,12 +1,13 @@ """Check if color is in visible gamut.""" from __future__ import annotations +import math import bisect from ..cat import WHITES from .. import algebra as alg from .. import util from ..types import Matrix, AnyColor # noqa: F401 from .rosch_macadam_solid import LUT, LUMINANCE, HUE -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: #pragma: no cover from ..color import Color @@ -73,7 +74,7 @@ def get_chroma_limit(l: float, h: float) -> float: return alg.lerp(alg.lerp(row1[li], row1[li + 1], lf), alg.lerp(row2[li], row2[li + 1], lf), hf) -def fit_macadam_limits(color: AnyColor) -> AnyColor: +def fit_macadam_limits(color: AnyColor, **kwargs: Any) -> AnyColor: """Fit a color to the approximation of the Macadam limits at the color's given luminance.""" # Convert to xyY @@ -96,7 +97,7 @@ def fit_macadam_limits(color: AnyColor) -> AnyColor: return color.update(color.new('xyz-d65', util.xy_to_xyz((x, y), new_Y), color[-1])) if adjusted else color -def in_macadam_limits(color: Color, tolerance: float) -> bool: +def in_macadam_limits(color: Color, tolerance: float | None = None) -> bool: """ See if color is within the approximation of the Macadam limits for the color's luminance. @@ -105,6 +106,9 @@ def in_macadam_limits(color: Color, tolerance: float) -> bool: color's chroma does not exceed the limit. """ + if tolerance is None: + tolerance = util.DEF_FIT_TOLERANCE + # Convert to xyY xyz = (color.convert('xyz-d65', norm=False) if color.space() != 'xyz-d65' else color.normalize(nans=False))[:-1] x, y, Y = util.xyz_to_xyY(xyz, WHITES['2deg']['D65']) @@ -142,3 +146,77 @@ def macadam_limits(luminance: float | None = None) -> Matrix: # Luminance exceeds threshold else: raise ValueError(f'Luminance must be between {LUMINANCE[0]} and {LUMINANCE[-1]}, but was {luminance}') + + +def in_visible_spectrum(color: Color, tolerance: float | None = None) -> bool: + """See if color is within the spectral locus.""" + + if tolerance is None: + tolerance = 1e-3 + + # Get white and xyY coordinates + white = color.white('xy-1931') + l = color.luminance(white=white) + xy = color.xy() + + # Get the dominant wavelength which will yield the point on the spectral locus in our direction + wave, dominant = color.wavelength()[:2] + + # See if we have an achromatic color + if math.isnan(wave): + oog_chroma = False + else: + # Calculate magnitude with vector normalized such that white is the origin + xy_temp = alg.subtract(xy, white, dims=alg.D1) + m1 = math.sqrt(xy_temp[0] ** 2 + xy_temp[1] ** 2) + xy_temp = alg.subtract(dominant, white, dims=alg.D1) + m2 = math.sqrt(xy_temp[0] ** 2 + xy_temp[1] ** 2) + oog_chroma = m1 > (m2 + tolerance) + + # See if we are within tolerance + return (0 - tolerance) <= l <= (1 + tolerance) and not oog_chroma + + +def fit_visible_spectrum(color: AnyColor, tolerance: float = 0.0, **kwargs: Any) -> AnyColor: + """Fit color to the visible spectrum.""" + + # Get white and xyY coordinates + white = color.white('xy-1931') + l = color.luminance(white=white) + xy = color.xy() + + # Get the dominant wavelength which will yield the point on the spectral locus in our direction + wave, dominant = color.wavelength()[:2] + + # See if we have an achromatic color + if math.isnan(wave): + dominant = white + oog_chroma = False + else: + # Calculate magnitude with vector normalized such that white is the origin + xy_temp = alg.subtract(xy, white, dims=alg.D1) + m1 = math.sqrt(xy_temp[0] ** 2 + xy_temp[1] ** 2) + xy_temp = alg.subtract(dominant, white, dims=alg.D1) + m2 = math.sqrt(xy_temp[0] ** 2 + xy_temp[1] ** 2) + + # Adjust range to pull in color relative to the spectral locus + if tolerance: + m2 += tolerance + h = math.degrees(math.atan2(xy_temp[1], xy_temp[0])) % 360 + dominant = list(alg.add(alg.polar_to_rect(m2, h), white, dims=alg.D1)) + + # Check if color is outside the spectral locus + oog_chroma = m1 > m2 + + # Adjust color is out of luminance range or outside the spectral locus limits + if l > 1 or l < 0 or oog_chroma: + color.update( + color.chromaticity( + color.space(), + [*(dominant if oog_chroma else xy), alg.clamp(l, 0, 1)], + 'xy-1931', + white=white, + scale=False + ) + ) + return color diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md index 7296df305..210cfcd77 100644 --- a/docs/src/markdown/about/changelog.md +++ b/docs/src/markdown/about/changelog.md @@ -5,6 +5,7 @@ icon: lucide/scroll-text ## 8.6 +- **NEW**: Add new special gamut `visible-spectrum`. - **NEW**: Add support for `carryforward` within `average()`. - **ENHANCE**: More accurate dominant wavelength calculations. - **FIX**: Ensure luminance calculations are all in the same white point with `scale` gamut mapping and color diff --git a/docs/src/markdown/examples/3d_models.html b/docs/src/markdown/examples/3d_models.html index 0ef0ef4e6..7f494e585 100644 --- a/docs/src/markdown/examples/3d_models.html +++ b/docs/src/markdown/examples/3d_models.html @@ -216,6 +216,7 @@

ColorAide Color Space Models

options['gamuts'].append(key) options['gamuts'].append('pointer-gamut') options['gamuts'].append('macadam-limits') +options['gamuts'].append('visible-spectrum') json.dumps(options)