Skip to content

Commit

Permalink
Gamut mapping updates (#431)
Browse files Browse the repository at this point in the history
* Gamut mapping updates

- Add support for adaptive lightness in gamut mapping approaches
- Restructure MINDE plugins
- Make MINDE approach faster when JND = 0
- Allow MINDE to be configured to use different perceptual spaces

* Update docs

* Clean up

Optimize a case related to dynamically figuring out max and min
lightness

* Add documentation

* Add script to generate max saturation images

* Fix spelling

* Rename MINDE class attribute from LIMIT to JND for consistency

* Remove unused image

* Update changelog
  • Loading branch information
facelessuser authored Aug 26, 2024
1 parent d749039 commit a4362ef
Show file tree
Hide file tree
Showing 27 changed files with 1,029 additions and 439 deletions.
5 changes: 3 additions & 2 deletions coloraide/algebra.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,9 @@ def rect_to_polar(a: float, b: float) -> tuple[float, float]:
def polar_to_rect(c: float, h: float) -> tuple[float, float]:
"""Take rectangular coordinates and make them polar."""

a = c * math.cos(math.radians(h))
b = c * math.sin(math.radians(h))
r = math.radians(h)
a = c * math.cos(r)
b = c * math.sin(r)
return a, b


Expand Down
2 changes: 2 additions & 0 deletions coloraide/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
from .contrast import ColorContrast
from .contrast.wcag21 import WCAG21Contrast
from .gamut import Fit
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
Expand Down Expand Up @@ -1424,6 +1425,7 @@ def alpha(self, *, nans: bool = True) -> float:
DEZ(),

# Fit
MINDEChroma(),
LChChroma(),
OkLChChroma(),
RayTrace(),
Expand Down
16 changes: 5 additions & 11 deletions coloraide/gamut/fit_hct_chroma.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
"""HCT gamut mapping."""
from __future__ import annotations
from ..gamut.fit_lch_chroma import LChChroma
from ..gamut.fit_minde_chroma import MINDEChroma


class HCTChroma(LChChroma):
class HCTChroma(MINDEChroma):
"""HCT chroma gamut mapping class."""

NAME = "hct-chroma"

EPSILON = 0.01
LIMIT = 2.0
DE = "hct"
DE_OPTIONS = {}
SPACE = "hct"
MIN_LIGHTNESS = 0
MAX_LIGHTNESS = 100
MIN_CONVERGENCE = 0.0001
JND = 2.0
DE_OPTIONS = {"method": "hct"}
PSPACE = "hct"
114 changes: 7 additions & 107 deletions coloraide/gamut/fit_lch_chroma.py
Original file line number Diff line number Diff line change
@@ -1,112 +1,12 @@
"""Fit by compressing chroma in LCh."""
"""Fit by compressing chroma in OkLCh."""
from __future__ import annotations
import functools
from ..gamut import Fit, clip_channels
from ..cat import WHITES
from .. import util
import math
from .. import algebra as alg
from typing import TYPE_CHECKING, Any
from .fit_minde_chroma import MINDEChroma

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

WHITE = util.xy_to_xyz(WHITES['2deg']['D65'])
BLACK = [0, 0, 0]


@functools.lru_cache(maxsize=10)
def calc_epsilon(jnd: float) -> float:
"""Calculate the epsilon to 2 degrees smaller than the specified JND."""

return float(f"1e{alg.order(jnd) - 2:d}")


class LChChroma(Fit):
"""
LCh chroma gamut mapping class.
Adjust chroma (using binary search).
This helps preserve the other attributes of the color.
Compress chroma until we are are right at the JND edge while still out of gamut.
Raise the lower chroma bound while we are in gamut or outside of gamut but still under the JND.
Lower the upper chroma bound anytime we are out of gamut and above the JND.
Too far under the JND we'll reduce chroma too aggressively.
This is the same as the CSS algorithm as described here: https://www.w3.org/TR/css-color-4/#binsearch.
There are some small adjustments to handle HDR colors as the CSS algorithm assumes SDR color spaces.
Additionally, this uses LCh instead of OkLCh, but we also offer a derived version that uses OkLCh.
"""
class LChChroma(MINDEChroma):
"""LCh chroma gamut mapping class."""

NAME = "lch-chroma"

EPSILON = 0.01
LIMIT = 2.0
DE = "2000"
DE_OPTIONS = {'space': 'lab-d65'} # type: dict[str, Any]
SPACE = "lch-d65"
MIN_LIGHTNESS = 0
MAX_LIGHTNESS = 100
MIN_CONVERGENCE = 0.0001

def fit(self, color: Color, space: str, *, jnd: float | None = None, **kwargs: Any) -> None:
"""Gamut mapping via CIELCh chroma."""

orig = color.space()
mapcolor = color.convert(self.SPACE, norm=False) if orig != self.SPACE else color.clone().normalize(nans=False)
gamutcolor = color.convert(space, norm=False) if orig != space else color.clone().normalize(nans=False)
l, c = mapcolor._space.indexes()[:2] # type: ignore[attr-defined]
lightness = mapcolor[l]
sdr = gamutcolor._space.DYNAMIC_RANGE == 'sdr'
if jnd is None:
jnd = self.LIMIT
epsilon = self.EPSILON
else:
epsilon = calc_epsilon(jnd)

# Return white or black if lightness is out of dynamic range for lightness.
# Extreme light case only applies to SDR, but dark case applies to all ranges.
if sdr and (lightness >= self.MAX_LIGHTNESS or math.isclose(lightness, self.MAX_LIGHTNESS, abs_tol=1e-6)):
clip_channels(color.update('xyz-d65', WHITE, mapcolor[-1]))
return
elif lightness <= self.MIN_LIGHTNESS:
clip_channels(color.update('xyz-d65', BLACK, mapcolor[-1]))
return

# Set initial chroma boundaries
low = 0.0
high = mapcolor[c]
clip_channels(gamutcolor._hotswap(mapcolor.convert(space, norm=False)))

# Adjust chroma if we are not under the JND yet.
if mapcolor.delta_e(gamutcolor, method=self.DE, **self.DE_OPTIONS) >= jnd:
# Perform "in gamut" checks until we know our lower bound is no longer in gamut.
lower_in_gamut = True

# If high and low get too close to converging,
# we need to quit in order to prevent infinite looping.
while (high - low) > self.MIN_CONVERGENCE:
mapcolor[c] = (high + low) * 0.5

# Avoid doing expensive delta E checks if in gamut
if lower_in_gamut and mapcolor.in_gamut(space, tolerance=0):
low = mapcolor[c]
else:
clip_channels(gamutcolor._hotswap(mapcolor.convert(space, norm=False)))
de = mapcolor.delta_e(gamutcolor, method=self.DE, **self.DE_OPTIONS)
if de < jnd:
# Kick out as soon as we are close enough to the JND.
# Too far below and we may reduce chroma too aggressively.
if (jnd - de) < epsilon:
break

# Our lower bound is now out of gamut, so all future searches are
# guaranteed to be out of gamut. Now we just want to focus on tuning
# chroma to get as close to the JND as possible.
if lower_in_gamut:
lower_in_gamut = False
low = mapcolor[c]
else:
# We are still outside the gamut and outside the JND
high = mapcolor[c]
color._hotswap(gamutcolor.convert(orig, norm=False)).normalize()
JND = 2.0
DE_OPTIONS = {'method': '2000', 'space': 'lab-d65'}
PSPACE = "lch-d65"
152 changes: 152 additions & 0 deletions coloraide/gamut/fit_minde_chroma.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Fit by compressing chroma in LCh."""
from __future__ import annotations
import functools
from ..gamut import Fit, clip_channels
from ..cat import WHITES
from .. import util
import math
from .. import algebra as alg
from .tools import adaptive_hue_independent
from typing import TYPE_CHECKING, Any

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

XYZ = 'xyz-d65'
WHITE = util.xy_to_xyz(WHITES['2deg']['D65'])
BLACK = [0.0, 0.0, 0.0]


@functools.lru_cache(maxsize=10)
def calc_epsilon(jnd: float) -> float:
"""Calculate the epsilon to 2 degrees smaller than the specified JND."""

return float(f"1e{alg.order(jnd) - 2:d}")


class MINDEChroma(Fit):
"""
LCh chroma gamut mapping class.
Adjust chroma (using binary search).
This helps preserve the other attributes of the color.
Compress chroma until we are are right at the JND edge while still out of gamut.
Raise the lower chroma bound while we are in gamut or outside of gamut but still under the JND.
Lower the upper chroma bound anytime we are out of gamut and above the JND.
Too far under the JND we'll reduce chroma too aggressively.
This is the same as the CSS algorithm as described here: https://www.w3.org/TR/css-color-4/#binsearch.
There are some small adjustments to handle HDR colors as the CSS algorithm assumes SDR color spaces.
Additionally, this uses LCh instead of OkLCh, but we also offer a derived version that uses OkLCh.
"""

NAME = "minde-chroma"
JND = 0.02
DE_OPTIONS = {"method": "ok"} # type: dict[str, Any]
PSPACE = "oklch"
MIN_CONVERGENCE = 0.0001

def fit(
self,
color: Color,
space: str,
*,
pspace: str | None = None,
jnd: float | None = None,
de_options: dict[str, Any] | None = None,
adaptive: float = 0.0,
**kwargs: Any
) -> None:
"""Gamut mapping via CIELCh chroma."""

# Identify the perceptual space and determine if it is rectangular or polar
if pspace is None:
pspace = self.PSPACE
polar = color.CS_MAP[pspace].is_polar()
orig = color.space()
mapcolor = color.convert(pspace, norm=False) if orig != pspace else color.clone().normalize(nans=False)
gamutcolor = color.convert(space, norm=False) if orig != space else color.clone().normalize(nans=False)
if polar:
l, c, h = mapcolor._space.indexes() # type: ignore[attr-defined]
else:
l, a, b = mapcolor._space.indexes() # type: ignore[attr-defined]
lightness = mapcolor[l]
sdr = gamutcolor._space.DYNAMIC_RANGE == 'sdr'
if jnd is None:
jnd = self.JND
epsilon = calc_epsilon(jnd)

if de_options is None:
de_options = self.DE_OPTIONS

temp = color.new(XYZ, WHITE, mapcolor[-1]).convert(pspace, in_place=True)
max_light = temp[l]

# Return white or black if lightness is out of dynamic range for lightness.
# Extreme light case only applies to SDR, but dark case applies to all ranges.
if not adaptive:
if sdr and (lightness >= max_light or math.isclose(lightness, max_light, abs_tol=1e-6)):
clip_channels(color.update(temp))
return
elif lightness <= temp.update(XYZ, BLACK).convert(pspace, in_place=True)[l]:
clip_channels(color.update(temp))
return

low = 0.0
high, hue = (mapcolor[c], mapcolor[h]) if polar else alg.rect_to_polar(mapcolor[a], mapcolor[b])
else:
chroma, hue = (mapcolor[c], mapcolor[h]) if polar else alg.rect_to_polar(mapcolor[a], mapcolor[b])
light = mapcolor[l]
alight = adaptive_hue_independent(light / max_light, max(chroma, 0.0) / max_light, adaptive) * max_light
achroma = 0.0
low = 0
high = 1

clip_channels(gamutcolor._hotswap(mapcolor.convert(space, norm=False)))

# Adjust chroma if we are not under the JND yet.
if not jnd or mapcolor.delta_e(gamutcolor, **de_options) >= jnd:
# Perform "in gamut" checks until we know our lower bound is no longer in gamut.
lower_in_gamut = True

# If high and low get too close to converging,
# we need to quit in order to prevent infinite looping.
while (high - low) > self.MIN_CONVERGENCE:
if not adaptive:
value = (high + low) * 0.5
if polar:
mapcolor[c] = value
else:
mapcolor[a], mapcolor[b] = alg.polar_to_rect(value, hue)
else:
value = (high + low) * 0.5
mapcolor[l], c_ = alg.lerp(alight, light, value), alg.lerp(achroma, chroma, value)
if polar:
mapcolor[c] = c_
else:
mapcolor[a], mapcolor[b] = alg.polar_to_rect(c_, hue)

# Avoid doing expensive delta E checks if in gamut
if lower_in_gamut and mapcolor.in_gamut(space, tolerance=0):
low = value
else:
clip_channels(gamutcolor._hotswap(mapcolor.convert(space, norm=False)))
# Bypass distance check if JND is 0
de = mapcolor.delta_e(gamutcolor, **de_options) if jnd else 0.0
if de < jnd:
# Kick out as soon as we are close enough to the JND.
# Too far below and we may reduce chroma too aggressively.
if (jnd - de) < epsilon:
break

# Our lower bound is now out of gamut, so all future searches are
# guaranteed to be out of gamut. Now we just want to focus on tuning
# chroma to get as close to the JND as possible.
if lower_in_gamut:
lower_in_gamut = False
low = value
else:
# We are still outside the gamut and outside the JND
high = value

color._hotswap(gamutcolor.convert(orig, norm=False)).normalize()
11 changes: 2 additions & 9 deletions coloraide/gamut/fit_oklch_chroma.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
"""Fit by compressing chroma in OkLCh."""
from __future__ import annotations
from .fit_lch_chroma import LChChroma
from .fit_minde_chroma import MINDEChroma


class OkLChChroma(LChChroma):
class OkLChChroma(MINDEChroma):
"""OkLCh chroma gamut mapping class."""

NAME = "oklch-chroma"

EPSILON = 0.0001
LIMIT = 0.02
DE = "ok"
DE_OPTIONS = {}
SPACE = "oklch"
MAX_LIGHTNESS = 1
Loading

0 comments on commit a4362ef

Please sign in to comment.