# SPDX-License-Identifier: GPL-3.0-or-later AND MIT # # Color conversion script for python. # Copyright (C) 2024 Lucifer # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # # This file incorporates work covered by the following copyright and # permission notice: # # Copyright (c) 2021 Björn Ottosson # # Permission is hereby granted, free of charge, to any person obtaining a copy of # this software and associated documentation files (the "Software"), to deal in # the Software without restriction, including without limitation the rights to # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies # of the Software, and to permit persons to whom the Software is furnished to do # so, subject to the following conditions: # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # # Pigment.O is a Krita plugin and it is a Color Picker and Color Mixer. # Copyright ( C ) 2020 Ricardo Jeremias. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # ( at your option ) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import math, sys # luma coefficents for ITU-R BT.709 Y709R = 0.2126 Y709G = 0.7152 Y709B = 0.0722 # constants for sRGB transfer ALPHA = 0.055 GAMMA = 2.4 PHI = 12.92 # toe functions K1 = 0.206 K2 = 0.03 K3 = (1.0 + K1) / (1.0 + K2) class Convert: @staticmethod def roundZero(n: float, d: int): s = -1 if n < 0 else 1 if not isinstance(d, int): raise TypeError("decimal places must be an integer") elif d < 0: raise ValueError("decimal places has to be 0 or more") elif d == 0: return math.floor(abs(n)) * s f = 10 ** d return math.floor(abs(n) * f) / f * s @staticmethod def clampF(f: float, u: float=1, l: float=0): # red may be negative in parts of blue due to color being out of gamut if f < l: return l # round up near 1 and prevent going over 1 from oklab conversion if (u == 1 and f > 0.999999) or f > u: return u return f @staticmethod def componentToSRGB(c: float): # round(CHI / PHI, 7) = 0.0031308 return (1 + ALPHA) * c ** (1 / GAMMA) - ALPHA if c > 0.0031308 else c * PHI @staticmethod def componentToLinear(c: float): # CHI = 0.04045 return ((c + ALPHA) / (1 + ALPHA)) ** GAMMA if c > 0.04045 else c / PHI @staticmethod def cartesianToPolar(a: float, b: float): c = math.hypot(a, b) hRad = math.atan2(b, a) if hRad < 0: hRad += math.pi * 2 h = math.degrees(hRad) return (c, h) @staticmethod def polarToCartesian(c: float, h: float): hRad = math.radians(h) a = c * math.cos(hRad) b = c * math.sin(hRad) return (a, b) @staticmethod def linearToOklab(r: float, g: float, b: float): # convert to approximate cone responses l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b # apply non-linearity l_ = l ** (1 / 3) m_ = m ** (1 / 3) s_ = s ** (1 / 3) # transform to Lab coordinates okL = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ okA = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ okB = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ return (okL, okA, okB) @staticmethod def oklabToLinear(okL: float, okA: float, okB: float): # inverse coordinates l_ = okL + 0.3963377774 * okA + 0.2158037573 * okB m_ = okL - 0.1055613458 * okA - 0.0638541728 * okB s_ = okL - 0.0894841775 * okA - 1.2914855480 * okB # reverse non-linearity l = l_ * l_ * l_ m = m_ * m_ * m_ s = s_ * s_ * s_ # convert to linear rgb r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s return(r, g, b) @staticmethod # toe function for L_r def toe(x): return 0.5 * (K3 * x - K1 + ((K3 * x - K1) * (K3 * x - K1) + 4 * K2 * K3 * x) ** (1 / 2)) @staticmethod # inverse toe function for L_r def toeInv(x): return (x * x + K1 * x) / (K3 * (x + K2)) @staticmethod # Finds the maximum saturation possible for a given hue that fits in sRGB # Saturation here is defined as S = C/L # a and b must be normalized so a^2 + b^2 == 1 def computeMaxSaturation(a: float, b: float): # Max saturation will be when one of r, g or b goes below zero. # Select different coefficients depending on which component goes below zero first # Blue component k0 = +1.35733652 k1 = -0.00915799 k2 = -1.15130210 k3 = -0.50559606 k4 = +0.00692167 wl = -0.0041960863 wm = -0.7034186147 ws = +1.7076147010 if -1.88170328 * a - 0.80936493 * b > 1: # Red component k0 = +1.19086277 k1 = +1.76576728 k2 = +0.59662641 k3 = +0.75515197 k4 = +0.56771245 wl = +4.0767416621 wm = -3.3077115913 ws = +0.2309699292 elif 1.81444104 * a - 1.19445276 * b > 1: # Green component k0 = +0.73956515 k1 = -0.45954404 k2 = +0.08285427 k3 = +0.12541070 k4 = +0.14503204 wl = -1.2684380046 wm = +2.6097574011 ws = -0.3413193965 # Approximate max saturation using a polynomial: maxS = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b # Do one step Halley's method to get closer # this gives an error less than 10e6, # except for some blue hues where the dS/dh is close to infinite # this should be sufficient for most applications, otherwise do two/three steps k_l = +0.3963377774 * a + 0.2158037573 * b k_m = -0.1055613458 * a - 0.0638541728 * b k_s = -0.0894841775 * a - 1.2914855480 * b l_ = 1.0 + maxS * k_l m_ = 1.0 + maxS * k_m s_ = 1.0 + maxS * k_s l = l_ * l_ * l_ m = m_ * m_ * m_ s = s_ * s_ * s_ l_dS = 3.0 * k_l * l_ * l_ m_dS = 3.0 * k_m * m_ * m_ s_dS = 3.0 * k_s * s_ * s_ l_dS2 = 6.0 * k_l * k_l * l_ m_dS2 = 6.0 * k_m * k_m * m_ s_dS2 = 6.0 * k_s * k_s * s_ f = wl * l + wm * m + ws * s f1 = wl * l_dS + wm * m_dS + ws * s_dS f2 = wl * l_dS2 + wm * m_dS2 + ws * s_dS2 maxS = maxS - f * f1 / (f1*f1 - 0.5 * f * f2) return maxS @staticmethod # finds L_cusp and C_cusp for a given hue # a and b must be normalized so a^2 + b^2 == 1 def findCuspLC(a: float, b: float): # First, find the maximum saturation (saturation S = C/L) maxS = Convert.computeMaxSaturation(a, b) # Convert to linear sRGB to find the first point where at least one of r,g or b >= 1: maxRgb = Convert.oklabToLinear(1, maxS * a, maxS * b) cuspL = (1.0 / max(maxRgb[0], maxRgb[1], maxRgb[2])) ** (1 / 3) cuspC = cuspL * maxS return (cuspL, cuspC) @staticmethod # Finds intersection of the line defined by # L = L0 * (1 - t) + t * L1 # C = t * C1 # a and b must be normalized so a^2 + b^2 == 1 def findGamutIntersection(a: float, b: float, l1: float, c1: float, l0: float, cuspLC=None): # Find the cusp of the gamut triangle if cuspLC is None: cuspLC = Convert.findCuspLC(a, b) # Find the intersection for upper and lower half separately if ((l1 - l0) * cuspLC[1] - (cuspLC[0] - l1) * c1) <= 0.0: # Lower half t = cuspLC[1] * l0 / (c1 * cuspLC[0] + cuspLC[1] * (l0 - l1)) else: # Upper half # First intersect with triangle t = cuspLC[1] * (l0 - 1.0) / (c1 * (cuspLC[0] - 1.0) + cuspLC[1] * (l0 - l1)) # Then one step Halley's method dL = l1 - l0 dC = c1 k_l = +0.3963377774 * a + 0.2158037573 * b k_m = -0.1055613458 * a - 0.0638541728 * b k_s = -0.0894841775 * a - 1.2914855480 * b l_dt = dL + dC * k_l m_dt = dL + dC * k_m s_dt = dL + dC * k_s # If higher accuracy is required, 2 or 3 iterations of the following block can be used: l = l0 * (1.0 - t) + t * l1 c = t * c1 l_ = l + c * k_l m_ = l + c * k_m s_ = l + c * k_s l = l_ * l_ * l_ m = m_ * m_ * m_ s = s_ * s_ * s_ ldt = 3 * l_dt * l_ * l_ mdt = 3 * m_dt * m_ * m_ sdt = 3 * s_dt * s_ * s_ ldt2 = 6 * l_dt * l_dt * l_ mdt2 = 6 * m_dt * m_dt * m_ sdt2 = 6 * s_dt * s_dt * s_ r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s - 1 r1 = 4.0767416621 * ldt - 3.3077115913 * mdt + 0.2309699292 * sdt r2 = 4.0767416621 * ldt2 - 3.3077115913 * mdt2 + 0.2309699292 * sdt2 u_r = r1 / (r1 * r1 - 0.5 * r * r2) t_r = -r * u_r g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s - 1 g1 = -1.2684380046 * ldt + 2.6097574011 * mdt - 0.3413193965 * sdt g2 = -1.2684380046 * ldt2 + 2.6097574011 * mdt2 - 0.3413193965 * sdt2 u_g = g1 / (g1 * g1 - 0.5 * g * g2) t_g = -g * u_g b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s - 1 b1 = -0.0041960863 * ldt - 0.7034186147 * mdt + 1.7076147010 * sdt b2 = -0.0041960863 * ldt2 - 0.7034186147 * mdt2 + 1.7076147010 * sdt2 u_b = b1 / (b1 * b1 - 0.5 * b * b2) t_b = -b * u_b t_r = t_r if u_r >= 0.0 else sys.float_info.max t_g = t_g if u_g >= 0.0 else sys.float_info.max t_b = t_b if u_b >= 0.0 else sys.float_info.max t += min(t_r, t_g, t_b) return t @staticmethod def cuspToST(cuspLC: tuple): l: float = cuspLC[0] c: float = cuspLC[1] return (c / l, c / (1 - l)) # Returns a smooth approximation of the location of the cusp # This polynomial was created by an optimization process # It has been designed so that S_mid < S_max and T_mid < T_max @staticmethod def getMidST(a_: float, b_: float): s = 0.11516993 + 1.0 / (+7.44778970 + 4.15901240 * b_ + a_ * (-2.19557347 + 1.75198401 * b_ + a_ * (-2.13704948 - 10.02301043 * b_ + a_ * (-4.24894561 + 5.38770819 * b_ + 4.69891013 * a_ )))) t = 0.11239642 + 1.0 / (+1.61320320 - 0.68124379 * b_ + a_ * (+0.40370612 + 0.90148123 * b_ + a_ * (-0.27087943 + 0.61223990 * b_ + a_ * (+0.00299215 - 0.45399568 * b_ - 0.14661872 * a_ )))) return (s, t) @staticmethod def getCs(l: float, a_: float, b_: float): cuspLC = Convert.findCuspLC(a_, b_) cMax = Convert.findGamutIntersection(a_, b_, l, 1, l, cuspLC) maxST = Convert.cuspToST(cuspLC) # Scale factor to compensate for the curved part of gamut shape: k = cMax / min(l * maxST[0], (1 - l) * maxST[1]) midST = Convert.getMidST(a_, b_) # Use a soft minimum function, # instead of a sharp triangle shape to get a smooth value for chroma. cMid = 0.9 * k * (1 / (1 / (l * midST[0]) ** 4 + 1 / ((1 - l) * midST[1]) ** 4)) ** (1 / 4) # for C_0, the shape is independent of hue, so ST are constant. # Values picked to roughly be the average values of ST. c0 = (1 / (1 / (l * 0.4) ** 2 + 1 / ((1 - l) * 0.8) ** 2)) ** (1 / 2) return (c0, cMid, cMax) @staticmethod def rgbToTRC(rgb: tuple, trc: str): if trc == "sRGB": r = Convert.clampF(Convert.componentToSRGB(rgb[0])) g = Convert.clampF(Convert.componentToSRGB(rgb[1])) b = Convert.clampF(Convert.componentToSRGB(rgb[2])) return (r, g, b) else: r = Convert.componentToLinear(rgb[0]) g = Convert.componentToLinear(rgb[1]) b = Convert.componentToLinear(rgb[2]) return (r, g, b) @staticmethod def rgbFToInt8(r: float, g: float, b: float, trc: str): if trc == "sRGB": r = int(r * 255) g = int(g * 255) b = int(b * 255) else: r = round(Convert.componentToSRGB(r) * 255) g = round(Convert.componentToSRGB(g) * 255) b = round(Convert.componentToSRGB(b) * 255) return (r, g, b) @staticmethod def rgbFToHexS(r: float, g: float, b: float, trc: str): # hex codes are in 8 bits per color rgb = Convert.rgbFToInt8(r, g, b, trc) # hex converts int to str with first 2 char being 0x r = hex(rgb[0])[2:].zfill(2).upper() g = hex(rgb[1])[2:].zfill(2).upper() b = hex(rgb[2])[2:].zfill(2).upper() return f"#{r}{g}{b}" @staticmethod def hexSToRgbF(syntax: str, trc: str): if len(syntax) != 7: print("Invalid syntax") return try: r = int(syntax[1:3], 16) / 255.0 g = int(syntax[3:5], 16) / 255.0 b = int(syntax[5:7], 16) / 255.0 except ValueError: print("Invalid syntax") return if trc == "sRGB": return (r, g, b) r = Convert.componentToLinear(r) g = Convert.componentToLinear(g) b = Convert.componentToLinear(b) return (r, g, b) @staticmethod def rgbFToOklabS(r: float, g: float, b: float, trc: str): # if rgb not linear, convert to linear for oklab conversion if trc == "sRGB": r = Convert.componentToLinear(r) g = Convert.componentToLinear(g) b = Convert.componentToLinear(b) oklab = Convert.linearToOklab(r, g, b) # l in percentage, a and b is 0 to 0.3+ okL = round(oklab[0] * 100, 2) okA = Convert.roundZero(oklab[1], 4) okB = Convert.roundZero(oklab[2], 4) return f"oklab({okL}% {okA} {okB})" @staticmethod def oklabSToRgbF(syntax: str, trc: str): strings = syntax[5:].strip("( )").split() if len(strings) != 3: print("Invalid syntax") return okL = strings[0] okA = strings[1] okB = strings[2] try: if "%" in okL: okL = Convert.clampF(float(okL.strip("%")) / 100) else: okL = Convert.clampF(float(okL)) if "%" in okA: okA = Convert.clampF(float(okA.strip("%")) / 250, 0.4, -0.4) else: okA = Convert.clampF(float(okA), 0.4, -0.4) if "%" in okB: okB = Convert.clampF(float(okB.strip("%")) / 250, 0.4, -0.4) else: okB = Convert.clampF(float(okB), 0.4, -0.4) except ValueError: print("Invalid syntax") return rgb = Convert.oklabToLinear(okL, okA, okB) # if rgb not linear, perform transfer functions for components r = Convert.componentToSRGB(rgb[0]) if trc == "sRGB" else rgb[0] g = Convert.componentToSRGB(rgb[1]) if trc == "sRGB" else rgb[1] b = Convert.componentToSRGB(rgb[2]) if trc == "sRGB" else rgb[2] return (Convert.clampF(r), Convert.clampF(g), Convert.clampF(b)) @staticmethod def rgbFToOklchS(r: float, g: float, b: float, trc: str): # if rgb not linear, convert to linear for oklab conversion if trc == "sRGB": r = Convert.componentToLinear(r) g = Convert.componentToLinear(g) b = Convert.componentToLinear(b) oklab = Convert.linearToOklab(r, g, b) l = round(oklab[0] * 100, 2) ch = Convert.cartesianToPolar(oklab[1], oklab[2]) c = ch[0] h = 0 # chroma of neutral colors will not be exactly 0 due to floating point errors if c < 0.000001: c = 0 else: # chroma adjustment due to rounding up blue hue if 264.052 < ch[1] < 264.06: h = 264.06 c = round(c - 0.0001, 4) else: h = round(ch[1], 2) c = Convert.roundZero(c, 4) # l in percentage, c is 0 to 0.3+, h in degrees return f"oklch({l}% {c} {h})" @staticmethod def oklchSToRgbF(syntax: str, trc: str): strings = syntax[5:].strip("( )").split() if len(strings) != 3: print("Invalid syntax") return l = strings[0] c = strings[1] h = strings[2] try: if "%" in l: l = Convert.clampF(float(l.strip("%")) / 100) else: l = Convert.clampF(float(l)) if "%" in c: c = Convert.clampF(float(c.strip("%")) / 250, 0.4) else: c = Convert.clampF(float(c), 0.4) h = Convert.clampF(float(h.strip("deg")), 360.0) except ValueError: print("Invalid syntax") return # clip chroma if exceed sRGB gamut ab = Convert.polarToCartesian(1, h) if c: u = Convert.findGamutIntersection(*ab, l, 1, l) if c > u: c = u rgb = Convert.oklabToLinear(l, ab[0] * c, ab[1] * c) # if rgb not linear, perform transfer functions for components r = Convert.componentToSRGB(rgb[0]) if trc == "sRGB" else rgb[0] g = Convert.componentToSRGB(rgb[1]) if trc == "sRGB" else rgb[1] b = Convert.componentToSRGB(rgb[2]) if trc == "sRGB" else rgb[2] return (Convert.clampF(r), Convert.clampF(g), Convert.clampF(b)) @staticmethod def hSectorToRgbF(hSector: float, v: float, m: float, x: float, trc: str="sRGB"): # assign max, med and min according to hue sector if hSector == 1: # between yellow and green r = x g = v b = m elif hSector == 2: # between green and cyan r = m g = v b = x elif hSector == 3: # between cyan and blue r = m g = x b = v elif hSector == 4: # between blue and magenta r = x g = m b = v elif hSector == 5: # between magenta and red r = v g = m b = x else: # between red and yellow r = v g = x b = m # convert to linear if not sRGB if trc == "sRGB": return (r, g, b) r = Convert.componentToLinear(r) g = Convert.componentToLinear(g) b = Convert.componentToLinear(b) return (r, g, b) @staticmethod def rgbFToHsv(r: float, g: float, b: float, trc: str): # if rgb is linear, convert to sRGB if trc == "linear": r = Convert.componentToSRGB(r) g = Convert.componentToSRGB(g) b = Convert.componentToSRGB(b) # value is equal to max(R,G,B) while min(R,G,B) determines saturation v = max(r,g,b) m = min(r,g,b) # chroma is the colorfulness of the color compared to the neutral color of equal value c = v - m if c == 0: # hue cannot be determined if the color is neutral return (0, 0, round(v * 100, 2)) # hue is defined in 60deg sectors # hue = primary hue + deviation # max(R,G,B) determines primary hue while med(R,G,B) determines deviation # deviation has a range of -0.999... to 0.999... if v == r: # red is 0, range of hues that are predominantly red is -0.999... to 0.999... # dividing (g - b) by chroma takes saturation and value out of the equation # resulting in hue deviation of the primary color h = ((g - b) / c) % 6 elif v == g: # green is 2, range of hues that are predominantly green is 1.000... to 2.999... h = (b - r) / c + 2 elif v == b: # blue is 4, range of hues that are predominantly blue is 3.000... to 4.999... h = (r - g) / c + 4 # saturation is the ratio of chroma of the color to the maximum chroma of equal value # which is normalized chroma to fit the range of 0-1 s = c / v return (round(h * 60, 2), round(s * 100, 2), round(v * 100, 2)) @staticmethod def hsvToRgbF(h: float, s: float, v: float, trc: str): # derive hue in 60deg sectors h /= 60 hSector = int(h) # scale saturation and value range from 0-100 to 0-1 s /= 100 v /= 100 # max(R,G,B) = value # chroma = saturation * value # min(R,G,B) = max(R,G,B) - chroma m = v * (1 - s) # calculate deviation from closest secondary color with range of -0.999... to 0.999... # |deviation| = 1 - derived hue - hue sector if deviation is positive # |deviation| = derived hue - hue sector if deviation is negative d = h - hSector if hSector % 2 else 1 - (h - hSector) # med(R,G,B) = max(R,G,B) - (|deviation| * chroma) x = v * (1 - d * s) return Convert.hSectorToRgbF(hSector, v, m, x, trc) @staticmethod def rgbFToHsl(r: float, g: float, b: float, trc: str): # if rgb is linear, convert to sRGB if trc == "linear": r = Convert.componentToSRGB(r) g = Convert.componentToSRGB(g) b = Convert.componentToSRGB(b) v = max(r,g,b) m = min(r,g,b) # lightness is defined as the midrange of the RGB components l = (v + m) / 2 c = v - m # hue cannot be determined if the color is neutral if c == 0: return (0, 0, round(l * 100, 2)) # same formula as hsv to find hue if v == r: h = ((g - b) / c) % 6 elif v == g: h = (b - r) / c + 2 elif v == b: h = (r - g) / c + 4 # saturation = chroma / chroma range # max chroma range when lightness at half s = c / (1 - abs(2 * l - 1)) return (round(h * 60, 2), round(s * 100, 2), round(l * 100, 2)) @staticmethod def hslToRgbF(h: float, s: float, l: float, trc: str): # derive hue in 60deg sectors h /= 60 hSector = int(h) # scale saturation and value range from 0-100 to 0-1 s /= 100 l /= 100 # max(R,G,B) = s(l) + l if l<0.5 else s(1 - l) + l v = l * (1 + s) if l < 0.5 else s * (1 - l) + l m = 2 * l - v # calculate deviation from closest secondary color with range of -0.999... to 0.999... d = h - hSector if hSector % 2 else 1 - (h - hSector) x = v - d * (v - m) return Convert.hSectorToRgbF(hSector, v, m, x, trc) @staticmethod def rgbFToHcy(r: float, g: float, b: float, h: float, trc: str, luma: bool): # if y should always be luma, convert to sRGB if luma and trc == "linear": r = Convert.componentToSRGB(r) g = Convert.componentToSRGB(g) b = Convert.componentToSRGB(b) # y can be luma or relative luminance depending on rgb format y = Y709R * r + Y709G * g + Y709B * b v = max(r, g, b) m = min(r, g, b) c = v - m yHue = 0 # if color is neutral, use previous hue to calculate luma coefficient of hue # max(R,G,B) coefficent + med(R,G,B) coefficient * deviation from max(R,G,B) hue if (c != 0 and v == g) or (c == 0 and 60 <= h <= 180): h = (b - r) / c + 2 if c != 0 else h / 60 if 1 <= h <= 2: # between yellow and green d = h - 1 # luma coefficient of hue ranges from 0.9278 to 0.7152 yHue = Y709G + Y709R * (1 - d) elif 2 < h <= 3: # between green and cyan d = h - 2 # luma coefficient of hue ranges from 0.7152 to 0.7874 yHue = Y709G + Y709B * d elif (c != 0 and v == b) or (c == 0 and 180 < h <= 300): h = (r - g) / c + 4 if c != 0 else h / 60 if 3 < h <= 4: # between cyan and blue d = h - 3 # luma coefficient of hue ranges from 0.7874 to 0.0722 yHue = Y709B + Y709G * (1 - d) elif 4 < h <= 5: # between blue and magenta d = h - 4 # luma coefficient of hue ranges from 0.0722 to 0.2848 yHue = Y709B + Y709R * d elif (c != 0 and v == r) or (c == 0 and (h > 300 or h < 60)): h = ((g - b) / c) % 6 if c != 0 else h / 60 if 5 < h <= 6: # between magenta and red d = h - 5 # luma coefficient of hue ranges from 0.2848 to 0.2126 yHue = Y709R + Y709B * (1 - d) elif 0 <= h < 1: # between red and yellow d = h # luma coefficient of hue ranges from 0.2126 to 0.9278 yHue = Y709R + Y709G * d # calculate upper limit of chroma for hue and luma pair u = y / yHue if y <= yHue else (1 - y) / (1 - yHue) return (round(h * 60, 2), round(c * 100, 3), round(y * 100, 2), round(u * 100, 3)) @staticmethod def hcyToRgbF(h: float, c: float, y: float, u: float, trc: str, luma: bool): # derive hue in 60deg sectors h /= 60 hSector = int(h) # pass in y and u as -1 for max chroma conversions if y != -1: # scale luma to 1 y /= 100 if c == 0 or y == 0 or y == 1: # if y is always luma, convert to linear if luma and trc == "linear": y = Convert.componentToLinear(y) # luma coefficients add up to 1 return (y, y, y) # calculate deviation from closest primary color with range of -0.999... to 0.999... # |deviation| = 1 - derived hue - hue sector if deviation is negative # |deviation| = derived hue - hue sector if deviation is positive d = h - hSector if hSector % 2 == 0 else 1 - (h - hSector) # calculate luma coefficient of hue yHue = 0 if hSector == 1: # between yellow and green yHue = Y709G + Y709R * d elif hSector == 2: # between green and cyan yHue = Y709G + Y709B * d elif hSector == 3: # between cyan and blue yHue = Y709B + Y709G * d elif hSector == 4: # between blue and magenta yHue = Y709B + Y709R * d elif hSector == 5: # between magenta and red yHue = Y709R + Y709B * d else: # between red and yellow yHue = Y709R + Y709G * d # when chroma is at maximum, y = luma coefficient of hue if y == -1: y = yHue # it is not always possible for chroma to be constant when adjusting hue or luma # adjustment have to either clip chroma or have consistent saturation instead cMax = y / yHue if y <= yHue else (1 - y) / (1 - yHue) if u == -1: # scale chroma to 1 before comparing c /= 100 # clip chroma to new limit if c > cMax: c = cMax else: # scale chroma to hue or luma adjustment s = 0 if u: s = c / u c = s * cMax # luma = max(R,G,B) * yHue + min(R,G,B) * (1 - yHue) # calculate min(R,G,B) based on the equation above m = y - c * yHue # med(R,G,B) = min(R,G,B) + (|deviation| * chroma) x = y - c * (yHue - d) # max(R,G,B) = min(R,G,B) + chroma v = y + c * (1 - yHue) # if y is always luma, hsector to rgbf needs trc param if luma: return Convert.hSectorToRgbF(hSector, v, m, x, trc) return Convert.hSectorToRgbF(hSector, v, m, x) @staticmethod def rgbFToOkhcl(r: float, g: float, b: float, h: float, trc: str): # if rgb not linear, convert to linear for oklab conversion if trc == "sRGB": r = Convert.componentToLinear(r) g = Convert.componentToLinear(g) b = Convert.componentToLinear(b) oklab = Convert.linearToOklab(r, g, b) l = oklab[0] ch = Convert.cartesianToPolar(oklab[1], oklab[2]) c = ch[0] # chroma of neutral colors will not be exactly 0 due to floating point errors if c < 0.000001: # use current hue to calulate chroma limit in sRGB gamut for neutral colors ab = Convert.polarToCartesian(1, h) cuspLC = Convert.findCuspLC(*ab) u = Convert.findGamutIntersection(*ab, l, 1, l, cuspLC) u /= cuspLC[1] c = 0 else: # gamut intersection jumps for parts of blue h = ch[1] if not 264.052 < ch[1] < 264.06 else 264.06 # a and b must be normalized to c = 1 to calculate chroma limit in sRGB gamut a_ = oklab[1] / c b_ = oklab[2] / c cuspLC = Convert.findCuspLC(a_, b_) u = Convert.findGamutIntersection(a_, b_, l, 1, l, cuspLC) if c > u: c = u u /= cuspLC[1] c /= cuspLC[1] l = Convert.toe(l) return (round(h, 2), round(c * 100, 3), round(l * 100, 2), round(u * 100, 3)) @staticmethod def okhclToRgbF(h: float, c: float, l: float, u: float, trc: str): # convert lref back to okL l = Convert.toeInv(l / 100) # clip chroma if exceed sRGB gamut ab = (0, 0) if c: ab = Convert.polarToCartesian(1, h) cuspLC = Convert.findCuspLC(*ab) cMax = Convert.findGamutIntersection(*ab, l, 1, l, cuspLC) if u == -1: c = c / 100 * cuspLC[1] if c > cMax: c = cMax else: s = c / u c = s * cMax ab = Convert.polarToCartesian(c, h) rgb = Convert.oklabToLinear(l, *ab) # perform transfer functions for components if output to sRGB r = Convert.componentToSRGB(rgb[0]) if trc == "sRGB" else rgb[0] g = Convert.componentToSRGB(rgb[1]) if trc == "sRGB" else rgb[1] b = Convert.componentToSRGB(rgb[2]) if trc == "sRGB" else rgb[2] return (Convert.clampF(r), Convert.clampF(g), Convert.clampF(b)) @staticmethod def rgbFToOkhsv(r: float, g: float, b: float, trc: str): # if rgb not linear, convert to linear for oklab conversion if trc == "sRGB": r = Convert.componentToLinear(r) g = Convert.componentToLinear(g) b = Convert.componentToLinear(b) oklab = Convert.linearToOklab(r, g, b) l = oklab[0] ch = Convert.cartesianToPolar(oklab[1], oklab[2]) c = ch[0] # chroma of neutral colors will not be exactly 0 due to floating point errors if c < 0.000001: return (0, 0, round(Convert.toe(l) * 100, 2)) else: # gamut intersection jumps for parts of blue h = ch[1] if not 264.052 < ch[1] < 264.06 else 264.06 # a and b must be normalized to c = 1 to calculate chroma limit in sRGB gamut a_ = oklab[1] / c b_ = oklab[2] / c cuspLC = Convert.findCuspLC(a_, b_) st = Convert.cuspToST(cuspLC) sMax = st[0] tMax = st[1] s0 = 0.5 k = 1 - s0 / sMax # first we find L_v, C_v, L_vt and C_vt t = tMax / (c + l * tMax) l_v = t * l c_v = t * c l_vt = Convert.toeInv(l_v) c_vt = c_v * l_vt / l_v # we can then use these to invert the step that compensates for the toe # and the curved top part of the triangle: rgbScale = Convert.oklabToLinear(l_vt, a_ * c_vt, b_ * c_vt) scaleL = (1 / max(rgbScale[0], rgbScale[1], rgbScale[2])) ** (1 / 3) l = Convert.toe(l / scaleL) # // we can now compute v and s: v = l / l_v s = (s0 + tMax) * c_v / ((tMax * s0) + tMax * k * c_v) if s > 1: s = 1.0 return (round(h, 2), round(s * 100, 2), round(v * 100, 2)) @staticmethod def okhsvToRgbF(h: float, s: float, v: float, trc: str): # scale saturation and value range from 0-100 to 0-1 s /= 100 v /= 100 rgb = None if v == 0: return (0, 0, 0) elif s == 0: rgb = Convert.oklabToLinear(Convert.toeInv(v), 0, 0) else: ab = Convert.polarToCartesian(1, h) cuspLC = Convert.findCuspLC(*ab) st = Convert.cuspToST(cuspLC) sMax = st[0] tMax = st[1] s0 = 0.5 k = 1 - s0 / sMax # first we compute L and V as if the gamut is a perfect triangle: # L, C when v==1: l_v = 1 - s * s0 / (s0 + tMax - tMax * k * s) c_v = s * tMax * s0 / (s0 + tMax - tMax * k * s) l = v * l_v c = v * c_v # then we compensate for both toe and the curved top part of the triangle: l_vt = Convert.toeInv(l_v) c_vt = c_v * l_vt / l_v l_new = Convert.toeInv(l) c *= l_new / l l = l_new rgbScale = Convert.oklabToLinear(l_vt, ab[0] * c_vt, ab[1] * c_vt) scaleL = (1 / max(rgbScale[0], rgbScale[1], rgbScale[2])) ** (1 / 3) l *= scaleL c *= scaleL rgb = Convert.oklabToLinear(l, ab[0] * c, ab[1] * c) # perform transfer functions for components if output to sRGB r = Convert.componentToSRGB(rgb[0]) if trc == "sRGB" else rgb[0] g = Convert.componentToSRGB(rgb[1]) if trc == "sRGB" else rgb[1] b = Convert.componentToSRGB(rgb[2]) if trc == "sRGB" else rgb[2] return (Convert.clampF(r), Convert.clampF(g), Convert.clampF(b)) @staticmethod def rgbFToOkhsl(r: float, g: float, b: float, trc: str): # if rgb not linear, convert to linear for oklab conversion if trc == "sRGB": r = Convert.componentToLinear(r) g = Convert.componentToLinear(g) b = Convert.componentToLinear(b) oklab = Convert.linearToOklab(r, g, b) l = oklab[0] ch = Convert.cartesianToPolar(oklab[1], oklab[2]) s = 0 c = ch[0] # chroma of neutral colors will not be exactly 0 due to floating point errors if c >= 0.000001: a_ = oklab[1] / c b_ = oklab[2] / c cs = Convert.getCs(l, a_, b_) c0 = cs[0] cMid = cs[1] cMax = cs[2] # Inverse of the interpolation in okhsl_to_srgb: mid = 0.8 midInv = 1.25 if c < cMid: k1 = mid * c0 k2 = 1 - k1 / cMid t = c / (k1 + k2 * c) s = t * mid else: k1 = (1 - mid) * cMid * cMid * midInv * midInv / c0 k2 = 1 - k1 / (cMax - cMid) t = (c - cMid) / (k1 + k2 * (c - cMid)) s = mid + (1 - mid) * t # gamut intersection jumps for parts of blue h = ch[1] if not 264.052 < ch[1] < 264.06 else 264.06 l = Convert.toe(l) return (round(h, 2), round(s * 100, 2), round(l * 100, 2)) @staticmethod def okhslToRgbF(h: float, s: float, l: float, trc: str): # scale saturation and lightness range from 0-100 to 0-1 s /= 100 l /= 100 if l == 0 or l == 1: return (l, l, l) ab = Convert.polarToCartesian(1, h) l = Convert.toeInv(l) c = 0 if s: cs = Convert.getCs(l, *ab) c0 = cs[0] cMid = cs[1] cMax = cs[2] # Interpolate the three values for C so that: # At s=0: dC/ds = C_0, C=0 # At s=0.8: C=C_mid # At s=1.0: C=C_max mid = 0.8 midInv = 1.25 if s < mid: t = midInv * s k1 = mid * c0 k2 = 1 - k1 / cMid c = t * k1 / (1 - k2 * t) else: t = (s - mid) / (1 - mid) k1 = (1 - mid) * cMid * cMid * midInv * midInv / c0 k2 = 1 - k1 / (cMax - cMid) c = cMid + t * k1 / (1 - k2 * t) rgb = Convert.oklabToLinear(l, ab[0] * c, ab[1] * c) # perform transfer functions for components if output to sRGB r = Convert.componentToSRGB(rgb[0]) if trc == "sRGB" else rgb[0] g = Convert.componentToSRGB(rgb[1]) if trc == "sRGB" else rgb[1] b = Convert.componentToSRGB(rgb[2]) if trc == "sRGB" else rgb[2] return (Convert.clampF(r), Convert.clampF(g), Convert.clampF(b))