You can edit the below JavaScript code to customize the image tool.
Apply Changes
async function processImage(originalImg, numColors = 32, detailLevel = 8, outlineThickness = 2, saturation = 1.2) {
// === HELPER FUNCTIONS SCOPED WITHIN THE MAIN FUNCTION ===
/**
* Converts an RGB color value to HSL.
* @param {number} r - The red color value (0-255)
* @param {number} g - The green color value (0-255)
* @param {number} b - The blue color value (0-255)
* @returns {Array<number>} - The HSL representation [h, s, l]
*/
const rgbToHsl = (r, g, b) => {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0; // achromatic
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [h, s, l];
};
/**
* Converts an HSL color value to RGB.
* @param {number} h - The hue
* @param {number} s - The saturation
* @param {number} l - The lightness
* @returns {Array<number>} - The RGB representation [r, g, b]
*/
const hslToRgb = (h, s, l) => {
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
};
/**
* K-Means clustering algorithm for color quantization.
* @param {Array<Array<number>>} data - Array of [r,g,b] pixels
* @param {number} k - The number of clusters (colors)
* @returns {{palette: Array<Array<number>>, assignments: Array<number>}}
*/
const kMeans = (data, k) => {
// 1. Initialize centroids by picking k random points
const centroids = [];
const usedIndices = new Set();
while (centroids.length < k && centroids.length < data.length) {
const index = Math.floor(Math.random() * data.length);
if (!usedIndices.has(index)) {
centroids.push([...data[index]]);
usedIndices.add(index);
}
}
const assignments = new Array(data.length);
const distance = (a, b) => (a[0] - b[0])**2 + (a[1] - b[1])**2 + (a[2] - b[2])**2;
for (let iter = 0; iter < 10; iter++) { // Iterate 10 times
// 2. Assign each point to the closest centroid
for (let i = 0; i < data.length; i++) {
let bestDist = Infinity;
let bestIndex = 0;
for (let j = 0; j < centroids.length; j++) {
const d = distance(data[i], centroids[j]);
if (d < bestDist) {
bestDist = d;
bestIndex = j;
}
}
assignments[i] = bestIndex;
}
// 3. Recalculate centroids
const sums = Array.from({ length: k }, () => [0, 0, 0]);
const counts = new Array(k).fill(0);
for (let i = 0; i < data.length; i++) {
const clusterIndex = assignments[i];
sums[clusterIndex][0] += data[i][0];
sums[clusterIndex][1] += data[i][1];
sums[clusterIndex][2] += data[i][2];
counts[clusterIndex]++;
}
for (let j = 0; j < k; j++) {
if (counts[j] > 0) {
centroids[j] = [
Math.round(sums[j][0] / counts[j]),
Math.round(sums[j][1] / counts[j]),
Math.round(sums[j][2] / counts[j]),
];
}
}
}
return { palette: centroids, assignments };
};
// === MAIN LOGIC START ===
// 1. SETUP CANVAS
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const width = originalImg.width;
const height = originalImg.height;
canvas.width = width;
canvas.height = height;
// Create a working canvas for intermediate steps
const workCanvas = document.createElement('canvas');
workCanvas.width = width;
workCanvas.height = height;
const workCtx = workCanvas.getContext('2d');
workCtx.drawImage(originalImg, 0, 0);
// 2. APPLY SATURATION
if (saturation !== 1.0) {
const imageData = workCtx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const [h, s, l] = rgbToHsl(data[i], data[i+1], data[i+2]);
const newS = Math.max(0, Math.min(1, s * saturation));
const [r, g, b] = hslToRgb(h, newS, l);
data[i] = r; data[i+1] = g; data[i+2] = b;
}
workCtx.putImageData(imageData, 0, 0);
}
// 3. SIMPLIFY AND QUANTIZE
const simplificationFactor = Math.max(2, 12 - Math.max(1, Math.min(10, detailLevel)));
const smallWidth = Math.round(width / simplificationFactor);
const smallHeight = Math.round(height / simplificationFactor);
const smallCanvas = document.createElement('canvas');
smallCanvas.width = smallWidth;
smallCanvas.height = smallHeight;
const smallCtx = smallCanvas.getContext('2d');
smallCtx.drawImage(workCanvas, 0, 0, smallWidth, smallHeight);
const smallImageData = smallCtx.getImageData(0, 0, smallWidth, smallHeight);
const pixels = [];
for (let i = 0; i < smallImageData.data.length; i += 4) {
pixels.push([smallImageData.data[i], smallImageData.data[i+1], smallImageData.data[i+2]]);
}
const { palette, assignments } = kMeans(pixels, Math.max(2, numColors));
const quantizedData = smallCtx.createImageData(smallWidth, smallHeight);
for (let i = 0; i < assignments.length; i++) {
const color = palette[assignments[i]];
quantizedData.data[i * 4 + 0] = color[0];
quantizedData.data[i * 4 + 1] = color[1];
quantizedData.data[i * 4 + 2] = color[2];
quantizedData.data[i * 4 + 3] = 255;
}
smallCtx.putImageData(quantizedData, 0, 0);
// 4. RENDER FINAL IMAGE
// Draw enlarged, pixelated (posterized) colors
ctx.imageSmoothingEnabled = false;
ctx.drawImage(smallCanvas, 0, 0, width, height);
// Apply a slight blur to simulate marker color bleeding
if (detailLevel < 10) { // Don't blur on max detail
ctx.filter = `blur(0.75px)`;
ctx.drawImage(canvas, 0, 0);
ctx.filter = 'none';
}
// Add marker paper/texture
const textureCanvas = document.createElement('canvas');
textureCanvas.width = width;
textureCanvas.height = height;
const textureCtx = textureCanvas.getContext('2d');
const textureData = textureCtx.createImageData(width, height);
for (let i = 0; i < textureData.data.length; i += 4) {
const val = Math.random() * 40 + 215; // Light gray noise
textureData.data[i] = val;
textureData.data[i+1] = val;
textureData.data[i+2] = val;
textureData.data[i+3] = 255;
}
textureCtx.putImageData(textureData, 0, 0);
ctx.globalCompositeOperation = 'multiply';
ctx.globalAlpha = 0.4;
ctx.drawImage(textureCanvas, 0, 0);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1.0;
// Add outlines if enabled
if (outlineThickness > 0) {
const posterizedData = ctx.getImageData(0, 0, width, height).data;
const outlineMaskCanvas = document.createElement('canvas');
outlineMaskCanvas.width = width;
outlineMaskCanvas.height = height;
const outlineCtx = outlineMaskCanvas.getContext('2d');
const outlineData = outlineCtx.createImageData(width, height);
const threshold = 20;
for (let y = 0; y < height - 1; y++) {
for (let x = 0; x < width - 1; x++) {
const i = (y * width + x) * 4;
const i_right = (y * width + (x + 1)) * 4;
const i_down = ((y + 1) * width + x) * 4;
const isEdge =
Math.abs(posterizedData[i] - posterizedData[i_right]) > threshold ||
Math.abs(posterizedData[i+1] - posterizedData[i_right+1]) > threshold ||
Math.abs(posterizedData[i+2] - posterizedData[i_right+2]) > threshold ||
Math.abs(posterizedData[i] - posterizedData[i_down]) > threshold ||
Math.abs(posterizedData[i+1] - posterizedData[i_down+1]) > threshold ||
Math.abs(posterizedData[i+2] - posterizedData[i_down+2]) > threshold;
if (isEdge) {
outlineData.data[i] = 0;
outlineData.data[i+1] = 0;
outlineData.data[i+2] = 0;
outlineData.data[i+3] = 255;
}
}
}
outlineCtx.putImageData(outlineData, 0, 0);
// Use a shadow trick to "thicken" the 1px outline
ctx.save();
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
ctx.shadowBlur = outlineThickness;
ctx.shadowOffsetX = width * 2; // Move shadow far away
ctx.shadowOffsetY = 0;
// Draw the mask off-screen to only render its shadow
ctx.drawImage(outlineMaskCanvas, -width * 2, 0);
ctx.drawImage(outlineMaskCanvas, -width * 2, 0); // Draw twice for solidity
ctx.restore();
}
// 5. RETURN THE FINAL CANVAS
return canvas;
}
Apply Changes