3645216669
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).
328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""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()
|