Back to Blog
Technicalsvgimage-tracingvectorization

Turning a Bitmap Into SVG: How Image Tracing Works

A grounded technical explainer of how loopaloo's Image to SVG Tracer converts a bitmap into vector paths: thresholding/quantization, chain-code contour following, RDP simplification, smoothing, and quadratic-bezier path emission, plus why logos trace well and photos do not.

LoopalooPublished May 22, 20269 min read

A bitmap and an SVG are two completely different ways of describing a picture. A PNG is a grid of pixels: row by row, each cell holds a color. An SVG is a set of instructions: "draw a path from here, curve through there, fill it black." Image tracing is the act of looking at the pixel grid and reverse-engineering a plausible set of instructions that would redraw it.

That reverse-engineering is lossy and opinionated. There is no single correct vector representation of a pixel grid, so a tracer has to make decisions: where does one shape end and another begin, how smooth should an edge be, how many colors are worth keeping. Those decisions are exactly why a flat two-color logo traces beautifully and a photo of a beach turns into a muddy pile of blobs.

This post walks through the actual tracing pipeline in loopaloo's Image to SVG Tracer (src/components/tools/image/ImageToSvgTracer.tsx). It is a small, self-contained implementation that runs entirely in the browser, so every stage is inspectable. I will use it to explain the general technique and where it breaks down.

Why photos lose and logos win

The core problem is that vector tracing wants regions: large, contiguous areas of a single color bounded by a clean edge. A logo is made of regions by design. A corporate mark might be three solid colors with crisp boundaries. Trace it and you get a handful of paths that reproduce the original almost exactly, at any zoom level, in a few kilobytes.

A photograph is the opposite. It is millions of subtly different colors arranged in smooth gradients, with noise on top. There are no clean regions. If you force a tracer to find them, it either invents thousands of tiny paths (one per noise speckle) or it collapses the gradient into a few flat bands and throws away everything that made the photo look like a photo. Neither result is what you wanted. The output is often bigger than the original JPEG and looks worse.

So the rule of thumb is mechanical, not aesthetic: trace things that are already made of flat regions. Logos, icons, line art, signatures, simple illustrations, screenshots of solid-color UI. For continuous-tone images, vectorization is the wrong tool.

The pipeline at a glance

The tracer runs a fixed sequence of transforms. Each one throws away information in service of the next:

StageInputOutputWhat it discards
DownscaleSource imageMax 800px on the long edgeResolution
Blur (optional)RGBA pixelsSmoothed RGBAHigh-frequency noise
Threshold / quantizeRGBA pixelsBinary mask(s)Most color information
Contour followBinary maskLists of edge pointsInterior pixels
Smooth + simplifyPoint listsFewer, softer pointsPixel-grid jaggedness and redundant points
Curve fitPoint listsSVG path stringsStraight-line segment data

The first thing worth noting: before any of this, large images are scaled down so the longest side is at most 800 pixels (maxDim = 800 in the trace callback). Tracing cost scales with pixel count, and more pixels mostly means more noise to trace, not more useful detail. Downscaling is a feature, not a compromise.

Step 1: collapse color into a binary mask

Contour following needs a yes/no question for every pixel: is this part of a shape or not? Producing that boolean grid is where most of the "trace quality" decisions live.

In black-and-white mode the tool first converts each pixel to a single brightness value using the standard luminance weights, then compares it to a threshold:

const gray = data[idx] * 0.299 + data[idx + 1] * 0.587 + data[idx + 2] * 0.114;
binary[i] = options.invert ? gray >= options.threshold : gray < options.threshold;

The weights (0.299, 0.587, 0.114) reflect how the human eye perceives red, green, and blue: green carries most of the perceived brightness, blue almost none. The default threshold is 128, the midpoint of the 0 to 255 range. Everything darker than the threshold becomes "shape," everything lighter becomes "background." invert flips the comparison for light-on-dark art.

This single number is the most important control for line art. A scanned signature with light strokes needs a higher threshold so the faint pixels still count as ink. A bold logo on a busy background needs a lower one so the background does not bleed in. There is no universally right value, which is why it is a slider and not a constant.

Blurring before thresholding

