Skip to content

Commit

Permalink
Gamut mapping fixes and adjustments (#382)
Browse files Browse the repository at this point in the history
* Gamut mapping fixes and adjustments

1. When gamut mapping, the color space attribute GAMUT_CHECK should be
   used not only to check whether a color is in gamut, but it should be
   used as the color reference color space when gamut mapping.
2. When clipping, there are some cases where we can get away with
   clipping a color in the origin color space even when GAMUT_CHECK
   specifies a different color space. This saves us time and improves
   performance. It also allows us to keep compatibility with CSS. To
   enforce a clipping color space, a space can now set the color space
   attribute `CLIP_SPACE` to a specific space.

* Ensure we can still gamut map/clip in other spaces

* Cleanup Oklab matrix calculation and remove unnecessary operations

* Okhsl and Okhsv must be clipped in sRGB

* Update docs

* Update calc script

* Okhsl and Okhsv are gamut mapped in their own gamut

Okhsl and Okhsv do not represent a proper sRGB gamut. It is close, but
not exact. Its bounds clip a little of sRGB but also include a little
past sRGB. It doesn't make sense to gamut map it directly to sRGB.
Users should expressly gamut in sRGB if that is desired.
  • Loading branch information
facelessuser authored Dec 7, 2023
1 parent 2ea1df3 commit 7036501
Show file tree
Hide file tree
Showing 32 changed files with 282 additions and 204 deletions.
60 changes: 41 additions & 19 deletions coloraide/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,7 @@ def convert(
method = None if not isinstance(fit, str) else fit
if not self.in_gamut(space, tolerance=0.0):
converted = self.convert(space, in_place=in_place, norm=norm)
return converted.fit(space, method=method)
return converted.fit(method=method)

# Nothing to do, just return the color with no alterations.
if space == self.space():
Expand Down Expand Up @@ -854,14 +854,25 @@ def clip(self, space: str | None = None) -> Color:

orig_space = self.space()
if space is None:
space = self.space()

# Convert to desired space
c = self.convert(space, in_place=True, norm=False)
gamut.clip_channels(c)

# Adjust "this" color
return c.convert(orig_space, in_place=True)
space = self._space.CLIP_SPACE or self._space.GAMUT_CHECK or orig_space
else:
cs = self.CS_MAP[space]
space = cs.CLIP_SPACE or cs.GAMUT_CHECK or cs.NAME

# Convert to desired space and clip the color
if space != orig_space:
conv = self.convert(space, norm=False)
if not gamut.clip_channels(conv):
# Clipping only made non-essential changes (normalize hue),
# just clip in the current space to preserve 'None' and clean up noise
# at color space boundary limits (if any).
gamut.clip_channels(self)
return self
# Copy results to current color.
return self._hotswap(conv.convert(orig_space, in_place=True))

gamut.clip_channels(self)
return self

def fit(
self,
Expand All @@ -881,7 +892,10 @@ def fit(

orig_space = self.space()
if space is None:
space = self.space()
space = self._space.GAMUT_CHECK or orig_space
else:
cs = self.CS_MAP[space]
space = cs.GAMUT_CHECK or cs.NAME

# Select appropriate mapping algorithm
mapping = self.FIT_MAP.get(method)
Expand All @@ -890,18 +904,26 @@ def fit(
raise ValueError("'{}' gamut mapping is not currently supported".format(method))

# Convert to desired space
self.convert(space, in_place=True, norm=False)
if space != orig_space:
conv = self.convert(space, norm=False)

# If within gamut, just normalize hue range by calling clip.
if self.in_gamut(tolerance=0):
gamut.clip_channels(self)
# If within gamut, just normalize hue range by calling clip.
if conv.in_gamut(tolerance=0):
gamut.clip_channels(self)
return self

# Perform gamut mapping.
else:
mapping.fit(self, **kwargs)
# Perform gamut mapping.
mapping.fit(conv, **kwargs)

# Convert back to the original color space
return self._hotswap(conv.convert(orig_space, in_place=True))

# Convert back to the original color space
return self.convert(orig_space, in_place=True)
# Gamut map the color directly
if self.in_gamut(tolerance=0):
gamut.clip_channels(self)
return self
mapping.fit(self, **kwargs)
return self

def in_gamut(self, space: str | None = None, *, tolerance: float = util.DEF_FIT_TOLERANCE) -> bool:
"""Check if current color is in gamut."""
Expand Down
16 changes: 13 additions & 3 deletions coloraide/gamut/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Gamut handling."""
from __future__ import annotations
import math
from .. import algebra as alg
from ..channels import FLG_ANGLE
from abc import ABCMeta, abstractmethod
from ..types import Plugin
Expand All @@ -15,9 +14,11 @@
__all__ = ('clip_channels', 'verify', 'Fit', 'pointer')


def clip_channels(color: Color, nans: bool = True) -> None:
def clip_channels(color: Color, nans: bool = True) -> bool:
"""Clip channels."""

clipped = False

for i, value in enumerate(color[:-1]):

chan = color._space.CHANNELS[i]
Expand All @@ -32,7 +33,16 @@ def clip_channels(color: Color, nans: bool = True) -> None:
continue

# Fit value in bounds.
color[i] = alg.clamp(value, chan.low, chan.high)
if value < chan.low:
color[i] = chan.low
elif value > chan.high:
color[i] = chan.high
else:
continue

clipped = True

return clipped


def verify(color: Color, tolerance: float) -> bool:
Expand Down
1 change: 1 addition & 0 deletions coloraide/harmonies.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ class HarmonyHSL(_HarmonyHSL, HSL):
SERIALIZE = ('---harmoncy-cylinder',)
BASE = name
GAMUT_CHECK = name
CLIP_SPACE = None
WHITE = cs.WHITE
DYAMIC_RANGE = cs.DYNAMIC_RANGE
INDEXES = cs.indexes() if hasattr(cs, 'indexes') else [0, 1, 2]
Expand Down
20 changes: 13 additions & 7 deletions coloraide/spaces/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,15 +165,21 @@ class Space(Plugin, metaclass=SpaceMeta):
CHANNEL_ALIASES = {} # type: dict[str, str]
# Enable or disable default color format parsing and serialization.
COLOR_FORMAT = True
# Should this color also be checked in a different color space? Only when set to a string (specifying a color space)
# will the default gamut checking also check the specified space as well as the current.
# Some color spaces are a transform of a specific RGB color space gamut, e.g. HSL has a gamut of sRGB.
# When testing or gamut mapping a color within the current color space's gamut, `GAMUT_CHECK` will
# declare which space must be used as reference if anything other than the current space is required.
#
# Gamut checking:
# The specified color space will be checked first followed by the original. Assuming the parent color space fits,
# the original should fit as well, but there are some cases when a parent color space that is slightly out of
# gamut, when evaluated with a threshold, may appear to be in gamut enough, but when checking the original color
# space, the values can be greatly out of specification (looking at you HSL).
# Specifically, when testing if a color is in gamut, both the origin space and the specified gamut
# space will be checked as sometimes a color is within the threshold of being "close enough" to the gamut,
# but the color can still be far outside the origin space's coordinates. Checking both ensures sane values
# that are also close enough to the gamut.
#
# When actually gamut mapping, only the gamut space is used, if none is specified, the origin space is used.
GAMUT_CHECK = None # type: str | None
# `CLIP_SPACE` forces a different space to be used for clipping than what is specified by `GAMUT_CHECK`.
# This is used in cases like HSL where the `GAMUT_CHECK` space is sRGB, but we want to clip in HSL as it
# is still reasonable and faster.
CLIP_SPACE = None # type: str | None
# When set to `True`, this denotes that the color space has the ability to represent out of gamut in colors in an
# extended range. When interpolation is done, if colors are interpolated in a smaller gamut than the colors being
# interpolated, the colors will usually be gamut mapped, but if the interpolation space happens to support extended
Expand Down
1 change: 1 addition & 0 deletions coloraide/spaces/cubehelix.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ class Cubehelix(HSLish, Space):
"lightness": "l"
}
WHITE = WHITES['2deg']['D65']
GAMUT_CHECK = 'srgb'

def is_achromatic(self, coords: Vector) -> bool:
"""Check if color is achromatic."""
Expand Down
1 change: 1 addition & 0 deletions coloraide/spaces/hsi.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ class HSI(HSV):
}
WHITE = WHITES['2deg']['D65']
GAMUT_CHECK = "srgb"
CLIP_SPACE = None

def to_base(self, coords: Vector) -> Vector:
"""To sRGB from HSI."""
Expand Down
3 changes: 2 additions & 1 deletion coloraide/spaces/hsl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ class HSL(HSLish, Space):
"lightness": "l"
}
WHITE = WHITES['2deg']['D65']
GAMUT_CHECK = "srgb"
GAMUT_CHECK = "srgb" # type: str | None
CLIP_SPACE = "hsl" # type: str | None

def is_achromatic(self, coords: Vector) -> bool:
"""Check if color is achromatic."""
Expand Down
1 change: 1 addition & 0 deletions coloraide/spaces/hsluv.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ class HSLuv(HSLish, Space):
}
WHITE = WHITES['2deg']['D65']
GAMUT_CHECK = "srgb"
CLIP_SPACE = "hsluv"

def is_achromatic(self, coords: Vector) -> bool:
"""Check if color is achromatic."""
Expand Down
3 changes: 2 additions & 1 deletion coloraide/spaces/hsv.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ class HSV(HSVish, Space):
"saturation": "s",
"value": "v"
}
GAMUT_CHECK = "srgb"
GAMUT_CHECK = "srgb" # type: str | None
CLIP_SPACE = "hsv" # type: str | None
WHITE = WHITES['2deg']['D65']

def is_achromatic(self, coords: Vector) -> bool:
Expand Down
4 changes: 3 additions & 1 deletion coloraide/spaces/okhsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
# Limit
[0.13110758, 1.81333971],
# `Kn` coefficients
[1.35691251, -0.00926975, -1.15076744, -0.50647251, 0.00645585]
[1.35733652, -0.00915799, -1.1513021, -0.50559606, 0.00692167]
]
] # type: list[Matrix]

Expand Down Expand Up @@ -479,6 +479,8 @@ class Okhsl(HSL):
"saturation": "s",
"lightness": "l"
}
GAMUT_CHECK = None
CLIP_SPACE = None

def to_base(self, coords: Vector) -> Vector:
"""To Oklab from Okhsl."""
Expand Down
2 changes: 2 additions & 0 deletions coloraide/spaces/okhsv.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ class Okhsv(HSV):
"saturation": "s",
"value": "v"
}
GAMUT_CHECK = None
CLIP_SPACE = None

def to_base(self, okhsv: Vector) -> Vector:
"""To Oklab from Okhsv."""
Expand Down
1 change: 1 addition & 0 deletions coloraide/spaces/orgb.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class oRGB(Labish, Space):
CHANNEL_ALIASES = {
"luma": "l"
}
GAMUT_CHECK = 'srgb'

def to_base(self, coords: Vector) -> Vector:
"""To base from oRGB."""
Expand Down
2 changes: 2 additions & 0 deletions coloraide/spaces/prismatic.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ class Prismatic(Space):
"blue": 'b'
}
WHITE = WHITES['2deg']['D65']
GAMUT_CHECK = 'srgb'
CLIP_SPACE = 'prismatic'

def is_achromatic(self, coords: Vector) -> bool:
"""Test if color is achromatic."""
Expand Down
7 changes: 7 additions & 0 deletions docs/src/markdown/about/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@

## Unreleased

- **NEW**: Color spaces that specify the gamut space via `GAMUT_CHECK` must use that color space as a reference when
when gamut mapping or clipping by default.
- **NEW**: New color space attribute `CLIP_SPACE` added which will override the space specified by `GAMUT_CHECK`.
Used to force clipping in origin space even if a gamut mapping space is defined if it is advantageous (faster and
still practical) to clip in the origin space.
- **NEW**: Deprecate non-standard CAM16 (Jab) space. People should use the standard CAM16 JMh or the CAM16 UCS, SCD,
or LCD Jab spaces. The non-standard Jab is still available via `coloraide.spaces.cam16.CAM16`, but it is no longer
available in `coloraide.everything` and will be removed at a future time.
- **FIX**: The oRGB color space should be gamut mapped in `srgb` as it is a transform of the RGB space.
- **FIX**: Okhsl and Okhsv have a rough sRGB approximation and are instead gamut mapped to their own gamut.
- **FIX**: Much more accurate ICtCp matrices.
- **FIX**: Fix typing of deeply nested arrays in `algebra`.
- **FIX**: Fix issue with HCT undefined channel resolver.
Expand Down
6 changes: 3 additions & 3 deletions docs/src/markdown/colors/okhsl.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ Okhsl color space in 3D
/////
////

Okhsl is a another color space created by Björn Ottosson. It is based off his early work and leverages the
[Oklab](./oklab.md) color space. The aim was to create a color space that was better suited for being used in color pickers
than the current HSL.
Okhsl was created by Björn Ottosson and is a transform of the [Oklab](./oklab.md) color space that approximates the sRGB
gamut perceptually in an HSL color model. The aim was to create a color space that was better suited for being used in
color pickers than the current HSL.

_[Learn about Okhsv](https://bottosson.github.io/posts/colorpicker/)_
///
Expand Down
7 changes: 3 additions & 4 deletions docs/src/markdown/colors/okhsv.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,13 @@ Okhsv color space in 3D
/////
////

Okhsv is a color space created by Björn Ottosson. It is based off his early work and leverages the [Oklab](./oklab.md) color
space. The aim was to create a color space that was better suited for being used in color pickers than the current HSV.
Okhsv was created by Björn Ottosson and is a transform of the [Oklab](./oklab.md) color space that approximates the sRGB
gamut perceptually in an HSL color model. The aim was to create a color space that was better suited for being used in
color pickers than the current HSV.

_[Learn about Okhsv](https://bottosson.github.io/posts/colorpicker/)_
///

??? abstract "ColorAide Details"

## Channel Aliases

Channels | Aliases
Expand Down
2 changes: 2 additions & 0 deletions docs/src/markdown/demos/3d_models.html
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ <h1>ColorAide Color Space Models</h1>
NAME = 'hsl-{}'.format(gamut)
BASE = gamut
GAMUT_CHECK = gamut
CLIP_SPACE = None
WHITE = cs.WHITE
DYAMIC_RANGE = cs.DYNAMIC_RANGE

Expand Down Expand Up @@ -264,6 +265,7 @@ <h1>ColorAide Color Space Models</h1>
NAME = '-rgb-{}'.format(gamut)
BASE = gamut
GAMUT_CHECK = gamut
CLIP_SPACE = None
WHITE = cs.WHITE
DYAMIC_RANGE = cs.DYNAMIC_RANGE
INDEXES = cs.indexes()
Expand Down
Loading

0 comments on commit 7036501

Please sign in to comment.