Notes › Life Essays › How a Perler Pattern Picks Its Bead Color: One Dither Pass Rewrites 150 of 1024 Cells

How a Perler Pattern Picks Its Bead Color: One Dither Pass Rewrites 150 of 1024 Cells

Color matching runs on a redmean weighted distance rather than Euclidean RGB, and it does not even satisfy the triangle inequality; ties resolve purely by palette declaration order; turning on dithering recomputes the whole grid, rewriting 150 of 1024 cells and lifting the per-cell total deviation by 32.16 percent.

Opening the color-matching step is arithmetic, not intuition

A perler bead pattern is what you get when a picture is compressed into an m×n grid and every cell is filled with one bead of a fixed color code. The choice that really shapes the finished piece lives in a single step: given one RGB value, which color code do you assign to it? Spread that step out and three surprises show up. The distance used to pick the color is not the Euclidean RGB gap most people picture, a tie is settled purely by the order in which the palette declares its colors, and checking the dithering box does not patch up a handful of cells but recomputes the whole grid from scratch.

To measure all of it, I sliced the quantization lines out of the perler bead pattern generator template and drove 20 input colors through them one by one on Node v24.19.0, then ran handleImage to generatePattern end to end inside headless Chrome. The template itself is 1109 lines and 56659 B, and it ships four built-in palettes. This piece compares two of them, the classic 48-color set and the mini 24-color set.

The palette distance is redmean weighted, not Euclidean RGB

Lines 383 and 384 build the metric as rmean=(r1+r2)/2; weightR = 2 + rmean/256; weightG = 4; weightB = 2 + (255 - rmean)/256. Both the red and the blue weight slide between two and three as rmean moves, while the green axis stays pinned, so the whole thing traces a different surface from Euclidean RGB. For one color pair the gap stretches like this:

Color pairredmeanEuclideanRatio
White to Black764.834441.6731.732
Pure green to Black5102552 (green axis is fixed, sqrt of 4)
Pure red to Black403.0332551.581

Running 20 input colors through the classic 48-color palette collapsed them onto 13/20 distinct matches, with a summed distance of 1173.722 and an average of 58.686. The metric also violates the triangle inequality: for one triple d(A,B)+d(B,C)=394.312 came out smaller than d(A,C)=395.044, short by 0.733. So it preserves ordering only, not distance, and its "nearest color" gap cannot be read as a real color difference.

A tie is decided only by palette declaration order

Line 392 is a strict if (d<minDist), so on an exact tie it keeps whichever color was declared first, the one with the smaller index. Ask about rgb(160,160,160): C-39 light gray and C-40 gray sit at the same distance 95.979, and the call returns C-39 at idx=38 not because it is closer but because it is earlier. Take 5 queries that are exactly equal in floating point and reverse the palette declaration order before asking the same colors again, and 5/5 switch to the other color code. Across the four palettes there are no duplicate hex values (uniqHex=48/56/44/24), so a tie lands only where grid points meet.

Dithering recomputes the whole grid, it does not fill a few cells

Metric Off Dither Total error 21968.638 29033.911 Mean 21.454 28.353 Max 143.36 267.144 High-error cells 57 103

Figure 1: the per-cell error ledger for pp_swatches at 32×32 on the classic palette with dithering on; each row is scaled to its own value range. It rewrites 150/1024 cells and lifts the color count from 23 to 28.

With dithering off this swatch grid sits at 1024 cells and 23 colors. Turn dithering on and lines 640 through 643 rerun nearestColorIndex for every cell with alpha at or above 128 and then overwrite the entire beadGrid. The result is that 150/1024 cells get rewritten, the color count climbs to 28, and the per-cell total error rises by 32.16%. Across all 8/8 real photographs the total error goes up after dithering, in the band from +25.08% up to +33.71%. So the label "smoother color transitions" describes the visual gradient, not a reduction in per-cell deviation.

The two switches also fight each other. With only "remove white background" enabled, a 32×32 grid reports empty=502/1024 cells. Check dithering together with it and the empty count drops back to 0, while dither-refilled-empty-cells=502: the dither branch on line 640 re-tests only cells whose alpha is below 128 and never consults bgRemove, so the 502 white cells that were just hollowed out get refilled with beads anyway.

The error ledger of the two palettes has to be read separately

Metric classic mini Sum distance 1173.722 1466.662 Mean distance 58.686 73.333 Swatch mean 21.454 39.95 Photo mean 89.599 122.518

