#!/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) # --------------------------------------------------------------------------- # The reduced mark, for small sizes. # # NOT a simplification of the traced art — that was tried and does not work. # The source composition is dominated by a long diagonal plume, so every # mechanical reduction of it (hole-filling, morphological smoothing, dropping # components) collapses at 16px into a diagonal smear that reads as no object # at all. What reads at that size is a strong horizontal brim under a leaning # crown, which has to be DRAWN rather than derived. # # So this is hand-authored geometry, tuned against the real thing: the crown # peaks left of centre with a long right flank, the brim rides up at the right # tip, and a band slit keeps crown and brim from fusing into one lump. It is a # family member of the full mark, not a copy of it. # --------------------------------------------------------------------------- RN = 640 # design-space square for the reduced mark def _bez(pts, n=60): """Sample a chain of cubic beziers given as [P0, C1,C2,P1, C1,C2,P2, ...].""" out = [pts[0]] for i in range(1, len(pts), 3): p0, (c1, c2, p1) = out[-1], pts[i:i + 3] for t in np.linspace(0, 1, n)[1:]: u = 1 - t out.append((u**3 * p0[0] + 3*u*u*t * c1[0] + 3*u*t*t * c2[0] + t**3 * p1[0], u**3 * p0[1] + 3*u*u*t * c1[1] + 3*u*t*t * c2[1] + t**3 * p1[1])) return out def _reduced(plume): """The reduced hat. plume=False gives crown+brim only; the plume is taken as the difference between the two so it can be filled separately.""" im = Image.new("L", (RN, RN), 0) d = ImageDraw.Draw(im) by = 336 # Brim: the single strongest horizontal in the mark, and the shape that # says "hat" at 16px, so it stays thick and unbroken end to end. rt = by - 74 d.polygon(_bez([(38, by + 10), (150, by - 58), (430, by - 72), (590, rt), (614, rt + 10), (610, rt + 40), (586, rt + 54), (450, by + 56), (150, by + 58), (38, by + 30), (22, by + 26), (22, by + 16), (38, by + 10)]), fill=255) # Crown: peak pushed left of centre with a long right flank — the lean is # what keeps this recognisably the same hat as the traced one. px, top = 260, by - 256 d.polygon(_bez([(190, by + 16), (182, by - 96), (px - 92, top + 66), (px - 40, top + 10), (px - 6, top - 14), (px + 44, top + 10), (px + 62, top + 56), (px + 108, top + 150), (422, by - 120), (440, by - 34), (448, by - 4), (424, by + 18), (190, by + 16)]), fill=255) if plume: # A pointed leaf, deliberately short: a long thin plume is exactly what # turns the whole mark into a diagonal bar at small sizes. d.polygon(_bez([(404, by - 158), (466, by - 240), (546, by - 296), (586, by - 310), (580, by - 270), (532, by - 196), (458, by - 132)]), fill=255) a = np.asarray(im) > 128 # Band slit: across the crown base only. Never across the brim — breaking # the brim costs more legibility than the band gap buys. g = Image.new("L", (RN, RN), 0) ImageDraw.Draw(g).polygon(_bez([(192, by - 30), (256, by - 50), (376, by - 54), (438, by - 58), (446, by - 32), (300, by - 26), (192, by - 6)]), fill=255) return a & ~(np.asarray(g) > 128) def trace_reduced(tmp): """Trace the reduced hat and plume as two fills, on one shared viewBox.""" full, hat_only = _reduced(True), _reduced(False) plume_only = full & ~hat_only ys, xs = np.nonzero(full) pad = 8 box = (xs.min() - pad, ys.min() - pad, xs.max() + pad, ys.max() + pad) def one(mask, name): # Ink BRIGHT here, matching what trace() feeds potrace: the point() # below is what inverts to PBM's ink-is-black. Passing an already-dark # mask double-inverts and silently traces the background instead. img = Image.fromarray((mask * 255).astype(np.uint8)) img = img.resize((RN * SUPERSAMPLE, RN * 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 / f"{name}.pbm") subprocess.run(["potrace", *POTRACE, str(tmp / f"{name}.pbm"), "-o", str(tmp / f"{name}.svg")], check=True) s = (tmp / f"{name}.svg").read_text() return (" ".join(re.findall(r'\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.70: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")) # The reduced mark, for anywhere the full art cannot resolve. rhat_d, rplume_d, rtr, rvb = trace_reduced(tmp) (ROOT / "web/static/brand/mark-small.svg").write_text(svg_doc( rvb, rtr, PARCHMENT, ACCENT, rhat_d, rplume_d, head=" \n")) # Favicon uses the REDUCED mark: a browser tab renders it at 16px, and the # full art is unreadable there. This is the one consumer whose size is not # ours to choose, so it takes the form drawn for that size. (ROOT / "web/static/brand/favicon.svg").write_text(svg_doc( rvb, rtr, "currentColor", ACCENT, rhat_d, rplume_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)]: rasterize(plated, ROOT / out, px, plate=OBSIDIAN, radius_frac=0.20, art_frac=0.88) # 32px fallback favicon: reduced art, same reasoning as favicon.svg. plated_small = ROOT / ".brand-tmp/plated-small.svg" plated_small.write_text(svg_doc(rvb, rtr, PARCHMENT, ACCENT, rhat_d, rplume_d)) rasterize(plated_small, ROOT / "web/static/favicon.png", 32, plate=OBSIDIAN, radius_frac=0.20, art_frac=0.90) # 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()