The thin chevron between two chips the reader can't tell apart is now drawn in the previous chip's own fill stepped SEAM_SHADE darker (lighter for a dark fill) on the fill ahead, so nothing on the ribbon is a colour that isn't a chip's. The step is measured from whichever fill is further along, so the mark clears the fill it sits on by the full shade however close the two are. Whether two fills blur is now a perceptual distance, not a hue gap: ui/oklab.py is the reference OKLab transform (from_hex, to_hex, distance, shaded), and the seam interlocks only where the fills sit SEAM_BLUR (0.10) or more apart. The hue rule mis-sized the wheel badly: sixteen first-byte steps measure 0.026 across the greens and 0.165 across the cyans, so it blurred too little on one side and too much on the other, and an sRGB distance would only have restated the hue gap, a fixed byte step being a near-constant 55-60 of 8-bit RGB all the way round. 0.10 is five noticeable differences, the typical 0x10 pair with a little to spare, erring toward the thin chevron because a wrong blur still draws a legible seam and a wrong interlock draws none. The greys need no exemption any more: the keyless grey and the faded slate sit 0.18 apart and keep their interlock on the metric alone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
111 lines
4.9 KiB
Python
111 lines
4.9 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""OKLab — the perceptual colour space the UI measures and shades ``#rrggbb`` values in.
|
||
|
||
Where the app has to ask a question about two colours *as the eye sees them* — are these
|
||
two chip fills the same to a reader, what is a slightly darker version of this one — it
|
||
asks here, not in RGB and not by hue. sRGB is a device encoding: a fixed step in it is a
|
||
different size to the eye in every region of the wheel (a step across the greens is a
|
||
whisper, the same step across the cyans a shout), and a hue-only comparison is worse,
|
||
blind to lightness and saturation altogether. OKLab (Björn Ottosson, 2020) is built so
|
||
that Euclidean distance approximates perceived difference and so that changing ``L``
|
||
alone changes lightness and nothing else, which is exactly the two operations wanted.
|
||
|
||
Everything is the reference transform, no dependencies: linear sRGB → LMS → cube root →
|
||
Lab. ``L`` runs 0 (black) to 1 (white); ``a``/``b`` are the two chroma axes, roughly
|
||
±0.4 at the sRGB gamut's edge. A distance of about 0.02 is a just-noticeable difference
|
||
between two large patches.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
|
||
#: An OKLab triple ``(L, a, b)``.
|
||
Lab = tuple[float, float, float]
|
||
|
||
|
||
def _linear(channel: float) -> float:
|
||
"""The sRGB transfer function, 0–1 encoded → 0–1 linear."""
|
||
return channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4
|
||
|
||
|
||
def _encoded(channel: float) -> float:
|
||
"""The inverse: 0–1 linear → 0–1 sRGB encoded."""
|
||
return 12.92 * channel if channel <= 0.0031308 else 1.055 * channel ** (1 / 2.4) - 0.055
|
||
|
||
|
||
def from_hex(colour: str) -> Lab:
|
||
"""``#rrggbb`` → OKLab.
|
||
|
||
Args:
|
||
colour: A six-digit hex colour, ``#`` optional, either case.
|
||
|
||
Raises:
|
||
ValueError: If the string is not six hex digits.
|
||
"""
|
||
raw = colour.removeprefix("#")
|
||
if len(raw) != 6:
|
||
raise ValueError(f"not a #rrggbb colour: {colour!r}")
|
||
r, g, b = (_linear(int(raw[i : i + 2], 16) / 255) for i in (0, 2, 4))
|
||
l_ = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1 / 3)
|
||
m_ = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1 / 3)
|
||
s_ = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1 / 3)
|
||
return (
|
||
0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
|
||
1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
|
||
0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
|
||
)
|
||
|
||
|
||
def to_hex(lab: Lab) -> str:
|
||
"""OKLab → ``#rrggbb``, each channel clipped to the sRGB gamut.
|
||
|
||
Clipping is the honest answer for the one caller that can leave the gamut — a
|
||
saturated fill darkened at constant chroma pushes a channel below zero — because the
|
||
clipped colour is the nearest the terminal can show, and it stays the right hue to
|
||
the eye at the shifts the app asks for.
|
||
"""
|
||
lightness, a, b = lab
|
||
l_ = lightness + 0.3963377774 * a + 0.2158037573 * b
|
||
m_ = lightness - 0.1055613458 * a - 0.0638541728 * b
|
||
s_ = lightness - 0.0894841775 * a - 1.2914855480 * b
|
||
lms = (l_**3, m_**3, s_**3)
|
||
linear = (
|
||
4.0767416621 * lms[0] - 3.3077115913 * lms[1] + 0.2309699292 * lms[2],
|
||
-1.2684380046 * lms[0] + 2.6097574011 * lms[1] - 0.3413193965 * lms[2],
|
||
-0.0041960863 * lms[0] - 0.7034186147 * lms[1] + 1.7076147010 * lms[2],
|
||
)
|
||
return "#" + "".join(f"{round(_encoded(min(1.0, max(0.0, c))) * 255):02x}" for c in linear)
|
||
|
||
|
||
def distance(first: str, second: str) -> float:
|
||
"""Perceived difference between two ``#rrggbb`` colours: Euclidean distance in OKLab.
|
||
|
||
``0`` for the same colour; about ``0.02`` at the threshold of noticing between two
|
||
large patches; ``1`` is black to white.
|
||
"""
|
||
return math.dist(from_hex(first), from_hex(second))
|
||
|
||
|
||
def shaded(colour: str, by: float, *, floor: float | None = None) -> str:
|
||
"""``colour`` moved ``by`` in lightness, away from whichever end it is nearer.
|
||
|
||
A light colour comes back darker and a dark one lighter, at the same chroma, so the
|
||
result is recognisably *the same colour, shaded* rather than a different one — the
|
||
version of a fill that draws a line on itself.
|
||
|
||
Args:
|
||
colour: The ``#rrggbb`` to shade.
|
||
by: The lightness shift, in OKLab ``L`` (``0.1`` is a clear step, ``0.25`` bold).
|
||
floor: If given, the shift starts from this lightness rather than the colour's
|
||
own where that is further along — a caller guaranteeing the result clears a
|
||
*second* colour's lightness by ``by`` as well as this one's passes that
|
||
colour's ``L``. Read as "at least this far from both".
|
||
"""
|
||
lightness, a, b = from_hex(colour)
|
||
if lightness >= 0.5:
|
||
start = lightness if floor is None else min(lightness, floor)
|
||
return to_hex((start - by, a, b))
|
||
start = lightness if floor is None else max(lightness, floor)
|
||
return to_hex((start + by, a, b))
|