Files
minstrel/tools/gen-brand-assets.py
T
bvandeusenandClaude Opus 5 b06a1adfe8 feat(brand): draw a reduced mark so the favicon reads at 16px
The full hat does not resolve below ~32px, which the header worked around
by sizing up. A browser tab cannot: it renders the favicon at 16px and
does not ask. There the mark was a blob.

Deriving a small form from the traced art does not work, and this is the
non-obvious part. Hole-filling, morphological smoothing and dropping
components were all tried; every one of them preserves the overall
silhouette, and the overall silhouette — dominated by a long diagonal
plume — is precisely what fails. The result each time was a diagonal
smear that reads as no object at all.

So the reduced form is drawn rather than derived: a strong horizontal
brim under a crown that peaks left of centre, a band slit so the two do
not fuse, and a short pointed plume. Same lean and proportions as the
full mark, detail removed instead of minified.

favicon.svg and favicon.png now use it; apple-touch, icon-512 and the
Android launcher icons keep the full art, being large enough for it. The
plume carries the accent, which measures 3.04:1 on obsidian and 5.43:1 on
the light ground — both clear of the 3:1 graphics floor.

Also corrects the accent-on-iron figure in the generator's comment from
2.80:1 to 2.70:1. The real --fs-iron is #1E2228; 2.80 came from measuring
against a value I had guessed rather than read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 13:49:31 -04:00

378 lines
17 KiB
Python

#!/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'<path d="([^"]+)"', s)),
re.search(r'<g transform="([^"]+)"', s).group(1))
def wrap(d, width=78, indent=" " * 6):
"""Re-wrap path data so diffs stay reviewable instead of one endless line."""
out, line = [], ""
for tok in d.split():
if len(line) + len(tok) + 1 > 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'<path d="([^"]+)"', s)),
re.search(r'<g transform="([^"]+)"', s).group(1))
hat_d, tr = one(hat_only, "rhat")
plume_d, _ = one(plume_only, "rplume")
S = SUPERSAMPLE
vb = f"{box[0]*S} {box[1]*S} {(box[2]-box[0])*S} {(box[3]-box[1])*S}"
return hat_d, plume_d, tr, vb
def svg_doc(vb, tr, hat_fill, notes_fill, hat_d, notes_d, style="", head=""):
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vb}">\n'
f'{head}{style}'
f' <g transform="{tr}" fill-rule="evenodd">\n'
f' <path fill="{hat_fill}" d="{wrap(hat_d)}"/>\n'
f' <path fill="{notes_fill}" d="{wrap(notes_d)}"/>\n'
f' </g>\n</svg>\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=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
" Literal colours, for any consumer that cannot inline the SVG and\n"
" therefore cannot supply a currentColor. -->\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=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
" The reduced mark, literal colours. For use at or below ~24px,\n"
" where the full art collapses into a diagonal smear. -->\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=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
" The REDUCED mark, because a tab renders this at 16px.\n"
" The hat flips with the viewer's scheme because a favicon sits on\n"
" browser chrome we don't control: parchment would vanish on a light\n"
" tab strip, obsidian on a dark one. The plume holds the accent in\n"
" both — teal reads against either (3.04:1 on obsidian, 5.43:1 on\n"
" the light ground). -->\n",
style=" <style>\n"
f" svg {{ color: {PARCHMENT}; }}\n"
f" @media (prefers-color-scheme: light) {{ svg {{ color: {OBSIDIAN}; }} }}\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 = '''<script lang="ts">
// The Minstrel mark: a feathered bard's hat with an arc of notes overhead.
//
// Generated by tools/gen-brand-assets.py — do not edit the paths by hand.
// The same silhouette ships in web/static/brand/{{mark,favicon}}.svg and the
// Android launcher icons; regenerate all of them together.
//
// Inlined rather than <img src="mark.svg"> on purpose — an <img> cannot
// inherit currentColor, and inheriting it is the whole point: the hat takes
// the surrounding text colour, so it reads on both the dark and light
// palettes without a second asset. Parchment-on-parchment is invisible,
// which is exactly the bug a fixed fill would reintroduce.
//
// The notes keep the accent in both modes — one of the places the design
// system sanctions the accent (the wordmark).
//
// aria-hidden: every current use sits directly beside the words "Minstrel",
// so labelling it would make a screen reader announce the name twice. A
// STANDALONE use would need its own label.
let {{ size = 28, class: klass = '' }}: {{ size?: number; class?: string }} = $props();
</script>
<svg
viewBox="{vb}"
width={{size * {w} / {h}}}
height={{size}}
class={{klass}}
aria-hidden="true"
focusable="false"
>
<g transform="{tr}" fill-rule="evenodd">
<path fill="currentColor" d="{hat}"/>
<path fill="{accent}" d="{notes}"/>
</g>
</svg>
'''
if __name__ == "__main__":
main()