facelessuser / coloraide

A library to aid in using colors
https://facelessuser.github.io/coloraide
MIT License
195 stars 12 forks source link

Consider `lighten()`, `darken()`, `tint()`, and/or `shade()` #259

Closed facelessuser closed 1 year ago

facelessuser commented 1 year ago

Recently been thinking about adding some sort of quick and easy lightening/darkening methods.

Initially, the thought was to just create a simple method that adjusts the lightness in a perceptually uniform color space like Oklab.

The thought also occurred to me that maybe we just want to mix white or black in to lighten or darken.

We can consider these methods:

    def lighten(
        self,
        amount: float,
    ) -> Color:
        """Lighten a color by a given amount."""

        space = self.space()
        color = self.convert('oklab', in_place=True)
        l = min(color['l'], 1.0)
        color['l'] = l + amount * (1 - l)
        return color.convert(space, in_place=True)

    def darken(
        self,
        amount: float,
    ) -> Color:
        """Darken a color by a given amount."""

        space = self.space()
        color = self.convert('oklab', in_place=True)
        l = max(color['l'], 0.0)
        color['l'] = l - amount * l
        return color.convert(space, in_place=True)

    def tint(
        self,
        amount: float,
    ) -> Color:
        """Tint by mixing in white."""

        return self.mix('white', amount, space='oklab', in_place=True)

    def shade(
        self,
        amount: float,
    ) -> Color:
        """Shade by mixing in black."""

        return self.mix('black', amount, space='oklab', in_place=True)

Adjusting lightness in Oklab via lighten() and darken() works fine, but results are a bit funny when you gamut map. Currently, we are gamut mapping in LCh, which is not perfect, but it avoids some quirks of gamut mapping with OkLCh. In order to have the best lightening and darkening, you really need to gamut map in OkLCh.

tint() and shade() on the other hand seems to work well in either LCh or OkLCh gamut mapping as we are just linearly interpolating between the color and white or black. In the ideal cases, the results are very similar to the best lighten() / darken() cases.

Top two cases are using OkLCh gamut mapping and the bottom two are using LCh gamut mapping. Out of the two pairs, the first row is using tint() and the second row is using lighten():

Screenshot 2023-01-14 at 8 47 34 PM

Top two cases are using OkLCh gamut mapping and the bottom two are using LCh gamut mapping. Out of the two pairs, the first row is using shade() and the second row is using darken():

Screenshot 2023-01-14 at 8 50 01 PM

I'm wondering if something like tint() and shade() is really all we need. We could even just call them lighten() and darken() if we want.

facelessuser commented 1 year ago

In the end, it may not be worth it to add tint() and shade() as it is just color.mix('white', 0.5) or color.mix('black', 0.5). A user can really do that on their own without much effort. I guess if people really, really wanted it as a convenience, then we could add it, but if not, I don't see this as really being that useful.

If we were to go the tint/shade route for lightening and darkening, then it may not make sense for us to bother adding a function unless it was just really, really wanted as a convenience function as well.

Something to think about.

facelessuser commented 1 year ago

For now, I think we are going to pass on this. I imagine we could change our mind sometime in the future or maybe if people really want some these maybe that would cause us to change our mind.

apcamargo commented 2 months ago

I understand that this feature was ultimately decided against, but I wanted to share my thoughts that it might still be worth including these functions in the documentation, even though they included in the library. Not only are they pretty useful, but the code serve as a good example of how to use coloraid for simple color space manipulation tasks.

For reference, in case anyone finds this issue through a web search, I wrote slightly modified versions of the lighten and darken functions using the OKHSL space (just because the ranges are simpler to manage than OKLCH's, but I realize this limits manipulation to the sRGB gamut). I also implemented saturate and desaturate functions.

from coloraide import Color as Base
from coloraide.spaces.okhsl import Okhsl

class Color(Base):
    ...

Color.register(Okhsl())

def lighten(
    color: Color,
    amount: float,
) -> Color:
    """
    Lighten a color by a given amount.
    """
    space = color.space()
    color = color.convert("okhsl")
    color["l"] += amount * (1 - color["l"])
    return color.convert(space)

def darken(
    color: Color,
    amount: float,
) -> Color:
    """
    Darken a color by a given amount.
    """
    space = color.space()
    color = color.convert("okhsl")
    color["l"] -= amount * color["l"]
    return color.convert(space)

def saturate(
    color: Color,
    amount: float,
) -> Color:
    """
    Saturate a color by a given amount.
    """
    space = color.space()
    color = color.convert("okhsl")
    color["s"] += amount * (1 - color["s"])
    return color.convert(space)

def desaturate(
    color: Color,
    amount: float,
) -> Color:
    """
    Desaturate a color by a given amount.
    """
    space = color.space()
    color = color.convert("okhsl")
    color["s"] -= amount * color["s"]
    return color.convert(space)
facelessuser commented 2 months ago

It's so much easier just using mix, even for your case, but if you want functions, it is easy to do, you can even make them part of the class:

from coloraide import Color as Base
from coloraide.spaces.okhsl import Okhsl

class Color(Base):
    def lighten(self, amount):
        """Lighten."""

        return self.mix(Color('okhsl', [NaN, NaN, 1]), amount, space='okhsl', out_space=self.space())

    def darken(self, amount):
        """Darken."""

        return self.mix(Color('okhsl', [NaN, NaN, 0]), amount, space='okhsl', out_space=self.space())

    def saturate(self, amount):
        """Saturate."""

        return self.mix(Color('okhsl', [NaN, 1, NaN]), amount, space='okhsl', out_space=self.space())

    def desaturate(self, amount):
        """Desaturate."""

        return self.mix(Color('okhsl', [NaN, 0, NaN]), amount, space='okhsl', out_space=self.space())

Results are identical to yours (yours left, mix version right):

Screenshot 2024-08-11 at 1 03 50 PM
>>> pink = Color('pink')
>>> Steps([lighten(pink, 0.25), pink.lighten(0.25)])
[color(srgb 1 0.81691 0.84708 / 1), color(srgb 1 0.81691 0.84708 / 1)]
>>> Steps([darken(pink, 0.75), pink.darken(0.75)])
[color(srgb 0.37409 0.00001 0.1479 / 1), color(srgb 0.37409 0.00001 0.1479 / 1)]
>>> Steps([desaturate(pink, 0.75), pink.desaturate(0.75)])
[color(srgb 0.88737 0.80565 0.81814 / 1), color(srgb 0.88737 0.80565 0.81814 / 1)]
>>> Steps([saturate(pink, 0.75), pink.saturate(0.75)])
[color(srgb 1 0.75294 0.79608 / 1), color(srgb 1 0.75294 0.79608 / 1)]
facelessuser commented 2 months ago

As for putting examples in the documentation, It's probably not a bad idea to show some of these more advanced topics in extending the Color object.

apcamargo commented 2 months ago

That's a much smarter approach :)

But yeah, my main point is that having these as examples in the documentation would be useful both to illustrate different ways to use the Color object and to show people how to perform those common manipulations.