Skip to content

Instantly share code, notes, and snippets.

@facelessuser
Last active August 11, 2026 03:06
Show Gist options
  • Select an option

  • Save facelessuser/4b65d4a6210933517ec1e20682ccf0d3 to your computer and use it in GitHub Desktop.

Select an option

Save facelessuser/4b65d4a6210933517ec1e20682ccf0d3 to your computer and use it in GitHub Desktop.
OkTCh Tonal Pallets
"""OkTCh color space."""
from __future__ import annotations
import math
from coloraide import algebra as alg
from coloraide import util
from coloraide.cat import WHITES
from coloraide.channels import Channel, FLG_ANGLE
from coloraide.types import Vector
from coloraide.spaces.lab import y_to_lstar, lstar_to_y
from coloraide.spaces.lch import LCh
from coloraide.spaces.oklab import xyz_d65_to_oklab, oklab_to_xyz_d65
D65 = WHITES['2deg']['D65']
K = 3.0
def xyz_to_lch(xyz: Vector) -> Vector:
"""Convert XYZ to LCh where `Ch` is from OkLCh and `L` is from CIE Lab."""
l = y_to_lstar(xyz[1])
lab = xyz_d65_to_oklab(xyz)
c, h = alg.rect_to_polar(*lab[1:])
return [l / 100.0, c, h]
def lch_to_xyz(lch: Vector) -> Vector:
"""Convert LCh, where `L` is CIE Lab lightness and `Ch` is from OkLCh."""
t, c, h = lch
a, b = alg.polar_to_rect(c, h)
y = lstar_to_y(t * 100.0)
l = xyz_d65_to_oklab(util.xy_to_xyz(D65, Y=y))[0]
epsilon = 1e-14
maxiter = 16
last = math.inf
best = [0.0] * 3
# Try to find an Oklab L such that the returned y matches the returned y of the L*
for _ in range(maxiter):
xyz = oklab_to_xyz_d65([l, a, b])
f1 = xyz[1] - y
delta = abs(f1)
if delta < last:
last = delta
best = xyz
# If we are within range, return XYZ
if delta < epsilon:
return xyz
# Newton: 2nd order convergence
d1 = (K * xyz[1] / l) if l else 0
if abs(d1) < epsilon:
break
l -= f1 / d1
# Ostrowski: 4th order convergence
xyz2 = oklab_to_xyz_d65([l, a, b])
f2 = xyz2[1] - y
denom = f1 - 2 * f2
if abs(denom) >= epsilon: # pragma: no cover
l -= f1 / denom * (f2 / d1)
return best
class OkTCh(LCh):
"""OkTCh class."""
BASE = "xyz-d65"
NAME = "oktch"
SERIALIZE = ("--oktch",)
WHITE = WHITES['2deg']['D65']
CHANNEL_ALIASES = {
"lightness": "t",
"tone": "t",
"chroma": "c",
"hue": "h"
}
CHANNELS = (
Channel("t", 0.0, 1.0),
Channel("c", 0.0, 0.4),
Channel("h", flags=FLG_ANGLE)
)
def lightness_name(self) -> str:
"""Get lightness name."""
return "t"
def to_base(self, coords: Vector) -> Vector:
"""To XYZ from OkLCh with CIE lightness."""
return lch_to_xyz(coords)
def from_base(self, coords: Vector) -> Vector:
"""From XYZ to OkLCh with CIE lightness."""
return xyz_to_lch(coords)
from coloraide import Color as Base
class Color(Base): ...
Color.register(OkTCh())
def oktch_tonal_palette(c):
"""OkTCh tonal palettes."""
c = Color(c).convert('oktch')
tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
return [c.clone().set('tone', tone / 100).fit('srgb', pspace='oktch').convert('srgb') for tone in tones]
colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']
for color in colors:
Steps(oktch_tonal_palette(color))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment