The portrait on my home page isn't an image. It's 28,302 tiny programs, one per Braille character, all running the same 6,960-parameter neural network on your GPU. Scribble on it and the cells around the hole grow it back. The code, weights, training logs and a longer write-up are in braille-portrait-nca.
Picking the look
I started with pixels: three neural cellular automata and a diffusion inpainter, each trained to hold a 300 × 168 cut-out of my photo and repair it. The diffusion model fixed big holes best, but it has 4.3 million parameters, takes about a second per repair, and nothing grows. One finding stuck: a model that knows roughly where it is in the frame stops painting faces in random places.
Then I built a playground that renders the photo as text: brightness ramps, glyphs matched to the local shape, edge characters, 1-bit dithering. Braille won. Each character holds 2 × 4 dots, so a 318-column grid is really a 636 × 356 dot image.
The cell
Each character cell has 16 numbers: the ink of its 8 dots, an "alive" value, and 7 hidden channels it uses to talk to its neighbours. Every step, a cell looks at its 3 × 3 neighbourhood through fixed Sobel and Laplacian filters, at a ring 3 cells further out, and at a coarse sense of position (sin and cos of x and y at 4 frequencies). A small MLP turns that into a change to its state. Half the cells update each step, at random, and only cells next to living ones can change, so the portrait spreads out from a few seeds.
This follows Growing Neural Cellular Automata by Mordvintsev et al. The coarse position is my main change. It tells a cell it's near the left eye, not at cell (203, 31), so detail still has to come from neighbours agreeing.
Training
A pool holds 256 portraits at every stage of growth. Each batch takes 8, swaps the worst for a fresh seed, punches holes in 3 others, runs 64 to 112 steps and scores the result against the photo. Damaged portraits go back into the pool, so the network learns both to hold a finished portrait still and to repair it. 2,000 iterations took 31 minutes on one GPU.
In the browser
The weights ship as 19 KB of JSON. My WebGL2 engine folds the fixed filters into two 3 × 3 convolutions and runs the update as a few fragment shaders, at most 30 steps a second. Each dot is drawn against a 4 × 4 Bayer pattern, because ordered dithering stays put while the image changes, where error diffusion would make the growing portrait shimmer.
One cheat: a cell that settles close to the photo fades into the photo's exact dots. Growing and healing areas show the network's softer guess, finished areas sharpen. It also erases a tiny patch of itself every few seconds, so there's always something healing.