From 9c468ffb40f832ff25f23bd6027b93ae8d40c7bf Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 5 Oct 2026 18:05:05 +0200 Subject: feat(accent): pick the colour that stands out, not the average colorz returned cluster means, which averaged a small vivid element into its surroundings, so a violet motif on a frame of pale skin came out as a muddy red. signature_color() now reads the pixels of a 256px thumbnail itself and scores each by chroma times its distance from the image's mean colour. The top 2% vote by hue, neighbouring bands summed, and the most vivid pixel near the peak is snapped. A pick under chroma 25 is a tint and takes the fallback. Dropping colorz also fixes two bugs. Its k-means seeds are random, so one wallpaper could snap differently between runs. And it calls sys.exit() on some images, which `except Exception` in main() does not catch, so udt-accent died without writing and the previous accent stayed. Over 314 wallpapers: no errors (colorz failed on 18), 25 fall back, about 0.06s each. The selftest gains a small-vivid-patch case and a grey-image case. Co-Authored-By: Claude Opus 5.5 --- bin/udt-accent | 95 +++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 81 insertions(+), 14 deletions(-) (limited to 'bin/udt-accent') diff --git a/bin/udt-accent b/bin/udt-accent index b0b59b0..d758ea6 100755 --- a/bin/udt-accent +++ b/bin/udt-accent @@ -8,10 +8,10 @@ Usage: udt-accent 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 -(which this no longer writes to, but pywal still would) -including the terminal's ANSI colors. See the design spec for why that matters. +The extraction reads the image's pixels itself rather than running `wal -i`, +because `wal -i` rewrites the whole ~/.cache/wal directory (which this no +longer writes to, but pywal still would) including the terminal's ANSI colors. +See the design spec for why that matters. """ import getpass @@ -31,6 +31,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from udt_colors import ACCENTS, FALLBACK, PALETTE, SCHEME # noqa: E402 MIN_CHROMA = 10.0 +# A wallpaper's pick below this is a tint, not a colour that stands out: over +# 314 real wallpapers, near-monochrome images landed at 19-24 and every pick +# that read as the image's accent at 33 or more. +SIGNATURE_MIN_CHROMA = 25.0 +# The share of pixels, by salience, that votes on the accent's hue. +SALIENT_SHARE = 0.02 CACHE = Path.home() / ".cache" / "udt" OUTPUT = CACHE / "udt-accent.rasi" @@ -161,17 +167,58 @@ def snap(hexval): def signature_color(image): - """Extract the image's most chromatic mid-tone color. - - Calls the colorz backend directly. It returns a list and writes nothing, - which is what keeps the pywal cache (and so the terminal) untouched. + """Return the colour that stands out most in the image, or None for a grey one. + + A pixel's salience is its chroma times its distance from the image's mean + colour, so it has to be both vivid and unlike the rest of the image: a + small violet motif on a frame full of skin wins over the skin. The most + salient pixels vote by hue, each neighbouring hue band adding to the vote + so violet and purple do not split it, and the most vivid pixel in the + winning band is the result. + + This replaced pywal's colorz backend, whose cluster means averaged a + small vivid element into its surroundings, whose random seeds let one + wallpaper snap differently on two runs, and which calls sys.exit() on + some images. Reading pixels writes nothing, so the pywal cache (and so + the terminal) stays untouched. """ - from pywal.backends import colorz + from PIL import Image + + with Image.open(image) as im: + im = im.convert("RGB") + im.thumbnail((256, 256)) + raw = im.tobytes() + pixels = [raw[i:i + 3].hex() for i in range(0, len(raw), 3)] + lab = {p: _to_lab("#" + p) for p in set(pixels)} + + mean_a = sum(lab[p][1] for p in pixels) / len(pixels) + mean_b = sum(lab[p][2] for p in pixels) / len(pixels) + + def chroma(p): + return math.hypot(lab[p][1], lab[p][2]) + + def salience(p): + return chroma(p) * math.hypot(lab[p][1] - mean_a, lab[p][2] - mean_b) - colors = colorz.get(str(image), 16) - # Slot 0 trends near-black and the upper slots near-white; the signature - # color of an image lives in the middle. - return max(colors[1:7], key=_chroma) + bands = 36 + + def band(p): + angle = math.atan2(lab[p][2], lab[p][1]) % (2 * math.pi) + return int(angle / (2 * math.pi) * bands) % bands + + salient = sorted(pixels, key=salience, reverse=True) + salient = salient[:max(1, int(len(salient) * SALIENT_SHARE))] + votes = [0.0] * bands + for p in salient: + votes[band(p)] += chroma(p) + smoothed = [votes[i - 1] + votes[i] + votes[(i + 1) % bands] + for i in range(bands)] + peak = max(range(bands), key=smoothed.__getitem__) + + # The same three bands the vote summed: the peak itself may be empty. + near = ((peak - 1) % bands, peak, (peak + 1) % bands) + best = max((p for p in salient if band(p) in near), key=chroma) + return "#" + best if chroma(best) >= SIGNATURE_MIN_CHROMA else None def write_accent(name): @@ -459,7 +506,8 @@ def write_sddm(name, image): def main(image): try: - name = snap(signature_color(image)) + color = signature_color(image) + name = snap(color) if color else FALLBACK except Exception as exc: # A broken image must still leave a working theme. print(f"udt-accent: {exc}, falling back to {FALLBACK}", file=sys.stderr) @@ -519,6 +567,25 @@ def selftest(): # A near-grey has an unstable hue angle and must take the fallback. assert snap("#888888") == FALLBACK, snap("#888888") + + # The accent is what stands out, not what covers the most: a small violet + # patch on a frame of pale skin tone must win, and a grey image has none. + import io + from PIL import Image + + def png(size, fill, patch=None): + im = Image.new("RGB", size, fill) + if patch: + im.paste(patch, (10, 10, 30, 30)) + buf = io.BytesIO() + im.save(buf, "PNG") + buf.seek(0) + return buf + + got = signature_color(png((200, 100), "#e8b8a8", "#6a39b0")) + delta = abs(_hue(got) - _hue("#6a39b0")) + assert min(delta, 2 * math.pi - delta) < 0.2, got + assert signature_color(png((200, 100), "#777777", "#7a7a80")) is None assert FALLBACK in ACCENTS, FALLBACK # Border neighbours must be distinct from the accent and from each other, -- cgit v1.2.3