"""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 (0–1) 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 16–32 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()