Eight acrylic-marker paintings become NuGet package logos — and every shortcut the raccoon taught us has to be inverted for a page that drinks light.
Date 2026-07-11Status ShippedLogos 8 × 12 files
The raccoon was watercolour on bright cold-press paper, and the whole fight was cutting him off the page. This time the page switched sides: eight acrylic-marker paintings on black paper — a rooster, two geckos, a bat, a raven, two trees, and a dandelion gone to seed — destined to become NuGet package logos. On black paper the background is the logo’s canvas; you don’t cut the subject out, you make the paper true #000 and ship the square. Which sounds easier, and is, right up until every shortcut you take starts eating claws.
What We Worked On
Eleven phone photos, eight distinct paintings (the geckos posed for five frames, the oak for two, and the bat shares a page with a raven and two pink test swatches). The brief grew mid-session, the good kind of scope creep: NuGet icons and larger in-application sizes, each named after what is actually in the picture — rooster, green-gecko, orange-gecko, bat, raven, canopy-tree, oak-tree, dandelion.
The eleven source photos. The “black” paper metered from luminance 4 to 81 across corners.
The photos have the usual sins of paintings shot by hand under room light: a warm cast over everything, and an illumination gradient strong enough that the same paper reads twenty times brighter in one corner than another. Same diagnosis ritual as the bandit’s lighting session — measure the corners before touching a pixel — just aimed at black paper instead of white.
Three Wrong Ways, in Order
Wrong way #1: cut a mask and erode it
The raccoon methodology, applied reflexively: threshold the subject, erode a pixel to kill the fringe, feather the alpha. On marker paintings this fails twice. Acrylic markers leave gaps of bare paper between strokes, so the threshold mask is riddled with pinholes — invisible on a black background, fatal on a transparent one. And erosion eats thin structures whole: the first rooster came back missing a claw, with a hole punched in his front foot.
Black hides the stroke gaps; the transparent variant shows every hole.
Wrong way #2: subtract everywhere, vibrance first
Estimate the background illumination, subtract it from the whole frame, then boost saturation. The rooster looked great — bright saturated paint barely notices a subtraction of 50 grey levels. The trees and the dandelion noticed. Cropping to the largest bright blob amputated the canopy tree’s dim orange trunk; subtracting warm grey from mid-bright teal shifted it toward black; and vibrance, applied before the cast was corrected, amplified the warm light on the dandelion’s white puffs into an unmistakable red.
User bug report, verbatim: “we lost tree trunk, tree is oddly colored too. lowermost dandelion turned red.”
Wrong way #3: trust the extrapolation
The background estimate comes from a normalized convolution over background-only pixels. Deep inside a large subject — the middle of a tree canopy, say — there are no background pixels within reach, the denominator collapses, and the “estimated paper colour” becomes numerical noise. The white-balance gains built from that noise painted an orange ghost into the canopy, and the garbage local threshold reclassified the genuinely-dim teal band as paper and subtracted it to black.
Wrong way #3: an orange ghost where the extrapolation ran out of support.
Property, not surprise
Normalized convolution has no support deep inside a large mask. Anywhere a denominator can approach zero, it eventually will — the fix is a fallback ladder, not a bigger epsilon.
The Recipe That Worked
Five rules, each one bought with a visible failure:
1
Never cut or erode the subject.
Paint pixels ship exactly as photographed (after white balance). Background removal happens by subtraction around the subject, weighted to zero wherever there is paint. No mask, no holes, no lost claws.
2
Hysteresis, not a single threshold.
Otsu finds bright seeds; the subject is every region above a low threshold that connects to a seed. The dim trunk joins through the bright canopy — the raccoon’s border-connectivity trick, pointed the other way.
3
Cascade the illumination estimate.
Local normalized convolution where it has support, a 3.5× wider blur where it thins out, the global paper median where even that fails. Sane paper colour everywhere; no orange ghosts.
4
Paint is a colour distance, not a brightness.
Dark teal is as dim as lit black paper but nowhere near its colour. Classifying foreground by RGB distance from the local paper estimate keeps every dim-but-chromatic stroke.
5
White-balance off the black paper, then vibrance dead last.
The paper is supposed to be neutral, so whatever colour the background illumination map has is the colour of the light — divide it out, per pixel. Only then boost saturation, so it pops paint instead of amplifying lighting mistakes.
All of it lives in one script — the exact one that produced the shipped assets, embedded verbatim:
#!/usr/bin/env python3
"""
make-logo.py — turn a phone photo of an acrylic-marker painting on BLACK paper
into a clean, square package logo. Primary output is a true-black-background
logo (the medium's native look, and what pops on NuGet's light gallery); a
transparent die-cut variant is emitted as a secondary.
Lessons baked in (each one was a visible failure first):
* Never cut or erode the subject: masking punched a hole in the rooster's foot
and ate a claw. The subject's pixels ship exactly as photographed (after
white balance) — background removal is done by SUBTRACTION AROUND them.
* Never crop to the Otsu-bright blob alone: the canopy tree's dim orange trunk
fell below Otsu's canopy-tuned threshold and got cropped off. Fix is
hysteresis: Otsu finds bright SEEDS, the subject is every low-threshold
region CONNECTED to a seed (trunk connects through the canopy).
* Never subtract illumination from the subject: subtracting warm grey from
mid-bright paint shifted the tree's teal band toward black and helped turn
the lower dandelion red. Subtraction is weighted by (1 - subject).
* White-balance off the BLACK paper: the paper is neutral, so the colour of
the background illumination map IS the light colour. Dividing it out is a
locally-varying white balance (the dark-paper twin of the raccoon's
paper-referenced WB) — this is what un-reds the dandelion.
* Vibrance goes LAST, after neutralization, so it pops paint instead of
amplifying the lighting cast.
Usage:
make-logo.py <src.jpg> <out-dir> <slug>
[--crop WxH+X+Y] pre-crop the source (isolate one painting per frame)
[--rotate DEG] straighten (CCW), expands on black
[--pad 0.08] square padding fraction
[--vibrance 1.15] saturation pop applied at the very end
[--bg-sigma 0.06] background blur sigma as a fraction of width
[--bp 10] residual black point subtracted from background
[--margin-abs 18 --margin-rel 0.18] low-threshold = illum + abs + rel*illum
[--grow 2] dilation of the transparent variant's silhouette
"""
import os, argparse
import numpy as np
from PIL import Image, ImageFilter, ImageEnhance
from scipy import ndimage
SIZES = [2048, 1576, 1024, 512, 256, 128] # 1024/512/256 in-app, 128 NuGet gallery icon
LUMW = np.array([0.299, 0.587, 0.114], dtype=np.float32)
E8 = np.ones((3, 3)) # 8-connectivity
def otsu(gray_u8):
hist, _ = np.histogram(gray_u8, bins=256, range=(0, 255))
total, sum_all = gray_u8.size, np.dot(np.arange(256), hist)
wB = sumB = 0.0
best_t, best_var = 0, -1.0
for t in range(256):
wB += hist[t]
if wB == 0:
continue
wF = total - wB
if wF == 0:
break
sumB += t * hist[t]
mB, mF = sumB / wB, (sum_all - sumB) / wF
var = wB * wF * (mB - mF) ** 2
if var > best_var:
best_var, best_t = var, t
return best_t
def upscale(small_f32, w, h):
return np.asarray(Image.fromarray(small_f32, mode="F").resize((w, h), Image.BILINEAR),
dtype=np.float32)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("src"); ap.add_argument("outdir"); ap.add_argument("slug")
ap.add_argument("--crop", default="")
ap.add_argument("--rotate", type=float, default=0.0)
ap.add_argument("--pad", type=float, default=0.08)
ap.add_argument("--vibrance", type=float, default=1.15)
ap.add_argument("--bg-sigma", type=float, default=0.06)
ap.add_argument("--bp", type=int, default=10)
ap.add_argument("--margin-abs", type=float, default=18.0)
ap.add_argument("--margin-rel", type=float, default=0.18)
ap.add_argument("--margin-dist", type=float, default=26.0,
help="RGB distance from local paper colour that counts as paint")
ap.add_argument("--grow", type=int, default=2)
ap.add_argument("--gain-cap", type=float, default=1.6,
help="max white-balance channel gain (lower = gentler cast fix)")
ap.add_argument("--extra-dist", type=float, default=45.0,
help="min mean paint-distance for detached blobs to join")
ap.add_argument("--blackout", action="append", default=[],
help="x,y,w,h rect (SOURCE frame coords, pre-crop) filled with "
"surrounding paper colour — removes tape/smudges on the paper")
a = ap.parse_args()
os.makedirs(a.outdir, exist_ok=True)
im = Image.open(a.src).convert("RGB")
if a.blackout:
pix = np.asarray(im).copy()
for rect in a.blackout:
x, y, rw, rh = map(int, rect.split(","))
R = 40 # sample a ring around the rect for the local paper colour
ry0, ry1 = max(0, y - R), min(pix.shape[0], y + rh + R)
rx0, rx1 = max(0, x - R), min(pix.shape[1], x + rw + R)
ring = pix[ry0:ry1, rx0:rx1].reshape(-1, 3).copy()
paper = np.median(ring, axis=0)
pix[y:y + rh, x:x + rw] = paper.astype(np.uint8)
im = Image.fromarray(pix, "RGB")
if a.crop:
wh, xy = a.crop.split("+", 1)
W0, H0 = map(int, wh.split("x")); X0, Y0 = map(int, xy.split("+"))
im = im.crop((X0, Y0, X0 + W0, Y0 + H0))
if a.rotate:
im = im.rotate(a.rotate, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0))
arr = np.asarray(im).astype(np.float32)
h, w, _ = arr.shape
lum = arr @ LUMW
# --- bright seeds (Otsu) ------------------------------------------------
t = max(50, otsu(lum.astype(np.uint8)))
bright = lum > t
lblB, nB = ndimage.label(bright, structure=E8)
sizesB = np.bincount(lblB.ravel()); sizesB[0] = 0
seed_id = int(np.argmax(sizesB))
seeds = lblB == seed_id
# --- background illumination map (downscaled; excludes ALL bright art) ---
S = 8
ws, hs = max(1, w // S), max(1, h // S)
arr_s = np.asarray(im.resize((ws, hs), Image.BOX), dtype=np.float32)
bright_s = np.asarray(Image.fromarray((bright * 255).astype(np.uint8))
.resize((ws, hs), Image.BOX)) > 24
excl_s = ndimage.binary_dilation(bright_s, structure=E8,
iterations=max(2, int(0.02 * ws)))
bg_s = (~excl_s).astype(np.float32)
sig = a.bg_sigma * ws
# Normalized convolution runs out of support deep inside a large subject
# (the canopy tree's orange-blotch bug): cascade local -> wide -> global
# so the extrapolated paper colour is sane EVERYWHERE.
def nconv(sigma):
den = ndimage.gaussian_filter(bg_s, sigma)
est = np.empty_like(arr_s)
for c in range(3):
est[..., c] = ndimage.gaussian_filter(arr_s[..., c] * bg_s, sigma) / (den + 1e-8)
return est, den
il1, d1 = nconv(sig)
il2, d2 = nconv(sig * 3.5)
gmed = np.median(arr_s[bg_s > 0.5], axis=0)
w1 = np.clip(d1 / 0.05, 0, 1)[..., None]
w2 = np.clip(d2 / 0.02, 0, 1)[..., None]
il_s = w1 * il1 + (1 - w1) * (w2 * il2 + (1 - w2) * gmed)
illum = np.empty((h, w, 3), dtype=np.float32)
for c in range(3):
illum[..., c] = upscale(il_s[..., c].astype(np.float32), w, h)
illum_gray = illum.mean(axis=2)
# --- white balance off the black paper (paper is neutral => illum colour
# is the light colour; divide it out, locally) ------------------------
gains = np.clip(illum_gray[..., None] / (illum + 1e-3), 0.7, a.gain_cap)
arr_wb = arr * gains
# --- hysteresis subject mask: low-threshold blobs connected to the seed --
# Paint is either brighter than paper OR chromatic where paper is neutral
# (dark teal is as DIM as paper but far from its colour — luminance alone
# punched black holes in the canopy tree's lower band).
dist = np.sqrt(((arr - illum) ** 2).sum(axis=2))
low = (dist > a.margin_dist) | (lum > illum_gray + a.margin_abs + a.margin_rel * illum_gray)
lblL, nL = ndimage.label(low, structure=E8)
keep = np.unique(lblL[seeds]); keep = set(int(k) for k in keep if k > 0)
core = np.isin(lblL, list(keep))
# secondary blobs fully inside the padded core bbox (dandelion's loose
# florets) join too; neighbouring paintings outside the bbox stay out
ys, xs = np.nonzero(core)
y0, y1, x0, x1 = ys.min(), ys.max() + 1, xs.min(), xs.max() + 1
py, px = int(0.03 * h), int(0.03 * w)
by0, by1, bx0, bx1 = max(0, y0 - py), min(h, y1 + py), max(0, x0 - px), min(w, x1 + px)
sizesL = np.bincount(lblL.ravel())
objs = ndimage.find_objects(lblL)
cand = [bid for bid in range(1, nL + 1)
if bid not in keep and sizesL[bid] >= 500 and objs[bid - 1] is not None
and objs[bid - 1][0].start >= by0 and objs[bid - 1][0].stop <= by1
and objs[bid - 1][1].start >= bx0 and objs[bid - 1][1].stop <= bx1]
# keep only candidates that are unmistakably PAINT: faint page-transfer
# smudges sit just over the low threshold, real paint sits far above it
if cand:
means = ndimage.mean(dist, lblL, cand)
cand = [bid for bid, m in zip(cand, means) if m >= a.extra_dist]
extra = cand
if extra:
core |= np.isin(lblL, extra)
core = ndimage.binary_closing(core, structure=E8, iterations=2)
filled = ndimage.binary_fill_holes(core)
# --- subtract the background veil AROUND the subject, never from it ------
w_subj = ndimage.gaussian_filter(core.astype(np.float32), 1.2)
corrected = arr_wb - (illum_gray + a.bp)[..., None] * (1.0 - w_subj[..., None])
cleaned = Image.fromarray(np.clip(corrected, 0, 255).astype(np.uint8), "RGB")
cleaned = ImageEnhance.Color(cleaned).enhance(a.vibrance) # vibrance LAST
# --- crop to subject bbox, square-pad ------------------------------------
ys, xs = np.nonzero(filled)
y0, y1, x0, x1 = ys.min(), ys.max() + 1, xs.min(), xs.max() + 1
cw, ch = x1 - x0, y1 - y0
side = max(cw, ch); pad = round(side * a.pad); CAN = side + 2 * pad
black = Image.new("RGB", (CAN, CAN), (0, 0, 0))
black.paste(cleaned.crop((x0, y0, x1, y1)), ((CAN - cw) // 2, (CAN - ch) // 2))
# transparent die-cut: solid silhouette, grown outward (claw-safe)
sil = ndimage.binary_closing(filled, structure=E8, iterations=max(3, int(0.006 * w)))
sil = ndimage.binary_fill_holes(sil)
if a.grow:
sil = ndimage.binary_dilation(sil, structure=E8, iterations=a.grow)
alpha = Image.fromarray((sil * 255).astype(np.uint8), "L").filter(ImageFilter.GaussianBlur(0.8))
rgba = cleaned.convert("RGBA"); rgba.putalpha(alpha)
trans = Image.new("RGBA", (CAN, CAN), (0, 0, 0, 0))
sub_t = rgba.crop((x0, y0, x1, y1))
trans.paste(sub_t, ((CAN - cw) // 2, (CAN - ch) // 2), sub_t)
# --- emit ----------------------------------------------------------------
for size in SIZES:
for variant, base in (("", black), ("-transparent", trans)):
img = base.resize((size, size), Image.LANCZOS)
if size <= 128:
img = img.filter(ImageFilter.UnsharpMask(radius=1.0, percent=55, threshold=0))
name = f"{a.slug}{variant}.png" if size == 1024 else f"{a.slug}-{size}{variant}.png"
img.save(os.path.join(a.outdir, name), optimize=True)
print(f"{a.slug}: otsu={t} seed_px={int(sizesB[seed_id])} low_blobs={nL} "
f"kept={len(keep)} extra={len(extra)} bbox={cw}x{ch} square={CAN} "
f"illum_med={np.median(illum_gray):.0f} gainR/G/B="
f"{gains[...,0].mean():.2f}/{gains[...,1].mean():.2f}/{gains[...,2].mean():.2f}")
if __name__ == "__main__":
main()
Every subject emits twelve files: black-background (the primary — it is the medium’s native look, and it pops on NuGet’s light gallery) and a transparent die-cut, at 2048 down to 256 for applications and 128 for the gallery icon. The 128s land between 7 and 18 KB, comfortably under nuget.org’s 1 MB cap.
Wrong ways defeated: trunk, teal, and white puffs all intact.
Eight Logos, One Command
The per-image decisions — which frame, what crop, which paper defects to erase — are code, not memory:
#!/usr/bin/env bash
# build-all.sh — regenerate every NuGet package logo from the source photos.
# Each line records the frame choice and the per-image cleanup decisions:
# --crop isolates one painting / trims desk & page edges (frame px)
# --blackout fills paper defects (tape, neighbouring paint) with paper colour
# --margin-dist paint-vs-paper colour distance (raise to shed sheen/smudges)
# --extra-dist how paint-like a DETACHED blob must be to join (9999 = none)
# --gain-cap limits the paper-referenced white balance (grey subjects)
# Frames: geckos appear in 5 shots; C is the only one with the orange gecko's
# raised front foot un-clipped. Oak appears twice; 064350661 is the keeper.
set -euo pipefail
cd "$(dirname "$0")/.."
OUT=out
M=scripts/make-logo.py
python3 $M PXL_20260711_064323905.jpg $OUT rooster
python3 $M PXL_20260711_064136790.jpg $OUT green-gecko
python3 $M PXL_20260711_064217671.MP.jpg $OUT orange-gecko \
--crop 2780x2425+140+115 --extra-dist 100 --margin-dist 85 \
--blackout 1810,300,340,220 # translucent tape above the back
python3 $M PXL_20260711_064320196.jpg $OUT bat \
--crop 2600x1250+140+240 # bat only; pink swatches below
python3 $M PXL_20260711_064320196.jpg $OUT raven \
--crop 2000x1830+280+2250 --margin-dist 50 --gain-cap 1.12 \
--vibrance 1.05 --extra-dist 9999 \
--blackout 300,2200,1000,170 # bottom edge of a pink swatch
python3 $M PXL_20260711_064308212.MP.jpg $OUT canopy-tree
python3 $M PXL_20260711_064350661.jpg $OUT oak-tree \
--crop 2600x3840+100+200 # desk & page edge at frame top
python3 $M PXL_20260711_064339345.jpg $OUT dandelion
echo "done -> $OUT/"
Three flags, three war stories
--blackout fills a rectangle with the surrounding paper colour: it erased a strip of translucent tape above the orange gecko’s back, and the bottom edge of a pink test swatch that kept photobombing the raven.
--margin-dist 85 on the orange gecko sheds paper sheen — opaque acrylic sits at colour distance 120+ from the paper, hand-smear and sheen at 40–80, so a single number separates them.
And the orange gecko is built from frame C because it is the only shot of five where his raised front foot isn’t clipped by the frame edge — I spent two frames mistaking that foot for a different gecko’s toes and cropping it off.
The finished menagerie on a gallery-light background.
And the part that actually matters, the 128-pixel row — every subject still legible at icon size:
House tradition: the raccoon post was typeset in colours k-means surrendered from his fur. This page’s palette was lifted by eye from the shipped logos — no script this time, and honesty about that is part of the tradition too. The page background is the black paper; everything bright on it is somebody’s paint.
#0e0d0bthe paper
#3ab5a0rooster-tail teal
#e3a83crooster gold
#e05a4ecomb red
#9fd8b4gecko mint
#d8c9a3dandelion cream
What Went Well
Black paper is a gift. The background gradient problem and the cutout problem collapse into one operation — subtract the veil, and the messy warm grey becomes perfect #000 while the subject never gets touched.
The old posts paid rent. The diagnosis ritual, the paper-referenced white balance, the connectivity-beats-thresholding lesson — every trick from the raccoon sessions reappeared here, inverted for dark paper.
Per-image flags instead of per-image mystery.build-all.sh records every crop and blackout, so the whole set regenerates from the originals with one command.
What Didn’t Go Well
I shipped the subject-eating bug first. The claw loss and the foot hole were user-reported, not caught by my own review. The proof montage existed; I looked at the black variant and called it done.
Otsu flattered the bright subjects and betrayed the dim ones. The rooster survived every wrong version; the trees failed in three different ways before the recipe stabilized. Test the worst image, not the best.
The extrapolation failure was predictable. I found a known property of normalized convolution by shipping an orange ghost.
Coordinate archaeology. Crops were read off 600px preview grids at scale ×5.12, and I clipped the raven’s tail and then his crown before landing the right rectangle.
Takeaways
1
On dark paper, don’t cut — subtract.
Background removal by weighted subtraction preserves every paint pixel by construction; masks have to be proven not to eat the subject.
2
Seeds + connectivity beat any single threshold.
Bright paint votes for the subject; dim paint joins by touching it.
3
An extrapolated estimate needs a fallback ladder.
Local → wide → global, blended by support. Anywhere a denominator can approach zero, it eventually will.
4
Chromaticity separates paint from paper when brightness can’t.
Distance from the local paper colour is the marker-on-black analogue of “paper-coloured and reachable from the edge.”
5
Correct the cast before you amplify anything.
Vibrance before white balance turns lighting errors into art errors.
The Watercolor
Acrylic markers don’t behave like watercolour, and this painting wouldn’t either. I’d work on the black paper itself — the good, heavy, light-drinking stock the menagerie lives on — because this time the paper was never the antagonist. The paper was the canvas standard, the one thing every photo disagreed about and every fix converged toward.
Down the left edge, small and shameful and in full colour: the casualties. A rooster foot with one claw missing. A tree floating without its trunk, a strip of neon where its teal should be. A dandelion blushing red under light it was never painted in. Each one rendered carefully, because each one was the moment a plausible shortcut met an actual painting.
The centre of the sheet is the menagerie as it shipped: eight squares of true black, arranged like a NuGet gallery page, each animal and tree and seed-head sitting in its own pool of dark. Acrylic laid thick and opaque — teal, gold, cadmium red, that violet bat — with the stroke gaps left honest, bare paper showing between the feathers, because on black the gaps read as drawing and not as damage.
And in the corner, the signature this series has earned: a tiny grid of 128-pixel squares, the whole zoo at icon size. If a painting still reads at 128 pixels — comb, bandit-tail curl, seed-puff, all of it — then the pipeline did its one real job, which was to get out of the artist’s way.