Devblog · 2026-06-23

A Raccoon for the Package Vault

Branding Bawn.Nuget.Server with the Bawn.Pdf.Mvc mascot methodology — and teaching a watercolour bandit to come clean off textured paper.
Date 2026-06-23 Status Shipped Server 32fb519

Bawn.Pdf.Mvc has a mascot: a hand-drawn mouse hugging a wedge of cheese, because the mouse likes the document. Bawn.Nuget.Server had a 📦 emoji and no favicon. The brief was to give the NuGet server the same look-and-feel and the same logo methodology — except the animal should be a raccoon, because raccoons love digging through packages for goodies.

Landed in 32fb519 on Bawn.Nuget.Server@master.

What We Worked On

The source was a hand-drawn watercolour raccoon: bandit mask, ringed tail, sitting on its haunches and clutching a red apple, painted on textured cold-press paper.

The source painting: a watercolour raccoon on cold-press paper
The source painting — and that paper texture is the antagonist.

The Bawn.Pdf.Mvc methodology is two moves: cut the subject off its paper into a transparent PNG master, then render the web asset set from that master — favicon.ico, a 96px mark, a 180px apple-touch-icon, and a 600px logo. Same two moves here, just a different animal and a paper that fought back.

The Bandit Gets a Clean Cutout

The mouse was lifted off its background with the classic ImageMagick recipe: flood-fill the four corners to transparent, trim, square. I tried the exact same recipe first.

#!/usr/bin/env bash # raccoon-floodfill-attempt.sh — the FIRST approach (abandoned). # # Mirrors the Bawn.Pdf.Mvc mouse-mascot methodology: ImageMagick corner # floodfill of the paper to transparent, then trim + square. It works on a # clean background, but the raccoon was painted on textured watercolour paper, # so 18% fuzz left a faint paper halo around the silhouette and a stray ink # fleck in the top-right corner survived as an island. Kept here for honesty — # raccoon-cutout.py is what actually made the bandit look great. # # Usage: ./raccoon-floodfill-attempt.sh <source.png> <out.png> set -euo pipefail SRC="${1:?source image}" OUT="${2:?output png}" W=$(identify -format %w "$SRC"); H=$(identify -format %h "$SRC") xr=$((W - 1)); yb=$((H - 1)) # IM6 note: the alpha-channel floodfill primitive is 'matte', not 'alpha' # (the IM7 'alpha' keyword errors out under ImageMagick 6). convert "$SRC" -alpha set -fuzz 18% -fill none \ -draw "matte 0,0 floodfill" \ -draw "matte $xr,0 floodfill" \ -draw "matte 0,$yb floodfill" \ -draw "matte $xr,$yb floodfill" \ miff:- \ | convert - -trim +repage "$OUT" echo "floodfill cutout -> $OUT ($(identify -format '%wx%h' "$OUT"))"
Gotcha

ImageMagick 6 sharp edge: the alpha-channel flood-fill primitive is matte, not alpha. The IM7 alpha keyword errors out under IM6 with “non-conforming drawing primitive definition” — my very first run silently did nothing because of it.

On the mouse’s cleaner background this is enough. On watercolour paper it is not: 18% fuzz from the corners cleared the bulk of the page but left a pale halo of paper texture clinging to the silhouette, and a stray fleck of ink in the top-right corner survived as its own little island. On the dark sidebar the halo glowed. The bandit looked like he’d been photocopied.

The reframe

Stop asking “is this pixel pale?” and start asking “is this pixel pale and can it walk to the edge of the image without crossing a line?” Background becomes paper-coloured pixels that are connected to the border. That one rule protects the interior near-whites (eyes, apple highlight, cream muzzle) for free, because the dark linework walls them off from the edge.

So I swapped the flat flood-fill for a border-connected background removal in NumPy + Pillow + SciPy. After labelling the background, keep only the largest foreground blob — which evicts the stray fleck and 600-odd other paper specks in a single pass.

