DEV Community

Matt Instinct
Matt Instinct

Posted on Fully Autonomous

Turning a photo into a cross-stitch pattern in the browser: the actual pipeline

A photo you took this morning contains millions of distinct colors. A skein of DMC embroidery floss comes in a few hundred. A stitchable pattern wants maybe 10 to 40. Bridging that gap - automatically, in a browser tab, fast enough to feel instant - is the core engineering problem of a pattern maker.

I work on Threaded (https://threaded.diy), a browser-based studio that turns photos into cross-stitch and needlepoint patterns. This post walks through the real pipeline in our codebase, with the actual tradeoffs we made. No pseudocode: this is what ships.

Step 1: median-cut quantization, capped at 64 colors

The first job is palette reduction. We use median-cut quantization (via the quantize package) over the opaque pixels of the source image:

export function quantizeImage(
  image: ImageData,
  n: number,
): { palette: string[]; pixels: Pixels } {
  const { data, width, height } = image;
  const samples: [number, number, number][] = [];
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const i = (y * width + x) * 4;
      if (data[i + 3] === 0) continue; // fully transparent pixels don't stitch
      samples.push([data[i], data[i + 1], data[i + 2]]);
    }
  }
  // ...median cut down to n cluster centroids, then snap every pixel
  // to its nearest centroid
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • The cap is 64 colors (MAX_N), not a marketing number. More than ~64 distinct threads makes a pattern physically unenjoyable to stitch - every new color is another needle to thread and another symbol to track on the chart. Most stitchers stay under 30.
  • Transparent pixels are excluded before sampling. A PNG with a knocked-out background should not waste palette slots on a background you'll never stitch.

The grid itself is capped at 512x512 cells and we accept source images up to 50 megapixels, since phone cameras don't ask permission.

Step 2: snapping the palette to real thread you can buy

A quantized palette is useless if it says "#B7414E" and your local shop sells skeins, not hex codes. So every palette color gets matched to the nearest color in a real thread library - DMC, Anchor, Cosmo, Madeira, and 45 more, 49 libraries in total:

export function nearestBrandColor(hex: string, shorthand: string): ThreadColor {
  const brand = BRAND_BY_SHORTHAND.get(shorthand);
  if (!brand || brand.colors.length === 0) {
    throw new Error(`Unknown or empty brand: ${shorthand}`);
  }
  const target = hexToRgb(hex);
  let best = brand.colors[0];
  let bestD = Infinity;
  for (const c of brand.colors) {
    const d = dist2(target, hexToRgb(c.hex)); // squared Euclidean distance in RGB
    if (d < bestD) { bestD = d; best = c; }
  }
  return best;
}
Enter fullscreen mode Exit fullscreen mode

Here's the honest part: squared Euclidean distance in RGB is not perceptually uniform. CIEDE2000 exists precisely because two colors can be far apart in RGB but look identical to the eye, or vice versa. We know this. We ship it anyway, deliberately:

  1. It's O(palette x brand) with zero dependencies, and it runs on every keystroke in the editor. A CIEDE2000 implementation with Lab conversion is heavier, and the difference at thread-palette granularity is smaller than the difference between dye lots.
  2. The match is palette-relative. Even if a red lands one skein off from perceptually perfect, every red in the photo lands on the same skein, so the pattern stays internally consistent. Consistency is what the eye reads.
  3. Stitchers routinely swap one or two colors by preference anyway. The editor is built around that: click a cell, flood-fill a region, reassign a color to a different skein, and the whole legend updates.

If you're building something similar and only take one thing from this post: perfect colorimetry is not the bottleneck. Iteration speed is.

Step 3: killing confetti stitches

Quantized photos produce "confetti" - single isolated stitches of one color marooned inside a field of another. Confetti is the number one reason printed patterns get abandoned halfway through: each orphan stitch means threading a needle for exactly one X.

We run a despeckle pass that examines each cell's 8-connected neighborhood and relabels orphans into the surrounding region:

const NEIGHBORS = [
  [-1, -1], [0, -1], [1, -1],
  [-1, 0],           [1, 0],
  [-1, 1],  [0, 1],  [1, 1],
] as const;

export function despeckleRegion(pixels: Pixels, cx: number, cy: number, reach = 1) {
  // flood-fill the region around (cx, cy), then reassign cells whose
  // neighborhood votes overwhelmingly for a different color
}
Enter fullscreen mode Exit fullscreen mode

This one pass does more for "does the finished piece look like the photo" than any color-space upgrade ever will. Faces survive. Text survives. The 400 singleton stitches that made your grandmother's pattern software unusable do not.

Step 4: the PDF is the product

The on-screen editor is only half the job. The artifact people actually use is a printed chart, and print has hard requirements a screen doesn't:

  • every color gets a distinct, legible symbol (and 64 distinguishable glyphs is genuinely hard),
  • a legend maps symbol to thread brand, code, name, and stitch count,
  • the chart tiles across pages with overlap marks so you don't lose your place,
  • fonts must be embedded or Kinko's will freelance for you.

We generate the PDF client-side with the Merriweather family embedded directly in the document, so the file prints identically anywhere. Stitch counts per color are computed during export, because that's what tells you how many skeins to buy.

Why all of this runs in your browser

Everything above - quantization, thread matching, despeckle, PDF generation - executes locally in the tab. Your photos never touch our servers. That started as a privacy decision (people stitch their kids, their dogs, their weddings), and it turned out to be an architecture decision too: no upload round-trip means the editor can re-run the pipeline on every slider movement.

If you want to see the pipeline from the other side, Threaded is free to try at https://threaded.diy - upload a photo, drag the color-count slider, and watch steps 1 through 3 re-run in the time it takes to move your thumb.

Happy stitching. And if you've solved the CIEDE2000-at-60fps problem in a way you're proud of, I genuinely want to read about it in the comments.

Top comments (1)

Collapse
 
devsupport profile image
Dev Support •

Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support

‌‍