#!/usr/bin/env python3 """Trace the Minstrel logo and emit every brand asset that derives from it. Run by hand after the source artwork changes, never in CI — it needs potrace and Pillow, which the build images don't carry, and its outputs are committed (the app must render itself with no outbound network). python3 tools/gen-brand-assets.py Why a generator at all: the mark ships in four places that cannot share one file, because each needs a different colour mechanism — currentColor in the component, literal fills in mark.svg, a prefers-color-scheme swap in favicon.svg, and flattened pixels in the rasters. Hand-copying ~20KB of path data four ways is how a silhouette change lands in three of them and not the fourth. The paths have one source here instead. The trace pipeline, and why each step is what it is: * Components are separated first (connected-component labelling) so the hat and the note arc can take different fills. Grouping is by component id against the source raster, which is stable as long as SOURCE doesn't move. * Tracing runs on the ORIGINAL antialiased greyscale, upsampled and blurred — not on the binary mask. Tracing the mask supersampled hits 100% IoU by reproducing the pixel staircase exactly: a perfect score for jagged art and a 120KB path. Smoothing first finds a sub-pixel boundary instead, which is 99.74% IoU at 20KB. * potrace reads PBM, where bit 1 is BLACK, so the ink mask is inverted on the way in. Getting this backwards traces the background and still produces a plausible-looking SVG. """ import re, shutil, subprocess, sys from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFilter ROOT = Path(__file__).resolve().parent.parent SOURCE = ROOT / "docs/brand/minstrel-logo-source.png" PARCHMENT = "#E8E4D8" ACCENT = "#4A6B5C" # --fs-accent; the one row Minstrel overrides OBSIDIAN = "#14171A" # plate for rasters — see the contrast note in README # Component ids from the source raster: the hat body, brim, feather, band and # jewel take the text colour; the noteheads and the arc swashes take the accent. HAT = [396, 58, 251, 497, 442] NOTES = [31, 207, 30, 247, 317, 135, 78, 161, 186, 107, 215] FRAME = [1] # the source's rounded-rect border — deliberately NOT traced: an # adaptive icon gets masked to the launcher's own shape, so a # baked border would be clipped raggedly, and at header size it # is noise around a mark that is already busy. SUPERSAMPLE, BLUR = 4, 1.5 POTRACE = ["-s", "--flat", "-a", "1.334", "-O", "0.2", "-u", "4", "-t", "20"] def components(gray): """Label 8-connected runs of ink. Two-pass union-find; no scipy here.""" ink = gray > 128 H, W = ink.shape parent, labels, nxt = {}, np.zeros((H, W), np.int32), 1 def find(x): r = x while parent[r] != r: r = parent[r] while parent[x] != r: parent[x], x = r, parent[x] return r for y in range(H): if not ink[y].any(): continue prev = labels[y - 1] if y else None for x in np.nonzero(ink[y])[0]: nb = [labels[y, x - 1]] if x and labels[y, x - 1] else [] if y: nb += [prev[xx] for xx in (x - 1, x, x + 1) if 0 <= xx < W and prev[xx]] if nb: m = min(nb) labels[y, x] = m for n in nb: rm, rn = find(m), find(n) if rm != rn: parent[max(rm, rn)] = min(rm, rn) else: parent[nxt] = nxt labels[y, x] = nxt nxt += 1 flat = np.array([0] + [find(i) for i in range(1, nxt)], np.int32) return flat[labels] def trace(labels, gray, ids, tmp): keep = np.isin(labels, ids) # Dilate the group mask so the component's own antialiased fringe survives; # thresholding a hard-cut mask would shave a half-pixel off every edge. grown = Image.fromarray((keep * 255).astype(np.uint8)).filter(ImageFilter.MaxFilter(5)) img = Image.fromarray(np.where(np.asarray(grown), gray, 0.0).astype(np.uint8)) H, W = labels.shape img = img.resize((W * SUPERSAMPLE, H * SUPERSAMPLE), Image.BICUBIC) img = img.filter(ImageFilter.GaussianBlur(BLUR)) img.point(lambda p: 0 if p > 128 else 255).convert("1", dither=Image.NONE).save(tmp / "g.pbm") subprocess.run(["potrace", *POTRACE, str(tmp / "g.pbm"), "-o", str(tmp / "g.svg")], check=True) s = (tmp / "g.svg").read_text() return (" ".join(re.findall(r' width: out.append(line) line = tok else: line = f"{line} {tok}".strip() out.append(line) return ("\n" + indent).join(out) def svg_doc(vb, tr, hat_fill, notes_fill, hat_d, notes_d, style="", head=""): return (f'\n' f'{head}{style}' f' \n' f' \n' f' \n' f' \n\n') def rasterize(svg_path, out, px, plate=None, radius_frac=0.0, art_frac=1.0): """Render the mark to a PNG, optionally on a rounded plate. Rasters carry their own background because a PNG cannot answer to a colour scheme and iOS composites the touch icon onto white regardless. Obsidian rather than the raised iron: the accent clears the 3:1 graphics floor against obsidian (3.04:1) and fails against iron (2.80:1). Lightening the plate makes this worse, not better, because the accent is a dark colour. """ art = round(px * art_frac) subprocess.run(["rsvg-convert", "-w", str(art), "-o", str(out), str(svg_path)], check=True) mark = Image.open(out).convert("RGBA") canvas = Image.new("RGBA", (px, px), (0, 0, 0, 0)) if plate: r = round(px * radius_frac) layer = Image.new("RGBA", (px, px), (0, 0, 0, 0)) ImageDraw.Draw(layer).rounded_rectangle([0, 0, px - 1, px - 1], radius=r, fill=plate) canvas.alpha_composite(layer) canvas.alpha_composite(mark, ((px - mark.width) // 2, (px - mark.height) // 2)) canvas.save(out) def main(): tmp = ROOT / ".brand-tmp" tmp.mkdir(exist_ok=True) if not SOURCE.exists(): sys.exit(f"source artwork missing: {SOURCE}") a = np.asarray(Image.open(SOURCE).convert("RGB")).astype(np.float32) gray = 0.299 * a[:, :, 0] + 0.587 * a[:, :, 1] + 0.114 * a[:, :, 2] labels = components(gray) hat_d, tr = trace(labels, gray, HAT, tmp) notes_d, _ = trace(labels, gray, NOTES, tmp) # Tight viewBox around the traced art (frame excluded), in potrace's user # space, which is source pixels x SUPERSAMPLE. ys, xs = np.nonzero(np.isin(labels, HAT + NOTES)) pad = 6 x0, x1 = xs.min() - pad, xs.max() + pad y0, y1 = ys.min() - pad, ys.max() + pad S = SUPERSAMPLE vb = f"{x0*S} {y0*S} {(x1-x0)*S} {(y1-y0)*S}" w, h = (x1 - x0) * S, (y1 - y0) * S (ROOT / "web/static/brand/mark.svg").write_text(svg_doc( vb, tr, PARCHMENT, ACCENT, hat_d, notes_d, head=" \n")) (ROOT / "web/static/brand/favicon.svg").write_text(svg_doc( vb, tr, "currentColor", ACCENT, hat_d, notes_d, head=" \n", style=" \n")) plated = ROOT / ".brand-tmp/plated.svg" plated.write_text(svg_doc(vb, tr, PARCHMENT, ACCENT, hat_d, notes_d)) # Web rasters: plate radius and art scale measured off the icons these # replace, so the new mark sits where the old one did. for out, px in [("web/static/brand/icon-512.png", 512), ("web/static/apple-touch-icon.png", 180), ("web/static/favicon.png", 32)]: rasterize(plated, ROOT / out, px, plate=OBSIDIAN, radius_frac=0.20, art_frac=0.88) # Adaptive icon foreground: transparent, art inside the 66/108dp safe zone # so no launcher mask can clip it. The plate comes from the XML instead. res = ROOT / "android/app/src/main/res" for d, fg, legacy in [("mdpi", 108, 48), ("hdpi", 162, 72), ("xhdpi", 216, 96), ("xxhdpi", 324, 144), ("xxxhdpi", 432, 192)]: rasterize(plated, res / f"mipmap-{d}/ic_launcher_foreground.png", fg, art_frac=0.58) rasterize(plated, res / f"mipmap-{d}/ic_launcher.png", legacy, plate=OBSIDIAN, radius_frac=0.20, art_frac=0.88) (ROOT / "web/src/lib/components/MinstrelMark.svelte").write_text( COMPONENT.format(vb=vb, tr=tr, w=w, h=h, hat=wrap(hat_d, indent=" " * 6), notes=wrap(notes_d, indent=" " * 6), accent=ACCENT)) print(f"viewBox {vb} aspect {w/h:.4f} hat {len(hat_d)/1024:.1f}KB notes {len(notes_d)/1024:.1f}KB") shutil.rmtree(tmp) COMPONENT = ''' ''' if __name__ == "__main__": main()