Last active
September 15, 2021 18:12
-
-
Save facelessuser/f4a154e4d74f17ae7049f9e548ab09ea to your computer and use it in GitHub Desktop.
Luv and Lchuv for Coloraide
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Luv and Lch class. | |
https://en.wikipedia.org/wiki/CIELUV | |
""" | |
from coloraide.spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Percent, WHITES, Cylindrical, Angle | |
from coloraide.spaces.xyz import XYZ | |
from coloraide import util | |
from coloraide import Color as ColorOrig | |
import copy | |
import re | |
import math | |
ACHROMATIC_THRESHOLD = 0.000000000002 | |
def xyz_to_luv(xyz, white): | |
"""XYZ to Luv.""" | |
u, v = util.xyz_to_uv(xyz) | |
wp = util.xy_to_xyz(*WHITES[white]) | |
un, vn = util.xyz_to_uv(wp) | |
y = xyz[1] / wp[1] | |
l = 116 * util.nth_root(y, 3) - 16 if y > ((6 / 29) ** 3) else ((29 / 3) ** 3) * y | |
return [ | |
l, | |
13 * l * (u - un), | |
13 * l * (v - vn), | |
] | |
def luv_to_xyz(luv, white): | |
"""Luv to XYZ.""" | |
l, u, v = luv | |
wp = util.xy_to_xyz(*WHITES[white]) | |
un, vn = util.xyz_to_uv(wp) | |
if l != 0: | |
up = (u / ( 13 * l)) + un | |
vp = (v / ( 13 * l)) + vn | |
else: | |
up = vp = 0 | |
y = wp[1] * ((l + 16) / 116) ** 3 if l > 8 else wp[1] * l * ((3 / 29) ** 3) | |
if vp != 0: | |
x = y * ((9 * up) / (4 * vp)) | |
z = y * ((12 - 3 * up - 20 * vp) / (4 * vp)) | |
else: | |
x = z = 0 | |
return [x, y, z] | |
class Luv(Space): | |
"""Oklab class.""" | |
SPACE = "luv" | |
SERIALIZE = ("--luv",) | |
CHANNEL_NAMES = ("lightness", "u", "v", "alpha") | |
DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3)) | |
WHITE = "D65" | |
RANGE = ( | |
GamutUnbound([Percent(0), Percent(100.0)]), | |
GamutUnbound([-175.0, 175.0]), | |
GamutUnbound([-175.0, 175.0]) | |
) | |
@property | |
def lightness(self): | |
"""L channel.""" | |
return self._coords[0] | |
@lightness.setter | |
def lightness(self, value): | |
"""Get true luminance.""" | |
self._coords[0] = self._handle_input(value) | |
@property | |
def u(self): | |
"""U channel.""" | |
return self._coords[1] | |
@u.setter | |
def u(self, value): | |
"""U axis.""" | |
self._coords[1] = self._handle_input(value) | |
@property | |
def v(self): | |
"""V channel.""" | |
return self._coords[2] | |
@v.setter | |
def v(self, value): | |
"""V axis.""" | |
self._coords[2] = self._handle_input(value) | |
@classmethod | |
def _to_xyz(cls, parent, luv): | |
"""To XYZ.""" | |
return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, luv_to_xyz(luv, cls.WHITE)) | |
@classmethod | |
def _from_xyz(cls, parent, xyz): | |
"""From XYZ.""" | |
return xyz_to_luv(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz), cls.WHITE) | |
def luv_to_lchuv(luv): | |
"""Luv to Lch(uv).""" | |
l, u, v = luv | |
c = math.sqrt(u ** 2 + v ** 2) | |
h = math.degrees(math.atan2(v, u)) | |
# Achromatic colors will often get extremely close, but not quite hit zero. | |
# Essentially, we want to discard noise through rounding and such. | |
if c < ACHROMATIC_THRESHOLD: | |
h = util.NaN | |
return [l, c, util.constrain_hue(h)] | |
def lchuv_to_luv(lchuv): | |
"""Lch(uv) to Luv.""" | |
l, c, h = lchuv | |
h = util.no_nan(h) | |
# If, for whatever reason (mainly direct user input), | |
# if chroma is less than zero, clamp to zero. | |
if c < 0.0: | |
c = 0.0 | |
return ( | |
l, | |
c * math.cos(math.radians(h)), | |
c * math.sin(math.radians(h)) | |
) | |
class Lchuv(Cylindrical, Space): | |
"""Lch(uv) class.""" | |
SPACE = "lchuv" | |
SERIALIZE = ("--lchuv",) | |
CHANNEL_NAMES = ("lightness", "chroma", "hue", "alpha") | |
DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3)) | |
WHITE = "D65" | |
RANGE = ( | |
GamutUnbound([Percent(0), Percent(100.0)]), | |
GamutUnbound([0.0, 176.0]), | |
GamutUnbound([Angle(0.0), Angle(360.0)]), | |
) | |
@property | |
def lightness(self): | |
"""Lightness.""" | |
return self._coords[0] | |
@lightness.setter | |
def lightness(self, value): | |
"""Get true luminance.""" | |
self._coords[0] = self._handle_input(value) | |
@property | |
def chroma(self): | |
"""Chroma.""" | |
return self._coords[1] | |
@chroma.setter | |
def chroma(self, value): | |
"""chroma.""" | |
self._coords[1] = self._handle_input(value) | |
@property | |
def hue(self): | |
"""Hue.""" | |
return self._coords[2] | |
@hue.setter | |
def hue(self, value): | |
"""Shift the hue.""" | |
self._coords[2] = self._handle_input(value) | |
@classmethod | |
def null_adjust(cls, coords, alpha): | |
"""On color update.""" | |
if coords[1] < ACHROMATIC_THRESHOLD: | |
coords[2] = util.NaN | |
return coords, alpha | |
@classmethod | |
def _to_luv(cls, parent, lchuv): | |
"""To Luv.""" | |
return lchuv_to_luv(lchuv) | |
@classmethod | |
def _from_luv(cls, parent, luv): | |
"""To Luv.""" | |
return luv_to_lchuv(luv) | |
@classmethod | |
def _to_xyz(cls, parent, lchuv): | |
"""To XYZ.""" | |
return Luv._to_xyz(parent, cls._to_luv(parent, lchuv)) | |
@classmethod | |
def _from_xyz(cls, parent, xyz): | |
"""From XYZ.""" | |
return cls._from_luv(parent, Luv._from_xyz(parent, xyz)) | |
class Color(ColorOrig): | |
"""Color with Luv.""" | |
CS_MAP = copy.copy(ColorOrig.CS_MAP) | |
CS_MAP["luv"] = Luv | |
CS_MAP["lchuv"] = Lchuv | |
Color("blue").interpolate( | |
"white", | |
space='lab' | |
) | |
Color("blue").interpolate( | |
"white", | |
space='luv' | |
) | |
Color("blue").interpolate( | |
"white", | |
space='jzazbz' | |
) | |
Color("blue").interpolate( | |
"white", | |
space='oklab' | |
) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment