Skip to content

Commit

Permalink
Add a CSS Color Level 4 compliant gamut mapping
Browse files Browse the repository at this point in the history
  • Loading branch information
facelessuser committed Mar 12, 2022
1 parent 0fc54f7 commit 21d0227
Show file tree
Hide file tree
Showing 6 changed files with 78 additions and 15 deletions.
4 changes: 2 additions & 2 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[run]
omit=
omit=coloraide/gamut/fit_css_color_4.py

[report]
omit=
omit=coloraide/gamut/fit_css_color_4.py
3 changes: 2 additions & 1 deletion coloraide/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from .gamut import Fit
from .gamut.fit_lch_chroma import LchChroma
from .gamut.fit_oklch_chroma import OklchChroma
from .gamut.fit_css_color_4 import CssColor4
from typing import Union, Sequence, Dict, List, Optional, Any, cast, Callable, Set, Tuple, Type, Mapping

SUPPORTED_DE = (
Expand All @@ -64,7 +65,7 @@
)

SUPPORTED_FIT = (
LchChroma, OklchChroma
LchChroma, OklchChroma, CssColor4
)


Expand Down
53 changes: 53 additions & 0 deletions coloraide/gamut/fit_css_color_4.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""CSS Color Level 4 gamut mapping."""
from ..gamut import Fit, clip_channels
from ..util import NaN
from typing import TYPE_CHECKING, Any

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


class CssColor4(Fit):
"""Uses the CSS Color Level 4 algroithm for gamut mapping: Oklch: https://www.w3.org/TR/css-color-4/#binsearch."""

NAME = "css-color-4"
LIMIT = 0.02
DE = "ok"
SPACE = "oklch"
MIN_LIGHTNESS = 0
MAX_LIGHTNESS = 100

@classmethod
def fit(cls, color: 'Color', **kwargs: Any) -> None:
"""Gamut mapping via Oklch chroma."""

space = color.space()
mapcolor = color.convert(cls.SPACE)
lightness = mapcolor.lightness

# Return white or black if lightness is out of range
if lightness >= cls.MAX_LIGHTNESS or lightness <= cls.MIN_LIGHTNESS:
mapcolor.chroma = 0
mapcolor.hue = NaN
clip_channels(color.update(mapcolor))
return

# Set initial chroma boundaries
low = 0.0
high = mapcolor.chroma
clip_channels(color.update(mapcolor))

# Adjust chroma (using binary search).
# This helps preserve the other attributes of the color.
# Compress chroma until we are are right outside the gamut, but under the JND.
if not mapcolor.in_gamut(space):
while True:
mapcolor.chroma = (high + low) * 0.5

if mapcolor.in_gamut(space, tolerance=0):
low = mapcolor.chroma
else:
clip_channels(color.update(mapcolor))
if mapcolor.delta_e(color, method=cls.DE) < cls.LIMIT:
break
high = mapcolor.chroma
5 changes: 3 additions & 2 deletions coloraide/gamut/fit_lch_chroma.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,15 @@ def fit(cls, color: 'Color', **kwargs: Any) -> None:
de = mapcolor.delta_e(color, method=cls.DE)
if de < cls.LIMIT:
# Kick out as soon as we are close enough to the JND.
# Too far below and will reduce chroma too aggressively.
# Too far below and we may reduce chroma too aggressively.
if (cls.LIMIT - de) < cls.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.
lower_in_gamut = False
if lower_in_gamut:
lower_in_gamut = False
low = mapcolor.chroma
else:
# We are still outside the gamut and outside the JND
Expand Down
1 change: 1 addition & 0 deletions docs/src/markdown/about/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 0.12.0

- **NEW**: Add a gamut mapping variant that matches the CSS Color Level 4 spec.
- **FIX**: Fix precision rounding issue.

## 0.11.0
Expand Down
27 changes: 17 additions & 10 deletions docs/src/markdown/gamut.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,24 +130,31 @@ okhsv.in_gamut(tolerance=0.0005)
## Mapping Colors

Gamut mapping is the process of taking a color that is out of gamut and adjusting it such that it fits within the gamut.
While there are various different ways to gamut map a color into a smaller gamut, ColorAide currently provides only
three:
While there are various different ways to gamut map a color into a smaller gamut, ColorAide currently only offers a
couple.

Method | Description
-------------- | -----------
`clip` | Simple, naive clipping.
`lch-chroma` | Uses a combination of chroma reduction and MINDE in the CIELCH color space to bring a color into gamut. This is the default method used.
`oklch-chroma` | Like `lch-chroma`, but uses the Oklch color space instead. Currently experimental and closer to the current proposed [CSS Color Level 4 specification](https://drafts.csswg.org/css-color/#binsearch).
`oklch-chroma` | Like `lch-chroma`, but uses the Oklch color space instead. Currently experimental and is meant to be similar to `css-color-4`, but provides better results at the cost of being a little slower.
`css-color-4` | This is the algorithm as currently specified by the [CSS Color Level 4 specification](https://drafts.csswg.org/css-color/#binsearch). It is like `oklch-chroma`, but it is faster at the cost of providing slightly inferior results.

!!! note "CSS Level 4 Gamut Mapping"
The current [CSS Level 4 specification](https://drafts.csswg.org/css-color/#binsearch) describes the suggested gamut
mapping algorithm as a combination of chroma reduction in the Oklch color space and MINDE.
`css-color-4` matches the CSS algorithm as described in the [CSS Color Level 4 specification](https://drafts.csswg.org/css-color/#binsearch).
`oklch-chroma` is an improved version of `css-color-4`, and while not as fast as `css-color-4`, provides better
results. This is most evident when generating gradients as they are more smooth when using `oklch-chroma`. While
maybe some eyes may struggle to see the difference, some may notice some color banding.

ColorAide is currently using `lch-chroma` by default. Oklch is pretty new as a target for gamut mapping in CSS and
in general. We are currently waiting and testing to see how well it does overall before making it the default.
```playground
class ColorCss(Color):
FIT = 'css-color-4'
class ColorOk(Color):
FIT = 'oklch-chroma'

The `oklch-chroma` implementation we use is really close to the CSS spec except that we have a small tweak that
seems to prevent aggressive chroma reduction just a little bit more.
ColorCss("lch(85% 80 310)").interpolate("lch(85% 100 85)", space='oklch')
ColorOk("lch(85% 80 310)").interpolate("lch(85% 100 85)", space='oklch')
```

Gamut mapping occurs automatically any time a color is serialized to a string via `#!py3 to_string()` and in a few other
specific cases, like interpolating in a color space that cannot represent out of gamut colors. With this said, gamut
Expand All @@ -163,7 +170,7 @@ c1.fit('srgb', in_place=True)
c1.in_gamut()
```

We can use also specify a specific gamut mapping method, such as `clip` or `oklch-chroma`:
We can use also specify a specific gamut mapping method, such as `clip`, `oklch-chroma`, etc.

```playground
c1 = Color('color(display-p3 1 1 0)')
Expand Down

0 comments on commit 21d0227

Please sign in to comment.