1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
|
#!/usr/bin/env python3
# udt-accent: pick a Catppuccin Macchiato accent matching a wallpaper.
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
# Licensed under the GNU General Public License v2 only.
"""Extract a wallpaper's signature color and snap it to a Macchiato accent.
Usage: udt-accent <wallpaper-path>
udt-accent --selftest
The extraction deliberately calls pywal's colorz backend directly rather than
running `wal -i`, because `wal -i` rewrites the whole ~/.cache/wal directory
including the terminal's ANSI colors. See the design spec for why that matters.
"""
import math
import sys
# The nine candidate accents. rosewater and flamingo are excluded as
# near-neutral tints that capture saturated inputs; maroon, sapphire and
# lavender are excluded as near-duplicate hues of red, sky and mauve.
ACCENTS = {
"pink": "#f5bde6",
"mauve": "#c6a0f6",
"red": "#ed8796",
"peach": "#f5a97f",
"yellow": "#eed49f",
"green": "#a6da95",
"teal": "#8bd5ca",
"sky": "#91d7e3",
"blue": "#8aadf4",
}
FALLBACK = "mauve"
MIN_CHROMA = 10.0
def _to_lab(hexval):
"""Convert #rrggbb to CIELAB. sRGB D65, the standard conversion."""
r, g, b = (int(hexval[i:i + 2], 16) / 255 for i in (1, 3, 5))
def linear(c):
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
r, g, b = linear(r), linear(g), linear(b)
x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
y = (0.2126 * r + 0.7152 * g + 0.0722 * b)
z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
def f(t):
return t ** (1 / 3) if t > 0.008856 else 7.787 * t + 16 / 116
fx, fy, fz = f(x), f(y), f(z)
return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz))
def _hue(hexval):
"""Perceptual hue angle in radians."""
_, a, b = _to_lab(hexval)
return math.atan2(b, a)
def _chroma(hexval):
"""Distance from the neutral axis. Near-greys sit close to zero."""
_, a, b = _to_lab(hexval)
return math.hypot(a, b)
def snap(hexval):
"""Return the name of the nearest candidate accent by perceptual hue."""
if _chroma(hexval) < MIN_CHROMA:
return FALLBACK
target = _hue(hexval)
def distance(name):
delta = abs(_hue(ACCENTS[name]) - target)
return min(delta, 2 * math.pi - delta) # hue is circular
return min(ACCENTS, key=distance)
def selftest():
# Every accent must snap to itself, or the metric is not self-consistent.
for name, hexval in ACCENTS.items():
got = snap(hexval)
assert got == name, f"{name} ({hexval}) snapped to {got}"
# Representative real-world inputs.
assert snap("#ff8800") == "peach", snap("#ff8800")
assert snap("#00cc44") == "green", snap("#00cc44")
# A near-grey has an unstable hue angle and must take the fallback.
assert snap("#888888") == FALLBACK, snap("#888888")
print("selftest OK")
if __name__ == "__main__":
if len(sys.argv) == 2 and sys.argv[1] == "--selftest":
selftest()
|