Skip to content

Commit

Permalink
Restrict correction algorithm to produce sane results in extreme cases
Browse files Browse the repository at this point in the history
When using ray trace and the adaptive lightness approach, Luv and its
cylindrical counter part can produce chroma reduction curves that stress
the algorithm in the dark blue region. This can yield yellows which make
no sense.

Add a restriction in the correction code that projects the color onto
the chroma reduction vector in the Lab model to restrict extreme
results, outside the range of the vector, which can create colors
outside the color space's ability to convert the color causing massive
hue shifts.

Colors will still not be accurate, but they will be much closer to
then intended target and be a more sane representation.
  • Loading branch information
facelessuser committed Sep 14, 2024
1 parent 6c72669 commit 4cfb11b
Show file tree
Hide file tree
Showing 3 changed files with 46 additions and 32 deletions.
9 changes: 7 additions & 2 deletions coloraide/gamut/fit_raytrace.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ def project_onto(a: Vector, b: Vector, o: Vector) -> Vector:
vec_ob = [b[0] - ox, b[1] - oy, b[2] - oz]
# Project `vec_oa` onto `vec_ob` and convert back to a point
r = alg.vdot(vec_oa, vec_ob) / alg.vdot(vec_ob, vec_ob)
# Some spaces may be project something that exceeds the range of our target vector.
if r > 1.0:
r = 1.0
elif r < 0.0:
r = 0.0
return [vec_ob[0] * r + ox, vec_ob[1] * r + oy, vec_ob[2] * r + oz]