A hard threshold is brutal to noise. A single stray dark pixel in a light region becomes its own tiny shape. To soften this, an optional Gaussian blur runs first. The implementation builds a 1D Gaussian kernel and applies it in two passes, horizontal then vertical:

// Horizontal pass writes into temp, vertical pass reads temp into result
for (let k = 0; k < size; k++) {
  const px = Math.min(width - 1, Math.max(0, x + k - half));
  r += data[idx] * kernel[k];
  // ...
}

This separable approach is the standard performance trick. A true 2D Gaussian convolution costs work proportional to the square of the kernel diameter per pixel, but because the Gaussian is separable, two 1D passes give an identical result at cost proportional to the diameter instead. The default blur is 1px, just enough to take the edge off sensor noise without dissolving thin strokes. The kernel itself is sized as ceil(radius) * 2 + 1, so the radius slider directly controls how wide the smoothing reaches.

Step 2: follow the contours

Now we have a binary grid and need to find the outlines of the filled regions. The tracer uses a border-following (boundary-tracing) approach in the Moore-neighborhood family. (A code comment calls it "marching squares," but the actual implementation is a chain-code walk over border pixels, not the marching-squares cell-lookup method; the distinction matters if you go reading the source expecting one and finding the other.)

It first defines a border pixel as a filled pixel that has at least one non-filled neighbor among its eight surrounding cells:

function isBorderPixel(x, y) {
  if (!getPixel(x, y)) return false;
  for (let d = 0; d < 8; d++) {
    if (!getPixel(x + dx[d], y + dy[d])) return true;
  }
  return false;
}

Interior pixels (surrounded entirely by other filled pixels) are ignored, because they carry no shape information. Only the boundary matters.

Then, starting from an unvisited border pixel, it walks the boundary using an 8-directional chain code. From the current pixel it searches its neighbors in a rotating order, beginning three steps back from the direction it last moved ((dir + 5 + i) % 8). Starting the search slightly behind the previous heading is what keeps the walk hugging the edge instead of cutting across the shape. It records each pixel, marks it visited, and continues until it returns to the start or runs out of unvisited border pixels.

for (let i = 0; i < 8; i++) {
  const nd = (dir + 5 + i) % 8;
  if (isBorderPixel(nx, ny) && !visited[ny * width + nx]) {
    cx = nx; cy = ny; dir = nd; found = true; break;
  }
}

The result is an ordered list of points outlining one region. Contours shorter than minPathLength (default 5) are dropped, which is the primary noise filter: a three-pixel speck never becomes a path. Raise it to ignore larger specks, lower it to keep fine detail.

A practical limitation worth being honest about: this is a single-pass, visited-based walk, not a topologically complete region decomposition. It captures outer boundaries of regions, which is exactly what flat logos need, but it is not trying to perfectly model nested holes the way a heavyweight library like Potrace does. For the target use case (logos, icons, line art) that tradeoff is mostly invisible; for pathological inputs it is part of why photos look wrong.

Step 3: smooth and simplify

A raw contour has one point per boundary pixel. A straight diagonal edge becomes a staircase of dozens of points. Emitting that verbatim produces a huge, jagged path. Two cleanup passes fix it, and the order matters: smoothing runs first, then simplification.

Smooth nudges each point toward the midpoint of its two neighbors, repeated for a configurable number of passes (default 2):

result.push([
  curr[0] * (1 - t) + (prev[0] + next[0]) * 0.5 * t,
  curr[1] * (1 - t) + (prev[1] + next[1]) * 0.5 * t,
]);

This is a small Laplacian smoothing step. More passes round off the pixel staircase further, at the cost of softening genuine sharp corners. It is a taste control, not a correctness one.

Simplify then uses the Ramer-Douglas-Peucker algorithm. RDP keeps the two endpoints of a contour, finds the point farthest from the straight line between them, and recurses only if that distance exceeds a tolerance:

if (maxDist > tolerance) {
  const left = simplifyContour(points.slice(0, maxIdx + 1), tolerance);
  const right = simplifyContour(points.slice(maxIdx), tolerance);
  return [...left.slice(0, -1), ...right];
}
return [first, last];

