A 58×58 bead board contains 3,364 positions. In one of our production checks, treating every position as part of the picture produced exactly 3,364 beads. With automatic background analysis enabled, the same source produced 900.
That difference is not a cosmetic optimization. It changes the material list, the cost of the project, and whether the exported guide is usable at all.
While building Pixel Bead Studio, I learned that converting an image into a physical bead pattern is not simply a matter of resizing a photo and choosing the nearest colors. A useful converter must preserve small features, understand empty space, map digital colors to a finite physical palette, and produce the same result everywhere—from the preview to the printable PDF.
This post explains the deterministic TypeScript pipeline we built to do that.
Why naive image resizing fails
The simplest implementation looks reasonable:
- Resize the source image to the target grid.
- Read one RGB value for each cell.
- Find the nearest bead color.
- Export the grid.
It also fails in predictable ways.
Thin outlines disappear because their average coverage is smaller than the surrounding fill. Eyes and facial details become muddy. White backgrounds turn into thousands of white beads. Near-neutral colors jump between visually inconsistent palette entries. Dithering can improve gradients, but it may also break a clean outline into isolated specks.
The central problem is that a bead cell is both a color sample and a structural decision. We therefore split the conversion into explicit stages:
source pixels
-> source-resolution background analysis
-> source-type detection
-> 8×8 shape-aware sampling per bead
-> perceptual palette matching
-> optional protected dithering
-> one canonical pattern matrix
-> preview, material list, PNG, and PDF
Each stage solves one problem and leaves evidence that can be tested.
1. Remove backgrounds before resizing
Background removal must happen at source resolution. If the image is resized first, antialiasing mixes the foreground and background together. The converter then has to decide whether a pale edge pixel belongs to the subject or the canvas—with less information than it had at the start.
Our automatic mode samples the source perimeter, estimates a candidate background color, and checks how consistently that color appears across multiple sides. It then flood-fills only matching regions connected to the exterior.
The connectivity rule matters. An enclosed white area may be a real part of the design, such as the center of a ring. Removing every pixel close to white would erase it. Removing only exterior-connected pixels preserves enclosed regions.
The API makes uncertainty visible instead of hiding it:
type BackgroundMode = "auto" | "manual" | "off";
type BackgroundDiagnostics = {
mode: BackgroundMode;
decision: "removed" | "preserved" | "disabled";
reason: string;
confidence: number;
removedPixels: number;
};
Automatic removal is deliberately conservative. If the border is inconsistent, the foreground is too similar to the candidate background, or compression artifacts make the decision ambiguous, the pipeline preserves the pixels. Users can then choose a background color manually or disable removal.
For creative tools, “I am not confident” is often a better result than a confident destructive edit.
2. Detect discrete pixel art before sampling it like a photo
A screenshot of pixel art and a photograph should not follow the same resampling path.
Photographs contain continuous tones and benefit from area-based resampling. Pixel art contains a deliberate lattice. Smoothing it creates colors that never existed in the source and blurs the boundaries we want to preserve.
We inspect horizontal and vertical edge peaks, search for a repeating period, and measure how strongly edges align with that lattice. A source can then be classified as continuous, a detected pixel lattice, or native low-resolution pixel art.
When the lattice evidence is strong, we recover colors from stable cell cores and use nearest-neighbor resampling. Otherwise, the image stays on the continuous-tone path.
This prevents an important category error: improving a photo and preserving pixel art are different jobs.
3. Treat every bead as an 8×8 analysis problem
One center pixel is too fragile, while a plain average erases small details. Our shape-aware sampler analyzes 64 subpixels for every output bead.
Inside each 8×8 region, the samples are grouped into two color clusters in Lab space. We then measure:
- how much of the cell each cluster covers;
- the perceptual distance between the clusters;
- whether the smaller cluster forms a connected feature;
- which sides of the cell the feature touches;
- how that feature continues into neighboring cells.
A simplified decision looks like this:
const protectFeature =
featureCoverage >= 0.18 &&
featureCoverage <= 0.50 &&
ciede2000(feature, surface) >= 9 &&
connectedCoverage >= 0.60;
const representative = protectFeature ? feature : dominantColor;
Coverage alone is not enough. Random noise may occupy 20% of a cell, but it will not form a coherent local shape. Connectivity helps distinguish an eye, border, or narrow stroke from compression noise.
The sampler also records an outlineProtected flag. Later stages are allowed to simplify ordinary surface cells, but they cannot casually overwrite a cell selected to preserve structure.
4. Match physical colors perceptually
Bead palettes are finite. A photograph may contain thousands of RGB colors, while a selected bead brand provides a fixed catalog of purchasable color IDs.
Euclidean RGB distance is a poor proxy for human perception. Equal numeric changes in different RGB channels do not look equally large, especially around skin tones and low-chroma colors.
We convert both the sampled color and every palette entry from sRGB to CIE Lab, then choose the lowest CIEDE2000 distance:
function nearestPaletteColor(sample: RGB, palette: PaletteColor[]) {
const sampleLab = rgbToLab(sample);
return palette.reduce((best, color) => {
const distance = ciede2000(sampleLab, hexToLab(color.hex));
return distance < best.distance ? { color, distance } : best;
}, { color: palette[0], distance: Infinity });
}
This still cannot make a limited palette reproduce every source color. What it can do is make the error correspond more closely to what a person sees.
The output stores real palette IDs rather than anonymous display colors. That lets the same matrix power editing, quantity calculation, and export.
5. Keep dithering away from structure
Floyd–Steinberg dithering distributes quantization error into neighboring cells. It can simulate intermediate shades with a limited palette, but physical beadwork exposes its cost: every extra speck is another bead the maker must place.
Our implementation uses serpentine traversal—left to right on one row, right to left on the next—to avoid a directional bias. More importantly, protected outline cells neither receive nor diffuse error.
for (let y = 0; y < height; y++) {
const direction = y % 2 === 0 ? 1 : -1;
for (const x of scanRow(width, direction)) {
const cell = y * width + x;
const match = nearestPaletteColor(rgb[cell], palette);
output[cell] = match.id;
if (!dither || outlineProtected[cell]) continue;
diffuseError(cell, rgb[cell], match, direction, outlineProtected);
}
}
The product exposes three choices: Off for clean flat regions, Auto for color-rich images, and On for stronger gradient simulation. The default should reflect the physical task, not just what looks smooth on a screen.
6. Make one pattern matrix the source of truth
An image converter is not finished when the preview looks correct.
The material count, editable workspace, PNG guide, and PDF guide must all represent the same cells. If each output performs its own interpretation of transparency or background removal, they will drift.
Our pipeline returns one integer matrix. A non-negative value is a palette index; an empty cell uses a sentinel value. Every downstream output consumes that matrix.
We verify the contract in browser tests:
- automatic, manual, and disabled background modes recompute the pattern;
- material quantities add up to the visible bead count;
- enclosed foreground-colored regions remain present;
- exterior empty cells stay empty in exported PNG files;
- the PDF contains the same palette IDs and quantities shown in the UI.
For the release described here, the validation gate included 625 unit tests and six targeted desktop/mobile browser tests. Two supplemental visual-dataset tests were skipped in the clean release environment because their local image assets were intentionally not stored in the repository.
That last detail is worth reporting. A skipped visual test is not a passed visual test.
What I would do differently next time
Three lessons changed how I think about image conversion.
First, background removal is a topology problem before it is a color problem. Exterior connectivity is more useful than a global “remove white” rule.
Second, downsampling needs to preserve decisions, not pixels. The important question is not “What is the average color?” but “Is there a connected feature here that the final object needs?”
Third, printable exports are part of the algorithm. A correct preview with an incorrect material list is still an incorrect converter.
AI-assisted coding helped us explore implementations, generate adversarial cases, and tighten regression tests. It does not generate the final pattern in this pipeline. The shipped result is produced by deterministic image-processing rules and remains editable by the user.
That boundary has been useful: use AI to accelerate engineering, but keep the artifact reproducible, inspectable, and under the maker’s control.
You can try the current converter at Pixel Bead Studio.
Top comments (0)