Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion coloraide/__meta__.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,5 +204,5 @@ def parse_version(ver: str) -> Version:
return Version(major, minor, micro, release, pre, post, dev)


__version_info__ = Version(8, 12, 1, "final")
__version_info__ = Version(8, 13, 0, "final")
__version__ = __version_info__._get_canonical()
38 changes: 29 additions & 9 deletions coloraide/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,16 @@
from .distance.delta_e_z import DEZ
from .contrast import ColorContrast
from .contrast.wcag21 import WCAG21Contrast
from .gamut import Fit
from .gamut import Fit, Gamut
from .gamut.fit_minde_chroma import MINDEChroma
from .gamut.fit_lch_chroma import LChChroma
from .gamut.fit_oklch_chroma import OkLChChroma
from .gamut.fit_raytrace import RayTrace
from .gamut.fit_scale import Scale
from .gamut.fit_scale_luminance import ScaleLuminance
from .gamut.pointer import PointerGamut
from .gamut.macadam_limits import MacAdamLimits
from .gamut.visible_spectrum import VisibleSpectrum
from .cat import CAT, Bradford
from .filters import Filter
from .filters.w3c_filter_effects import Sepia, Brightness, Contrast, Saturate, Opacity, HueRotate, Grayscale, Invert
Expand Down Expand Up @@ -138,6 +141,7 @@ def __init__(cls, name: str, bases: tuple[object, ...], clsdict: dict[str, Any])
cls.CONTRAST_MAP = cls.CONTRAST_MAP.copy() # type: dict[str, ColorContrast]
cls.INTERPOLATE_MAP = cls.INTERPOLATE_MAP.copy() # type: dict[str, Interpolate]
cls.CCT_MAP = cls.CCT_MAP.copy() # type: dict[str, CCT]
cls.GAMUT_MAP = cls.GAMUT_MAP.copy() # type: dict[str, Gamut]

