feat(brand): replace the M mark with the traced bard-hat logo
The mark is now a feathered hat with an arc of eighth notes, traced from the operator's reference artwork at 99.74% IoU. The hat takes the text colour and the note arc holds the accent — the same construction the M used, and for the same reason: parchment on a light surface is invisible, so the silhouette has to flip with its background while the accent stays constant. This reverses the subject-neutrality argument recorded in Minstrel's design system, which held that depicting a bard would tell a new user the app is for renaissance-faire music and had twice rejected a hat. The operator commissioned this artwork and chose it with that objection on the table; the record is updated rather than silently contradicted. Both accent-filled alternatives were measured and rejected: #4A6B5C is 3.04:1 on obsidian and 2.80:1 on the raised iron, so an accent hat drops under the 3:1 graphics floor as soon as it sits on a card. tools/gen-brand-assets.py is the single source for the four copies, which cannot share a file because each needs a different colour mechanism — currentColor inlined, a prefers-color-scheme swap in the favicon, literal fills in mark.svg, flat pixels in the rasters. Hand-copying 20KB of path data four ways is how a silhouette change lands in three of them. Two notes on the trace, both non-obvious: it runs on the original antialiased greyscale rather than a binary mask, because tracing a supersampled mask scores ~100% IoU by reproducing the pixel staircase exactly — a perfect number for jagged art at 120KB of path, versus 99.74% at 20KB. And potrace reads PBM where bit 1 is black, so the ink mask is inverted going in; backwards, it traces the background and still emits a plausible-looking SVG. The header lockup moves 20px → 28px: the hat carries far more detail than the M and does not resolve below ~32px. The 16px browser-tab favicon is still a blob at that size and is not addressed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
#!/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)
|
||||
|
||||
|
||||
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.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=" <!-- 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"))
|
||||
|
||||
(ROOT / "web/static/brand/favicon.svg").write_text(svg_doc(
|
||||
vb, tr, "currentColor", ACCENT, hat_d, notes_d,
|
||||
head=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\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 notes hold the accent in\n"
|
||||
" both — teal reads against either. -->\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),
|
||||
("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 = '''<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()
|
||||
Reference in New Issue
Block a user