DEV Community

Peacoke
Peacoke

Posted on

How We Built a 100% In-Browser Image to Bead Pattern Generator (CIE Lab Color Matching & Zero-Cost Architecture)

When converting user-uploaded photographs into physical fuse bead patterns (such as Perler, Hama, or Artkal beads) or retro 8-bit game sprites, most web services take the expensive path: upload the JPEG to a server, process it with OpenCV or ImageMagick in Python, and return a grid of hex codes.

For BeadPattern, we wanted a completely different architecture: 100% client-side processing directly in the user's browser. No image leaves the user's device, zero server compute or egress costs for image transformations, and instant feedback when users tweak bead counts or contrast sliders.

Here is an architectural walkthrough of how we solved the three core engineering bottlenecks: perceptual color quantization, real-time canvas dithering, and responsive mobile interaction.


1. The Trap of Euclidean RGB Distance (Why Skin Tones Look Gray)

The simplest way to match a pixel $(R_1, G_1, B_1)$ to a known manufacturer's bead palette is calculating Euclidean distance in standard sRGB space:

$$\text{distance} = \sqrt{(R_1 - R_2)^2 + (G_1 - G_2)^2 + (B_1 - B_2)^2}$$

If you implement this, you quickly notice a serious visual problem: subtle peach, skin tones, and pastels turn into dull gray or olive green.

The human visual cortex does not perceive light linearly. A 10-unit delta in pure green is far more noticeable to the human eye than a 10-unit delta in dark blue. To get accurate plastic bead matches, we convert every pixel and every catalog bead color into CIE L*a*b* color space:

  • $L^*$: Perceptual Lightness (0 = black, 100 = white)
  • $a^*$: Position between green (negative) and red/magenta (positive)
  • $b^*$: Position between blue (negative) and yellow (positive)

The Conversion Pipeline

// Step 1: sRGB to linear RGB with inverse gamma companding
function srgbToLinear(c: number): number {
  c /= 255;
  return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}

// Step 2: Linear RGB to CIE XYZ (D65 standard illuminant)
function rgbToXyz(r: number, g: number, b: number): [number, number, number] {
  const rLin = srgbToLinear(r);
  const gLin = srgbToLinear(g);
  const bLin = srgbToLinear(b);

  const x = rLin * 0.4124564 + gLin * 0.3575761 + bLin * 0.1804375;
  const y = rLin * 0.2126729 + gLin * 0.7151522 + bLin * 0.0721750;
  const z = rLin * 0.0193339 + gLin * 0.1191920 + bLin * 0.9503041;
  return [x * 100, y * 100, z * 100];
}

// Step 3: XYZ to CIE L*a*b*
function xyzToLab(x: number, y: number, z: number): [number, number, number] {
  // Normalized to D65 reference white
  const xn = x / 95.047;
  const yn = y / 100.0;
  const zn = z / 108.883;

  const f = (t: number) => t > 0.008856 ? Math.cbrt(t) : (7.787 * t) + (16 / 116);

  const fx = f(xn);
  const fy = f(yn);
  const fz = f(zn);

  const L = (116 * fy) - 16;
  const a = 500 * (fx - fy);
  const b = 200 * (fy - fz);
  return [L, a, b];
}
Enter fullscreen mode Exit fullscreen mode

Once colors are in $L^a^*b^$, we measure delta using the standard CIE76 Euclidean distance:

$$\Delta E = \sqrt{(L_1 - L_2)^2 + (a_1 - a_2)^2 + (b_1 - b_2)^2}$$

This single math adjustment immediately eliminated the "muddy portrait" bug when matching plastic beads against official catalogs. You can inspect the real-time cross-brand color mappings on our BeadPattern Interactive Color Directory.


2. High-Performance Palette Quantization with Precomputed Lookups

A typical high-resolution photograph downsampled to a $58 \times 58$ pegboard grid contains 3,364 cells. While that is small, comparing each pixel against 200+ commercial bead codes (Perler + Hama + Artkal + Mard) on every slider change requires roughly $3,364 \times 200 = 672,800$ distance calculations.

To keep slider dragging at 60 FPS without dropping frames:

  1. Precompute $L^a^*b^$ vectors for the palette: When the app boots, we parse all official color codes into typed Float32Array buffers so coordinate lookups don't trigger JavaScript garbage collection.
  2. K-d Tree Nearest Neighbor Lookup: Instead of scanning linearly through the palette for each pixel, we index the active palette into a balanced 3D K-d tree in $L^a^*b^$ space. This drops search complexity from $O(N)$ to $O(\log N)$ per bead cell.

3. Spatial Error Diffusion (Floyd-Steinberg Dithering)

Downsampling natural photographs to a fixed 48-color plastic palette causes harsh visual color banding, particularly across smooth gradients like skies and sunsets.

To preserve perceived detail without adding artificial noise, we implemented 2D error diffusion (Floyd-Steinberg algorithm):

// Error weights distributed to neighboring unfinished pixels:
//           [ current ]   7/16 (x+1, y)
// 3/16(x-1, y+1)  5/16(x, y+1)  1/16(x+1, y+1)

function applyErrorDiffusion(
  width: number,
  height: number,
  pixels: Float32Array, // RGB buffer in 0..255 range
  palette: ColorEntry[]
) {
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const idx = (y * width + x) * 3;
      const oldR = pixels[idx];
      const oldG = pixels[idx + 1];
      const oldB = pixels[idx + 2];

      const closest = findClosestPaletteColor(oldR, oldG, oldB, palette);
      pixels[idx] = closest.r;
      pixels[idx + 1] = closest.g;
      pixels[idx + 2] = closest.b;

      const errR = oldR - closest.r;
      const errG = oldG - closest.g;
      const errB = oldB - closest.b;

      // Distribute error
      distributeError(pixels, width, height, x + 1, y, errR * (7 / 16), errG * (7 / 16), errB * (7 / 16));
      distributeError(pixels, width, height, x - 1, y + 1, errR * (3 / 16), errG * (3 / 16), errB * (3 / 16));
      distributeError(pixels, width, height, x, y + 1, errR * (5 / 16), errG * (5 / 16), errB * (5 / 16));
      distributeError(pixels, width, height, x + 1, y + 1, errR * (1 / 16), errG * (1 / 16), errB * (1 / 16));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The difference is night and day: smooth skin tones and skies transition gracefully into stippled dots that naturally mimic physical bead art. For detailed step-by-step visual comparisons between clustered quantization vs dithered sprites, see our engineering guide on Photo to Pixel Art Conversion.


4. Zero-Server Cost Architecture

Because all computational heavy lifting happens in client memory:

  • Static Delivery: The application shell is served as lightweight static assets on Cloudflare Workers edge nodes.
  • Privacy by Design: Crafters frequently upload family portraits, custom anime art, and personal logos. Zero bytes of image data touch any server disk or cloud database.
  • Instant Export: Vectorized 1:1 printable PDF pegboard charts and bead inventory tables are assembled on the client using jsPDF and offscreen canvas renderers.

You can try the live tool directly in your browser at BeadPattern—no signup or installation required.

Top comments (0)