diff options
Diffstat (limited to 'bin')
| -rwxr-xr-x | bin/udt-accent | 95 |
1 files changed, 81 insertions, 14 deletions
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 <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 -(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, |
