Last active
August 11, 2026 03:06
-
-
Save facelessuser/4b65d4a6210933517ec1e20682ccf0d3 to your computer and use it in GitHub Desktop.
OkTCh Tonal Pallets
This file contains hidden or 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
| """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