You can edit the below JavaScript code to customize the image tool.
Apply Changes
/**
* Processes an image to create a "paint by alphabet" style canvas.
* It quantizes the image colors to a limited palette, creates outlines between the
* resulting color regions, and places letters from a specified alphabet in each region.
* Each letter corresponds to a color in the generated palette. A legend showing the
* color-to-letter mapping is drawn at the bottom of the canvas.
*
* @param {Image} originalImg The original image object to process.
* @param {number} [numColors=16] The target number of colors for the final palette. The actual number may be lower if the image has fewer colors.
* @param {string} [alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZ'] The string of characters to use for labeling color regions.
* @param {number} [fontSize=10] The font size in pixels for the letter labels on the canvas.
* @param {number} [showOutlines=1] A flag to control drawing outlines. Use 1 to show outlines, 0 to hide them.
* @param {string} [outlineColor='black'] The CSS color string for the outlines and text labels.
* @param {number} [outlineWidth=1] The width in pixels of the outlines between color regions.
* @returns {HTMLCanvasElement} A new canvas element containing the "paint by alphabet" representation of the image.
*/
function processImage(originalImg, numColors = 16, alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', fontSize = 10, showOutlines = 1, outlineColor = 'black', outlineWidth = 1) {
// Helper function to calculate squared Euclidean distance between two RGB colors
const colorDistanceSq = (c1, c2) => {
const dr = c1[0] - c2[0];
const dg = c1[1] - c2[1];
const db = c1[2] - c2[2];
return dr * dr + dg * dg + db * db;
};
// Helper to find the index of the closest centroid for a given color
const findClosestCentroid = (color, centroids) => {
let minDistanceSq = Infinity;
let bestIndex = 0;
for (let i = 0; i < centroids.length; i++) {
const distanceSq = colorDistanceSq(color, centroids[i]);
if (distanceSq < minDistanceSq) {
minDistanceSq = distanceSq;
bestIndex = i;
}
}
return bestIndex;
};
// --- 1. Canvas Setup and Pixel Data Extraction ---
const width = originalImg.naturalWidth;
const height = originalImg.naturalHeight;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = width;
tempCanvas.height = height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCtx.drawImage(originalImg, 0, 0, width, height);
let imageData;
try {
imageData = tempCtx.getImageData(0, 0, width, height);
} catch (e) {
// Handle security errors if the image is cross-origin
const errorCanvas = document.createElement('canvas');
errorCanvas.width = width || 300;
errorCanvas.height = height || 150;
const errorCtx = errorCanvas.getContext('2d');
errorCtx.fillStyle = '#f0f0f0';
errorCtx.fillRect(0, 0, errorCanvas.width, errorCanvas.height);
errorCtx.fillStyle = 'red';
errorCtx.textAlign = 'center';
errorCtx.textBaseline = 'middle';
errorCtx.font = '14px sans-serif';
errorCtx.fillText('Could not process image due to cross-origin restrictions.', errorCanvas.width / 2, errorCanvas.height / 2);
return errorCanvas;
}
const pixels = imageData.data;
// Create a representative sample of colors from the image
const colorSamples = [];
for (let i = 0; i < pixels.length; i += 4) {
// Sample only non-transparent pixels
if (pixels[i + 3] > 128) {
colorSamples.push([pixels[i], pixels[i + 1], pixels[i + 2]]);
}
}
if (colorSamples.length === 0) {
const emptyCanvas = document.createElement('canvas');
emptyCanvas.width = width || 100;
emptyCanvas.height = height || 100;
const emptyCtx = emptyCanvas.getContext('2d');
emptyCtx.fillStyle = 'white';
emptyCtx.fillRect(0, 0, emptyCanvas.width, emptyCanvas.height);
emptyCtx.fillStyle = 'black';
emptyCtx.textAlign = 'center';
emptyCtx.textBaseline = 'middle';
emptyCtx.fillText('Image is empty or transparent.', emptyCanvas.width / 2, emptyCanvas.height / 2);
return emptyCanvas;
}
// --- 2. Color Quantization using K-Means Clustering ---
const k = Math.min(numColors, [...new Set(colorSamples.map(c => c.join(',')))].length);
if (k === 0) return processImage(new Image()); // Recurse with empty image to get the error message
// Initialize centroids by picking unique random colors from the samples
let centroids = [];
const usedColors = new Set();
while (centroids.length < k && usedColors.size < colorSamples.length) {
const index = Math.floor(Math.random() * colorSamples.length);
const colorKey = colorSamples[index].join(',');
if (!usedColors.has(colorKey)) {
centroids.push(colorSamples[index]);
usedColors.add(colorKey);
}
}
const maxIterations = 20;
for (let iter = 0; iter < maxIterations; iter++) {
const clusters = Array.from({ length: k }, () => []);
for (const color of colorSamples) {
const clusterIndex = findClosestCentroid(color, centroids);
clusters[clusterIndex].push(color);
}
let hasChanged = false;
const newCentroids = [];
for (let i = 0; i < k; i++) {
if (clusters[i].length === 0) {
newCentroids.push(centroids[i]);
continue;
}
const sum = clusters[i].reduce((acc, c) => [acc[0] + c[0], acc[1] + c[1], acc[2] + c[2]], [0, 0, 0]);
const avgColor = [
Math.round(sum[0] / clusters[i].length),
Math.round(sum[1] / clusters[i].length),
Math.round(sum[2] / clusters[i].length)
];
newCentroids.push(avgColor);
if (!centroids[i] || colorDistanceSq(avgColor, centroids[i]) > 0) {
hasChanged = true;
}
}
centroids = newCentroids;
if (!hasChanged) break;
}
const palette = centroids;
// --- 3. Create a map of the image where each pixel maps to a palette index ---
const indexMap = new Int16Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
if (pixels[i + 3] < 128) {
indexMap[y * width + x] = -1; // Special value for transparent pixels
} else {
const color = [pixels[i], pixels[i + 1], pixels[i + 2]];
indexMap[y * width + x] = findClosestCentroid(color, palette);
}
}
}
// --- 4. Prepare the final output Canvas ---
const legendItemSize = 20;
const legendPadding = 10;
const legendItemWidth = 70;
const itemsPerRow = Math.max(1, Math.floor((width - 2 * legendPadding) / legendItemWidth));
const numRows = Math.ceil(palette.length / itemsPerRow);
const legendHeight = numRows > 0 ? (numRows * (legendItemSize + legendPadding)) + legendPadding : 0;
const outputCanvas = document.createElement('canvas');
outputCanvas.width = width;
outputCanvas.height = height + legendHeight;
const ctx = outputCanvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, outputCanvas.width, outputCanvas.height);
// --- 5. Draw Outlines between different color regions ---
if (showOutlines == 1 && parseFloat(outlineWidth) > 0) {
ctx.beginPath();
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const currentIndex = indexMap[y * width + x];
if (currentIndex === -1) continue;
if (x < width - 1) {
const rightIndex = indexMap[y * width + x + 1];
if (currentIndex !== rightIndex && rightIndex !== -1) {
ctx.moveTo(x + 1, y);
ctx.lineTo(x + 1, y + 1);
}
}
if (y < height - 1) {
const downIndex = indexMap[(y+1) * width + x];
if (currentIndex !== downIndex && downIndex !== -1) {
ctx.moveTo(x, y + 1);
ctx.lineTo(x + 1, y + 1);
}
}
}
}
ctx.strokeStyle = outlineColor;
ctx.lineWidth = parseFloat(outlineWidth);
ctx.stroke();
}
// --- 6. Find contiguous color regions and draw letter labels ---
const visited = new Uint8Array(width * height);
ctx.font = `${fontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = outlineColor;
const minRegionSize = Math.pow(fontSize * 1.5, 2);
for(let y = 0; y < height; y++) {
for(let x = 0; x < width; x++) {
const pos = y * width + x;
if(visited[pos]) continue;
const regionColorIndex = indexMap[pos];
if(regionColorIndex === -1) continue;
const stack = [[x, y]];
visited[pos] = 1;
let sumX = 0, sumY = 0, count = 0;
while(stack.length > 0) {
const [cx, cy] = stack.pop();
sumX += cx;
sumY += cy;
count++;
const neighbors = [[cx,cy-1], [cx,cy+1], [cx-1, cy], [cx+1, cy]];
for(const [nx, ny] of neighbors) {
if(nx >= 0 && nx < width && ny >= 0 && ny < height) {
const neighborPos = ny * width + nx;
if(!visited[neighborPos] && indexMap[neighborPos] === regionColorIndex) {
visited[neighborPos] = 1;
stack.push([nx, ny]);
}
}
}
}
if (count > minRegionSize) {
const centerX = sumX / count;
const centerY = sumY / count;
const char = alphabet[regionColorIndex] || '?';
ctx.fillText(char, centerX, centerY);
}
}
}
// --- 7. Draw the color palette legend at the bottom ---
ctx.font = `${legendItemSize * 0.7}px sans-serif`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let i = 0; i < palette.length; i++) {
const color = palette[i];
if(!color) continue;
const char = alphabet[i] || '?';
const row = Math.floor(i / itemsPerRow);
const col = i % itemsPerRow;
const lx = legendPadding + col * legendItemWidth;
const ly = height + legendPadding + row * (legendItemSize + legendPadding);
ctx.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
ctx.fillRect(lx, ly, legendItemSize, legendItemSize);
ctx.strokeStyle = '#999';
ctx.lineWidth = 1;
ctx.strokeRect(lx - 0.5, ly - 0.5, legendItemSize + 1, legendItemSize + 1);
ctx.fillStyle = outlineColor;
ctx.fillText(`: ${char}`, lx + legendItemSize + 5, ly + legendItemSize / 2);
}
return outputCanvas;
}
Apply Changes