testsAndMisc/python_pkg/wallpaper_rotator/_style_procedural.py
Krzysztof kuhy Rudnicki d502580e3f Dark wallpaper rotator: new style every 3 h
Six text-free styles (gradient, flow/Voronoi, habit heatmap, shutdown
clock, Earth day/night, plasma) replace the white i3 root. A 15-minute
systemd user timer picks a new random style per monitor every 3 hours,
unlike its previous one and the other monitor's; live styles refresh in
between. Also syncs i3/config with the live config and prunes the
oldest evidence artifact for the 60-file cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWX51FMD58yKUC1eYWf
2026-11-11 16:31:06 +01:00

110 lines
4.0 KiB
Python

"""Style 2: fresh procedural art each showing (flow field or Voronoi)."""
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy as np
from PIL import Image, ImageDraw
from python_pkg.wallpaper_rotator import _palette
if TYPE_CHECKING:
from python_pkg.wallpaper_rotator._context import RenderContext
_STEPS = 90
_SITES = 36
_TINTED_SHARE = 0.2 # fraction of Voronoi cells given an accent tint
_FLOW_SHARE = 0.5 # chance of the flow field over Voronoi
def _angle_field(xs: np.ndarray, ys: np.ndarray, waves: np.ndarray) -> np.ndarray:
"""Cheap smooth noise: a sum of sines in each particle's position."""
total = np.zeros_like(xs)
for fx, fy, phase in waves:
total += np.sin(xs * fx + ys * fy + phase)
return total * np.pi
def _trace(
start: tuple[np.ndarray, np.ndarray],
size: tuple[int, int],
waves: np.ndarray,
) -> np.ndarray:
"""Step every particle along the angle field; return all positions."""
xs, ys = start
width, height = size
step = max(width, height) / 600
trails = np.empty((_STEPS, len(xs), 2))
for i in range(_STEPS):
trails[i, :, 0], trails[i, :, 1] = xs, ys
angle = _angle_field(xs / width * 4, ys / height * 4, waves)
xs = xs + np.cos(angle) * step
ys = ys + np.sin(angle) * step
return trails
def _flow(size: tuple[int, int], rng: np.random.Generator) -> Image.Image:
"""Trace particles through a smooth angle field as translucent strokes."""
width, height = size
image = Image.new("RGB", size, _palette.DEEP)
draw = ImageDraw.Draw(image, "RGBA")
count = max(width * height // 2000, 50)
xs = rng.uniform(0, width, count)
ys = rng.uniform(0, height, count)
waves = rng.uniform([0.5, 0.5, 0.0], [3.0, 3.0, 6.3], size=(3, 3))
trails = _trace((xs, ys), size, waves)
colours = [_palette.ACCENTS[k] for k in rng.choice(6, size=2, replace=False)]
line_width = max(width // 1500, 1)
for p in range(count):
r, g, b = colours[p % 2]
points = [(float(x), float(y)) for x, y in trails[:, p]]
draw.line(points, fill=(r, g, b, 40), width=line_width)
return image
def _nearest_sites(
grid: tuple[np.ndarray, np.ndarray], sites: np.ndarray
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Per pixel: distance to the nearest and second-nearest site, and owner."""
xs, ys = grid
best = np.full(xs.shape, np.inf)
second = np.full(xs.shape, np.inf)
owner = np.zeros(xs.shape, dtype=np.int64)
for k, (sx, sy) in enumerate(sites):
dist = np.hypot(xs - sx, ys - sy)
closer = dist < best
second = np.where(closer, best, np.minimum(second, dist))
owner = np.where(closer, k, owner)
best = np.where(closer, dist, best)
return best, second, owner
def _voronoi(size: tuple[int, int], rng: np.random.Generator) -> Image.Image:
"""Shade Voronoi cells darker towards their edges, a few cells tinted."""
width, height = size
half_w, half_h = max(width // 2, 1), max(height // 2, 1)
ys, xs = np.indices((half_h, half_w), dtype=np.float64)
sites = rng.uniform([0, 0], [half_w, half_h], size=(_SITES, 2))
best, second, owner = _nearest_sites((xs, ys), sites)
edge = np.clip((second - best) / (0.05 * max(half_w, half_h)), 0.0, 1.0)
tints = [_palette.mix(_palette.BG, c, 0.25) for c in _palette.ACCENTS]
cell_colour = np.array(
[
tints[rng.integers(6)] if rng.random() < _TINTED_SHARE else _palette.BG
for _ in sites
],
dtype=np.float64,
)
deep = np.array(_palette.DEEP, dtype=np.float64)
rgb = deep + (cell_colour[owner] - deep) * edge[..., None]
small = Image.fromarray(rgb.round().astype(np.uint8), "RGB")
return small.resize(size, Image.Resampling.BILINEAR)
def render(
_ctx: RenderContext, size: tuple[int, int], rng: np.random.Generator
) -> Image.Image:
"""Pick one of the two generators and run it with a fresh seed."""
return _flow(size, rng) if rng.random() < _FLOW_SHARE else _voronoi(size, rng)