Move color parsing code to C for performance
This commit is contained in:
parent
a4d88beddb
commit
7c13c04c84
4 changed files with 318 additions and 437 deletions
307
kitty/colors.c
307
kitty/colors.c
|
|
@ -8,6 +8,9 @@
|
|||
#include "state.h"
|
||||
#include <structmember.h>
|
||||
#include "colors.h"
|
||||
#include <locale.h>
|
||||
float strtof_l(const char *restrict nptr, char **restrict endptr, locale_t locale);
|
||||
locale_t c_locale;
|
||||
|
||||
|
||||
static uint32_t FG_BG_256[256] = {
|
||||
|
|
@ -776,8 +779,311 @@ contrast(Color* self, PyObject *o) {
|
|||
return PyFloat_FromDouble(rgb_contrast(self->color, other->color));
|
||||
}
|
||||
|
||||
static char
|
||||
hexchar_to_int(char c) {
|
||||
if ('0' <= c && c <= '9') return c - '0';
|
||||
if ('a' <= c && c <= 'f') return c - 'a' + 10;
|
||||
if ('A' <= c && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static bool
|
||||
parse_base16_uchar(const char *hex, unsigned char *out) {
|
||||
const char hi = hexchar_to_int(hex[0]);
|
||||
const char lo = hexchar_to_int(hex[1]);
|
||||
if (hi < 0 || lo < 0) return false;
|
||||
*out = (unsigned char)((hi << 4) | lo);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool
|
||||
parse_double(const char *src, double *out) {
|
||||
char *endptr;
|
||||
errno = 0;
|
||||
*out = strtod_l(src, &endptr, c_locale);
|
||||
return endptr != src && *endptr == 0 && errno == 0;
|
||||
}
|
||||
|
||||
static bool
|
||||
parse_single_color(const char *c, size_t len, unsigned char *out) {
|
||||
char buf[2];
|
||||
if (len == 1) { buf[0] = c[0]; buf[1] = c[0]; c = buf; }
|
||||
return parse_base16_uchar(c, out);
|
||||
}
|
||||
|
||||
static PyObject*
|
||||
parse_sharp(const char *spec, size_t len) {
|
||||
unsigned char r, g, b;
|
||||
switch(len) {
|
||||
case 3:
|
||||
if (!parse_single_color(spec, 1, &r) || !parse_single_color(spec + 1, 1, &g) || !parse_single_color(spec + 2, 1, &b)) Py_RETURN_NONE;
|
||||
break;
|
||||
case 6: case 9: case 12:
|
||||
if (!parse_single_color(spec, 2, &r) || !parse_single_color(spec + len/3, 2, &g) || !parse_single_color(spec + 2 * len / 3, 2, &b)) Py_RETURN_NONE;
|
||||
break;
|
||||
default:
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
return (PyObject*)alloc_color(r, g, b, 0);
|
||||
}
|
||||
|
||||
static PyObject*
|
||||
parse_rgb(const char *spec, size_t len) {
|
||||
char buf[32];
|
||||
if (len >= sizeof(buf)) Py_RETURN_NONE;
|
||||
memcpy(buf, spec, len); buf[len] = 0;
|
||||
unsigned char r, g, b; char *tok;
|
||||
#define p(buf, out) if (!(tok = strtok(buf, "/")) || !parse_single_color(tok, strlen(tok), &out)) Py_RETURN_NONE;
|
||||
p(buf, r); p(NULL, g); p(NULL, b);
|
||||
#undef p
|
||||
return (PyObject*)alloc_color(r, g, b, 0);
|
||||
}
|
||||
|
||||
static unsigned char as8bit(double f) { return (unsigned char)((MAX(0., MIN(f, 1.))) * 255.); }
|
||||
|
||||
static bool
|
||||
parse_single_intensity(const char *s, unsigned char *out) {
|
||||
double f; if (!parse_double(s, &f)) return false;
|
||||
*out = as8bit(f);
|
||||
return true;
|
||||
}
|
||||
|
||||
static PyObject*
|
||||
parse_rgbi(const char *spec, size_t len) {
|
||||
char buf[256];
|
||||
if (len >= sizeof(buf)) Py_RETURN_NONE;
|
||||
memcpy(buf, spec, len); buf[len] = 0;
|
||||
unsigned char r, g, b; char *tok;
|
||||
#define p(buf, out) if (!(tok = strtok(buf, "/")) || !parse_single_intensity(tok, &out)) Py_RETURN_NONE;
|
||||
p(buf, r); p(NULL, g); p(NULL, b);
|
||||
#undef p
|
||||
return (PyObject*)alloc_color(r, g, b, 0);
|
||||
}
|
||||
|
||||
static bool
|
||||
parse_double_intensity(char *s, double *out, double percentage_divider) {
|
||||
size_t l = strlen(s);
|
||||
if (l == 0) return false;
|
||||
double divisor = 1;
|
||||
if (s[l-1] == '%') { s[l-1] = 0; divisor = percentage_divider; }
|
||||
if (!parse_double(s, out)) return false;
|
||||
*out /= divisor;
|
||||
return true;
|
||||
}
|
||||
|
||||
static double clamp(const double f) { return MAX(0, MIN(f, 1)); }
|
||||
|
||||
static double
|
||||
linear_to_srgb(double c) { return c <= 0.0031308 ? c * 12.92 : (1.055 * pow(c, (1 / 2.4)) - 0.055); }
|
||||
|
||||
static double degrees_to_radians(double degrees) { return degrees * (M_PI / 180); }
|
||||
static double radians_to_degrees(double radians) { return 180 * radians / M_PI; }
|
||||
|
||||
static void
|
||||
oklch_to_srgb(double l, double c, double h, double *r, double *g, double *b) {
|
||||
// Convert OKLCH to OKLab
|
||||
const double h_rad = degrees_to_radians(h);
|
||||
const double a = c * cos(h_rad);
|
||||
const double lb = c * sin(h_rad);
|
||||
// Convert OKLab to Linear sRGB
|
||||
// Using the OKLab to Linear sRGB transformation
|
||||
const double l_ = l + 0.3963377774 * a + 0.2158037573 * lb;
|
||||
const double m_ = l - 0.1055613458 * a - 0.0638541728 * lb;
|
||||
const double s_ = l - 0.0894841775 * a - 1.2914855480 * lb;
|
||||
|
||||
const double l_lin = l_ * l_ * l_;
|
||||
const double m_lin = m_ * m_ * m_;
|
||||
const double s_lin = s_ * s_ * s_;
|
||||
|
||||
const double r_lin = +4.0767416621 * l_lin - 3.3077115913 * m_lin + 0.2309699292 * s_lin;
|
||||
const double g_lin = -1.2684380046 * l_lin + 2.6097574011 * m_lin - 0.3413193965 * s_lin;
|
||||
const double b_lin = -0.0041960863 * l_lin - 0.7034186147 * m_lin + 1.7076147010 * s_lin;
|
||||
|
||||
*r = linear_to_srgb(clamp(r_lin)); *g = linear_to_srgb(clamp(g_lin)); *b = linear_to_srgb(clamp(b_lin));
|
||||
}
|
||||
|
||||
static double srgb_to_linear(double c) { return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4); }
|
||||
|
||||
|
||||
static void
|
||||
srgb_to_oklab(double r, double g, double b, double *l, double *a, double *lb) {
|
||||
// Convert sRGB to linear sRGB
|
||||
const double r_lin = srgb_to_linear(r);
|
||||
const double g_lin = srgb_to_linear(g);
|
||||
const double b_lin = srgb_to_linear(b);
|
||||
|
||||
// Convert Linear sRGB to OKLab (inverse of oklch_to_srgb)
|
||||
const double l_lin = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin;
|
||||
const double m_lin = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin;
|
||||
const double s_lin = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin;
|
||||
|
||||
const double l_ = l_lin != 0 ? copysign(pow(fabs(l_lin), 1./3.), l_lin) : 0;
|
||||
const double m_ = m_lin != 0 ? copysign(pow(fabs(m_lin), 1./3.), m_lin) : 0;
|
||||
const double s_ = s_lin != 0 ? copysign(pow(fabs(s_lin), 1./3.), s_lin) : 0;
|
||||
|
||||
// OKLab coordinates
|
||||
*l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
|
||||
*a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
|
||||
*lb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
|
||||
}
|
||||
|
||||
static double
|
||||
distance(double x_l, double x_a, double x_b, double y_l, double y_a, double y_b) {
|
||||
return sqrt((x_l - y_l)*(x_l - y_l) + (x_a - y_a)*(x_a - y_a) + (x_b - y_b)*(x_b - y_b));
|
||||
}
|
||||
|
||||
static void
|
||||
oklch_to_srgb_gamut_map(double l, double c, double h, double *r, double *g, double *b) {
|
||||
// Edge cases: pure black or white don't need gamut mapping
|
||||
if (!isfinite(l) || !isfinite(c) || !isfinite(h) || l <= 0) { *r = 0; *g = 0; *b = 0; return; }
|
||||
if (l >= 1) { *r = 1; *g = 1; *b = 1; return; }
|
||||
// Constants from CSS Color Module Level 4
|
||||
static const double JND = 0.02; // Just Noticeable Difference threshold (2% in deltaEOK)
|
||||
static const double MIN_CONVERGENCE = 0.0001; // Binary search precision (0.01% chroma)
|
||||
static const double EPSILON = 0.00001; // Small value for doubleing point comparisons
|
||||
|
||||
// If chroma is very small, color is essentially achromatic
|
||||
if (c < EPSILON) { *r = linear_to_srgb(l); *g = *r; *b = *r; return; }
|
||||
// Try the original color first
|
||||
oklch_to_srgb(l, c, h, r, g, b);
|
||||
#define in_gamut(r,g,b) (0. <= r && r <= 1. && 0. <= g && g <= 1. && 0. <= b && b <= 1.)
|
||||
if (in_gamut(*r,*g,*b)) return;
|
||||
// Binary search for maximum in-gamut chroma
|
||||
double low_chroma = 0, high_chroma = c, r_test, g_test, b_test, r_clipped, g_clipped, b_clipped;
|
||||
|
||||
// Convert original color to OKLab for deltaE calculations
|
||||
while ((high_chroma - low_chroma) > MIN_CONVERGENCE) {
|
||||
double mid_chroma = (high_chroma + low_chroma) * 0.5;
|
||||
// Try this chroma value
|
||||
oklch_to_srgb(l, mid_chroma, h, &r_test, &g_test, &b_test);
|
||||
// Check if in gamut (before clipping)
|
||||
if (in_gamut(r_test, g_test, b_test)) {
|
||||
// In gamut - try higher chroma
|
||||
low_chroma = mid_chroma;
|
||||
} else {
|
||||
// Out of gamut - clip and check deltaE
|
||||
r_clipped = clamp(r_test); g_clipped = clamp(g_test); b_clipped = clamp(b_test);
|
||||
|
||||
// Convert both to OKLab for comparison
|
||||
double l_test, a_test, lb_test, l_clipped, a_clipped, lb_clipped;
|
||||
srgb_to_oklab(r_test, g_test, b_test, &l_test, &a_test, &lb_test);
|
||||
srgb_to_oklab(r_clipped, g_clipped, b_clipped, &l_clipped, &a_clipped, &lb_clipped);
|
||||
|
||||
// Calculate perceptual difference
|
||||
double de = distance(l_test, a_test, lb_test, l_clipped, a_clipped, lb_clipped);
|
||||
|
||||
if (de < JND) {
|
||||
// Difference is imperceptible - accept this chroma
|
||||
low_chroma = mid_chroma;
|
||||
} else {
|
||||
// Difference is noticeable - reduce chroma more
|
||||
high_chroma = mid_chroma;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use the final chroma value and clip to ensure in-gamut
|
||||
oklch_to_srgb(l, low_chroma, h, r, g, b);
|
||||
*r = clamp(*r); *g = clamp(*g); *b = clamp(*b);
|
||||
#undef in_gamut
|
||||
}
|
||||
|
||||
static double
|
||||
f_inv(double t) {
|
||||
static const double delta = 6. / 29.;
|
||||
return t > delta ? t*t*t : 3 * delta * delta * (t - 4. / 29.);
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
lab_to_oklch(double l, double a, double b, double *okl, double *c, double *h) {
|
||||
const double y = (l + 16.) / 116.;
|
||||
const double x = a / 500. + y;
|
||||
const double z = y - b / 200.;
|
||||
const double x_val = 0.95047 * f_inv(x);
|
||||
const double y_val = f_inv(y);
|
||||
const double z_val = 1.08883 * f_inv(z);
|
||||
|
||||
// XYZ to Linear sRGB (don't clip here to preserve out-of-gamut info)
|
||||
const double r_lin = +3.2404542 * x_val - 1.5371385 * y_val - 0.4985314 * z_val;
|
||||
const double g_lin = -0.9692660 * x_val + 1.8760108 * y_val + 0.0415560 * z_val;
|
||||
const double b_lin = +0.0556434 * x_val - 0.2040259 * y_val + 1.0572252 * z_val;
|
||||
|
||||
// Convert linear sRGB to sRGB gamma
|
||||
const double r_srgb = r_lin >= 0 ? linear_to_srgb(r_lin) : 0;
|
||||
const double g_srgb = g_lin >= 0 ? linear_to_srgb(g_lin) : 0;
|
||||
const double b_srgb = b_lin >= 0 ? linear_to_srgb(b_lin) : 0;
|
||||
|
||||
// Convert to OKLab
|
||||
double a_ok, b_ok;
|
||||
srgb_to_oklab(r_srgb, g_srgb, b_srgb, okl, &a_ok, &b_ok);
|
||||
// Convert OKLab to OKLCH
|
||||
*c = sqrt(a_ok * a_ok + b_ok * b_ok);
|
||||
*h = fmod(radians_to_degrees(atan2(b_ok, a_ok)), 360.f);
|
||||
}
|
||||
|
||||
static PyObject*
|
||||
parse_oklch(const char *spec, size_t len) {
|
||||
if (len < 10 || spec[--len] != ')') Py_RETURN_NONE;
|
||||
if (spec[0] != 'k' || spec[1] != 'l' || spec[2] != 'c' || spec[3] != 'h' || spec[4] != '(') Py_RETURN_NONE;
|
||||
spec += 5; len -= 5;
|
||||
char buf[256]; if (len >= sizeof(buf)) Py_RETURN_NONE;
|
||||
memcpy(buf, spec, len); buf[len] = 0;
|
||||
double l, c, h; char *tok;
|
||||
#define p(buf, out) if (!(tok = strtok(buf, " ,")) || !parse_double_intensity(tok, &out, 100)) Py_RETURN_NONE;
|
||||
p(buf, l); p(NULL, c); p(NULL, h);
|
||||
#undef p
|
||||
// Clamp to reasonable ranges
|
||||
l = clamp(l);
|
||||
c = MAX(0.f, c); // Chroma is unbounded but we don't clamp high end
|
||||
h = fmod(h, 360); // Wrap hue to 0-360
|
||||
double r, g, b;
|
||||
oklch_to_srgb_gamut_map(l, c, h, &r, &g, &b);
|
||||
return (PyObject*)alloc_color(as8bit(r), as8bit(g), as8bit(b), 0);
|
||||
}
|
||||
|
||||
static PyObject*
|
||||
parse_lab(const char *spec, size_t len) {
|
||||
if (len < 8 || spec[--len] != ')') Py_RETURN_NONE;
|
||||
if (spec[0] != 'a' || spec[1] != 'b' || spec[2] != '(') Py_RETURN_NONE;
|
||||
spec += 3; len -= 3;
|
||||
char buf[256]; if (len >= sizeof(buf)) Py_RETURN_NONE;
|
||||
memcpy(buf, spec, len); buf[len] = 0;
|
||||
double l, a, b; char *tok;
|
||||
#define p(buf, out) if (!(tok = strtok(buf, " ,")) || !parse_double_intensity(tok, &out, 1)) Py_RETURN_NONE;
|
||||
p(buf, l); p(NULL, a); p(NULL, b);
|
||||
#undef p
|
||||
// Clamp to reasonable ranges
|
||||
double okl, c, h, r, g, bb;
|
||||
lab_to_oklch(MAX(0., MIN(l, 100.)), a, b, &okl, &c, &h);
|
||||
oklch_to_srgb_gamut_map(okl, c, h, &r, &g, &bb);
|
||||
return (PyObject*)alloc_color(as8bit(r), as8bit(g), as8bit(bb), 0);
|
||||
}
|
||||
|
||||
static PyObject*
|
||||
parse_color(PyTypeObject *type UNUSED, PyObject *pspec) {
|
||||
if (!PyUnicode_Check(pspec)) { PyErr_SetString(PyExc_TypeError, "spec must be a string"); return NULL; }
|
||||
Py_ssize_t len;
|
||||
const char *spec = PyUnicode_AsUTF8AndSize(pspec, &len);
|
||||
if (len < 4) Py_RETURN_NONE;
|
||||
switch (spec[0]) {
|
||||
case '#': return parse_sharp(spec + 1, len - 1);
|
||||
case 'r':
|
||||
if (spec[1] != 'g' || spec[2] != 'b' || len < 6) Py_RETURN_NONE;
|
||||
switch(spec[3]) {
|
||||
case ':': return parse_rgb(spec + 4, len - 4);
|
||||
case 'i':
|
||||
if (spec[4] == 'i' && spec[5] == ':') return parse_rgbi(spec + 5, len - 5);
|
||||
}
|
||||
Py_RETURN_NONE;
|
||||
case 'o': return parse_oklch(spec + 1, len - 1);
|
||||
case 'l': return parse_lab(spec + 1, len - 1);
|
||||
}
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
static PyMethodDef color_methods[] = {
|
||||
METHODB(contrast, METH_O),
|
||||
METHODB(parse_color, METH_O | METH_CLASS),
|
||||
{NULL} /* Sentinel */
|
||||
};
|
||||
|
||||
|
|
@ -816,6 +1122,7 @@ static PyMethodDef module_methods[] = {
|
|||
};
|
||||
|
||||
int init_ColorProfile(PyObject *module) {\
|
||||
c_locale = newlocale(LC_NUMERIC_MASK, "C", (locale_t)0);
|
||||
if (PyType_Ready(&ColorProfile_Type) < 0) return 0;
|
||||
if (PyModule_AddObject(module, "ColorProfile", (PyObject *)&ColorProfile_Type) != 0) return 0;
|
||||
Py_INCREF(&ColorProfile_Type);
|
||||
|
|
|
|||
|
|
@ -740,6 +740,9 @@ def patch_global_colors(spec: Dict[str, Optional[int]], configured: bool) -> Non
|
|||
|
||||
|
||||
class Color:
|
||||
@classmethod
|
||||
def parse_color(cls, spec: str) -> Color | None: ...
|
||||
|
||||
@property
|
||||
def rgb(self) -> int:
|
||||
pass
|
||||
|
|
@ -1071,6 +1074,7 @@ def mark_tab_bar_dirty(os_window_id: int, should_be_shown: bool) -> None:
|
|||
|
||||
def is_tab_bar_visible(os_window_id: int) -> bool: ...
|
||||
|
||||
|
||||
def detach_window(os_window_id: int, tab_id: int, window_id: int) -> None:
|
||||
pass
|
||||
|
||||
|
|
|
|||
436
kitty/rgb.py
generated
436
kitty/rgb.py
generated
|
|
@ -1,10 +1,6 @@
|
|||
#!/usr/bin/env python
|
||||
# License: GPL v3 Copyright: 2017, Kovid Goyal <kovid at kovidgoyal.net>
|
||||
|
||||
import math
|
||||
import re
|
||||
from contextlib import suppress
|
||||
|
||||
from .fast_data_types import Color
|
||||
|
||||
|
||||
|
|
@ -20,42 +16,6 @@ def alpha_blend(top_color: Color, bottom_color: Color, alpha: float) -> Color:
|
|||
)
|
||||
|
||||
|
||||
def parse_single_color(c: str) -> int:
|
||||
if len(c) == 1:
|
||||
c += c
|
||||
return int(c[:2], 16)
|
||||
|
||||
|
||||
def parse_sharp(spec: str) -> Color | None:
|
||||
if len(spec) in (3, 6, 9, 12):
|
||||
part_len = len(spec) // 3
|
||||
colors = re.findall(fr'[a-fA-F0-9]{{{part_len}}}', spec)
|
||||
return Color(*map(parse_single_color, colors))
|
||||
return None
|
||||
|
||||
|
||||
def parse_rgb(spec: str) -> Color | None:
|
||||
colors = spec.split('/')
|
||||
if len(colors) == 3:
|
||||
return Color(*map(parse_single_color, colors))
|
||||
return None
|
||||
|
||||
|
||||
def parse_single_intensity(x: str) -> int:
|
||||
val = float(x)
|
||||
# Validate for NaN and infinity
|
||||
if not math.isfinite(val):
|
||||
return 0
|
||||
return int(max(0, min(abs(val), 1)) * 255)
|
||||
|
||||
|
||||
def parse_rgbi(spec: str) -> Color | None:
|
||||
colors = spec.split('/')
|
||||
if len(colors) == 3:
|
||||
return Color(*map(parse_single_intensity, colors))
|
||||
return None
|
||||
|
||||
|
||||
def color_from_int(x: int) -> Color:
|
||||
return Color((x >> 16) & 255, (x >> 8) & 255, x & 255)
|
||||
|
||||
|
|
@ -72,381 +32,6 @@ def color_as_sgr(x: Color) -> str:
|
|||
return x.as_sgr
|
||||
|
||||
|
||||
# Color space conversion functions
|
||||
|
||||
def srgb_to_linear(c: float) -> float:
|
||||
"""Convert sRGB component (0-1) to linear light"""
|
||||
if c <= 0.04045:
|
||||
return c / 12.92
|
||||
return math.pow((c + 0.055) / 1.055, 2.4)
|
||||
|
||||
|
||||
def linear_to_srgb(c: float) -> float:
|
||||
"""Convert linear light component (0-1) to sRGB"""
|
||||
if c <= 0.0031308:
|
||||
return c * 12.92
|
||||
return 1.055 * math.pow(c, (1 / 2.4)) - 0.055
|
||||
|
||||
|
||||
def oklch_to_srgb(l: float, c: float, h: float) -> tuple[float, float, float]: # noqa: E741
|
||||
"""Convert OKLCH to sRGB RGB (0-1)
|
||||
|
||||
OKLCH is a perceptual color space based on OKLab.
|
||||
L: Lightness (0-1, typically 0-1)
|
||||
C: Chroma (0-0.4, unbounded but practical max ~0.4)
|
||||
H: Hue (0-360 degrees)
|
||||
|
||||
Conversion path: OKLCH -> OKLab -> Linear sRGB -> sRGB
|
||||
"""
|
||||
# Convert OKLCH to OKLab
|
||||
h_rad = math.radians(h)
|
||||
a = c * math.cos(h_rad)
|
||||
b = c * math.sin(h_rad)
|
||||
|
||||
# Convert OKLab to Linear sRGB
|
||||
# Using the OKLab to Linear sRGB transformation
|
||||
l_ = l + 0.3963377774 * a + 0.2158037573 * b
|
||||
m_ = l - 0.1055613458 * a - 0.0638541728 * b
|
||||
s_ = l - 0.0894841775 * a - 1.2914855480 * b
|
||||
|
||||
l_lin = l_ * l_ * l_
|
||||
m_lin = m_ * m_ * m_
|
||||
s_lin = s_ * s_ * s_
|
||||
|
||||
r_lin = +4.0767416621 * l_lin - 3.3077115913 * m_lin + 0.2309699292 * s_lin
|
||||
g_lin = -1.2684380046 * l_lin + 2.6097574011 * m_lin - 0.3413193965 * s_lin
|
||||
b_lin = -0.0041960863 * l_lin - 0.7034186147 * m_lin + 1.7076147010 * s_lin
|
||||
|
||||
# Clip to valid range
|
||||
r_lin = max(0.0, min(1.0, r_lin))
|
||||
g_lin = max(0.0, min(1.0, g_lin))
|
||||
b_lin = max(0.0, min(1.0, b_lin))
|
||||
|
||||
# Convert linear sRGB to sRGB
|
||||
return (linear_to_srgb(r_lin), linear_to_srgb(g_lin), linear_to_srgb(b_lin))
|
||||
|
||||
|
||||
def srgb_to_oklab(r: float, g: float, b: float) -> tuple[float, float, float]:
|
||||
"""Convert sRGB RGB (0-1) to OKLab
|
||||
|
||||
Reverse conversion from sRGB to OKLab.
|
||||
Needed for deltaE calculations in gamut mapping.
|
||||
|
||||
Conversion path: sRGB -> Linear sRGB -> OKLab
|
||||
"""
|
||||
# Convert sRGB to linear sRGB
|
||||
r_lin = srgb_to_linear(r)
|
||||
g_lin = srgb_to_linear(g)
|
||||
b_lin = srgb_to_linear(b)
|
||||
|
||||
# Convert Linear sRGB to OKLab (inverse of oklch_to_srgb)
|
||||
l_lin = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin
|
||||
m_lin = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin
|
||||
s_lin = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin
|
||||
|
||||
l_ = math.copysign(abs(l_lin) ** (1/3), l_lin) if l_lin != 0 else 0
|
||||
m_ = math.copysign(abs(m_lin) ** (1/3), m_lin) if m_lin != 0 else 0
|
||||
s_ = math.copysign(abs(s_lin) ** (1/3), s_lin) if s_lin != 0 else 0
|
||||
|
||||
# OKLab coordinates
|
||||
l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ # noqa: E741
|
||||
a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
|
||||
b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
|
||||
|
||||
return (l, a, b)
|
||||
|
||||
|
||||
def deltaE_ok(lab1: tuple[float, float, float], lab2: tuple[float, float, float]) -> float:
|
||||
"""Calculate deltaE in OKLab space (Euclidean distance)
|
||||
|
||||
This is the color difference metric used in CSS Color Module Level 4
|
||||
for gamut mapping. It measures perceptual difference between two colors.
|
||||
|
||||
Args:
|
||||
lab1: First color in OKLab coordinates (L, a, b)
|
||||
lab2: Second color in OKLab coordinates (L, a, b)
|
||||
|
||||
Returns:
|
||||
Perceptual color difference (deltaE OK)
|
||||
"""
|
||||
return math.sqrt(
|
||||
(lab1[0] - lab2[0]) ** 2 +
|
||||
(lab1[1] - lab2[1]) ** 2 +
|
||||
(lab1[2] - lab2[2]) ** 2
|
||||
)
|
||||
|
||||
|
||||
def oklch_to_srgb_gamut_map(l: float, c: float, h: float) -> tuple[float, float, float]: # noqa: E741
|
||||
"""Convert OKLCH to sRGB with CSS Color Module Level 4 gamut mapping
|
||||
|
||||
For colors outside the sRGB gamut, this uses binary search chroma reduction
|
||||
to find the maximum displayable chroma while preserving lightness and hue.
|
||||
|
||||
This implements the algorithm from CSS Color Module Level 4 Section 13:
|
||||
https://www.w3.org/TR/css-color-4/#css-gamut-mapping
|
||||
|
||||
Args:
|
||||
l: Lightness (0-1)
|
||||
c: Chroma (0-0.4+, unbounded)
|
||||
h: Hue (0-360 degrees)
|
||||
|
||||
Returns:
|
||||
tuple: sRGB values (r, g, b) in range 0-1
|
||||
"""
|
||||
# Validate for NaN and infinity as a safety check
|
||||
if not (math.isfinite(l) and math.isfinite(c) and math.isfinite(h)):
|
||||
return (0.0, 0.0, 0.0) # Fallback to black
|
||||
|
||||
# Constants from CSS Color Module Level 4
|
||||
JND = 0.02 # Just Noticeable Difference threshold (2% in deltaEOK)
|
||||
MIN_CONVERGENCE = 0.0001 # Binary search precision (0.01% chroma)
|
||||
EPSILON = 0.00001 # Small value for floating point comparisons
|
||||
|
||||
# Edge cases: pure black or white don't need gamut mapping
|
||||
if l <= 0.0:
|
||||
return (0.0, 0.0, 0.0)
|
||||
if l >= 1.0:
|
||||
return (1.0, 1.0, 1.0)
|
||||
|
||||
# If chroma is very small, color is essentially achromatic
|
||||
if c < EPSILON:
|
||||
gray = linear_to_srgb(l)
|
||||
return (gray, gray, gray)
|
||||
|
||||
# Try the original color first
|
||||
r, g, b = oklch_to_srgb(l, c, h)
|
||||
|
||||
# Check if already in gamut (no clipping needed)
|
||||
if 0.0 <= r <= 1.0 and 0.0 <= g <= 1.0 and 0.0 <= b <= 1.0:
|
||||
return (r, g, b)
|
||||
|
||||
# Binary search for maximum in-gamut chroma
|
||||
low_chroma = 0.0
|
||||
high_chroma = c
|
||||
|
||||
# Convert original color to OKLab for deltaE calculations
|
||||
|
||||
while (high_chroma - low_chroma) > MIN_CONVERGENCE:
|
||||
mid_chroma = (high_chroma + low_chroma) * 0.5
|
||||
|
||||
# Try this chroma value
|
||||
r_test, g_test, b_test = oklch_to_srgb(l, mid_chroma, h)
|
||||
|
||||
# Check if in gamut (before clipping)
|
||||
in_gamut = (0.0 <= r_test <= 1.0 and
|
||||
0.0 <= g_test <= 1.0 and
|
||||
0.0 <= b_test <= 1.0)
|
||||
|
||||
if in_gamut:
|
||||
# In gamut - try higher chroma
|
||||
low_chroma = mid_chroma
|
||||
else:
|
||||
# Out of gamut - clip and check deltaE
|
||||
r_clipped = max(0.0, min(1.0, r_test))
|
||||
g_clipped = max(0.0, min(1.0, g_test))
|
||||
b_clipped = max(0.0, min(1.0, b_test))
|
||||
|
||||
# Convert both to OKLab for comparison
|
||||
test_lab = srgb_to_oklab(r_test, g_test, b_test)
|
||||
clipped_lab = srgb_to_oklab(r_clipped, g_clipped, b_clipped)
|
||||
|
||||
# Calculate perceptual difference
|
||||
de = deltaE_ok(test_lab, clipped_lab)
|
||||
|
||||
if de < JND:
|
||||
# Difference is imperceptible - accept this chroma
|
||||
low_chroma = mid_chroma
|
||||
else:
|
||||
# Difference is noticeable - reduce chroma more
|
||||
high_chroma = mid_chroma
|
||||
|
||||
# Use the final chroma value and clip to ensure in-gamut
|
||||
r_final, g_final, b_final = oklch_to_srgb(l, low_chroma, h)
|
||||
return (
|
||||
max(0.0, min(1.0, r_final)),
|
||||
max(0.0, min(1.0, g_final)),
|
||||
max(0.0, min(1.0, b_final))
|
||||
)
|
||||
|
||||
|
||||
def lab_to_srgb(l: float, a: float, b: float) -> tuple[float, float, float]: # noqa: E741
|
||||
"""Convert CIE LAB to sRGB RGB (0-1)
|
||||
|
||||
LAB is a device-independent color space.
|
||||
L: Lightness (0-100)
|
||||
a: Green-red axis (-128 to +127, typically -100 to +100)
|
||||
b: Blue-yellow axis (-128 to +127, typically -100 to +100)
|
||||
|
||||
Conversion path: LAB -> XYZ -> Linear sRGB -> sRGB
|
||||
"""
|
||||
# LAB to XYZ (using D65 illuminant)
|
||||
y = (l + 16) / 116
|
||||
x = a / 500 + y
|
||||
z = y - b / 200
|
||||
|
||||
def f_inv(t: float) -> float:
|
||||
delta = 6 / 29
|
||||
if t > delta:
|
||||
return t ** 3
|
||||
return 3 * delta * delta * (t - 4 / 29)
|
||||
|
||||
# D65 white point
|
||||
x_n, y_n, z_n = 0.95047, 1.00000, 1.08883
|
||||
|
||||
x_val = x_n * f_inv(x)
|
||||
y_val = y_n * f_inv(y)
|
||||
z_val = z_n * f_inv(z)
|
||||
|
||||
# XYZ to Linear sRGB
|
||||
r_lin = +3.2404542 * x_val - 1.5371385 * y_val - 0.4985314 * z_val
|
||||
g_lin = -0.9692660 * x_val + 1.8760108 * y_val + 0.0415560 * z_val
|
||||
b_lin = +0.0556434 * x_val - 0.2040259 * y_val + 1.0572252 * z_val
|
||||
|
||||
# Clip to valid range
|
||||
r_lin = max(0.0, min(1.0, r_lin))
|
||||
g_lin = max(0.0, min(1.0, g_lin))
|
||||
b_lin = max(0.0, min(1.0, b_lin))
|
||||
|
||||
# Convert linear sRGB to sRGB
|
||||
return (linear_to_srgb(r_lin), linear_to_srgb(g_lin), linear_to_srgb(b_lin))
|
||||
|
||||
|
||||
def lab_to_oklch(l_lab: float, a_lab: float, b_lab: float) -> tuple[float, float, float]:
|
||||
"""Convert CIE LAB to OKLCH
|
||||
|
||||
Conversion path: LAB -> XYZ -> Linear sRGB -> sRGB -> OKLab -> OKLCH
|
||||
"""
|
||||
# First convert LAB to sRGB (unclipped to preserve out-of-gamut values)
|
||||
# LAB to XYZ (using D65 illuminant)
|
||||
y = (l_lab + 16) / 116
|
||||
x = a_lab / 500 + y
|
||||
z = y - b_lab / 200
|
||||
|
||||
def f_inv(t: float) -> float:
|
||||
delta = 6 / 29
|
||||
if t > delta:
|
||||
return t ** 3
|
||||
return 3 * delta * delta * (t - 4 / 29)
|
||||
|
||||
# D65 white point
|
||||
x_n, y_n, z_n = 0.95047, 1.00000, 1.08883
|
||||
|
||||
x_val = x_n * f_inv(x)
|
||||
y_val = y_n * f_inv(y)
|
||||
z_val = z_n * f_inv(z)
|
||||
|
||||
# XYZ to Linear sRGB (don't clip here to preserve out-of-gamut info)
|
||||
r_lin = +3.2404542 * x_val - 1.5371385 * y_val - 0.4985314 * z_val
|
||||
g_lin = -0.9692660 * x_val + 1.8760108 * y_val + 0.0415560 * z_val
|
||||
b_lin = +0.0556434 * x_val - 0.2040259 * y_val + 1.0572252 * z_val
|
||||
|
||||
# Convert linear sRGB to sRGB gamma
|
||||
r_srgb = linear_to_srgb(max(0.0, r_lin)) if r_lin >= 0 else 0.0
|
||||
g_srgb = linear_to_srgb(max(0.0, g_lin)) if g_lin >= 0 else 0.0
|
||||
b_srgb = linear_to_srgb(max(0.0, b_lin)) if b_lin >= 0 else 0.0
|
||||
|
||||
# Convert to OKLab
|
||||
l_ok, a_ok, b_ok = srgb_to_oklab(r_srgb, g_srgb, b_srgb)
|
||||
|
||||
# Convert OKLab to OKLCH
|
||||
c = math.sqrt(a_ok * a_ok + b_ok * b_ok)
|
||||
h = math.degrees(math.atan2(b_ok, a_ok)) % 360
|
||||
|
||||
return (l_ok, c, h)
|
||||
|
||||
|
||||
# Color parsing functions for new formats
|
||||
|
||||
def parse_oklch(spec: str) -> Color | None:
|
||||
"""Parse OKLCH color: oklch(l c h) or oklch(l, c, h)
|
||||
L: 0-1 (lightness)
|
||||
C: 0-0.4 (chroma, unbounded but practical max)
|
||||
H: 0-360 (hue in degrees)
|
||||
"""
|
||||
# Remove parentheses and split
|
||||
spec = spec.strip('()')
|
||||
parts = [p.strip().rstrip('%,') for p in re.split(r'[,\s]+', spec) if p.strip()]
|
||||
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
|
||||
try:
|
||||
l = float(parts[0]) # noqa: E741
|
||||
c = float(parts[1])
|
||||
h = float(parts[2])
|
||||
|
||||
# Validate for NaN and infinity
|
||||
if not (math.isfinite(l) and math.isfinite(c) and math.isfinite(h)):
|
||||
return None
|
||||
|
||||
# Handle percentages for L
|
||||
if '%' in parts[0]:
|
||||
l = l / 100.0 # noqa: E741
|
||||
|
||||
# Clamp to reasonable ranges
|
||||
l = max(0.0, min(1.0, l)) # noqa: E741
|
||||
c = max(0.0, c) # Chroma is unbounded but we don't clamp high end
|
||||
h = h % 360 # Wrap hue to 0-360
|
||||
|
||||
# Convert OKLCH to sRGB with gamut mapping
|
||||
# This uses CSS Color Module Level 4 algorithm for out-of-gamut colors
|
||||
r, g, b = oklch_to_srgb_gamut_map(l, c, h)
|
||||
|
||||
return Color(
|
||||
int(r * 255),
|
||||
int(g * 255),
|
||||
int(b * 255)
|
||||
)
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_lab(spec: str) -> Color | None:
|
||||
"""Parse LAB color: lab(l a b) or lab(l, a, b)
|
||||
L: 0-100 (lightness)
|
||||
a: -128 to 127 (green-red)
|
||||
b: -128 to 127 (blue-yellow)
|
||||
|
||||
Uses CSS Color Module Level 4 gamut mapping for out-of-gamut colors.
|
||||
Conversion path: LAB -> OKLCH -> gamut-mapped sRGB
|
||||
This preserves perceptual characteristics better than simple clipping.
|
||||
"""
|
||||
# Remove parentheses and split
|
||||
spec = spec.strip('()')
|
||||
parts = [p.strip().rstrip('%,') for p in re.split(r'[,\s]+', spec) if p.strip()]
|
||||
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
|
||||
try:
|
||||
l = float(parts[0]) # noqa: E741
|
||||
a = float(parts[1])
|
||||
b = float(parts[2])
|
||||
|
||||
# Validate for NaN and infinity
|
||||
if not (math.isfinite(l) and math.isfinite(a) and math.isfinite(b)):
|
||||
return None
|
||||
|
||||
# Clamp L to 0-100
|
||||
l = max(0.0, min(100.0, l)) # noqa: E741
|
||||
|
||||
# Convert LAB to OKLCH, then use gamut mapping to sRGB
|
||||
# This is better than simple LAB -> sRGB clipping as it preserves
|
||||
# perceptual properties (lightness and hue) while reducing chroma
|
||||
l_ok, c, h = lab_to_oklch(l, a, b)
|
||||
|
||||
# Apply gamut mapping in OKLCH space (reduces chroma if needed)
|
||||
r, g, b = oklch_to_srgb_gamut_map(l_ok, c, h)
|
||||
|
||||
return Color(
|
||||
int(r * 255),
|
||||
int(g * 255),
|
||||
int(b * 255)
|
||||
)
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def to_color(raw: str, validate: bool = False) -> Color | None:
|
||||
# See man XParseColor
|
||||
# Strip inline comments (e.g., "oklch(...) # comment")
|
||||
|
|
@ -458,26 +43,11 @@ def to_color(raw: str, validate: bool = False) -> Color | None:
|
|||
raw = raw.partition(' ')[0]
|
||||
else:
|
||||
# For non-hex colors, strip everything after #
|
||||
raw = raw.partition('#')[0]
|
||||
x = raw.strip().lower()
|
||||
raw = raw.partition('#')[0].strip()
|
||||
x = raw.lower()
|
||||
if ans := color_names.get(x):
|
||||
return ans
|
||||
val: Color | None = None
|
||||
with suppress(Exception):
|
||||
match raw[0]:
|
||||
case '#':
|
||||
val = parse_sharp(raw[1:])
|
||||
case 'o':
|
||||
val = parse_oklch(x[6:])
|
||||
case 'l':
|
||||
val = parse_lab(x[4:])
|
||||
case 'r':
|
||||
k, _, v = raw.partition(':')
|
||||
if k == 'rgb':
|
||||
val = parse_rgb(v)
|
||||
elif k == 'rgbi':
|
||||
val = parse_rgbi(v)
|
||||
if val is None and validate:
|
||||
if (val := Color.parse_color(x)) is None and validate:
|
||||
raise ValueError(f'Invalid color name: {raw!r}')
|
||||
return val
|
||||
|
||||
|
|
|
|||
|
|
@ -65,10 +65,10 @@ def test_to_color(self):
|
|||
|
||||
def c(spec, r=0, g=0, b=0, a=0):
|
||||
c = to_color(spec)
|
||||
self.ae(c.red, r)
|
||||
self.ae(c.green, g)
|
||||
self.ae(c.blue, b)
|
||||
self.ae(c.alpha, a)
|
||||
self.ae(c.red, r, spec)
|
||||
self.ae(c.green, g, spec)
|
||||
self.ae(c.blue, b, spec)
|
||||
self.ae(c.alpha, a, spec)
|
||||
|
||||
c('#eee', 0xee, 0xee, 0xee)
|
||||
c('#234567', 0x23, 0x45, 0x67)
|
||||
|
|
|
|||
Loading…
Reference in a new issue