Skip to content

Commit

Permalink
Fix atypical achromatic response cases in ray trace (#421)
Browse files Browse the repository at this point in the history
CAM16, HCT, ZCAM, etc. often have atypical achromatic responses due to
adapting luminance and background luminance handling. Ray trace now
better handles such cases by averaging the achromatic component values
giving an approximate achromatic value in the RGB space that is more
appropriate. This prevents cutoffs near white at high luminance levels.
On rare occasions MINDE gamut mapping can be a little more accurate in
these cases, but differences are imperceptible making the speed boost
generally more preferable.
  • Loading branch information
facelessuser authored Apr 18, 2024
1 parent 82e4db8 commit 9e20d21
Show file tree
Hide file tree
Showing 12 changed files with 52 additions and 31 deletions.
2 changes: 1 addition & 1 deletion coloraide/__meta__.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,5 +193,5 @@ def parse_version(ver: str) -> Version:
return Version(major, minor, micro, release, pre, post, dev)


__version_info__ = Version(3, 3, 0, "final")
__version_info__ = Version(3, 3, 1, "final")
__version__ = __version_info__._get_canonical()
19 changes: 15 additions & 4 deletions coloraide/gamut/fit_raytrace.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,16 +214,27 @@ def fit(
light = mapcolor[l]
hue = mapcolor[h]
achroma[c] = 0
achromatic = achroma.convert(space)[:-1]

# Floating point math can cause some deviations between the max and min
# value in the achromatic RGB color. This is usually not an issue, but
# some perceptual spaces, such as CAM16 or HCT, may compensate for adapting
# luminance which may give an achromatic that is not quite achromatic,
# causing a more sizeable delta between the max and min value in the
# achromatic RGB color. To compensate for such deviations, take the
# average value of the RGB components and use that as the achromatic point.
# When dealing with simple floating point deviations, little to no change
# is observed, but for spaces like CAM16 or HCT, this can provide more
# reasonable gamut mapping.
achromatic = [sum(achroma.convert(space)[:-1]) / 3] * 3

# Return white or black if the achromatic version is not within the RGB cube.
# HDR colors currently use the RGB maximum lightness. We do not currently
# clip HDR colors to SDR white, but that could be done if required.
mn, mx = alg.minmax(achromatic)
bmx = bmax[0]
if mx >= bmx:
point = achromatic[0]
if point >= bmx:
color.update(space, bmax, mapcolor[-1])
elif mn <= 0:
elif point <= 0:
color.update(space, [0.0, 0.0, 0.0], mapcolor[-1])
else:
# Create a ray from our current color to the color with zero chroma.
Expand Down
6 changes: 6 additions & 0 deletions docs/src/markdown/about/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 3.3.1

- **FIX**: Ray trace gamut mapping algorithm will better handle perceptual spaces like CAM16 and HCT which have
atypical achromatic responses. This prevents unexpected cutoff close to white.
- **FIX**: Fix some documentation examples regarding gamut mapping in HCT.

## 3.3

- **NEW**: Extend the `Cylindrical` mixin class to expose `radial_name()` and `radial_index()` on the color space to
Expand Down
2 changes: 1 addition & 1 deletion docs/src/markdown/demos/3d_models.html
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,7 @@ <h1>ColorAide Color Space Models</h1>
let colorSpaces = null
let colorGamuts = null
let lastModel = null
let package = 'coloraide-3.3-py3-none-any.whl'
let package = 'coloraide-3.3.1-py3-none-any.whl'
const defaultSpace = 'lab'
const defaultGamut = 'srgb'
const exceptions = new Set(['hwb', 'ryb', 'ryb-biased'])
Expand Down
2 changes: 1 addition & 1 deletion docs/src/markdown/demos/colorpicker.html
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ <h1>ColorAide Color Picker</h1>
let pyodide = null
let webspace = ''
let initial = 'oklab(0.69 0.13 -0.1 / 0.85)'
let package = 'coloraide-3.3-py3-none-any.whl'
let package = 'coloraide-3.3.1-py3-none-any.whl'

const base = `${window.location.origin}/${window.location.pathname.split('/')[1]}/playground/`
package = base + package
Expand Down
6 changes: 5 additions & 1 deletion docs/src/markdown/distance.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,10 +273,14 @@ This approach was specifically added to help produce tonal palettes, but with th
approach to chroma reduction in any perceptual space](./gamut.md#ray-tracing-chroma-reduction-in-any-perceptual-space),
users can defer to the ray tracing approach which does not require a special ∆E method and it performs much faster.

On occasions, MINDE approach can be slightly more accurate very close to white due to the way ray trace handles HCT's
atypical achromatic response, but differences should be imperceptible to the eye at such lightness levels making the
the improved performance of the ray trace approach much more desirable.

```py play
c = Color('hct', [325, 24, 50])
tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
Steps([c.clone().set('tone', tone).convert('srgb').to_string(hex=True, fit={'method': 'raytrace', 'hct': 0.0}) for tone in tones])
Steps([c.clone().set('tone', tone).convert('srgb').to_string(hex=True, fit={'method': 'raytrace', 'pspace': 'hct'}) for tone in tones])
```
///

Expand Down
6 changes: 5 additions & 1 deletion docs/src/markdown/gamut.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,10 +367,14 @@ approach to chroma reduction in any perceptual space](#ray-tracing-chroma-reduct
recommended that users apply that approach as it performs a tight chroma reduction much quicker, and it doesn't require
a special ∆E method.

On occasions, MINDE approach can be slightly more accurate very close to white due to the way ray trace handles HCT's
atypical achromatic response, but differences should be imperceptible to the eye at such lightness levels making the
the improved performance of the ray trace approach much more desirable.

```py play
c = Color('hct', [325, 24, 50])
tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
Steps([c.clone().set('tone', tone).convert('srgb').to_string(hex=True, fit={'method': 'raytrace', 'hct': 0.0}) for tone in tones])
Steps([c.clone().set('tone', tone).convert('srgb').to_string(hex=True, fit={'method': 'raytrace', 'pspace': 'hct'}) for tone in tones])
```
///

Expand Down
2 changes: 1 addition & 1 deletion docs/src/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ extra_css:
- assets/coloraide-extras/extra.css
extra_javascript:
- https://unpkg.com/mermaid@10.6.1/dist/mermaid.min.js
- playground-config-3905dc29.js
- playground-config-91c1cf40.js
- https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js
- assets/coloraide-extras/extra-notebook.js

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
var colorNotebook = {
"playgroundWheels": ['Pygments-2.16.1-py3-none-any.whl', 'coloraide-3.3-py3-none-any.whl'],
"notebookWheels": ['pyyaml', 'Markdown-3.5.1-py3-none-any.whl', 'pymdown_extensions-10.5-py3-none-any.whl', 'Pygments-2.16.1-py3-none-any.whl', 'coloraide-3.3-py3-none-any.whl'],
"playgroundWheels": ['Pygments-2.16.1-py3-none-any.whl', 'coloraide-3.3.1-py3-none-any.whl'],
"notebookWheels": ['pyyaml', 'Markdown-3.5.1-py3-none-any.whl', 'pymdown_extensions-10.5-py3-none-any.whl', 'Pygments-2.16.1-py3-none-any.whl', 'coloraide-3.3.1-py3-none-any.whl'],
"defaultPlayground": "import coloraide\ncoloraide.__version__\nColor('red')"
}
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ extra_css:
- assets/coloraide-extras/extra-e1cd7ecf37.css
extra_javascript:
- https://unpkg.com/mermaid@10.6.1/dist/mermaid.min.js
- playground-config-3905dc29.js
- playground-config-91c1cf40.js
- https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js
- assets/coloraide-extras/extra-notebook-Cs6O_Czb.js

Expand Down
2 changes: 1 addition & 1 deletion tests/test_acescc.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class TestACESccSerialize(util.ColorAssertsPyTest):
(
'color(--acescc 1.5 0.3 0)',
{'color': True, 'fit': 'lch-raytrace'},
'color(--acescc 1.468 0.3 -0.00071)'
'color(--acescc 1.468 0.3 -0.00064)'
),
('color(--acescc 1.5 0.2 0)', {'fit': False}, 'color(--acescc 1.5 0.2 0)')
]
Expand Down
30 changes: 13 additions & 17 deletions tools/raytrace_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,32 +217,33 @@ def simulate_raytrace_gamut_mapping(args):

orig = color.space()
mapcolor = color.convert(pspace, norm=False) if orig != pspace else color.clone().normalize(nans=False)
achroma = mapcolor.clone()
first = mapcolor.clone()
if is_lab:
l, a, b = mapcolor._space.indexes() # type: ignore[attr-defined]
chroma, hue = alg.rect_to_polar(mapcolor[a], mapcolor[b])
mapcolor[a] = 0
mapcolor[b] = 0
achroma[a] = 0
achroma[b] = 0
else:
l, c, h = mapcolor._space.indexes() # type: ignore[attr-defined]
chroma = mapcolor[c]
hue = mapcolor[h]
mapcolor[c] = 0
achroma = mapcolor.clone().convert(space, in_place=True)[:-1]
achroma[c] = 0
achromatic = [sum(achroma.clone().convert(space, in_place=True)[:-1]) / 3] * 3

# Return white or black if the achromatic version is not within the RGB cube.
mn, mx = alg.minmax(achroma)
bmx = bmax[0]
if mx >= bmx:
point = achromatic[0]
if point >= bmx:
color.update(space, bmax, mapcolor[-1])
points.append(first.convert(space)[:-1])
points.append(color.convert(space)[:-1])
points.append(achroma)
elif mn <= 0:
points.append(achromatic)
elif point <= 0:
color.update(space, [0.0, 0.0, 0.0], mapcolor[-1])
points.append(first.convert(space)[:-1])
points.append(color.convert(space)[:-1])
points.append(achroma)
points.append(achromatic)
else:
light = mapcolor[l]
if is_lab:
Expand Down Expand Up @@ -270,11 +271,11 @@ def simulate_raytrace_gamut_mapping(args):
gamutcolor[l] = light
gamutcolor[h] = hue
gamutcolor.convert(space, in_place=True)
intersection = raytrace_box(achroma, gamutcolor[:-1], bmax=bmax)
intersection = raytrace_box(achromatic, gamutcolor[:-1], bmax=bmax)
if intersection:
points.append(gamutcolor[:-1])
points.append(intersection)
points.append(achroma)
points.append(achromatic)
gamutcolor[:-1] = intersection
continue
break # pragma: no cover
Expand Down Expand Up @@ -318,15 +319,10 @@ def simulate_raytrace_gamut_mapping(args):
fig.add_traces(data)

if args.gamut_interp:
if is_lab:
mapcolor[a] = 0
mapcolor[b] = 0
else:
mapcolor[c] = 0
plot_interpolation(
fig,
space,
first.to_string(fit=False) + ';' + mapcolor.to_string(fit=False),
first.to_string(fit=False) + ';' + achroma.to_string(fit=False),
pspace,
'linear',
'shorter',
Expand Down

0 comments on commit 9e20d21

Please sign in to comment.