aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md13
-rwxr-xr-xbin/udt-accent95
2 files changed, 92 insertions, 16 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 55bdf2d..afb685a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -239,8 +239,17 @@ which would move every cache on the system, so `udt-accent` writes the real
file to `~/.cache/udt/` and `install.sh` symlinks the old path at it. That
directory now holds the link and nothing else.
-**Extraction is deliberately isolated from pywal's cache.** It calls
-`pywal.backends.colorz.get()` directly, which returns a list and writes nothing.
+**The accent is what stands out, not what dominates.** `signature_color()`
+reads the pixels itself (PIL, on a 256px thumbnail) and scores each by chroma
+times its distance from the image's mean colour; the top 2% vote by hue. It
+replaced pywal's colorz backend, whose cluster means averaged a small vivid
+element into its surroundings, whose random seeds made one wallpaper snap
+differently between runs, and which `sys.exit()`s on some images, which
+`except Exception` did not catch, so the old accent stayed. A pick below
+chroma 25 is a tint, and takes the fallback.
+
+**Extraction is deliberately isolated from pywal's cache.** It reads pixels
+and writes nothing.
Running `wal -i` instead would rewrite all of `~/.cache/wal`, including the
terminal's ANSI colours, and that is the exact failure this design exists to
avoid. If you change `signature_color()`, verify it still writes nothing.
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,