Figure 2: the same batch of input colors and the same image measured on the classic 48-color palette and the mini 24-color palette; the mean climbs +24.96%, and 15/20 inputs land on different hex values.

Feed those same 20 input colors into the mini 24-color palette instead. Now 15/20 of them resolve to a different hex value, the summed distance becomes 1466.662, and the average reaches 73.333, which is +24.96% above the classic figure of 58.686. The most striking case is rgb(128,128,128): on the classic palette it is an exact hit, the C-40 gray with d=0, while on the mini palette the very same gray is handed to M-19, a mustard yellow sitting at distance 146.12. Shrinking the palette from 48 colors to 24 barely changes how many colors a photo uses, dropping it only from 11 to 9, yet the average deviation still climbs all the way to 122.518.

Pattern pixels are set only by the grid size, while bytes follow the content

The export canvas fixes every cell at 20 px, with no DPI step and no scale factor to choose. A 32×32 grid exports as 640×802 / 58794 B and a 200×200 grid as 4000×4132 / 497624 B, so that width of 4000 px is simply 200×20. The legend height follows the legendPad=20 formula, and the reconstructed IHDR headers matched the measured sizes. Toggling options changes the byte count but never the pixel dimensions: turning off the per-cell color label drops the file from 58794 B to 40761 B, saving 18033 B, while a pattern clipped down until every cell is empty still exports as a valid PNG at 640×712 / 20914 B.

The share column runs through toFixed(1), so the total falls short of 100%

The share column is line 786 (cnt/total*100).toFixed(1). Add the column up and the 11 rows of a 32×32 grid total 99.9%, and the 12 rows of a 200×200 grid also total 99.9%, never a clean hundred percent. The smallest entry, 12 C-14 magenta 3 0.0%, is really 3/40000=0.0075% rounded down until it reads as zero, so that bead is not actually absent. The TXT export carries no BOM: the 32×32 list is 439 B, the 200×200 list is 480 B, and the fully empty pattern still writes a 193 B file.

Failure paths are mostly silent, and an out-of-range clamp never writes back

The entry point on line 471 is file.type.match(/image\//), which accepts a file on the MIME prefix alone. Send a text/plain and it raises the toast "please upload an image file", but feed it a corrupt.png (72 B) that fails to decode and the toast array stays empty while the screen simply remains in its blank state. The autoFitSize helper on line 488 can convert an aspect ratio into a cell count, yet a scan of the whole template finds only its definition and no call site, so uploading a 1200×900 photo leaves the grid parked on the default 32×32.

The cell count is squeezed by lines 543 and 544, Math.max(4, Math.min(200, parseInt(value)||32)): typing 999 becomes a 200×200 grid of 40000 cells, but the input box keeps displaying 999. Calling setSize(300,300) skips this clamp entirely and yields gridW=300. Edge quantization compresses each channel down to 16 levels, so the key space is 16³=4096, and the white-background test checks whether r, g and b all exceed 230, which in practice is a threshold of 231.

A few questions that come up often

Q: Is it really picking the smallest color difference?

For the template's own redmean function, yes, but that is a weighted distance rather than Euclidean RGB, and it does not even satisfy the triangle inequality, so the value behind "closest" is not a color difference you can trust as a measurement.

Q: Does checking the dithering box make the colors more accurate?

No. Across all 8/8 samples the per-cell total error rises after dithering (the band +25.08% up to +33.71%), and 150/1024 cells get rewritten; only with dithering off do you keep the guarantee that each cell holds its own nearest color.

Q: What about counting cells and buying beads once the pattern is done?

This article only accounts for the color-matching and dithering layer. The workflow of using a finished pattern and budgeting bead purchases is covered in a separate article, the perler bead pattern usage notes, so it is not repeated here.

📎 Related articles

The Same 23 Bytes at Four Error-Correction Levels: How Far This Page's Own QR Encoder Goes 2026-10-30 Type 137 ms as the frame delay and every frame holds 140 ms: tick steps, 4096-byte padding, canvas from the first image 2026-10-30 Landscape Loses 25% And Portrait 43.75%: A Cell-By-Cell Geometry Audit Of 7 Collage Templates 2026-10-28 A 5% White Watermark On A Light Base Adds Nothing: The PNG Exports At 4,782 Bytes, Identical To The Base Image 2026-10-28 Add One Month to January 31: Only Days, Hours, Minutes, Seconds 2026-10-28