-
Notifications
You must be signed in to change notification settings - Fork 715
[css-color-4] Gamut Mapping Algorithm and Color Banding #7135
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
That was more of a consideration when we were working in CIE LCH and using ΔE2000. Less so with the simple ΔEOK formula. While it is an intent to make the computation performant enough to be implementable, I wouldn't say speed was the primary criterion (else we would just use per-component clip which is super fast). The primary motivator was perceptual hue preservation with minimal chroma reduction.
That doesn't affect the speed that much, and is not done for speed. It is done to avoid excessive chroma reduction with slightly-concave upper gamut boundaries, which was a severe problem.
That sounds very interesting and is entirely consistent with the aims of the CSS gamut mapping algorithm. Could you take a shot at expressing it in terms of a modified pseudocode from the spec? |
I should also add that I am trying not to over-constrain implementations, in particular if they prefer to find the gamut volume intersection geometrically rather than by binary search. I'm not sure I have been successful in that though, or whether anyone cares about that aspect. |
I've actually had some more time to play with this and experiment. I don't think you actually have to change the algorithm, just the limit. I would tentatively suggest moving from The issue is that when you have two adjacent colors, which are already some distance apart, and then you have a full JND range of I think |
This is good to know.
Yeah, I'm imagining that squeezing the window by using So, here is some pseudo code for the option of keeping the ∆E matching close to the upper limit of the JND. This ensures adjacent colors are mapped similarly to reduce worst case scenario of greatly maximizing the color distance with adjacent colors. This also ensures we favor the upper edge of the JND to catch the higher chroma resolution when possible.
If anything is unclear, I figure I'd post some real code (in Python). Hopefully, this makes sense. class OklchChroma(Fit):
"""Algorithm to gamut map a color using chroma reduction and minimum ∆E."""
NAME = "oklch-chroma"
EPSILON = 0.001
LIMIT = 0.02
DE = "ok"
SPACE = "oklch"
MIN_LIGHTNESS = 0
MAX_LIGHTNESS = 1
@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))
lower_in_gamut = True
# Adjust chroma if we are not under the JND yet.
if mapcolor.delta_e(color, method=cls.DE) >= cls.LIMIT:
while True:
mapcolor.chroma = (high + low) * 0.5
# Avoid doing expensive delta E checks if in gamut
if lower_in_gamut and mapcolor.in_gamut(space, tolerance=0):
low = mapcolor.chroma
else:
clip_channels(color.update(mapcolor))
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 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
low = mapcolor.chroma
else:
# We are still outside the gamut and outside the JND
high = mapcolor.chroma |
I've also recently implemented the css-color-4 gamut mapping algorithm along with a few other related ideas and published a demo. Barring any implementation mistakes, I can confirm the issues with banding and discontinuities (#7071) with the algorithm described in the spec. I'm plotting hue slices of the various uniform color spaces (CIELCH, OKLCH, etc). I imagine authors will use With the current algorithm, I believe the JND would have to be lowered out of existence: when the JND becomes small enough to eliminate banding, it's virtually indistinguishable from simple chroma reduction. Here are some of the related ideas I've tried (just to get them out of the way):
I will try to implement @facelessuser's proposed algorithm & plot some results. |
I've implemented @facelessuser's algorithm under In the source code, you can compare |
The basic idea with my implementation is:
It's honestly similar to Color.js's approach but cuts the ∆E calls by probably half. With the way the current CSS algorithm works, you can end up reducing one color to the low end of the JND and an adjacent color on the high end of the JND. If this happens, you have something like 2 * JND difference between them, plus any natural color difference between the two colors. That creates the banding. Also, keeping the JND on the high end lets you catch any weird geometric quirks of a color space. When gamut mapping with Lch instead of Oklch, there was a problem with Display P3 yellow being reduced too aggressively, and this was just because of the weird shape of the LCH slice. Reducing chroma on the high end helps it catch the peak. This is obviously less of a problem when using Oklch, but Oklch seems to have other issues in the green-ish blue region. Below, you can see how the gamut mapping line would have missed the peak had it not been reduced to the high end of the JND. |
@danburzo cool demo by the way 🙂. And yes, the results seem very similar to the fuzzy implementation. |
Here are the two algorithms, shown as a diff for clearer comparison: +let lower_bound_in_gamut = true;
let start = 0;
let end = candidate.c;
let ε = 0.001;
let e;
while (end - start > ε) {
candidate.c = (start + end) * 0.5;
- if (inGamut(candidate)) {
+ if (inGamut(candidate) && lower_bound_in_gamut) {
start = candidate.c;
} else {
clipped = clipToGamut(candidate);
e = delta(candidate, clipped);
if (e <= jnd) {
- start = candidate.c;
+ if (jnd - e < ε) {
+ return clipped;
+ } else {
+ lower_bound_in_gamut = false;
+ start = candidate.c;
+ }
} else {
end = candidate.c;
} They converge to the same solution, but your proposed version has the following optimizations:
|
I would further suggest swapping to the following to avoid doing unnecessary in gamut checks as well: - if (inGamut(candidate) && lower_bound_in_gamut) {
+ if (lower_bound_in_gamut && inGamut(candidate)) { |
Oops, I had arranged it like that for symmetry but that kind of contradicts my when we've left the gamut, the lower_bound_in_gamut flag prevents further gamut computations, which we already know return false from now on statement 🤪 |
EDIT: Has two bugs, do not use, see later, corrected version Here is my tester code, using color.js but re-implementing the GMA from the pseudocode above. I found that epsilon cold be reduced without noticeable impact on runtime. In general this algorithm is running 4-5x faster than the current color.js one (which uses CIE LCH as the GM space and deltaE2000 as the distance metric, and also makes more in-gamut checks and uses more large objects): const JND = 0.02;
const ε = 0.0001;
function gamutMap (origin, destination) {
// OKLCH is the CSS GMA working space
let origin_OKLCH = to(origin, 'oklch');
let L = origin_OKLCH.coords[0];
// return media white or black, if lightness is out of range
if (L >= 1) return {space: destination, coords: [1, 1, 1], alpha: origin.alpha};
if (L <= 0) return {space: destination, coords: [0, 0, 0], alpha: origin.alpha};
// otherwise, return origin in destination, if in gamut
if (inGamut(origin, destination)) return to(origin, destination);
// set up for OKLCH chroma reduction
let min = 0;
let max = origin_OKLCH.coords[1];
let min_inGamut = true;
let current = clone(origin_OKLCH);
let clipped = clip(current, destination);
// but first check if we are "close" to in gamut
let E = deltaEOK(clipped, current);
if (E < JND) return clipped;
// now actually binary search for the in-gamut chroma value
// console.log("pre-checks complete, still here, doing actual gamut mapping");
while (max - min > ε) {
let chroma = (min + max) / 2;
current.coords[1] = chroma;
if (min_inGamut && inGamut(current, destination)) {
min = chroma
} else {
clipped = clip(current, destination);
E = deltaEOK(clipped, current);
if (E < JND) {
if (E - JND < ε) {
return clipped;
} else {
min_inGamut = false;
min = chroma
}
} else {
max = chroma;
}
}
} // end of chroma reduction loop
} Live versions: |
Yeah, the speed and reduced color banding make this an obvious winner for me :). Happy to see your formal results match what I was observing. |
The upadated spec text reflects the above code:
but |
That does look like a typo and is likely to give some incorrect results. It's likely that this would cause the algorithm to kick out too fast. |
It is indeed, oops.
Yes. Which, once corrected, reveals the second bug in the code I posted above; almost always, the clipped value is returned but sometimes,
It did; the speedup advantage has now reduced, but it is still 2-3x faster than color.js current (OKLCH chroma, deltaE2000). |
const JND = 0.02;
const ε = 0.0001;
function gamutMap (origin, destination) {
// OKLCH is the CSS GMA working space
let origin_OKLCH = to(origin, 'oklch');
let L = origin_OKLCH.coords[0];
// return media white or black, if lightness is out of range
if (L >= 1) return {space: destination, coords: [1, 1, 1], alpha: origin.alpha};
if (L <= 0) return {space: destination, coords: [0, 0, 0], alpha: origin.alpha};
// otherwise, return origin in destination, if in gamut
if (inGamut(origin, destination)) return to(origin, destination);
// set up for OKLCH chroma reduction
let min = 0;
let max = origin_OKLCH.coords[1];
let min_inGamut = true;
let current = clone(origin_OKLCH);
let clipped = clip(current, destination);
// but first check if we are "close" to in gamut
let E = deltaEOK(clipped, current);
if (E < JND) return clipped;
// now actually binary search for the in-gamut chroma value
// console.log("pre-checks complete, still here, doing actual gamut mapping");
while (max - min > ε) {
let chroma = (min + max) / 2;
current.coords[1] = chroma;
if (min_inGamut && inGamut(current, destination)) {
min = chroma
} else {
clipped = clip(current, destination);
E = deltaEOK(clipped, current);
if (E < JND) {
if (JND - E < ε) {
return clipped;
} else {
min_inGamut = false;
min = chroma
}
} else {
max = chroma;
}
}
} // end of chroma reduction loop
return current;
} |
Yep, that is much more like my measured gains, still really good though. |
@svgeesus should we update WPT to account for the changes in e30aec4? When I tried to update Lightning CSS to use the new algorithm, I got a bunch of test failures. For example, |
Yes, we should. |
I'll preface this by saying I'm not entirely sure what the top priority is in the gamut mapping, so maybe this amount of banding is perfectly acceptable.
So the gamut mapping algorithm, I assume, is balancing the best possible match with speed, so there will be a bit of a trade-off, and in some cases may be some concessions.
The current algorithm seems to want to minimize calling the distancing algorithm as much as possible, which makes sense as that will provide the greatest speed. So, the algorithm breaks the binary search loop as soon as the chroma reduction yields a color outside the gamut but within the JND range from the clipped form of the chroma reduced color. The color can fall anywhere within this window and it will kick out. In a gradient with other colors that should be fairly close, banding can occur.
I've implemented the CSS color gamut mapping algorithm as outlined in the spec, but I've also implemented a variant that tries to keep the color on the high end of the JND to prevent reducing the chroma more than necessary. When doing this, the results provide smoother transitions. The variant only performs the distancing on iterations where the color is out of gamut and kicks out as soon as it is close-ish to the upper end of the JND from its clipped counterpart. Obviously, there is a tradeoff in speed, but not as bad as performing the distancing on every iteration.
I'll admit, the banding can be kind of subtle, and sometimes a little less so, and again, I'm not sure if this is an acceptable amount or not, figured it would be worth bringing it up as I imagine a system doing lots of gamut mapping could produce some banding, but speed is also a concern.
The text was updated successfully, but these errors were encountered: