Re: [csswg-drafts] [css-color-4] Gamut Mapping Algorithm and Color Banding (#7135)

> 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.

This is good to know.

> Could you take a shot at expressing it in terms of a modified [pseudocode from the spec](https://drafts.csswg.org/css-color-4/#binsearch)?

Yeah, I'm imagining that squeezing the window by using `0.003` takes us closer to an almost pure chorma reduction case which is not the intention.  As the geometry of a color space may have some less favorable shapes, squeezing that window could cause us to miss a higher chroma resolution as we might pass right by them.

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 favors the upper edge of the JND to catch the higher chroma resolution when possible.

1. let origin_OKLCH be origin converted from origin color space to the OKLCH color space
2. if the Lightness of origin_OKLCH is greater than or equal to 100%, return { 1 1 1 origin.alpha } in destination
3. if the Lightness of origin_OKLCH is less than than or equal to 0%, return { 0 0 0 origin.alpha } in destination
4. let inGamut(color) be a function which returns true if, when passed a color, that color is inside the gamut of destination
5. if inGamut(origin_OKLCH) is true, convert origin_OKLCH to destination and return it as the gamut mapped color
6. otherwise, let delta(one, two) be a function which returns the deltaEOK of color one compared to color two
7. let JND be 0.02
8. Let EPSILON be 0.001
8. let clip(color)| be a function which converts color to destination, converts all negative components to zero, converts all components greater that one to one, and returns the result
10. set min to zero
11. set max to the OKLCH chroma of origin_OKLCH
12. let lower_bound_in_gamut be a boolean that represents when the min chroma is still found to be in gamut and set it to true
13. set current to origin_OKLCH
14. set clipped to clip(current)
15. set E to delta(clipped, current)
16. if E < JND return clipped as the gamut mapped color
17. repeat the following steps
    1. set chroma to (min + max) / 2
    2. set current to origin_OKLCH and then set the chroma component to chroma
    3. if lower_bound_in_gamut is true and inGamut(current) is true, set min to chroma and continue to repeat these steps
    4. otherwise, if inGamut(current) is false carry out these steps:
        1. set clipped to clip(current)
        2. set E to delta(clipped, current)
        3. if E < JND check and follow these steps
            1. if (E - JND) < EPSILON is true, return the clipped color, otherwise folow these steps:
                1. set lower_bound_in_gamut to false
                2. set min to chroma and continue
        4. otherwise, set max to chroma and continue to repeat these steps

If anything is unclear, I figure I'd post some real code (in Python). Hopefully, this makes sense.

```py
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
```

-- 
GitHub Notification of comment by facelessuser
Please view or discuss this issue at https://github.com/w3c/csswg-drafts/issues/7135#issuecomment-1066121795 using your GitHub account


-- 
Sent via github-notify-ml as configured in https://github.com/w3c/github-notify-ml-config

Received on Sunday, 13 March 2022 15:10:14 UTC