From Bitmap to Vector: Implementing K-Means Color Quantization and Contour Tracing
From Bitmap to Vector: Implementing K-Means Color Quantization and Contour Tracing#
I needed to convert some PNG icons to SVG for a project. Tried a few online tools—either poor quality or paywalled. So I built my own and dove into the core algorithms of image vectorization.
Bitmap vs Vector: The Fundamental Difference#
Bitmaps (PNG/JPG) store pixel color values. Scale them up, they blur. Vectors (SVG) store mathematical paths. Scale infinitely, stay sharp.
Vectorization in a nutshell:Extract color regions from pixel matrix, convert to geometric paths.
Three steps:
- Color Quantization - Reduce color count
- Region Labeling - Mark connected same-color regions
- Contour Tracing - Extract boundary paths
K-Means Color Quantization#
A photo might have tens of thousands of colors. Direct processing creates fragmented regions. K-Means reduces colors to a specified number (e.g., 8) while preserving visual quality.
The Algorithm#
K-Means minimizes squared color distance:
argmin Σ ||pixel - center(pixel)||²
I used K-Means++ initialization to avoid local optima:
function kMeans(pixels: [number,number,number][], k: number, maxIter: number): [number,number,number][] {
if (pixels.length <= k) return pixels.map(p => [...p] as [number,number,number]);
// K-Means++: First center random, subsequent centers pick farthest points
const centers: [number,number,number][] = [pixels[Math.floor(Math.random() * pixels.length)]];
for (let i = 1; i < k; i++) {
let maxD = -1, best: [number,number,number] = pixels[0];
for (const p of pixels) {
// Distance to nearest center
let minD = Infinity;
for (const c of centers) {
const d = (p[0]-c[0])**2 + (p[1]-c[1])**2 + (p[2]-c[2])**2;
if (d < minD) minD = d;
}
// Pick farthest point as new center
if (minD > maxD) { maxD = minD; best = p; }
}
centers.push(best);
}
// Iterative updates
const assign = new Uint8Array(pixels.length);
for (let it = 0; it < maxIter; it++) {
let changed = false;
// Assign pixels to nearest center
for (let i = 0; i < pixels.length; i++) {
let md = Infinity, b = 0;
const p = pixels[i];
for (let j = 0; j < k; j++) {
const d = (p[0]-centers[j][0])**2 + (p[1]-centers[j][1])**2 + (p[2]-centers[j][2])**2;
if (d < md) { md = d; b = j; }
}
if (assign[i] !== b) { changed = true; assign[i] = b; }
}
if (!changed) break;
// Recalculate centers
const sums: [number,number,number,number][] = Array.from({length: k}, () => [0,0,0,0]);
for (let i = 0; i < pixels.length; i++) {
const c = assign[i];
sums[c][0] += pixels[i][0];
sums[c][1] += pixels[i][1];
sums[c][2] += pixels[i][2];
sums[c][3]++;
}
for (let j = 0; j < k; j++) {
if (sums[j][3] > 0) {
centers[j] = [
Math.round(sums[j][0] / sums[j][3]),
Math.round(sums[j][1] / sums[j][3]),
Math.round(sums[j][2] / sums[j][3])
];
}
}
}
return centers;
}
Key points:
- Distance metric uses squared Euclidean distance (skip the sqrt)
- K-Means++ initialization spreads centers more evenly
- Early termination if no pixels change assignment
Color Assignment#
After getting the palette, assign each pixel to the nearest color:
const indexed = new Uint8Array(n);
for (let i = 0; i < n; i++) {
const idx = i * 4;
if (d[idx+3] <= 128) { indexed[i] = 255; continue; } // Transparent pixels
const r = d[idx], g = d[idx+1], b = d[idx+2];
let md = Infinity, best = 0;
for (let j = 0; j < palette.length; j++) {
const dr = r - palette[j][0];
const dg = g - palette[j][1];
const db = b - palette[j][2];
const dist = dr*dr + dg*dg + db*db;
if (dist < md) { md = dist; best = j; }
}
indexed[i] = best;
}
Moore-Neighbor Contour Tracing#
After quantization, the image becomes several color regions. Now extract boundary paths for each region.
The Algorithm#
Moore-Neighbor is a classic boundary tracing algorithm. Core idea:“Walk around” the boundary pixels.
Start from a boundary pixel, search 8 neighbors clockwise, find the next boundary point, repeat until back to start.
function traceContours(indexed: Uint8Array, w: number, h: number, ci: number): Pt[][] {
// Build binary image: current color=1, others=0
const grid = new Uint8Array(w * h);
for (let i = 0; i < w * h; i++) grid[i] = indexed[i] === ci ? 1 : 0;
const visited = new Uint8Array(w * h);
const contours: Pt[][] = [];
// 8 directions: right, bottom-right, down, bottom-left, left, top-left, up, top-right
const dirs = [[1,0],[1,-1],[0,-1],[-1,-1],[-1,0],[-1,1],[0,1],[1,1]];
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x;
if (grid[idx] !== 1 || visited[idx]) continue;
// Check if boundary pixel (at least one neighbor is different color)
const nb = [
x > 0 ? grid[y*w+x-1] : 0,
x < w-1 ? grid[y*w+x+1] : 0,
y > 0 ? grid[(y-1)*w+x] : 0,
y < h-1 ? grid[(y+1)*w+x] : 0
];
if (nb.every(n => n === 1)) continue; // Interior point, skip
// Moore-Neighbor tracing
const contour: Pt[] = [{x, y}];
visited[idx] = 1;
let cx = x, cy = y, dir = 0;
for (let step = 0; step < w * h * 4; step++) {
let found = false;
// Search counterclockwise from current direction
for (let i = 0; i < 8; i++) {
const d = (dir + 7 - i) % 8;
const nx = cx + dirs[d][0], ny = cy + dirs[d][1];
if (nx >= 0 && nx < w && ny >= 0 && ny < h && grid[ny*w+nx] === 1) {
// Back to start with path length > 2, done
if (nx === x && ny === y && contour.length > 2) { found = true; break; }
cx = nx; cy = ny; dir = d;
contour.push({x: nx, y: ny});
visited[ny*w+nx] = 1;
found = true;
break;
}
}
if (!found) break;
if (cx === x && cy === y) break; // Loop closed
}
if (contour.length >= 4) contours.push(simplifyContour(contour, 1.5));
}
}
return contours;
}
Key details:
- Starting direction searches counterclockwise from current direction to follow outer boundary
- Loop detection stops when coordinates return to start
- Boundary check only pixels with at least one different-colored neighbor are boundary points
Douglas-Peucker Path Simplification#
Contour tracing produces too many points. Direct SVG conversion creates huge files. Douglas-Peucker reduces points while preserving shape.
function simplifyContour(pts: Pt[], eps: number): Pt[] {
if (pts.length <= 2) return pts;
// Find point farthest from line connecting first and last
let maxD = 0, maxI = 0;
const f = pts[0], l = pts[pts.length - 1];
for (let i = 1; i < pts.length - 1; i++) {
const d = perpDist(pts[i], f, l);
if (d > maxD) { maxD = d; maxI = i; }
}
// If max distance exceeds threshold, recursively simplify
if (maxD > eps) {
const left = simplifyContour(pts.slice(0, maxI + 1), eps);
const right = simplifyContour(pts.slice(maxI), eps);
return [...left.slice(0, -1), ...right];
}
// Otherwise replace with straight line
return [f, l];
}
function perpDist(p: Pt, a: Pt, b: Pt): number {
const dx = b.x - a.x, dy = b.y - a.y;
if (dx === 0 && dy === 0) return Math.sqrt((p.x-a.x)**2 + (p.y-a.y)**2);
return Math.abs(dy*p.x - dx*p.y + b.x*a.y - b.y*a.x) / Math.sqrt(dx*dx + dy*dy);
}
Generating SVG#
Finally, convert paths to SVG <path> elements:
const svgParts: string[] = [];
for (let ci = 0; ci < palette.length; ci++) {
const contours = traceContours(indexed, sw, sh, ci);
if (contours.length === 0) continue;
const hex = toHex(palette[ci]);
for (const contour of contours) {
if (contour.length < 3) continue;
// Build path's d attribute
let d = `M${contour[0].x},${contour[0].y}`;
for (let i = 1; i < contour.length; i++) {
d += `L${contour[i].x},${contour[i].y}`;
}
d += 'Z'; // Close path
svgParts.push(`<path d="${d}" fill="${hex}" stroke="${hex}" stroke-width="1"/>`);
}
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${img.width}" height="${img.height}" viewBox="0 0 ${sw} ${sh}">
<g transform="scale(${scaleX.toFixed(4)},${scaleY.toFixed(4)})">
<rect width="${sw}" height="${sh}" fill="#fff"/>
${svgParts.join('\n')}
</g>
</svg>`;
Performance Optimizations#
For large images:
1. Downscale Processing#
const maxDim = 400;
let sw = img.width, sh = img.height;
const r = maxDim / Math.max(sw, sh);
if (r < 1) { sw = Math.floor(sw * r); sh = Math.floor(sh * r); }
Process at max 400px, then scale back to original size.
2. requestAnimationFrame to Avoid Blocking#
setProcessing(true);
requestAnimationFrame(() => {
// Heavy computation
setProcessing(false);
});
3. Early Termination#
K-Means stops early if no pixels change assignment.
The Result#
Based on these algorithms, I built: Image to Vector
Features:
- K-Means color quantization (2-32 colors)
- Moore-Neighbor contour tracing
- Douglas-Peucker path simplification
- Paste to upload
Works well for icons, logos, simple illustrations. For complex photos, consider professional tools like Illustrator’s Image Trace.
Related: SVG Optimizer | Image Compress