989 lines
38 KiB
Python
989 lines
38 KiB
Python
|
|
# SPDX-License-Identifier: GPL-3.0-or-later AND MIT
|
||
|
|
#
|
||
|
|
# Color conversion script for python.
|
||
|
|
# Copyright (C) 2024 Lucifer <krita-artists.org/u/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 <http://www.gnu.org/licenses/>.
|
||
|
|
#
|
||
|
|
# 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 <http://www.gnu.org/licenses/>.
|
||
|
|
|
||
|
|
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))
|
||
|
|
|