culori: using `clampChroma` within a conversion sequence results in invalid values

Issue

Using clampChroma within a conversion sequence results in a color object with invalid values.

Expected

Post-clampChroma conversions result in color objects with valid values as defined by culorijs.org/color-spaces.

Example

import {rgb, oklch, clampChroma} from "culori"

const max = {l:1, c:0.322, h:360}

rgb(
  clampChroma(
    oklch({
      l: (max.l * 0.5),
      c: (max.c * 0.5),
      h: (max.h * 0.5),
    })
  )
)

Result

oklch > clampChroma > rgb

r has a negative value, which is outside of the rgb color-space’s documented {min:0, max:1} range.

{mode:'rgb', r:-0.00015269415291914584, g:0.4633768089712706, b:0.40462053745496246}

Other Values

oklch

{mode:'oklch', l:0.5, c:0.161, h:180}

oklch > clampChroma

{mode:'oklch', l:0.5074222643221025, c:0.09214221649333439, h:179.92987163513519}

oklch > rgb

{mode:'rgb', r:-1.1275082865428603, g:0.4943967336505179, b:0.40272542671134454}

About this issue

Commits related to this issue

Most upvoted comments

I’m currently working through the data you provided. The color table you provided with chromaClamp(oklch()):

Screenshot 2021-07-25 at 21 40 57

This converts the colors to lch, then tries to find the closest chroma that’s displayable while maintaining the lch hue constant, which doesn’t necessarily result in the oklch hue to remain constant. The fact that oklch has its own Chroma and Hue dimensions is incidental — nonetheless it may become confusing.

When doing clampChroma in the color’s original color space, clampChroma(oklch(...), 'oklch') we get rid of the hue drift, and the Lightness increases monotonically:

Screenshot 2021-07-25 at 21 41 08

Now, a remaining problem in both tables is the row corresponding to the OKLCh Lightness = 1. This is due to a quirk/side-effect in OKLab, in that sRGB white has L: 0.9999882345920056, not L: 1. So any OKLCh color with L: 1 will be too bright to be displayable in sRGB on any Chroma (even 0), so clampChroma falls back to clampRgb, and the row appears out of order with the others. I should probably update the OKLab / OKLCh Lightness range on the Color Spaces to be more accurate. (Also, there are other ways to clamp out-of-gamut colors that may be better for this particular situation)

@danburzo Hey I think I might have found some more issues with the chroma clamping.

Video Demonstration:


I wrote a small wrapper for culori that uses unit intervals [0-1] for [r,g,b,h,c,l,a, etc.], but I do have some tests implemented that ensure accurate conversions. I’ll do a bit more debugging tomorrow to double check that the issues aren’t being caused by anything on my end & let you know how it goes.

If I do narrow the issue down to something within culori, I’d be open to setting up a screen sharing session with you if you think it might lead to any insights or want to check out any of the generated data.


This is the function I’m calling in the demo:

Color.Range({
  count,
  color:  {h:(hue/100), c:(chroma/100), l:0},
  result: "Hex6",
  modify: {
    l: {ease:"Linear", range:{from:(min/100), to:(max/100)}},
  },
})

The count, min, max, hue, & chroma variables are being passed to a Svelte component via Storybook’s controls.

h and c remain static for each generated range, and l values are being generated from min to max via the easing library, after which all of the resulting color objects are passed through my wrapper which converts the unit interval values to culori values and then runs them through a similar color conversion process as initially detailed in this issue, finally applying the generated hex values to the HTML elements.

So, the clampChroma() method made the following assumption: there is a value for c for which we know the color is displayable, we’re going to iterate towards it until the search interval becomes very small. At this point, for a small enough interval, the color should be displayable. But it didn’t guarantee you landed on a displayable color on the last iteration. With the fix above we use the last color in the interation we know to be displayable (sacrificing a tiny bit of chroma in the process).