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
378 lines
17 KiB
Python
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()
|