Expand Down Expand Up @@ -275,8 +280,8 @@ def fit(
# to the new corrected color finding the intersection again.
mapcolor.convert(space, in_place=True)

# Interpolation path
if adaptive:
# Interpolation path
start = [light, *ab]
end = [alight, 0.0, 0.0]

Expand Down Expand Up @@ -323,7 +328,7 @@ def fit(
# Adjust anchor point closer to surface to improve results for some spaces.
# Don't move point too close to the surface to avoid corner cases with some spaces.
if i and all(low < x < high for x in coords):
anchor = mapcolor[:-1]
anchor = coords

# Update color with the intersection point on the RGB surface.
if intersection:
Expand Down
59 changes: 30 additions & 29 deletions docs/src/markdown/gamut.md
Original file line number Diff line number Diff line change
Expand Up @@ -795,30 +795,32 @@ Steps(
///

//// note | Adaptive Lightness with Ray Trace
Generally, adaptive lightness can be used on any perceptual space against any target gamut, but it should be noted that
the ray trace algorithm can have trouble if gamut mapping in certain perceptual spaces or if given high **ɑ** in others.
In about 90% of the perceptual spaces, it can be sufficiently reliable. For instance, Oklab/OkLCh has no noticeable
issues even up to an **ɑ** value of 5. Many times we can still take advantage of the speed improvements of ray trace
with adaptive lightness, but in some cases, MINDE may be the only option due to its slower, but more direct approach at
reducing chroma.

The ray trace approach was designed to be a faster way to calculate constant lightness chroma reduction, something it
excels at. Constant lightness is particularly well suited for this approach as the chroma reduction lines of the
perceptual space form gentle curves in the linear light RGB spaces. When using adaptive lightness, the lightness is no
longer constant and in some perceptual spaces can causes the curves to be more severe or bend in multiple dimensions
causing the algorithm to be slightly less accurate or, in some cases, extremely less accurate, especially when using
larger **ɑ** values.

The worst offender out of the perceptual spaces that ColorAide currently offers is Luv/LCh~uv~. This particular space
bends the blue hues differently than most of the others. When using constant lightness, Luv/LCH~uv~ works great, but as
soon as adaptive lightness is introduced, even at low levels of **ɑ** = 0.05, the ray trace algorithm will struggle in
the dark blue region.

Comparing constant lightness and adaptive lightness in Luv/LCH~uv~ with the ray trace method in the dark blue region, we
can see that with constant lightness the results are quite accurate. When we enable adaptive lightness, it creates an
extreme curve in the dark blue region that can cause some of our traces along the chroma reduction curve to find colors
that are completely inaccurate. Additionally, we can run these same tests using the MINDE approach (with JND set to zero
for similar results) and see results in both cases are accurate.
Generally, adaptive lightness can be used within any perceptual space against any target gamut using the ray trace
approach or MINDE chroma reduction approach. It should be noted though, that some spaces will not perform as well at
high **ɑ** values due to their geometry regardless of whether the ray trace or MINDE chroma reduction approach is used.
Through our testing, results are generally comparable when using either approach.

Out of all the Lab-ish and LCh-ish color spaces, there is one that stresses the ray trace algorithm enough when using
hue independent adaptive lightness to cause a noticeable difference between the ray trace and MINDE chroma reduction
approach, and that space is Luv/LCH~uv~.

The ray trace approach was originally designed to be a faster way to calculate chroma reduction with constant lightness,
something it excels at. Constant lightness is particularly well suited for this approach as the chroma reduction lines
of the perceptual space form gentle curves in the linear light RGB spaces. When using adaptive lightness, the lightness
is no longer constant and, in some perceptual spaces, can causes the curves to be more severe causing the algorithm to
be less accurate in certain regions, especially when using larger **ɑ** values.

When using constant lightness and low adaptive **ɑ** values (around 0.05), Luv/LCH~uv~ and ray trace will behave pretty
well, but if we push the **a** value higher, the algorithm will struggle in the dark blue region of Luv/LCH~uv~. This is
because this space will create a bend in the chroma reduction path that is too severe for the ray trace algorithm to
select the most optimal color. This is simply a limitation when the algorithm is pushed too hard. It may be possible to
mitigate this issue in the future, but the current design does not account for these more extreme curves.

As an example, we can observe constant lightness and an adaptive lightness level of 0.5 in Luv/LCH~uv~ with the ray
trace method in the dark blue region. We can see that with constant lightness the results are quite accurate. When we
enable an adaptive lightness level of 0.5, the chroma reduction path stresses the algorithm too much and yields less
accurate results. Additionally, we can run these same tests using the MINDE approach (with JND set to zero for similar
results) and see improved results in the adaptive lightness case.

/// tab | Ray Trace
```py play
Expand Down Expand Up @@ -854,11 +856,10 @@ Steps(
```
///

If you are using a perceptual space such as Oklab, you will likely have no issues and can take full advantage of the
speed improvements that ray trace brings. If you are using one of the few perceptual spaces that stress the algorithm
too much, such as Luv/LCh~uv~, you may want to use MINDE which is more accurate, but slower. When using MINDE, if you
want to also have the closest chroma reduction while maintaining the hue, then selecting a JND of 0 will not only
increase its speed, but provide results closer to ray trace, albeit still slower.
When using almost any perceptual space with adaptive lightness and ray trace the results will be comparable with either
MINDE or ray trace chroma reduction, but if using a high adaptive value in Luv/LCh~uv~ (or some other space that
introduces similar responses), it may be better to use MINDE chroma reduction whose slower, more straight forward
approach will have no issues in these such cases.
////

## Pointer's Gamut
Expand Down
10 changes: 9 additions & 1 deletion tools/raytrace_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,8 @@ def simulate_raytrace_gamut_mapping(args):
points.append(color.convert(space)[:-1])
points.append(achromatic)
else:
print('Initial:', mapcolor)
print('Anchor:', achroma.convert(pspace), '\n----')
gamutcolor = mapcolor.convert(space)

# Threshold for anchor adjustment
Expand All @@ -274,6 +276,7 @@ def simulate_raytrace_gamut_mapping(args):
for i in range(4):
if i:
gamutcolor.convert(pspace, in_place=True, norm=False)
print('Uncorrected:', gamutcolor)

if adaptive:
# Correct the point onto the desired interpolation path
Expand All @@ -290,6 +293,7 @@ def simulate_raytrace_gamut_mapping(args):
[light, *ab],
[alight, 0.0, 0.0]
)

else:
# Correct lightness and hue
gamutcolor[l] = alight
Expand All @@ -301,13 +305,15 @@ def simulate_raytrace_gamut_mapping(args):
hue
)

print('Corrected:', gamutcolor)
gamutcolor.convert(space, in_place=True)
print('Corrected RGB:', gamutcolor, '\n----')

coords = gamutcolor[:-1]
intersection = raytrace_box(achromatic, coords, bmax=bmax)

if i and all(low < x < high for x in coords):
achromatic = gamutcolor[:-1]
achromatic = coords

if intersection:
points.append(gamutcolor[:-1])
Expand All @@ -317,7 +323,9 @@ def simulate_raytrace_gamut_mapping(args):
continue
break # pragma: no cover

print('Final:', gamutcolor.convert(pspace, norm=False))
color.update(space, [alg.clamp(x, 0.0, bmx) for x in gamutcolor[:-1]])
print('Clipped RGB:', color.convert(space))

# If we have coerced a space to RGB, update the original
if coerced:
Expand Down

0 comments on commit 4cfb11b

Please sign in to comment.