mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
brand: meet Pip, the courier bird (#132)
* docs: add mascot & brand design spec (Pip the courier bird) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: Pip wears a tie - update mascot spec to v5 flight loop Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: add Pip brand PR implementation plan Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plan): v7 master GIF - drop frame scaling, add enclosed-hole transparency Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plan): v8 master GIF - fix hole classification (chest stays opaque) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plan): scrub stale v5 references Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plan): v10 master GIF - line-fitted envelope border clipping Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plan): label pipeline as v10 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plan): v16 master GIF - targeted removal of gap blob Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plan): v17 master GIF - drop envelope border clipping, keep blob removal Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plan): v19 final master GIF - user-approved thin outline repair Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(brand): add Pip mascot assets and regeneration pipeline Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(brand): Pip takes over the README header Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(spec): scrub stale scaling line Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(brand): avatar, social card, and mascot sources (PNG allowlist) The global *.png personal-data rule silently excluded the mascot's source sheets and generated PNGs; allowlist the upstream-controlled assets/mascot/ paths without weakening the fork-protecting rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8bee3ddb97
commit
09f0417d78
@@ -0,0 +1,200 @@
|
||||
"""Regenerate pip_flight_loop.gif from sources/chatgpt_tie_sheet.png.
|
||||
|
||||
Pipeline: 2D-cluster the sheet into 6 birds, snap every pixel to the 7-color
|
||||
brand palette, order frames by wing centroid (no per-frame rescaling),
|
||||
anchor on the beak, flood-fill edge-connected background to transparent.
|
||||
Requires: pillow, numpy, scipy (maintainer tooling; not needed by CI).
|
||||
"""
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
from scipy import ndimage
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
SHEET = HERE / "sources" / "chatgpt_tie_sheet.png"
|
||||
OUT_GIF = HERE / "pip_flight_loop.gif"
|
||||
|
||||
PAL = np.array([
|
||||
(42, 157, 143), (31, 111, 101), (231, 111, 81), (150, 155, 160),
|
||||
(255, 255, 255), (230, 57, 70), (34, 51, 59),
|
||||
], dtype=int)
|
||||
TRANSPARENT = 7
|
||||
CANVAS = 560
|
||||
|
||||
im = Image.open(SHEET).convert("RGB")
|
||||
a_full = np.asarray(im).astype(int)
|
||||
nonwhite_full = (a_full < 245).any(axis=2)
|
||||
|
||||
# 2D clustering: dilate to glue beak tips, label, keep 6 largest, sort by x
|
||||
dil = ndimage.binary_dilation(nonwhite_full, iterations=2)
|
||||
lab2, n2 = ndimage.label(dil)
|
||||
sizes = ndimage.sum(dil, lab2, range(1, n2 + 1))
|
||||
keep = np.argsort(sizes)[::-1][:6] + 1
|
||||
comps = []
|
||||
for k in keep:
|
||||
mask = (lab2 == k) & nonwhite_full
|
||||
ys, xs = np.where(mask)
|
||||
comps.append(dict(mask=mask, x0=xs.min(), x1=xs.max(), y0=ys.min(), y1=ys.max(), cx=xs.mean()))
|
||||
comps.sort(key=lambda d: d["cx"])
|
||||
assert len(comps) == 6, f"expected 6 birds, found {len(comps)}"
|
||||
|
||||
def snap(arr):
|
||||
d = ((arr[:, :, None, :] - PAL[None, None, :, :]) ** 2).sum(-1)
|
||||
return PAL[d.argmin(-1)].astype(np.uint8)
|
||||
|
||||
cells = []
|
||||
for comp in comps:
|
||||
pad = 8
|
||||
y0, y1 = max(0, comp["y0"] - pad), comp["y1"] + pad
|
||||
x0, x1 = max(0, comp["x0"] - pad), comp["x1"] + pad
|
||||
sub = a_full[y0:y1, x0:x1].copy()
|
||||
submask = comp["mask"][y0:y1, x0:x1]
|
||||
sub[~submask] = (255, 255, 255)
|
||||
cells.append(snap(sub))
|
||||
|
||||
def color_mask(arr, ci, tol=10):
|
||||
return np.abs(arr.astype(int) - PAL[ci]).sum(-1) < tol
|
||||
|
||||
infos = []
|
||||
for i, c in enumerate(cells):
|
||||
coral = color_mask(c, 2)
|
||||
lab, n = ndimage.label(coral)
|
||||
best, besty = None, 10 ** 9
|
||||
for k in range(1, n + 1):
|
||||
ys, xs = np.where(lab == k)
|
||||
if len(ys) < 80:
|
||||
continue
|
||||
if ys.mean() < besty:
|
||||
besty, best = ys.mean(), (xs, ys)
|
||||
bx, by = best[0].mean(), best[1].mean()
|
||||
dteal = color_mask(c, 1)
|
||||
wing_c = float(np.where(dteal)[0].mean()) if dteal.sum() else by
|
||||
infos.append(dict(i=i, beak=(bx, by), area=len(best[0]), wing_c=wing_c))
|
||||
|
||||
for d_ in infos:
|
||||
# birds in the sheet are size-consistent (within 5%); rescaling by noisy
|
||||
# beak measurements caused a visible zoom pulse in an earlier build
|
||||
d_["scale"] = 1.0
|
||||
d_["wing_rel"] = d_["wing_c"] - d_["beak"][1]
|
||||
|
||||
order_sorted = sorted(infos, key=lambda d: d["wing_rel"])
|
||||
seq = [order_sorted[0], order_sorted[2], order_sorted[4], order_sorted[5], order_sorted[3], order_sorted[1]]
|
||||
|
||||
p_frames = []
|
||||
for d_ in seq:
|
||||
c = cells[d_["i"]]
|
||||
h, w = c.shape[:2]
|
||||
sc = d_["scale"]
|
||||
c2 = np.asarray(Image.fromarray(c).resize((int(w * sc), int(h * sc)), Image.NEAREST))
|
||||
beak = (d_["beak"][0] * sc, d_["beak"][1] * sc)
|
||||
canvas = np.full((CANVAS, CANVAS, 3), 255, dtype=np.uint8)
|
||||
tx, ty = int(CANVAS * 0.72), int(CANVAS * 0.38)
|
||||
px, py = int(tx - beak[0]), int(ty - beak[1])
|
||||
H2, W2 = c2.shape[:2]
|
||||
x0, y0 = max(0, px), max(0, py)
|
||||
x1, y1 = min(CANVAS, px + W2), min(CANVAS, py + H2)
|
||||
canvas[y0:y1, x0:x1] = c2[y0 - py:y0 - py + (y1 - y0), x0 - px:x0 - px + (x1 - x0)]
|
||||
|
||||
nonwhite = (canvas < 250).any(axis=2)
|
||||
lab, n = ndimage.label(nonwhite)
|
||||
for k in range(1, n + 1):
|
||||
ys, xs = np.where(lab == k)
|
||||
cols = canvas[ys, xs].astype(int)
|
||||
is_gray = (np.abs(cols - PAL[3]).sum(-1) < 60).mean() > 0.6
|
||||
if len(ys) < 400 and ys.mean() < 300 and is_gray:
|
||||
canvas[ys, xs] = (255, 255, 255)
|
||||
nonwhite[ys, xs] = False
|
||||
|
||||
d2 = ((canvas.astype(int)[:, :, None, :] - PAL[None, None, :, :]) ** 2).sum(-1)
|
||||
idx = d2.argmin(-1).astype(np.uint8)
|
||||
white = ~nonwhite
|
||||
wlab, wn = ndimage.label(white)
|
||||
border = set(wlab[0, :]) | set(wlab[-1, :]) | set(wlab[:, 0]) | set(wlab[:, -1])
|
||||
border.discard(0)
|
||||
idx[np.isin(wlab, list(border))] = TRANSPARENT
|
||||
# enclosed white pockets: classify by boundary composition. The chest patch
|
||||
# always borders light teal (body interior); the envelope face is >=70%
|
||||
# gray/red; true background pockets (between legs, body-envelope gap) are
|
||||
# neither. NOTE: do not use outline/dark-teal ratios here - anti-aliased
|
||||
# white-to-outline edges quantize to gray and poison those ratios.
|
||||
for wl in range(1, wn + 1):
|
||||
if wl in border:
|
||||
continue
|
||||
comp = wlab == wl
|
||||
ring = ndimage.binary_dilation(comp, iterations=2) & ~comp & nonwhite
|
||||
ridx = idx[ring]
|
||||
if len(ridx) == 0:
|
||||
continue
|
||||
teal = (ridx == 0).sum() / len(ridx) # light teal only
|
||||
envelope = np.isin(ridx, [3, 5]).sum() / len(ridx) # gray, red
|
||||
if teal >= 0.08:
|
||||
continue # chest: real content
|
||||
if envelope >= 0.70:
|
||||
continue # envelope face: real content, keep whole. (A border-clip
|
||||
# was tried here to split merged face+gap components; it misfit
|
||||
# tilted envelopes and bit into the face. The merged white reads
|
||||
# fine as-is - do not reintroduce clipping.)
|
||||
idx[comp] = TRANSPARENT
|
||||
# targeted art cleanup: the source sheet has one large outlined teal blob
|
||||
# (a vestigial appendage) drawn into the body-envelope gap of one frame.
|
||||
# Only fragments >=100px qualify - smaller teal fragments are legitimate
|
||||
# pixel-art texture, and generic cleanup rules damage the tie and feet.
|
||||
bird_mask = np.isin(idx, [0, 1])
|
||||
blab, bn = ndimage.label(bird_mask)
|
||||
if bn > 1:
|
||||
bsizes = ndimage.sum(bird_mask, blab, range(1, bn + 1))
|
||||
bmain = int(np.argmax(bsizes)) + 1
|
||||
gray_mask = idx == 3
|
||||
glab, gn = ndimage.label(gray_mask)
|
||||
a_fit, b_fit = 0.0, 10 ** 6
|
||||
if gn:
|
||||
gsizes = ndimage.sum(gray_mask, glab, range(1, gn + 1))
|
||||
env_gray = glab == (int(np.argmax(gsizes)) + 1)
|
||||
cols = np.where(env_gray.any(axis=0))[0]
|
||||
tops = env_gray.argmax(axis=0)[cols].astype(float)
|
||||
med = np.median(tops)
|
||||
good = np.abs(tops - med) <= 20
|
||||
if good.sum() >= 10:
|
||||
a_fit, b_fit = np.polyfit(cols[good], tops[good], 1)
|
||||
else:
|
||||
a_fit, b_fit = 0.0, med
|
||||
for bk in range(1, bn + 1):
|
||||
if bk == bmain or not (100 <= bsizes[bk - 1] < 600):
|
||||
continue
|
||||
ys3, xs3 = np.where(blab == bk)
|
||||
cy, cx = ys3.mean(), xs3.mean()
|
||||
border_here = a_fit * cx + b_fit
|
||||
if not (border_here - 55 <= cy < border_here):
|
||||
continue
|
||||
fmask = blab == bk
|
||||
ring = ndimage.binary_dilation(fmask, iterations=2) & ~fmask
|
||||
if not ring.any() or (idx[ring] == 6).mean() < 0.40:
|
||||
continue
|
||||
# erase the blob's own outline ring, but protect outline pixels
|
||||
# that belong to the body, chest, tie, or feet (anything near the
|
||||
# main structures) - an unprotected shell bit the body outline
|
||||
protected = ndimage.binary_dilation(
|
||||
(blab == bmain) | (idx == 4) | (idx == 2), iterations=2)
|
||||
shell = ndimage.binary_dilation(fmask, iterations=3) & (idx == 6) & ~protected
|
||||
erased = fmask | shell
|
||||
idx[fmask] = TRANSPARENT
|
||||
idx[shell] = TRANSPARENT
|
||||
# repair: the blob's boundary doubled as the local body outline, so
|
||||
# erasing it exposes bare chest white to the background. Repaint the
|
||||
# exposed content edge (white/teal within the wound area, near the
|
||||
# new transparency) as outline so the bird's underside stays closed.
|
||||
near_wound = ndimage.binary_dilation(erased, iterations=6)
|
||||
transp = idx == TRANSPARENT
|
||||
near_gap = ndimage.binary_dilation(transp, iterations=6)
|
||||
repaint = near_wound & near_gap & np.isin(idx, [0, 1, 4])
|
||||
idx[repaint] = 6
|
||||
p = Image.fromarray(idx, mode="P")
|
||||
palette = PAL.astype(np.uint8).flatten().tolist() + [255, 0, 255]
|
||||
p.putpalette(palette + [0] * (768 - len(palette)))
|
||||
p.info["transparency"] = TRANSPARENT
|
||||
p_frames.append(p)
|
||||
|
||||
p_frames[0].save(OUT_GIF, save_all=True, append_images=p_frames[1:],
|
||||
duration=110, loop=0, disposal=2, transparency=TRANSPARENT, optimize=False)
|
||||
print(f"wrote {OUT_GIF} ({OUT_GIF.stat().st_size} bytes)")
|
||||
assert OUT_GIF.stat().st_size < 50_000, "README asset must stay under 50 KB"
|
||||
Reference in New Issue
Block a user