§ NOTAS TÉCNICAS aGo · 2026-05-24
Vectorizar PNG en el browser con potrace.js
aGo Canvas necesita convertir un PNG en un SVG editable sin enviar el archivo a un servidor. Llegamos a potrace.js después de descartar dos alternativas. Esta nota cuenta el por qué, el código real que usamos y las cosas que el algoritmo simplemente no resuelve.
El contexto
Canvas es la pieza de tools.ago.cl donde el usuario monta composiciones con assets propios. Un caso recurrente: el cliente arrastra el logo de su marca en PNG y quiere que se renderice nítido a cualquier escala. PNG no escala. La conversión a SVG resuelve el problema y permite recolorear, recortar y combinar con otros vectores en la misma capa.
La restricción dura es la misma de siempre en tools.ago.cl: cero servidor. El archivo no sale del navegador. Eso descarta servicios de vectorización en la nube (Vector Magic, autotrace remoto). También descarta correr binarios nativos de potrace o autotrace en el backend, porque no hay backend.
El target de calidad es claro: logos simples, iconos, ilustraciones planas. No prometemos vectorizar fotos. Vectorizar una foto produce un SVG enorme y feo y eso lo sabíamos desde antes de empezar. El producto pone el cartel: "diseñado para logos e iconos".
Y el último constraint, performance: el usuario espera que un PNG de 1024x1024 se vectorice en menos de un segundo en un laptop promedio. Más que eso y el flujo creativo se rompe.
Las opciones que evaluamos
1. potrace.js (port JS del original de Peter Selinger)
Potrace es el estándar de facto para vectorización de siluetas binarias. El algoritmo de Selinger es de 2001 y se usa todavía porque hace una cosa muy bien: convertir un bitmap binario en curvas de Bézier suaves con muy poco ruido. El port a JavaScript pesa cerca de 30 KB gzip, API simple, sin dependencias.
Contras reales: solo trabaja con un canal binario por pasada. Para una imagen multi-color hay que descomponer en bandas de color, vectorizar cada banda, y componer el SVG final. Eso lo resolvemos nosotros, no viene gratis.
2. imagetracer.js
Soporta multi-color nativamente con cuantización K-means interna. Tentador. Lo probamos sobre una batería de 40 logos.
Resultado honesto: para logos planos con 3-5 colores funciona razonable, pero los bordes salen más ruidosos que con potrace y el archivo SVG resultante pesa el doble. Para iconos con detalle fino se ve sucio. Y la API requiere más tunning para llegar a calidad pareja.
3. autotrace compilado a WASM
autotrace produce vectorizaciones muy elegantes y soporta multi-color nativamente. El problema es de empaquetado: el port WASM existente pesa más de 800 KB, y mantenerlo es operacionalmente caro. Para una tool dentro de Canvas (que ya carga su propio runtime de edición) eso es demasiado. Lo dejamos como opción para un futuro modo "alta calidad opcional".
La decisión y por qué
Elegimos potrace.js con descomposición por bandas de color cuando aplica. Razones, en orden de peso.
Primera: calidad de borde. En la batería de 40 logos, potrace produjo bordes consistentemente más limpios que imagetracer. SVG resultante con menos comandos de path, lo que importa porque ese SVG después se manipula en el editor (resize, recolor) y la latencia de manipulación depende del número de nodos.
Segunda: peso del bundle. 30 KB gzip es perfectamente absorbible. Canvas ya tiene su propio peso y agregar 30 KB no mueve la aguja. autotrace WASM hubiera sumado casi un mega y para la mayoría de los logos no compensa.
Tercera: la API es de las más simples que probamos. Tres parámetros que
importan de verdad: threshold (corte de binarización),
turdSize (descarte de manchas pequeñas), alphamax
(curvatura máxima en esquinas). Con esos tres el 80 % de los logos
sale bien. El resto se ajusta a mano.
Cuarta y operacional: el código del port lo entendemos. No es magia. Si un día tenemos que parchearlo para Canvas, podemos. Con autotrace WASM no tendríamos esa libertad sin cargar herramientas de C.
Cómo lo implementamos
La pieza completa vive en un worker y devuelve un string SVG. El flujo: decodificar PNG, opcionalmente cuantizar a N colores, vectorizar cada banda con potrace, componer el SVG final.
// src/islands/canvas/worker/vectorizer.ts
// Vectoriza PNG a SVG en el navegador.
// Modo 1 color: potrace directo sobre el alpha o luminance.
// Modo N colores: cuantizacion K-means + N pasadas potrace.
import Potrace from 'potrace.js'; // 30 KB gzip aprox
interface VectorizeOptions {
mode: 'mono' | 'palette';
threshold?: number; // 0..255, default 128
turdSize?: number; // pixels^2 minimos, default 2
alphamax?: number; // 0..1.34, default 1
paletteSize?: number; // solo en mode palette, default 4
optTolerance?: number; // suavizado curva, default 0.2
}
interface VectorizeResult {
svg: string;
width: number;
height: number;
pathCount: number;
msElapsed: number;
}
export async function vectorize(
file: Blob,
opts: VectorizeOptions
): Promise<VectorizeResult> {
const start = performance.now();
const bitmap = await createImageBitmap(file);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
ctx.drawImage(bitmap, 0, 0);
const imgData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
bitmap.close?.();
const paths: string[] = [];
if (opts.mode === 'mono') {
const svgPath = await traceBand(imgData, () => true, opts);
paths.push(`<path d="${svgPath}" fill="#0B1F33"/>`);
} else {
const palette = quantizeKMeans(imgData, opts.paletteSize ?? 4);
for (const color of palette) {
const mask = (i: number) => sameColor(imgData.data, i, color, 24);
const svgPath = await traceBand(imgData, mask, opts);
if (svgPath) {
paths.push(`<path d="${svgPath}" fill="${rgbToHex(color)}"/>`);
}
}
}
const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${bitmap.width} ${bitmap.height}" shape-rendering="geometricPrecision">`,
...paths,
'</svg>'
].join('');
return {
svg,
width: bitmap.width,
height: bitmap.height,
pathCount: paths.length,
msElapsed: performance.now() - start
};
}
function traceBand(
imgData: ImageData,
belongsTo: (pixelIndex: number) => boolean,
opts: VectorizeOptions
): Promise<string> {
// Construimos una imagen binaria temporal: 0 si pertenece a la banda, 255 si no.
const { width, height, data } = imgData;
const bin = new Uint8ClampedArray(width * height * 4);
for (let i = 0; i < width * height; i++) {
const value = belongsTo(i * 4) ? 0 : 255;
bin[i * 4] = bin[i * 4 + 1] = bin[i * 4 + 2] = value;
bin[i * 4 + 3] = 255;
}
const bandData = new ImageData(bin, width, height);
return new Promise((resolve, reject) => {
const tracer = new Potrace({
threshold: opts.threshold ?? 128,
turdSize: opts.turdSize ?? 2,
alphaMax: opts.alphamax ?? 1,
optTolerance: opts.optTolerance ?? 0.2,
turnPolicy: 'minority'
});
tracer.loadImageData(bandData, (err: unknown) => {
if (err) return reject(err);
// getPathTag devuelve solo la d= del path principal compuesto.
resolve(tracer.getPathTag().match(/d="([^"]+)"/)?.[1] ?? '');
});
});
}
// K-means simple (1D euclidean en RGB). Suficiente para 3-6 colores.
// Mas que eso conviene usar median cut o pasar a Lab.
function quantizeKMeans(img: ImageData, k: number): number[][] {
const samples = sampleEvery(img.data, 16, img.width * img.height);
let centroids = pickInitialCentroids(samples, k);
for (let iter = 0; iter < 12; iter++) {
const buckets: number[][][] = Array.from({ length: k }, () => []);
for (const s of samples) {
let best = 0, bestD = Infinity;
for (let i = 0; i < k; i++) {
const d = sqDist(s, centroids[i]);
if (d < bestD) { bestD = d; best = i; }
}
buckets[best].push(s);
}
centroids = buckets.map((bucket, i) =>
bucket.length === 0 ? centroids[i] : averageRGB(bucket)
);
}
return centroids;
}
function sampleEvery(data: Uint8ClampedArray, stride: number, totalPixels: number): number[][] {
const out: number[][] = [];
for (let i = 0; i < totalPixels; i += stride) {
out.push([data[i * 4], data[i * 4 + 1], data[i * 4 + 2]]);
}
return out;
}
function pickInitialCentroids(samples: number[][], k: number): number[][] {
// Estrategia kmeans++ minima: primero random, despues lejano.
const first = samples[Math.floor(samples.length / 2)];
const cs = [first];
while (cs.length < k) {
let best = samples[0], bestD = -1;
for (const s of samples) {
const d = Math.min(...cs.map(c => sqDist(s, c)));
if (d > bestD) { bestD = d; best = s; }
}
cs.push(best);
}
return cs;
}
function sqDist(a: number[], b: number[]): number {
const dr = a[0] - b[0], dg = a[1] - b[1], db = a[2] - b[2];
return dr * dr + dg * dg + db * db;
}
function averageRGB(bucket: number[][]): number[] {
const sum = bucket.reduce((acc, p) => [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]], [0, 0, 0]);
return sum.map(v => Math.round(v / bucket.length));
}
function sameColor(data: Uint8ClampedArray, i: number, target: number[], tol: number): boolean {
return (
Math.abs(data[i] - target[0]) < tol &&
Math.abs(data[i + 1] - target[1]) < tol &&
Math.abs(data[i + 2] - target[2]) < tol
);
}
function rgbToHex([r, g, b]: number[]): string {
return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
}
Tres detalles del código merecen atención. El primero es la binarización por banda: para multi-color no le pedimos a potrace que entienda colores, le pasamos N imágenes binarias y vectorizamos cada una. Cada banda produce su path, todos se apilan en el SVG final con su fill correspondiente. Simple y robusto.
El segundo es el sampleEvery dentro del K-means. Para una
imagen 1024x1024 son un millón de píxeles. Correr K-means sobre el
total triplica el tiempo sin mejorar el resultado. Tomando uno cada 16
píxeles obtenemos centroides equivalentes en cuestión de milisegundos.
El tercero es turnPolicy: 'minority'. Esa opción dice a
potrace cómo desambigua esquinas donde dos píxeles diagonales son del
mismo color. Para logos, "minority" produce esquinas más limpias que
el default "majority". Lo aprendimos comparando 40 logos en paralelo.
Limitaciones que descubrimos
Lo más obvio: fotos no se vectorizan bien con este pipeline. Una foto tiene cientos de colores y bordes continuos. Cuantizada a 4 colores se ve mal. Cuantizada a 16 produce un SVG de 500 KB. No es el caso de uso y el producto lo dice explícitamente en la UI.
Gradientes se pierden. Potrace produce paths con fill plano. Un logo con
gradiente vertical se vectoriza como dos bandas planas. Para preservar
gradientes hay que detectarlos antes y emitir un
linearGradient en el defs del SVG, cosa que hacemos en
Canvas como paso opcional fuera del pipeline base.
Texto rasterizado. Si el PNG entra con texto, potrace vectoriza las letras como siluetas. El resultado pesa más que el PNG original y el texto deja de ser editable. Para esto la recomendación es subir el texto como capa de texto del editor, no como parte del PNG.
Imágenes con anti-aliasing fuerte y borde fino: el threshold mal elegido produce bordes serruchados. Lo resolvimos exponiendo un slider de threshold al usuario, con preview en vivo. Es un parámetro que vale la pena dejarle controlar.
Cuándo NO uses este approach
Si tu input son ilustraciones complejas con gradientes, sombras y texturas, vectorizarlas con potrace va a producir un SVG enorme y peor que el raster original. En ese caso lo correcto es no vectorizar. Optimiza el PNG con compresión inteligente o pasa a WebP.
Si tu input ya son iconos en SVG, obviamente no necesitas vectorizar. Curioso pero ocurre: alguien rasteriza un SVG a PNG y después quiere volver. Mejor recupera el SVG original.
Si el resultado debe coincidir píxel a píxel con el raster en pantalla a un único tamaño, potrace no lo hace y de hecho ningún vectorizador lo hace. Vectorizar produce una curva suave que aproxima, no copia. Si el píxel exacto importa, no vectorices.
Esta nota describe parte de cómo construimos aGo Canvas. Abre Canvas y arrastra un PNG para verlo en vivo.
¿Necesitas algo parecido para tu producto? aGo lab construye software cliente puro para empresas. Conversemos.