A fuse bead pattern looks like pixel art, but converting a photo into something you can actually build is more than shrinking an image.
Every output cell must map to a real bead color. The palette must stay manageable. Empty space should remain empty. And the result still needs to resemble the source after hundreds—or thousands—of pixels disappear.
While building Meltbead, a browser-based fuse bead pattern maker, I ended up with a compact image-processing pipeline that handles those constraints entirely on the client:
image
→ resize
→ divide into grid cells
→ sample one color per cell
→ match colors to a physical bead palette
→ remove the connected background
→ reduce the palette
→ export a buildable pattern
This post walks through the important parts in TypeScript.
1. Treat the output as a physical grid
The first design choice is the output width. If the user asks for a 50-bead-wide pattern, the height should preserve the source aspect ratio:
const targetHeight = Math.round(
targetWidth * sourceHeight / sourceWidth
);
Each output cell owns a rectangular region of the source image. Using floor for the starting coordinate and ceil for the ending coordinate ensures that edge pixels are not silently dropped:
const startX = Math.floor(col * sourceWidth / targetWidth);
const startY = Math.floor(row * sourceHeight / targetHeight);
const endX = Math.min(
sourceWidth,
Math.ceil((col + 1) * sourceWidth / targetWidth)
);
const endY = Math.min(
sourceHeight,
Math.ceil((row + 1) * sourceHeight / targetHeight)
);
I also downscale very large uploads before calling getImageData(). The final pattern may only be 30–100 cells wide, so processing a full-resolution phone photo adds work without adding useful detail.
const longestSide = Math.max(image.naturalWidth, image.naturalHeight);
const scale = Math.min(1, 1200 / longestSide);
const sourceWidth = Math.max(1, Math.round(image.naturalWidth * scale));
const sourceHeight = Math.max(1, Math.round(image.naturalHeight * scale));
This is one of the useful properties of the problem: the physical craft gives us a natural resolution limit.
2. Average color is simple—but not always representative
The most direct way to summarize a cell is to average its visible pixels:
function averageColor(pixels: Array<[number, number, number]>) {
let r = 0;
let g = 0;
let b = 0;
for (const [pr, pg, pb] of pixels) {
r += pr;
g += pg;
b += pb;
}
return {
r: Math.round(r / pixels.length),
g: Math.round(g / pixels.length),
b: Math.round(b / pixels.length),
};
}
This works well for gradients and photographs. But an average can invent a color that is not visually dominant. A cell split between red and blue may become purple, even if there is no purple object in the source.
For illustrations and pixel art, I offer a dominant-color mode. Each RGB channel is quantized into 16-value buckets. The largest bucket wins, and I average only the pixels inside it:
const buckets = new Map<
string,
{ count: number; r: number; g: number; b: number }
>();
for (const [r, g, b] of pixels) {
const key = `${r >> 4}-${g >> 4}-${b >> 4}`;
const bucket = buckets.get(key) ?? { count: 0, r: 0, g: 0, b: 0 };
bucket.count += 1;
bucket.r += r;
bucket.g += g;
bucket.b += b;
buckets.set(key, bucket);
}
const winner = [...buckets.values()]
.sort((a, b) => b.count - a.count)[0];
const color = {
r: Math.round(winner.r / winner.count),
g: Math.round(winner.g / winner.count),
b: Math.round(winner.b / winner.count),
};
The buckets deliberately trade precision for stability. We do not need to distinguish every camera-noise variation; we need a dependable bead choice.
Transparent pixels are skipped. If a cell contains no sufficiently opaque pixels, it becomes an empty cell instead of a white bead.
3. RGB distance needs a small perceptual correction
Once a cell has a representative color, it must be mapped to the nearest color in a real bead palette.
Plain Euclidean RGB distance is an acceptable baseline:
Math.sqrt(dr * dr + dg * dg + db * db)
But human vision does not weight all RGB differences equally. A lightweight improvement is the red-mean color-distance formula, which adjusts the red and blue weights according to the average red value:
type Rgb = { r: number; g: number; b: number };
function colorDistance(a: Rgb, b: Rgb): number {
const redMean = (a.r + b.r) / 2;
const dr = a.r - b.r;
const dg = a.g - b.g;
const db = a.b - b.b;
return Math.sqrt(
(2 + redMean / 256) * dr * dr +
4 * dg * dg +
(2 + (255 - redMean) / 256) * db * db
);
}
Nearest-color lookup then stays pleasantly boring:
function findClosestColor(target: Rgb, palette: BeadColor[]) {
let closest = palette[0];
let bestDistance = Number.POSITIVE_INFINITY;
for (const candidate of palette) {
const distance = colorDistance(target, candidate.rgb);
if (distance < bestDistance) {
closest = candidate;
bestDistance = distance;
}
}
return closest;
}
For a few hundred palette entries and a modest grid, a linear scan is fast enough. If you were matching millions of pixels, a spatial index or precomputed lookup table would become more interesting.
4. Reduce colors after the first mapping pass
A palette may contain 200 colors, but a maker might only own 12. That means “closest available color” is not the whole problem; the application also needs to choose a useful subset.
The pipeline first maps every cell against the full palette and counts the result:
const frequency = new Map<string, number>();
for (const cell of patternCells) {
const match = findClosestColor(cell.sampledColor, fullPalette);
cell.color = match;
frequency.set(match.key, (frequency.get(match.key) ?? 0) + 1);
}
Then it keeps the most frequently used colors up to the user's limit:
const reducedPalette = [...frequency.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, maxColors)
.map(([key]) => fullPalette.find(color => color.key === key)!)
.filter(Boolean);
Finally, every non-empty cell is remapped to that reduced palette.
This two-pass approach is intentionally pragmatic. It is not a globally optimal clustering algorithm, but it produces understandable material lists and gives users a predictable control: “make this pattern with at most N colors.”
A more advanced version could use weighted k-medoids, ensure rare accent colors survive, or optimize for the colors the user already owns.
5. Remove only the background connected to an edge
“Remove white” sounds easy until the subject contains white eyes, highlights, clothing, or text.
Deleting every light cell damages the design. Instead, I treat background removal as a flood-fill problem:
- Start with light, low-saturation cells touching the outer border.
- Walk to their four-directional neighbors.
- Mark only that connected region as external.
function isLightNeutral(rgb: Rgb) {
const min = Math.min(rgb.r, rgb.g, rgb.b);
const max = Math.max(rgb.r, rgb.g, rgb.b);
return min > 224 && max - min < 35;
}
The queue-based traversal looks like this:
const queue: Array<[number, number]> = [];
const visited = new Set<string>();
function push(row: number, col: number) {
if (row < 0 || col < 0 || row >= height || col >= width) return;
const id = `${row}:${col}`;
if (visited.has(id)) return;
if (!isLightNeutral(hexToRgb(cells[row][col].color))) return;
visited.add(id);
queue.push([row, col]);
}
for (let col = 0; col < width; col++) {
push(0, col);
push(height - 1, col);
}
for (let row = 0; row < height; row++) {
push(row, 0);
push(row, width - 1);
}
for (let i = 0; i < queue.length; i++) {
const [row, col] = queue[i];
cells[row][col].isExternal = true;
push(row - 1, col);
push(row + 1, col);
push(row, col - 1);
push(row, col + 1);
}
A white highlight enclosed by darker cells survives because it is not connected to the border. This small topology-aware rule performs much better than a global color deletion.
6. Keep the processing local
The entire pipeline can run through the Canvas API:
const canvas = document.createElement("canvas");
canvas.width = sourceWidth;
canvas.height = sourceHeight;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("Canvas is not available in this browser.");
context.drawImage(image, 0, 0, sourceWidth, sourceHeight);
const imageData = context.getImageData(0, 0, sourceWidth, sourceHeight);
That makes the interaction immediate and lets uploaded images stay on the device. It also keeps the architecture simple: no upload endpoint, no image-processing queue, and no temporary asset cleanup.
The trade-off is that browser memory and CPU are finite. Resize early, cap the output dimensions, and yield work to a Web Worker if larger patterns make the interface stutter.
7. The algorithm is only the first draft
Automatic conversion gets a pattern most of the way there, but good fuse bead art still benefits from human editing.
A useful editor should let the maker:
- paint and erase individual cells;
- replace one color everywhere;
- inspect exact bead counts;
- compare the pattern with the source image;
- export a printable chart and material list;
- save the project locally and return later.
This is an important product lesson: image processing creates a suggestion, not an unquestionable answer. The best tool makes its automatic decisions editable.
What I would improve next
The current approach is compact and easy to reason about, but several upgrades are tempting:
- Run palette matching in CIELAB and compare colors with Delta E.
- Preserve small, high-contrast details during palette reduction.
- Add optional dithering for gradients.
- Score pattern readability at the chosen grid size.
- Let users optimize against a personal inventory of bead colors.
- Move generation into a Web Worker for smoother interaction.
If you are building your own converter, start with the simple pipeline. The quality gains from correct cell boundaries, a physical palette, and connected-background removal are larger than the gains from immediately reaching for a sophisticated clustering model.
You can try the finished browser tool at Meltbead. If you build a different quantization strategy, I would love to compare notes.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.