You can edit the below JavaScript code to customize the image tool.
Apply Changes
async function processImage(originalImg, amount = 6, colorIntensity = 1.3, textureIntensity = 0.3) {
// Parse arguments and set defaults
const radius = Math.max(1, parseInt(amount, 10) || 6);
const sat = parseFloat(colorIntensity) || 1.3;
const texStrength = parseFloat(textureIntensity) || 0.3;
// Set practical size limits for performance and memory (1200px max dimension)
const MAX_DIM = 1200;
let W = originalImg.naturalWidth || originalImg.width;
let H = originalImg.naturalHeight || originalImg.height;
if (W > MAX_DIM || H > MAX_DIM) {
const ratio = Math.min(MAX_DIM / W, MAX_DIM / H);
W = Math.round(W * ratio);
H = Math.round(H * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
// Yield control briefly to allow main UI thread to show any loading states
await new Promise(r => setTimeout(r, 0));
// Fill background with white (watercolor paper base)
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, W, H);
// Initial base preparation: enhance vibrancy and lift shadows for a painted look
ctx.filter = `saturate(${sat}) brightness(1.05) contrast(0.95)`;
ctx.drawImage(originalImg, 0, 0, W, H);
ctx.filter = 'none';
// Extract image data for Kuwahara calculation
const imgData = ctx.getImageData(0, 0, W, H);
const src = imgData.data;
const dst = new Uint8ClampedArray(src.length);
// Create Integral Images to make the Kuwahara filter extremely fast (O(1) region lookup)
const I_size = (W + 1) * (H + 1);
const I_R = new Float64Array(I_size);
const I_G = new Float64Array(I_size);
const I_B = new Float64Array(I_size);
const I_R2 = new Float64Array(I_size);
const I_G2 = new Float64Array(I_size);
const I_B2 = new Float64Array(I_size);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const idx = (y * W + x) * 4;
const r = src[idx], g = src[idx + 1], b = src[idx + 2];
const i_idx = (y + 1) * (W + 1) + (x + 1);
const i_left = i_idx - 1;
const i_top = y * (W + 1) + (x + 1);
const i_topleft = i_top - 1;
I_R[i_idx] = r + I_R[i_left] + I_R[i_top] - I_R[i_topleft];
I_G[i_idx] = g + I_G[i_left] + I_G[i_top] - I_G[i_topleft];
I_B[i_idx] = b + I_B[i_left] + I_B[i_top] - I_B[i_topleft];
I_R2[i_idx] = (r * r) + I_R2[i_left] + I_R2[i_top] - I_R2[i_topleft];
I_G2[i_idx] = (g * g) + I_G2[i_left] + I_G2[i_top] - I_G2[i_topleft];
I_B2[i_idx] = (b * b) + I_B2[i_left] + I_B2[i_top] - I_B2[i_topleft];
}
}
// Helper to calculate sums rapidly using integral arrays
const getSums = (x1, y1, x2, y2) => {
x1 = Math.max(0, Math.min(W - 1, x1));
y1 = Math.max(0, Math.min(H - 1, y1));
x2 = Math.max(0, Math.min(W - 1, x2));
y2 = Math.max(0, Math.min(H - 1, y2));
const count = (x2 - x1 + 1) * (y2 - y1 + 1);
const i_br = (y2 + 1) * (W + 1) + (x2 + 1);
const i_tr = y1 * (W + 1) + (x2 + 1);
const i_bl = (y2 + 1) * (W + 1) + x1;
const i_tl = y1 * (W + 1) + x1;
return {
count,
sR: I_R[i_br] - I_R[i_tr] - I_R[i_bl] + I_R[i_tl],
sG: I_G[i_br] - I_G[i_tr] - I_G[i_bl] + I_G[i_tl],
sB: I_B[i_br] - I_B[i_tr] - I_B[i_bl] + I_B[i_tl],
sR2: I_R2[i_br] - I_R2[i_tr] - I_R2[i_bl] + I_R2[i_tl],
sG2: I_G2[i_br] - I_G2[i_tr] - I_G2[i_bl] + I_G2[i_tl],
sB2: I_B2[i_br] - I_B2[i_tr] - I_B2[i_bl] + I_B2[i_tl]
};
};
// Apply fast Kuwahara filter to group/flatten colors (produces painterly blocks)
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const regions = [
[x - radius, y - radius, x, y],
[x, y - radius, x + radius, y],
[x - radius, y, x, y + radius],
[x, y, x + radius, y + radius]
];
let minVar = Infinity;
let bR = 0, bG = 0, bB = 0;
for (let i = 0; i < 4; i++) {
const reg = regions[i];
const sums = getSums(reg[0], reg[1], reg[2], reg[3]);
if (sums.count === 0) continue;
const meanR = sums.sR / sums.count;
const meanG = sums.sG / sums.count;
const meanB = sums.sB / sums.count;
const varR = (sums.sR2 / sums.count) - (meanR * meanR);
const varG = (sums.sG2 / sums.count) - (meanG * meanG);
const varB = (sums.sB2 / sums.count) - (meanB * meanB);
const totalVar = varR + varG + varB;
if (totalVar < minVar) {
minVar = totalVar;
bR = meanR;
bG = meanG;
bB = meanB;
}
}
const idx = (y * W + x) * 4;
dst[idx] = bR;
dst[idx + 1] = bG;
dst[idx + 2] = bB;
dst[idx + 3] = src[idx + 3];
}
}
// Apply Kuwahara filtered output mapping back onto a temporary canvas
const processedImgData = new ImageData(dst, W, H);
const tempCanvas = document.createElement('canvas');
tempCanvas.width = W;
tempCanvas.height = H;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.putImageData(processedImgData, 0, 0);
// Clean main canvas map and redraw applying color bleed/softness mimicking liquid diffusion
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, W, H);
ctx.filter = `blur(${Math.max(1, Math.round(radius / 4))}px)`;
ctx.drawImage(tempCanvas, 0, 0);
ctx.filter = 'none';
// Apply Watercolor rough paper grain blending directly over canvas
if (texStrength > 0) {
const texCanvas = document.createElement('canvas');
const texW = Math.ceil(W / 2);
const texH = Math.ceil(H / 2); // Reduced bounds to blow-up the grain size pleasantly
texCanvas.width = texW;
texCanvas.height = texH;
const texCtx = texCanvas.getContext('2d');
const texData = texCtx.createImageData(texW, texH);
for (let i = 0; i < texData.data.length; i += 4) {
// Generate combined frequencies for natural looking paper fiber noise
const noise = (Math.random() + Math.random() + Math.random()) / 3;
const val = 200 + Math.floor(noise * 55);
texData.data[i] = val;
texData.data[i + 1] = val;
texData.data[i + 2] = val;
texData.data[i + 3] = 255;
}
texCtx.putImageData(texData, 0, 0);
ctx.globalCompositeOperation = 'multiply';
ctx.globalAlpha = texStrength;
ctx.imageSmoothingEnabled = true;
ctx.drawImage(texCanvas, 0, 0, W, H);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1.0;
}
return canvas;
}
Apply Changes