With a tolerance of 1.0, a near-straight run of staircase points collapses to its 2 endpoints, while a genuine corner is preserved because the corner point sits far from the chord. This is where most of the point-count reduction (and the file-size win) comes from.

Step 4: the output is paths, not pixels

The final stage turns each point list into an SVG path string. Rather than connecting points with straight lines, the tracer fits quadratic bezier curves through the interior of the contour: each contour point becomes a control point, and the midpoint to the next point becomes that segment's endpoint.

const midX = (cp[0] + next[0]) / 2;
const midY = (cp[1] + next[1]) / 2;
d += ` Q ${cp[0].toFixed(1)} ${cp[1].toFixed(1)} ${midX.toFixed(1)} ${midY.toFixed(1)}`;

This midpoint-and-control-point trick produces a continuous, smooth curve through the simplified points without solving for true spline tangents. One detail to be exact about: only the interior segments are Q curves. The path opens with an M move, runs a chain of Q segments, and then the final segment is emitted as a straight line (L) to the last point before the path closes with Z. Coordinates are written to one decimal place (toFixed(1)), which keeps the file small while staying well below pixel-visible precision.

A traced black logo comes out looking like this:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 240" width="320" height="240">
  <path d="M 12.0 8.0 Q 40.0 8.0 54.0 22.0 L 54.0 60.0 Z" fill="#000000" fill-rule="evenodd"/>
  <path d="M 80.0 30.0 Q 96.0 30.0 110.0 44.0 L 110.0 70.0 Z" fill="#000000" fill-rule="evenodd"/>
</svg>

Filled mode uses fill-rule="evenodd", which is what makes holes work: a path drawn inside another path (think the hole in the letter O) cancels the fill where they overlap. Outline mode instead emits fill="none" with a stroke, useful for turning a shape into a single-line drawing. Inverted black-and-white mode adds a full-canvas black rect behind the white paths so the white actually shows.

The payoff of all this is in the word paths. The output is resolution-independent. Scale that 320px logo to a billboard and the curves re-render crisp, because the browser is re-evaluating bezier math, not stretching pixels.

Color tracing: one binary mask per color

Everything above is single-channel. Multi-color tracing reuses the exact same machinery, just N times. First, median-cut quantization reduces the image to a small palette (default 6 colors). Median cut repeatedly splits the set of pixel colors along whichever color channel has the widest spread, until it has the requested number of buckets, then averages each bucket to a representative color:

// pick the channel with the greatest range, sort by it, split at the median
colors.sort((a, b) => a[maxChannel] - b[maxChannel]);
const mid = Math.floor(colors.length / 2);

Then, for each palette color, the tracer builds a binary mask of "pixels that quantized to exactly this color" and runs the full contour-follow, smooth, simplify, curve-fit pipeline on it. Each color becomes its own set of filled paths in the output SVG.

This is also why color tracing degrades gracefully on logos and badly on photos. A six-color logo maps cleanly onto six masks. A photo of a sunset, forced into six colors, becomes six posterized bands with ragged boundaries, and tracing those boundaries gives you a jagged, oversized SVG that looks like a screen-printing error. The quantization step is doing exactly what it should; the input just is not made of flat colors.

When to reach for it

Use it forSkip it for
Logos and brand marksPhotographs
Icons and pictogramsAnything with gradients
Line art and signaturesDetailed illustrations with soft shading
Recovering a vector from a low-res rasterTextures and noise
Single-color silhouettes for cutting/engravingScreenshots full of antialiased text

If you remember one thing: tracing answers "what flat shapes approximate this image," and it is only the right question when the image is already flat shapes.

Related tools on loopaloo

If you want to try the pipeline described here, the Image to SVG Tracer is the tool these examples come from; upload a logo and watch the threshold, blur, smoothing, and color-layer sliders change the path output live. Once you have an SVG, the SVG Optimizer can strip metadata and redundant precision to shrink the file further, and if you ever need to go the other direction (render a vector back down to a fixed-size PNG for a thumbnail or social card) the SVG to Raster tool handles that. All three run entirely in your browser, so nothing you upload leaves your machine.

Related Tools

Related Articles

Try Our Free Tools

200+ browser-based tools for developers and creators. No uploads, complete privacy.

Explore All Tools