-
-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
d749039
commit a4362ef
Showing
27 changed files
with
1,029 additions
and
439 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.