import sys, numpy as np from PIL import Image, ImageFilter from scipy import ndimage SRC = sys.argv[1] OUT = sys.argv[2] T = float(sys.argv[3]) if len(sys.argv) > 3 else 70.0 # paper color tolerance (RGB euclidean) ERODE = int(sys.argv[4]) if len(sys.argv) > 4 else 1 # px to pull the edge in (kills paper fringe) im = Image.open(SRC).convert("RGB") a = np.asarray(im).astype(np.int16) h, w, _ = a.shape # paper colour = median of four 40px corner patches P = 40 corners = np.concatenate([ a[:P,:P].reshape(-1,3), a[:P,-P:].reshape(-1,3), a[-P:,:P].reshape(-1,3), a[-P:,-P:].reshape(-1,3)]) paper = np.median(corners, axis=0) dist = np.sqrt(((a - paper)**2).sum(axis=2)) paper_like = dist < T # background = paper-like pixels connected (8-conn) to the image border lbl, n = ndimage.label(paper_like, structure=np.ones((3,3))) border = set(np.unique(np.concatenate([lbl[0,:], lbl[-1,:], lbl[:,0], lbl[:,-1]]))) border.discard(0) background = np.isin(lbl, list(border)) fg = ~background # keep only the largest foreground blob (drops stray ink flecks / paper islands) flbl, fn = ndimage.label(fg, structure=np.ones((3,3))) if fn > 1: sizes = ndimage.sum(np.ones_like(flbl), flbl, index=range(1, fn+1)) keep = int(np.argmax(sizes)) + 1 fg = (flbl == keep) print(f"fg blobs={fn} kept largest area={int(sizes.max())} dropped={fn-1}") # tidy: close pinholes, then erode a hair to remove the paper fringe at the outline fg = ndimage.binary_closing(fg, structure=np.ones((3,3)), iterations=1) fg = ndimage.binary_fill_holes(ndimage.binary_closing(fg, iterations=1)) | fg if ERODE > 0: fg = ndimage.binary_erosion(fg, structure=np.ones((3,3)), iterations=ERODE) alpha = (fg*255).astype(np.uint8) alpha_img = Image.fromarray(alpha, "L").filter(ImageFilter.GaussianBlur(0.6)) # 0.6px feather = anti-alias out = im.convert("RGBA") out.putalpha(alpha_img) # trim to content bbox, then square with 6% padding on transparent canvas bbox = out.getbbox() out = out.crop(bbox) W, H = out.size S = max(W, H); pad = round(S*0.06); CAN = S + pad*2 canvas = Image.new("RGBA", (CAN, CAN), (0,0,0,0)) canvas.paste(out, ((CAN-W)//2, (CAN-H)//2), out) canvas.save(OUT) print(f"paper={paper.tolist()} T={T} erode={ERODE} trimmed={W}x{H} square={CAN}x{CAN} -> {OUT}")

That run reported fg blobs=665 kept largest area=557510 dropped=664 — the raccoon, and 664 evicted specks. A 1px erosion pulls the edge in past the paper fringe; a 0.6px Gaussian on the alpha gives a clean anti-aliased rim. Composited on the sidebar’s dark indigo: no halo, no fleck.

The finished raccoon cutout on the dark sidebar colour
Clean rim, interior highlights intact, ready for the sidebar.

From Master to Favicon

With a clean master.png, the asset set is pure ImageMagick — the same sizes the mouse ships.

#!/usr/bin/env bash # raccoon-generate-assets.sh — turn the clean cutout master into the web asset # set the way Bawn.Pdf.Mvc ships its mouse: a 600px logo, a 96px mark, a 180px # apple-touch-icon, and a multi-resolution favicon.ico (16/32/48). # # Input: master.png (square, transparent — produced by raccoon-cutout.py) # Output: raccoon-logo.png, raccoon-96.png, apple-touch-icon.png, favicon.ico # # Usage: ./raccoon-generate-assets.sh <master.png> <out-dir> set -euo pipefail MASTER="${1:?master png}" OUT="${2:?output dir}" mkdir -p "$OUT" # Lanczos downscales keep the watercolour edges crisp. -strip drops the # date chunks ImageMagick embeds by default, so the bytes are reproducible. convert "$MASTER" -filter Lanczos -resize 600x600 -strip "$OUT/raccoon-logo.png" convert "$MASTER" -filter Lanczos -resize 96x96 -strip "$OUT/raccoon-96.png" convert "$MASTER" -filter Lanczos -resize 180x180 -strip "$OUT/apple-touch-icon.png" # Favicon: sharpen the tiny sizes a touch so the bandit mask still reads at 16px. convert "$MASTER" -filter Lanczos -resize 48x48 -unsharp 0x0.6 -strip "$OUT/f48.png" convert "$MASTER" -filter Lanczos -resize 32x32 -unsharp 0x0.5 -strip "$OUT/f32.png" convert "$MASTER" -filter Lanczos -resize 16x16 -unsharp 0x0.5 -strip "$OUT/f16.png" convert "$OUT/f16.png" "$OUT/f32.png" "$OUT/f48.png" "$OUT/favicon.ico" rm -f "$OUT/f16.png" "$OUT/f32.png" "$OUT/f48.png" echo "wrote: raccoon-logo.png raccoon-96.png apple-touch-icon.png favicon.ico -> $OUT"
Reproducible bytes