# Ensure each derived class tracks its own conversion paths for color spaces
# relative to the installed color space plugins.
Expand Down Expand Up @@ -166,6 +170,7 @@ class Color(metaclass=ColorMeta):
FILTER_MAP = {} # type: dict[str, Filter]
INTERPOLATE_MAP = {} # type: dict[str, Interpolate]
CCT_MAP = {} # type: dict[str, CCT]
GAMUT_MAP = {} # type: dict[str, Gamut]
PRECISION = util.DEF_PREC
ROUNDING = util.DEF_ROUND_MODE
FIT = util.DEF_FIT
Expand Down Expand Up @@ -377,8 +382,13 @@ def register(
mapping = cls.CS_MAP
reset_convert_cache = True
p = i
if p.NAME in gamut.SPECIAL_GAMUTS:
raise ValueError(f"Color space name '{p.NAME}' conflicts with the an internal, special gamut")
if p.NAME in cls.GAMUT_MAP:
raise ValueError(f"Color space '{p.NAME}' conflicts with gamut {p.NAME}")
elif isinstance(i, Gamut):
mapping = cls.GAMUT_MAP
p = i
if p.NAME in cls.CS_MAP:
raise ValueError(f"Gamut {p.NAME} conflicts with color space {p.NAME}")
elif isinstance(i, DeltaE):
mapping = cls.DE_MAP
p = i
Expand Down Expand Up @@ -441,12 +451,15 @@ def deregister(cls, plugin: str | Sequence[str], *, silent: bool = False) -> Non
cls.INTERPOLATE_MAP.clear()
cls.CCT_MAP.clear()
cls.FIT_MAP.clear()
cls.GAMUT_MAP.clear()
return

ptype, name = p.split(':', 1)
if ptype == 'space':
mapping = cls.CS_MAP
reset_convert_cache = True
elif ptype == 'gamut':
mapping = cls.GAMUT_MAP
elif ptype == "delta-e":
mapping = cls.DE_MAP
elif ptype == 'cat':
Expand Down Expand Up @@ -1038,8 +1051,9 @@ def fit(
return self.clip(space)

# Handle special gamut requests
if space in gamut.SPECIAL_GAMUTS:
return cast('Self', gamut.SPECIAL_GAMUTS[space]['fit'](self, **kwargs))
if space in self.GAMUT_MAP:
cast('Self', self.GAMUT_MAP[space].fit(self, method=method, **kwargs))
return self

# If within gamut, just normalize hue range by calling clip.
if self.in_gamut(space, tolerance=0):
Expand Down Expand Up @@ -1072,8 +1086,8 @@ def in_gamut(self, space: str | None = None, *, tolerance: float | None = None,
tolerance = util.DEF_FIT_TOLERANCE

# Handle special gamut requests
if space in gamut.SPECIAL_GAMUTS:
return cast('bool', gamut.SPECIAL_GAMUTS[space]['check'](self, tolerance=tolerance, **kwargs))
if space in self.GAMUT_MAP:
return self.GAMUT_MAP[space].in_gamut(self, tolerance=tolerance, **kwargs)

# 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
Expand All @@ -1093,13 +1107,14 @@ def in_gamut(self, space: str | None = None, *, tolerance: float | None = None,
def in_pointer_gamut(self, *, tolerance: float = util.DEF_FIT_TOLERANCE) -> bool: # pragma: no cover
"""Check if in pointer gamut."""

return gamut.pointer.in_pointer_gamut(self, tolerance)
return self.GAMUT_MAP['pointer-gamut'].in_gamut(self, tolerance)

@deprecated("`color.fit_pointer_gamut()` has been deprecated in favor of using `color.fit('pointer-gamut')`")
def fit_pointer_gamut(self) -> Self: # pragma: no cover
"""Check if in pointer gamut."""

return gamut.pointer.fit_pointer_gamut(self)
self.GAMUT_MAP['pointer-gamut'].fit(self)
return self

def mask(self, channel: str | Sequence[str], *, invert: bool = False, in_place: bool = False) -> Self:
"""Mask color channels."""
Expand Down Expand Up @@ -1689,6 +1704,11 @@ def alpha(
ProPhotoRGB(),
ProPhotoRGBLinear(),

# Gamuts
PointerGamut(),
VisibleSpectrum(),
MacAdamLimits(),

# CAT
Bradford(),

Expand Down
32 changes: 14 additions & 18 deletions coloraide/gamut/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
import math
from abc import ABCMeta, abstractmethod
from functools import lru_cache
from . import pointer
from . import visible_spectrum
from .. import util
from .. import algebra as alg
from ..channels import FLG_ANGLE
Expand All @@ -19,22 +17,7 @@
if TYPE_CHECKING: #pragma: no cover
from ..color import Color

__all__ = ('clip_channels', 'verify', 'Fit', 'pointer', 'visible_spectrum', 'scale_rgb', 'coerce_to_rgb')

SPECIAL_GAMUTS = {
'pointer-gamut': {
'check': pointer.in_pointer_gamut,
'fit': pointer.fit_pointer_gamut
},
'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]]]
__all__ = ('clip_channels', 'verify', 'Fit', 'Gamut', 'scale_rgb', 'coerce_to_rgb')


def hwb_to_srgb(coords: Vector) -> Vector: # pragma: no cover
Expand Down Expand Up @@ -267,3 +250,16 @@ class Fit(Plugin, metaclass=ABCMeta):
@abstractmethod
def fit(self, color: Color, space: str, **kwargs: Any) -> None:
"""Get coordinates of the new gamut mapped color."""


class Gamut(Plugin, metaclass=ABCMeta):
"""Gamut plugin class."""

@abstractmethod
def in_gamut(self, color: Color, tolerance: float, **kwargs: Any) -> bool:
"""Check if in gamut."""


@abstractmethod
def fit(self, color: Color, **kwargs: Any) -> None:
"""Check if in gamut."""
146 changes: 146 additions & 0 deletions coloraide/gamut/macadam_limits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Check if color is in Rösch-MacAdam color solid (MacAdam limits)."""
from __future__ import annotations
import bisect
from . import Gamut
from .. import util
from .. import algebra as alg
from ..cat import WHITES
from ..types import Matrix, VectorT, MatrixInt, StrictNumber
from .rosch_macadam_solid import LUT, LUMINANCE, HUE
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING: #pragma: no cover
from ..color import Color

XYw = WHITES['2deg']['D65']
XYZ_D65 = util.xy_to_xyz(WHITES['2deg']['D65'])


def macadam_limits(luminance: float | None = None) -> Matrix:
"""
Calculate the visible Macadam limit boundary points for the given lightness.

If no lightness is provided, calculate the maximum boundary.
Result is returned as xyY coordinates (in the D65 illuminant).
"""

# Maximum Pointer gamut boundary
# For each hue, find the lightness/chroma point that is furthest away from the white point.
if luminance is None:
return [[*alg.add(alg.polar_to_rect(LUT[i][1], h), XYw), 1] for i, h in enumerate(HUE[:-1])]

# Pointer gamut boundary at a given lightness
# Return all the points for a given lightness
elif LUMINANCE[0] <= luminance <= LUMINANCE[-1]:
# Handle too low lightness inside tolerance
li, lf = closest_lightness(luminance, LUMINANCE)
chroma = [alg.lerp(row[li], row[li + 1], lf) for row in LUT[:-1]]
return [[*alg.add(alg.polar_to_rect(c, h), XYw, dims=alg.D1), luminance] for c, h in zip(chroma, HUE[:-1])]

# Luminance exceeds threshold
else:
raise ValueError(f'Luminance must be between {LUMINANCE[0]} and {LUMINANCE[-1]}, but was {luminance}')


def closest_lightness(l: float, lightness: VectorT[StrictNumber]) -> tuple[int, float]:
"""Calculate the two closest lightness values and return the first index and interpolation factor."""

# Handle too low lightness inside tolerance
if l <= lightness[0]:
li = 0
lf = 0.0

# Handle too high lightness inside tolerance
elif l >= lightness[-1]:
li = len(lightness) - 2
lf = 1.0

# Handle lightness within gamut
else:
li = bisect.bisect(lightness, l) - 1
l1, l2 = lightness[li:li + 2]
lf = 1 - (l2 - l) / (l2 - l1)

return li, lf


def closest_hue(h: float, hues: VectorT[StrictNumber]) -> tuple[int, float]:
"""Calculate the two closest hues and return the first index and interpolation factor."""

# Handle hue at the start
if h == hues[0]: # pragma: no cover
hi = 0
hf = 0.0

# Handle hue at the end
elif h == hues[-1]: # pragma: no cover
hi = len(hues) - 2
hf = 1.0

# Handle all other hues
else:
hi = bisect.bisect(hues, h) - 1
h1, h2 = hues[hi:hi + 2]
hf = 1 - (h2 - h) / (h2 - h1)

return hi, hf


class MacAdamLimits(Gamut):
"""The Rösch-MacAdam color solid (MacAdam limits)."""

NAME = 'macadam-limits'
LIGHTNESS = LUMINANCE
HUE = HUE
LUT: Matrix | MatrixInt = LUT

def get_chroma_limit(self, l: float, h: float) -> float:
"""Get the chroma limit."""

# Find the two closest lightness columns and calculate the needed interpolation factor.
li, lf = closest_lightness(l, self.LIGHTNESS)

# Find the two closest hue rows and calculate the needed interpolation factor.
hi, hf = closest_hue(h, self.HUE)

# Interpolate the chroma limit by interpolating chroma values for the closest lightness values and hues.
row1, row2 = self.LUT[hi:hi + 2]
return alg.lerp(alg.lerp(row1[li], row1[li + 1], lf), alg.lerp(row2[li], row2[li + 1], lf), hf)

def in_gamut(self, color: Color, tolerance: float, **kwargs: Any) -> bool:
"""Test if in gamut."""

# 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'])
# Operate in a polar configuration
c, h = alg.rect_to_polar(*alg.subtract((x, y), WHITES['2deg']['D65'], dims=alg.D1))

# If lightness exceeds the acceptable range, then we are not in gamut
if (Y < (self.LIGHTNESS[0] - tolerance)) or (Y > (self.LIGHTNESS[-1] + tolerance)):
return False

# Test that the color does not exceed the max chroma
return c <= (self.get_chroma_limit(Y, h) + tolerance)

def fit(self, color: Color, **kwargs: Any) -> None:
"""Fit to gamut."""

# 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'])
# Operate in a polar configuration
c, h = alg.rect_to_polar(*alg.subtract((x, y), WHITES['2deg']['D65'], dims=alg.D1))

# Clamp lightness
new_Y = min(self.LIGHTNESS[-1], max(self.LIGHTNESS[0], Y))

# Get optimal chromaticity points
new_c = min(c, self.get_chroma_limit(Y, h))
x, y = alg.add(alg.polar_to_rect(new_c, h), WHITES['2deg']['D65'], dims=alg.D1)

# Check if we made any changes
adjusted = Y != new_Y or c != new_c

# Adjust original color only if a modification was made
color.update(color.new('xyz-d65', util.xy_to_xyz((x, y), new_Y), color[-1])) if adjusted else color
Loading
Loading