aboutsummaryrefslogtreecommitdiffstats
path: root/bin/udt-accent
blob: e459a3534c5dedae918dd2995246e69ffac98768 (plain)
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()