Files
ledgrab/build/generate_icon.py
T
alexei.dolgolyov 3645216669 feat(icons): spectrum aperture icon set + dedicated tray variant
Regenerate the LedGrab icon family from a single Pillow script
(build/generate_icon.py): a rounded-square aperture traced by a
continuous RGB color-wheel stroke over a vignette canvas with a soft
chromatic bloom. 4x supersampled then downsampled per output for
crispness. Outputs 192/512 standard, 512 maskable (safe-area padded
for PWA round-crops), and a new 256 transparent-background tray
variant so the taskbar icon reads cleanly against light themes
instead of showing a dark tile.

icon.ico now embeds 16/24/32/48/64/128/256 frames sourced from the
transparent tray master, fixing the dark-square halo around the
file/taskbar icon on light Windows themes.

__main__ picks icon-tray.png for the tray and falls back to
icon-192.png when the tray asset isn't present (older bundles /
forks).
2026-05-28 17:26:18 +03:00

328 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Generate LedGrab app icon assets.
Concept: "Spectrum Aperture" — a rounded-square frame (the screen/display)
traced by a continuous RGB color-wheel stroke (the bias-light LED strip),
on a near-black canvas with a soft chromatic bloom behind it.
Outputs:
server/src/ledgrab/static/icons/icon-512.png (standard, opaque vignette bg)
server/src/ledgrab/static/icons/icon-192.png (downscale of 512)
server/src/ledgrab/static/icons/icon-512-maskable.png (safe-area padded, opaque)
server/src/ledgrab/static/icons/icon-tray.png (256, transparent bg, frame + glow)
server/src/ledgrab/static/icons/icon.ico (16/24/32/48/64/128/256)
Run from repo root:
py -3.13 build/generate_icon.py
"""
from __future__ import annotations
import colorsys
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
# ── Tunables ────────────────────────────────────────────────────────────
SUPERSAMPLE = 4 # render at 4x and downsample for crispness
BASE = 1024 # logical canvas size
HQ = BASE * SUPERSAMPLE # render canvas
BG_TOP = (12, 14, 22) # near-black, faint cool tint
BG_BOTTOM = (6, 7, 12) # darker at edges (vignette feel)
FRAME_INSET = 0.18 # margin from canvas edge to frame (fraction)
FRAME_RADIUS = 0.22 # corner radius (fraction of frame side)
FRAME_STROKE = 0.085 # stroke width (fraction of canvas)
BLOOM_OPACITY = 0.62 # outer bloom strength (01)
INNER_GLOW_OPACITY = 0.38 # inner chromatic reflection strength
# Hue rotation offset so red sits at the top
HUE_OFFSET = -90.0 # degrees (negative = counter-clockwise shift)
def lerp(a: float, b: float, t: float) -> float:
return a + (b - a) * t
def hue_to_rgb(hue_deg: float) -> tuple[int, int, int]:
"""Bright, slightly desaturated spectral color (LED-like)."""
h = (hue_deg % 360) / 360.0
r, g, b = colorsys.hls_to_rgb(h, 0.58, 0.92)
return int(r * 255), int(g * 255), int(b * 255)
def vignette_background(size: int) -> Image.Image:
"""Dark canvas with a soft radial vignette + faint scanline noise."""
img = Image.new("RGB", (size, size), BG_TOP)
px = img.load()
cx, cy = size / 2, size / 2
max_r = math.hypot(cx, cy)
for y in range(size):
for x in range(size):
d = math.hypot(x - cx, y - cy) / max_r
t = min(1.0, d**1.6)
px[x, y] = (
int(lerp(BG_TOP[0], BG_BOTTOM[0], t)),
int(lerp(BG_TOP[1], BG_BOTTOM[1], t)),
int(lerp(BG_TOP[2], BG_BOTTOM[2], t)),
)
return img
def draw_chromatic_bloom(size: int) -> Image.Image:
"""Soft, large chromatic glow behind the frame — the bias-light effect."""
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
cx, cy = size / 2, size / 2
radius = size * 0.36
blob_r = int(size * 0.30)
n_blobs = 24
for i in range(n_blobs):
a = i / n_blobs * 360.0
bx = cx + math.cos(math.radians(a - 90)) * radius
by = cy + math.sin(math.radians(a - 90)) * radius
r, g, b = hue_to_rgb(a + HUE_OFFSET)
alpha = int(255 * BLOOM_OPACITY * 0.55)
draw.ellipse(
(bx - blob_r, by - blob_r, bx + blob_r, by + blob_r),
fill=(r, g, b, alpha),
)
# Heavy blur → continuous, dreamy halo
layer = layer.filter(ImageFilter.GaussianBlur(radius=size * 0.10))
return layer
def rounded_rect_mask(size: int, inset: int, radius: int, stroke: int) -> Image.Image:
"""L-mode mask of a rounded-rect ring (the frame stroke region)."""
mask = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(mask)
box_outer = (inset, inset, size - inset, size - inset)
box_inner = (
inset + stroke,
inset + stroke,
size - inset - stroke,
size - inset - stroke,
)
r_outer = radius
r_inner = max(0, radius - stroke)
draw.rounded_rectangle(box_outer, radius=r_outer, fill=255)
draw.rounded_rectangle(box_inner, radius=r_inner, fill=0)
return mask
def draw_spectrum_frame(size: int) -> Image.Image:
"""Draw the rounded-square frame stroke filled with a hue-rotation gradient.
Strategy: paint a full-canvas angular hue gradient (centered), then
clip it with the rounded-ring mask. This guarantees a continuous,
seam-free color flow around the entire frame.
"""
cx, cy = size / 2, size / 2
gradient = Image.new("RGB", (size, size), (0, 0, 0))
gpx = gradient.load()
for y in range(size):
dy = y - cy
for x in range(size):
dx = x - cx
ang = math.degrees(math.atan2(dy, dx)) + 90.0 # 0° = top
r, g, b = hue_to_rgb(ang + HUE_OFFSET)
gpx[x, y] = (r, g, b)
inset = int(size * FRAME_INSET)
frame_side = size - 2 * inset
stroke = int(size * FRAME_STROKE)
radius = int(frame_side * FRAME_RADIUS)
mask = rounded_rect_mask(size, inset, radius, stroke)
out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
out.paste(gradient, (0, 0), mask)
return out
def draw_inner_screen(size: int) -> Image.Image:
"""Subtle dark rounded square inside the frame, with faint chromatic
inner reflection along the edges — like a screen catching ambient light."""
inset = int(size * FRAME_INSET)
stroke = int(size * FRAME_STROKE)
frame_side = size - 2 * inset
radius = int(frame_side * FRAME_RADIUS)
pad = int(stroke * 0.35)
box = (
inset + stroke + pad,
inset + stroke + pad,
size - inset - stroke - pad,
size - inset - stroke - pad,
)
r_inner = max(0, radius - stroke - pad)
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
# Dark fill, very slight cool tint
draw.rounded_rectangle(box, radius=r_inner, fill=(10, 12, 18, 255))
# Inner chromatic glow: same spectrum, very soft, clipped to the screen
bloom = draw_chromatic_bloom(size)
screen_mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(screen_mask).rounded_rectangle(box, radius=r_inner, fill=255)
bloom_alpha = bloom.split()[-1].point(lambda v: int(v * INNER_GLOW_OPACITY))
bloom.putalpha(bloom_alpha)
masked_bloom = Image.new("RGBA", (size, size), (0, 0, 0, 0))
masked_bloom.paste(bloom, (0, 0), screen_mask)
layer.alpha_composite(masked_bloom)
# Faint highlight glint top-left
glint = Image.new("RGBA", (size, size), (0, 0, 0, 0))
gdraw = ImageDraw.Draw(glint)
glint_box = (
box[0] + int(frame_side * 0.04),
box[1] + int(frame_side * 0.04),
box[0] + int(frame_side * 0.42),
box[1] + int(frame_side * 0.18),
)
gdraw.rounded_rectangle(glint_box, radius=int(frame_side * 0.05), fill=(255, 255, 255, 22))
glint = glint.filter(ImageFilter.GaussianBlur(radius=size * 0.012))
masked_glint = Image.new("RGBA", (size, size), (0, 0, 0, 0))
masked_glint.paste(glint, (0, 0), screen_mask)
layer.alpha_composite(masked_glint)
return layer
def add_outer_frame_glow(frame_rgba: Image.Image) -> Image.Image:
"""Take the spectrum frame and produce a blurred, brightened copy for glow."""
glow = frame_rgba.copy()
# Slightly inflate brightness for glow
r, g, b, a = glow.split()
glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.85)))))
glow = glow.filter(ImageFilter.GaussianBlur(radius=glow.width * 0.025))
return glow
def render_tray(size: int) -> Image.Image:
"""Render a tray-optimised icon: transparent background, bolder frame,
tight outer glow. Designed to read clearly at 1632 px on top of any
taskbar color."""
hq = size * SUPERSAMPLE
# Pull the frame inward a touch and beef up the stroke so it reads at 16 px.
global FRAME_INSET, FRAME_STROKE
saved_inset, saved_stroke = FRAME_INSET, FRAME_STROKE
FRAME_INSET = 0.13
FRAME_STROKE = 0.115
try:
frame = draw_spectrum_frame(hq)
finally:
FRAME_INSET, FRAME_STROKE = saved_inset, saved_stroke
# Tight, bright glow that doesn't bleed past the tray cell.
glow = frame.copy()
r, g, b, a = glow.split()
glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.95)))))
glow = glow.filter(ImageFilter.GaussianBlur(radius=hq * 0.012))
canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0))
canvas.alpha_composite(glow)
canvas.alpha_composite(frame)
return canvas.resize((size, size), Image.LANCZOS)
def render(size: int, *, maskable: bool = False) -> Image.Image:
"""Render the full icon at the given size."""
hq = size * SUPERSAMPLE
if maskable:
# Maskable: pad inward so the entire icon survives a circular crop.
# We render the standard composition at 80% of canvas size, centered.
bg = Image.new("RGB", (hq, hq), BG_BOTTOM).convert("RGBA")
bg.paste(vignette_background(hq), (0, 0))
inner = render(size, maskable=False).resize((int(hq * 0.78), int(hq * 0.78)), Image.LANCZOS)
# Strip the bg from the inner render: composite the spectrum
# parts on top of our maskable background.
ox = (hq - inner.width) // 2
oy = (hq - inner.height) // 2
bg.alpha_composite(inner, (ox, oy))
return bg.resize((size, size), Image.LANCZOS)
bg = vignette_background(hq).convert("RGBA")
bloom = draw_chromatic_bloom(hq)
frame = draw_spectrum_frame(hq)
frame_glow = add_outer_frame_glow(frame)
inner_screen = draw_inner_screen(hq)
# Composite order: bg → bloom → frame_glow → inner_screen → frame
canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0))
canvas.alpha_composite(bg)
canvas.alpha_composite(bloom)
canvas.alpha_composite(frame_glow)
canvas.alpha_composite(inner_screen)
canvas.alpha_composite(frame)
return canvas.resize((size, size), Image.LANCZOS)
def main() -> None:
repo_root = Path(__file__).resolve().parent.parent
targets = [
repo_root / "server" / "src" / "ledgrab" / "static" / "icons",
repo_root
/ "android"
/ "app"
/ "build"
/ "python"
/ "sources"
/ "debug"
/ "ledgrab"
/ "static"
/ "icons",
]
print("Rendering 1024 master...")
master = render(1024, maskable=False)
print("Rendering maskable 1024 master...")
maskable_master = render(1024, maskable=True)
print("Rendering tray 512 master (transparent bg)...")
tray_master = render_tray(512)
for icons_dir in targets:
if not icons_dir.exists():
print(f" skip (missing): {icons_dir}")
continue
out_512 = icons_dir / "icon-512.png"
out_192 = icons_dir / "icon-192.png"
out_mask = icons_dir / "icon-512-maskable.png"
out_tray = icons_dir / "icon-tray.png"
out_ico = icons_dir / "icon.ico"
master.resize((512, 512), Image.LANCZOS).save(out_512, "PNG", optimize=True)
master.resize((192, 192), Image.LANCZOS).save(out_192, "PNG", optimize=True)
maskable_master.resize((512, 512), Image.LANCZOS).save(out_mask, "PNG", optimize=True)
tray_master.save(out_tray, "PNG", optimize=True)
# Pre-resize each frame from the 1024 master for maximum crispness.
# Pass them via the `sizes` arg so Pillow embeds every variant.
ico_sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
# Use the tray (transparent-bg) variant for ICO frames so the file/
# taskbar icon doesn't show a dark tile against light backgrounds.
ico_source = tray_master.resize((256, 256), Image.LANCZOS)
ico_source.save(out_ico, format="ICO", sizes=ico_sizes)
print(f" wrote: {icons_dir}")
if __name__ == "__main__":
main()