You can edit the below JavaScript code to customize the image tool.
Apply Changes
async function processImage(originalImg, numColors = 16, blurRadius = 3, minRegionSize = 50, outputMode = "outline") {
// Parse arguments
const k = parseInt(numColors) || 16;
const blur = parseInt(blurRadius) || 3;
const minSize = parseInt(minRegionSize) || 50;
const outMode = (outputMode === "color") ? "color" : "outline";
// Setup the internal rendering canvas, scale down if too large to ensure performance
const maxDimension = 800;
let width = originalImg.width;
let height = originalImg.height;
if (width > maxDimension || height > maxDimension) {
let scale = maxDimension / Math.max(width, height);
width = Math.floor(width * scale);
height = Math.floor(height * scale);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', {willReadFrequently: true});
// Apply pre-blur to reduce noise
ctx.filter = `blur(${blur}px)`;
ctx.drawImage(originalImg, 0, 0, width, height);
ctx.filter = 'none';
const imgData = ctx.getImageData(0, 0, width, height);
const data = imgData.data;
const totalPixels = width * height;
const yieldThread = () => new Promise(resolve => setTimeout(resolve, 0));
// Initialize Centroids (pick colors evenly spaced functionally)
let centroids = [];
const step = Math.max(4, Math.floor((data.length / 4) / k) * 4);
for (let i = 0; i < k; i++) {
let idx = (i * step) % data.length;
centroids.push([data[idx], data[idx + 1], data[idx + 2]]);
}
// K-Means Clustering
let assignments = new Int32Array(totalPixels);
const maxKMeansIters = 8;
for (let iter = 0; iter < maxKMeansIters; iter++) {
let newCentroids = Array.from({ length: k }, () => [0, 0, 0, 0]); // r, g, b, count
for (let i = 0; i < totalPixels; i++) {
let r = data[i * 4];
let g = data[i * 4 + 1];
let b = data[i * 4 + 2];
let minDist = Infinity;
let bestCluster = 0;
for (let c = 0; c < k; c++) {
let dr = r - centroids[c][0];
let dg = g - centroids[c][1];
let db = b - centroids[c][2];
let distSq = dr * dr + dg * dg + db * db;
if (distSq < minDist) {
minDist = distSq;
bestCluster = c;
}
}
assignments[i] = bestCluster;
newCentroids[bestCluster][0] += r;
newCentroids[bestCluster][1] += g;
newCentroids[bestCluster][2] += b;
newCentroids[bestCluster][3]++;
}
for (let c = 0; c < k; c++) {
if (newCentroids[c][3] > 0) {
centroids[c][0] = Math.round(newCentroids[c][0] / newCentroids[c][3]);
centroids[c][1] = Math.round(newCentroids[c][1] / newCentroids[c][3]);
centroids[c][2] = Math.round(newCentroids[c][2] / newCentroids[c][3]);
}
}
await yieldThread();
}
// Helper: Find connected component regions
function getRegions(assigns) {
let visited = new Uint8Array(totalPixels);
let foundRegions = [];
for (let i = 0; i < totalPixels; i++) {
if (!visited[i]) {
let colorIdx = assigns[i];
let queue = [i];
visited[i] = 1;
let regionPixels = [];
let head = 0;
while (head < queue.length) {
let p = queue[head++];
regionPixels.push(p);
let px = p % width;
let py = Math.floor(p / width);
if (px > 0 && !visited[p - 1] && assigns[p - 1] === colorIdx) { visited[p - 1] = 1; queue.push(p - 1); }
if (px < width - 1 && !visited[p + 1] && assigns[p + 1] === colorIdx) { visited[p + 1] = 1; queue.push(p + 1); }
if (py > 0 && !visited[p - width] && assigns[p - width] === colorIdx) { visited[p - width] = 1; queue.push(p - width); }
if (py < height - 1 && !visited[p + width] && assigns[p + width] === colorIdx) { visited[p + width] = 1; queue.push(p + width); }
}
foundRegions.push({
colorIdx: colorIdx,
pixels: regionPixels
});
}
}
return foundRegions;
}
let regions = getRegions(assignments);
await yieldThread();
// Smoothen: Absorb small regions (noise) into surrounding colors
let changed = false;
for (let r of regions) {
if (r.pixels.length < minSize) {
let newColor = r.colorIdx;
// Find an adjacent color
for (let p of r.pixels) {
let px = p % width;
let py = Math.floor(p / width);
let neighbors = [];
if (px > 0) neighbors.push(p - 1);
if (px < width - 1) neighbors.push(p + 1);
if (py > 0) neighbors.push(p - width);
if (py < height - 1) neighbors.push(p + width);
let found = false;
for (let n of neighbors) {
if (assignments[n] !== r.colorIdx) {
newColor = assignments[n];
found = true;
break;
}
}
if (found) break;
}
for (let p of r.pixels) {
assignments[p] = newColor;
}
changed = true;
}
}
if (changed) {
// Re-calculate robust regions after merging small ones
regions = getRegions(assignments).filter(r => r.pixels.length >= minSize);
}
await yieldThread();
// Calculate centroid for drawing numbers in each valid region
for (let r of regions) {
let sumX = 0, sumY = 0;
for (let p of r.pixels) {
sumX += p % width;
sumY += Math.floor(p / width);
}
let cx = Math.floor(sumX / r.pixels.length);
let cy = Math.floor(sumY / r.pixels.length);
let bestP = r.pixels[0];
let minD = Infinity;
for (let p of r.pixels) {
let px = p % width;
let py = Math.floor(p / width);
let d = (px - cx) * (px - cx) + (py - cy) * (py - cy);
if (d < minD) {
minD = d;
bestP = p;
}
}
r.centerX = bestP % width;
r.centerY = Math.floor(bestP / width);
}
// Build the Outlines & Colors Output
const outCanvas = document.createElement('canvas');
outCanvas.width = width;
outCanvas.height = height;
const outCtx = outCanvas.getContext('2d');
const outImgData = outCtx.createImageData(width, height);
const outData = outImgData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let p = y * width + x;
let idx = p * 4;
let currentRegionColor = assignments[p];
// Boundary detection
let isBorder = false;
if (x < width - 1 && assignments[p + 1] !== currentRegionColor) isBorder = true;
if (y < height - 1 && assignments[p + width] !== currentRegionColor) isBorder = true;
if (isBorder) {
outData[idx] = 0;
outData[idx + 1] = 0;
outData[idx + 2] = 0;
outData[idx + 3] = 255;
} else {
if (outMode === "color") {
let rgb = centroids[currentRegionColor];
outData[idx] = rgb[0];
outData[idx + 1] = rgb[1];
outData[idx + 2] = rgb[2];
outData[idx + 3] = 255;
} else {
outData[idx] = 255;
outData[idx + 1] = 255;
outData[idx + 2] = 255;
outData[idx + 3] = 255;
}
}
}
}
outCtx.putImageData(outImgData, 0, 0);
// Draw the Numbers
outCtx.fillStyle = (outMode === "color") ? 'rgba(0, 0, 0, 0.7)' : 'rgba(0, 0, 0, 0.9)';
outCtx.font = "12px Arial, sans-serif";
outCtx.textAlign = "center";
outCtx.textBaseline = "middle";
for (let r of regions) {
outCtx.fillText((r.colorIdx + 1).toString(), r.centerX, r.centerY);
}
// Build DOM structure (Image + Palette)
const container = document.createElement('div');
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.alignItems = 'center';
container.style.fontFamily = 'Arial, sans-serif';
container.style.width = '100%';
outCanvas.style.maxWidth = '100%';
outCanvas.style.height = 'auto';
outCanvas.style.border = '1px solid #ccc';
outCanvas.style.boxShadow = '0 2px 5px rgba(0,0,0,0.1)';
container.appendChild(outCanvas);
// Palette Section
const paletteDiv = document.createElement('div');
paletteDiv.style.display = 'flex';
paletteDiv.style.flexWrap = 'wrap';
paletteDiv.style.marginTop = '20px';
paletteDiv.style.gap = '15px';
paletteDiv.style.justifyContent = 'center';
paletteDiv.style.maxWidth = `${width}px`;
for (let i = 0; i < k; i++) {
const item = document.createElement('div');
item.style.display = 'flex';
item.style.alignItems = 'center';
item.style.gap = '8px';
item.style.background = '#f9f9f9';
item.style.padding = '5px 10px';
item.style.borderRadius = '5px';
item.style.border = '1px solid #ddd';
const numText = document.createElement('span');
numText.textContent = (i + 1).toString();
numText.style.fontWeight = 'bold';
numText.style.width = '20px';
numText.style.textAlign = 'right';
const swatch = document.createElement('div');
swatch.style.width = '24px';
swatch.style.height = '24px';
swatch.style.borderRadius = '3px';
swatch.style.backgroundColor = `rgb(${centroids[i][0]}, ${centroids[i][1]}, ${centroids[i][2]})`;
swatch.style.border = '1px solid #333';
item.appendChild(numText);
item.appendChild(swatch);
paletteDiv.appendChild(item);
}
container.appendChild(paletteDiv);
return container;
}
Apply Changes