Aeronauty — field notes on bad physics & AI slop — est. 2026
← Posts

Edge detection makes confetti

30 Aug 2026

Build notesImage processingAI

It started with a request for a poster of Grogu. Then a colouring-in sheet of Grogu. Then, inevitably, a poster and a colouring-in sheet of everyone else.

So I built a thing. You pick a face, it pulls the picture off the wiki, and out comes an A4 poster — and the same character again as black outlines you can fill in with pencils. It also prints four to a page as trading cards, because of course it does.

Three things I was wrong about on the way.

1. You cannot get line art out of a photo with edge detection

This is the obvious first move. Take the picture, run a Laplacian or a Sobel over it, threshold, thicken the lines. Every tutorial does this.

It produces confetti.

An edge operator answers wherever the image has local contrast, and a modern character render has local contrast everywhere: fabric weave, airbrushed shading, JPEG ringing round the ears. You get a few thousand disconnected dots. Thicken them and you get a few thousand blobs. There is nothing for a child to colour inside, because nothing is inside anything.

Second attempt: forget edges, use bands. Blur the luminance hard, quantise it into three or four levels, and trace the boundaries between them. Now the lines are continuous and closed, which is the whole game — a colouring page is a set of enclosed regions, not a set of strokes.

Better. But the lines wobbled like a coastline, and any subject without much tonal range came out as a silhouette with nothing in it.

Third attempt, and the one that worked: smooth the region, not the line.

  • Stretch the contrast first (normalise, then CLAHE). A uniformly dark subject has no bands to find until you give it some.
  • Use a median filter rather than a Gaussian. Median is edge-preserving. It takes out the fabric weave without rounding off the boundaries you are about to turn into lines — which is exactly what a Gaussian does to them.
  • Take each band as a mask, open it, close it, blur it, re-threshold it, and only then take its boundary. Morphology with a square structuring element leaves you axis-aligned staircases; the blur-and-threshold rounds those into curves a person would actually draw.

One more thing that mattered more than any of it: take the band cut points from the histogram inside the subject, not across the whole canvas. Otherwise most of your bands get spent separating the figure from its background, which you already knew about.

2. The knob that fixes your metric will happily destroy the thing

I wanted the three detail settings to mean something, so I set a target for ink coverage — what share of the page ends up black — and let the code solve for the smoothing radius by bisection.

It worked beautifully. Every sheet landed inside the target. The lines were smooth, closed, elegant.

Grogu had one eye.

A large smoothing radius will always hit an ink budget, and the way it hits it is by dissolving small features. Eyes are small. Robes are large. The optimiser did precisely what I asked and quietly deleted the face.

The fix is boring and worth writing down: bound the search. The radius now moves inside a narrow range where the figure survives, and if that cannot reach the target then the target does not get met. A metric with no guard rails is just a very confident way of being wrong.

3. dilate() in sharp grows the dark pixels

This one cost me an hour, so here it is, measured, in case it saves you the same hour.

In binary morphology as everyone teaches it, dilation expands the foreground — the white, the high values. In sharp (0.35.4, on libvips) it does the opposite. A 40×40 white square on a 200×200 black ground:

base        4.00% white
dilate(3)   2.89% white   <- the white shrank
erode(3)    5.29% white   <- the white grew

Invert the image and it stays consistent: dilate() grows the dark, erode() grows the light, whichever way round your mask is. sharp's own docs say "expand foreground objects", which reads the other way if you assume foreground means white.

It is not a bug, it is a convention, and it is fine once you know. But if you write dilate() on a white-lines-on-black image expecting thicker lines, you will get no lines at all, and it will look like your pipeline is producing an empty figure rather than erasing one.

The bonus round: Top Trumps without making anything up

The posters wanted stats. Top Trumps cards live on numbers, and the temptation is obvious: hand the wiki article to a model and ask for Blaster Skill: 82.

Don't. That is inventing lore and putting it on a child's wall as fact.

Turns out you don't need to, because the wiki already has real numbers, and they compare:

Height
Grogu0.34 m
R5-D41.15 m
Din Djarin1.80 m
Chewbacca2.28 m
Rancor4.20 m

That is a playable stat and every value is true. Nobody needed a made-up score out of 100.

The model does still earn its place, but at a much smaller job: editing, not recalling. The raw infobox gives you Length: 34.52 m (113.25 feet) and Side: Aurra Sing's crew; a card wants 34.52 m and Bounty hunters. So it gets shown that one subject's own fields and asked which four make the best card and how to say them in six words. It is never asked what it knows about Star Wars.

And then the part that actually makes it safe: every value that comes back is checked against the text it was given before it is written to disk. If a string doesn't appear in the source, the row is dropped and logged. The output is a committed file you can read in a diff, not a live call — so the running app never talks to a model at all, and two kids looking up the same character get the same numbers.

That is the whole trick with these things. Give the model a document and a judgement call, not a memory test, and then check its homework.

Postscript, for anyone rendering DOM to an image

The posters need to come out as PDFs. The tidy way to do that is to serialise the live DOM into an SVG <foreignObject>, draw it to a canvas, and export — no layout code duplicated anywhere.

Chromium taints the canvas for any SVG image containing a <foreignObject>. Not "if it has external resources". Any.

I checked, because I didn't believe it: a 40×40 SVG containing one red <div> and nothing else. drawImage fine, getImageData throws The canvas has been tainted by cross-origin data. There is no cross-origin anything in that file.

Which is, I now realise, exactly why html2canvas and every library like it goes to the enormous trouble of reimplementing CSS rendering instead of just doing the obvious thing. They're not being clever. That door is locked.

(Measured in headless Chromium. Other engines differ — Safari historically won't render foreignObject in an SVG image at all, which is its own kind of answer.)

So the exporter paints the poster with canvas primitives instead — but it reads every position, size, colour and font off the live DOM with getBoundingClientRect and getComputedStyle. The geometry stays in the stylesheet and is never restated in JavaScript, so the two can't drift. Change the CSS and the PDF follows.


It prints. Nobody has had to rate a rancor's bravery out of 100.

Found some shit physics or AI slop? Send me your worst → aeronauty.com/slop.

Comments

Sign in to join the conversation.