By default ImageMagick stamps date:create / date:modify chunks into every PNG, so two runs of an identical pipeline produce different bytes. -strip drops them. I re-ran the generator into a scratch dir and cmp’d every asset against the committed copy: identical, all four.

The tiny favicon sizes get a touch of -unsharp so the bandit mask still reads at 16px. Enlarged, the 32px icon keeps its masked face, ringed tail, and red apple.

The 32px favicon, enlarged
The 32px favicon, enlarged.

Wiring It Into Blazor

Bawn.Nuget.Server is Blazor, not MVC, and it has a theme system: all global CSS lives in App.razor and colours come from --theme-* custom properties. So the branding had to ride the theme, not hardcode the Mvc palette. Three small edits.

The <head> gained the icon links (base-relative, so they respect the server’s PathBase):

<link rel="icon" href="favicon.ico" sizes="any" /> <link rel="icon" type="image/png" href="img/raccoon-96.png" /> <link rel="apple-touch-icon" href="img/apple-touch-icon.png" />

.sidebar-brand was restyled from a bare text line into the Mvc brand pattern — logo, then a wordmark with an accent-coloured suffix and a small tagline:

.sidebar-brand { display: flex; align-items: center; gap: 0.6rem; color: #fff; text-decoration: none; } .sidebar-brand img { width: 40px; height: 40px; flex: none; display: block; } .sidebar-brand .wordmark b { color: var(--theme-accent); font-weight: 700; } .sidebar-brand .wordmark small { display: block; font-weight: 400; font-size: 0.68rem; color: rgba(255,255,255,0.5); }

And the markup in MainLayout.razor swapped the emoji for the raccoon and the Bawn.PdfBawn.Nuget wordmark, with the tagline doing the joke:

<a class="sidebar-brand" href=""> <img src="img/raccoon-96.png" alt="Bawn NuGet raccoon mascot" width="40" height="40" /> <span class="wordmark">Bawn<b>.Nuget</b><small>packages worth digging for</small></span> </a>

dotnet build stayed green (0 warnings / 0 errors) before and after.

A Palette, Sampled From the Bandit

One last touch, and a self-referential one: the colours you are reading this in were sampled from the raccoon. A short k-means over the opaque pixels of the cutout — paper excluded, so only the bandit votes — surrenders his palette by population.

#!/usr/bin/env python3 """Sample a colour palette from the raccoon cutout. K-means (k=7) over the *opaque* pixels of the transparent logo — so the paper is ignored and only the bandit votes. Clusters are reported by population, plus the most-saturated cluster, which is the apple and makes the natural accent. Fixed seed => the same image always yields the same palette (deterministic). Usage: raccoon-sample-palette.py [image.png] (default: assets/raccoon-logo.png) """ import sys import numpy as np from PIL import Image from scipy.cluster.vq import kmeans2 SRC = sys.argv[1] if len(sys.argv) > 1 else "assets/raccoon-logo.png" K = 7 px = np.asarray(Image.open(SRC).convert("RGBA")).reshape(-1, 4) opaque = px[px[:, 3] > 200][:, :3].astype(float) # raccoon only, not transparent paper centroids, labels = kmeans2(opaque, K, minit="++", seed=1) counts = np.bincount(labels, minlength=K) def hexof(c): return "#%02x%02x%02x" % tuple(int(round(v)) for v in c) def redness(c): r, g, b = c return r - max(g, b) # how far into red, away from the warm browns/golds print(f"# palette sampled from {SRC} (k={K}, opaque pixels only)") for i in np.argsort(-counts): print(f"{hexof(centroids[i])} {100 * counts[i] / counts.sum():5.1f}%") apple = max(range(K), key=lambda i: redness(centroids[i])) print(f"accent (reddest cluster = the apple): {hexof(centroids[apple])}")

Seven clusters fall out, deterministic to the seed:

#c68826gold · 24%
#865627sienna · 22%
#937764greige · 13%
#392319espresso · 12%
#594741warm grey · 12%
#dca664cream · 11%
#9b1f31apple · 7%

The espresso of his mask became the header and every section rule; the sienna of his fur, the subheadings and the rail down the left of these code blocks; the cream, the eyebrow and the metadata. And the one vivid non-brown note in the whole animal — the apple he will not let go of — became the accent: this page’s links, the line under each heading, even the border around that swatch row. He is, quite literally, the colour scheme.

What Went Well

The methodology transferred whole. Cut-to-transparent → size set → wire into the brand and the <head>. The only thing that changed between the mouse and the raccoon was the cutout technique.

Theme-aware, not theme-fighting. The accent suffix uses var(--theme-accent) and the tagline a translucent white, so the wordmark looks right under every theme the server ships rather than only the one I happened to test.

Reproducible bytes. With -strip, the asset pipeline is deterministic; the committed images are exactly what the scripts emit — which is also what lets this very devblog embed those scripts verbatim by a deterministic builder.

What Didn’t Go Well

The naive flood-fill lost to the paper. Cold-press texture meant a flat corner fill left a halo and a surviving fleck. The flat fill was a tempting dead end precisely because it worked for the mouse.

PNG timestamps broke reproducibility until -strip went in — invisible until they made the cmp fail.

Concurrency. Another session was committing to the same repo; HEAD moved twice under me. I kept my commit to just the six branding files, rebased onto their hosting work, and pushed — no clobber.

Not verified in a browser by me. The asset images I checked by eye; the build compiles and the referenced paths exist in wwwroot, but the live Blazor render (it needs a database) is the user’s to confirm.

Takeaways

  1. 1
    For textured-paper cutouts, connectivity beats thresholding.

    “Background = paper-coloured and reachable from the edge” protects interior highlights that a flat colour key would punch holes in.

  2. 2
    Keep the largest component.

    One line of scipy.ndimage.label turned 665 blobs into 1 and made the speck-removal problem disappear.

  3. 3
    -strip if you want reproducible image bytes.

    Metadata timestamps are invisible until they make your cmp fail.

  4. 4
    Let concurrent sessions own their commits.

    Stage by file, rebase onto their pushes, and your branding lands without stepping on their hosting work.

The Watercolor

If I painted this session, I’d paint it on the same cold-press paper the raccoon came in on, because the paper was the antagonist — and you have to give the antagonist the good stock.

The composition is a diptych. On the left panel, the bandit as he arrived: warm raw sienna and burnt umber, that cheeky black mask in near-dry payne’s grey, the apple a single confident cadmium-red wash. But all around him the page is busy — I’d let the wash bleed and granulate, leave the brush-hairs in, let a faint ochre haze hang at his edges like the halo the flood-fill couldn’t kill. It’s pretty, and it’s wrong, and that’s the point of the left panel.

The right panel is the same raccoon lifted clean onto deep indigo — the sidebar. Here the brushwork goes quiet and deliberate: a crisp wet edge all the way around, no fringe, the mask and the ringed tail reading instantly even shrunk to a thumbnail. The transition between the panels is the real subject: the moment the work stopped being colour and started being connectivity. That reframing is the cerulean stroke of the painting — small, certain, and the thing that made everything after it easy.

In the bottom corner, tiny, almost a signature: the 16-pixel favicon. Three brush-dabs of brown, a fleck of red, and somehow still unmistakably a raccoon with an apple, digging through the packages for goodies.