diff --git a/coloraide/color.py b/coloraide/color.py index e824b26bb..3b2cc3f2b 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, **kwargs: Any) -> bool: """Check if current color is in gamut.""" if space is None: @@ -1057,7 +1057,10 @@ def in_gamut(self, space: str | None = None, *, tolerance: float = util.DEF_FIT_ # Handle special gamut requests if space in gamut.SPECIAL_GAMUTS: - return cast(bool, gamut.SPECIAL_GAMUTS[space]['check'](self, tolerance=tolerance)) + return cast(bool, gamut.SPECIAL_GAMUTS[space]['check'](self, tolerance=tolerance, **kwargs)) + + 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..d81050d72 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, **kwargs: Any) -> 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..b9e5972e6 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, **kwargs: Any) -> 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,91 @@ 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, + ignore_luminance: bool = False, + **kwargs: Any +) -> 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) + + oog_lum = False if ignore_luminance else (l > (1 + tolerance) or l < (0 - tolerance)) + + # See if we are within tolerance + return not oog_lum and not oog_chroma + + +def fit_visible_spectrum( + color: AnyColor, + tolerance: float = 1e-3, + ignore_luminance: bool = False, + **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 + + oog_lum = False if ignore_luminance else (l > 1 or l < 0) + + # Adjust color is out of luminance range or outside the spectral locus limits + if oog_lum 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/api/index.md b/docs/src/markdown/api/index.md index 47cf4cf59..9ec1ee31a 100644 --- a/docs/src/markdown/api/index.md +++ b/docs/src/markdown/api/index.md @@ -1033,6 +1033,7 @@ def average( space: str | None = None, out_space: str | None = None, premultiplied: bool = True, + carryforward: bool = False, **kwargs: Any ) -> Self: ... @@ -1308,13 +1309,11 @@ Description Parameters - - Some methods could have additional parameters to configure the behavior, these would be done through `**kwargs`. - None of built-in gamut mapping methods currently have additional parameters. - Parameters | Defaults | Description ---------- | ------------------ | ----------- `space` | `#!py None` | The color space that the color must be mapped to. If space is `#!py None`, then the current color space will be used. `method` | `#!py None` | String that specifies which gamut mapping method to use. If `#!py None`, `lch-chroma` will be used. + `**kwargs` | | Any supported parameters that should be passed to special gamuts. Return @@ -1327,7 +1326,8 @@ Return def in_gamut( self, space: str | None = None, *, - tolerance: float = util.DEF_FIT_TOLERANCE + tolerance: float | None = None, + **kwargs: Any ) -> bool: ... ``` @@ -1343,7 +1343,8 @@ Parameters Parameters | Defaults | Description ---------- | ---------------- | ----------- `space` | `#!py None` | The color space that the color must be fit within. If space is `#!py None`, then the current color space will be used. - `tolerance`| `#!py 0.000075` | Tolerance allowed when checking bounds of color. + `tolerance`| `#!py None` | Tolerance allowed when checking bounds of color. If `#!py None`, the default is used. + `**kwargs` | | Any supported parameters that should be passed to special gamuts. Return diff --git a/docs/src/markdown/examples/3d_models.html b/docs/src/markdown/examples/3d_models.html index 0ef0ef4e6..304a46c8f 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)