You can edit the below JavaScript code to customize the image tool.
function processImage(originalImg, kernelSize = 7, colorLevels = 8) {
// 1. Parameter validation
if (typeof kernelSize !== 'number' || kernelSize < 3) {
kernelSize = 3; // Smallest practical Kuwahara kernel side length (radius 1)
}
if (kernelSize % 2 === 0) {
kernelSize += 1; // Ensure kernelSize is odd for a central pixel
}
const radius = Math.floor(kernelSize / 2);
if (typeof colorLevels !== 'number' || colorLevels < 0) {
colorLevels = 0; // Default to no posterization if invalid value provided
}
// Max 256 levels (0-255 for each channel).
// If colorLevels is 0 or 1, posterization is effectively skipped.
if (colorLevels > 256) colorLevels = 256;
// 2. Canvas setup
const canvas = document.createElement('canvas');
// Using { willReadFrequently: true } can be a performance hint for browsers
const ctx = canvas.getContext('2d', { willReadFrequently: true });
// Use naturalWidth/Height for intrinsic dimensions, fallback to width/height
const width = originalImg.naturalWidth || originalImg.width;
const height = originalImg.naturalHeight || originalImg.height;
if (width === 0 || height === 0) {
// Handle case where image has no dimensions: return an empty or minimal canvas
canvas.width = width; // Will be 0
canvas.height = height; // Will be 0
console.warn("Image has zero width or height.");
return canvas;
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(originalImg, 0, 0, width, height);
let imageData;
try {
imageData = ctx.getImageData(0, 0, width, height);
} catch (e) {
// This error can occur if the image is from a different origin (cross-origin)
// and the server does not provide appropriate CORS headers.
console.error("Error getting image data (potentially tainted canvas):", e);
// Draw an informative error message on the canvas
ctx.clearRect(0, 0, width, height); // Clear previously drawn image
ctx.fillStyle = 'rgba(230, 230, 230, 1)'; // Light gray background
ctx.fillRect(0,0,width,height);
// Calculate a responsive font size for the error message
const FONT_SIZE = Math.max(12, Math.min(24, width / 20, height / 8));
ctx.font = `bold ${FONT_SIZE}px Arial, sans-serif`;
ctx.fillStyle = 'rgba(200, 0, 0, 1)'; // Dark red text color
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const lines = ["Error: Could not process image.", "(This might be due to cross-origin restrictions)"];
const lineHeight = FONT_SIZE * 1.2;
const totalTextHeight = lines.length * lineHeight - (lineHeight - FONT_SIZE); // Adjusted for better centering
let textY = (height - totalTextHeight) / 2 + FONT_SIZE / 2;
for (const line of lines) {
ctx.fillText(line, width / 2, textY);
textY += lineHeight;
}
return canvas; // Return the canvas with the error message
}
const data = imageData.data;
// Create a working copy for source pixels, which might be posterized
// Posterization will be applied to sourceData, Kuwahara reads from sourceData
const sourceData = new Uint8ClampedArray(data);
// Optional: Posterization step (applied before Kuwahara filter)
// This reduces the number of colors, contributing to a "painterly" or "watercolor" look.
// Skip if colorLevels is 0 (no posterization) or 1 (results in a single color, not useful here).
if (colorLevels >= 2) {
const numLevels = Math.floor(colorLevels);
// Calculate the size of each color 'step'.
// For N levels, there are N-1 segments spanning the 0-255 range.
// e.g., 2 levels (0, 255) -> 1 segment of size 255.
// e.g., 3 levels (0, 127.5, 255) -> 2 segments, step = 255 / 2 = 127.5.
const step = 255 / (numLevels - 1);
for (let i = 0; i < data.length; i += 4) {
// Quantize R, G, B channels
sourceData[i] = Math.round(Math.round(data[i] / step) * step);
sourceData[i + 1] = Math.round(Math.round(data[i + 1] / step) * step);
sourceData[i + 2] = Math.round(Math.round(data[i + 2] / step) * step);
// Alpha channel (sourceData[i+3]) remains unchanged from original (data[i+3])
// as posterization typically doesn't affect alpha.
}
}
// If colorLevels is 0 or 1, sourceData is effectively a non-posterized copy of 'data'.
const outputData = new Uint8ClampedArray(data.length);
// 3. Kuwahara filter
// This filter smooths regions while preserving edges, giving a painterly effect.
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const meansR = [0, 0, 0, 0]; // Mean Red for each of 4 quadrants
const meansG = [0, 0, 0, 0]; // Mean Green
const meansB = [0, 0, 0, 0]; // Mean Blue
const variancesLum = [0, 0, 0, 0]; // Luminance Variance
// Define the 4 overlapping quadrants for the Kuwahara filter.
// Coordinates are [x_start, y_start, x_end, y_end] relative to the current pixel (x,y).
// Each quadrant has a size of (radius+1)x(radius+1) pixels.
const q_coords = [
[x - radius, y - radius, x, y], // Top-Left quadrant
[x, y - radius, x + radius, y], // Top-Right quadrant
[x - radius, y, x, y + radius], // Bottom-Left quadrant
[x, y, x + radius, y + radius] // Bottom-Right quadrant
];
for (let i = 0; i < 4; i++) { // Iterate over the four quadrants
let sumR = 0, sumG = 0, sumB = 0;
let sumLum = 0, sumLumSq = 0; // Sum of luminance and sum of squared luminance
let count = 0; // Number of pixels in the quadrant
const [qx_start, qy_start, qx_end, qy_end] = q_coords[i];
// Iterate over pixels within the current sub-quadrant
for (let qy_k = qy_start; qy_k <= qy_end; qy_k++) {
for (let qx_k = qx_start; qx_k <= qx_end; qx_k++) {
// Clamp coordinates to be within image boundaries
const currentX = Math.max(0, Math.min(width - 1, qx_k));
const currentY = Math.max(0, Math.min(height - 1, qy_k));
const pixelIndex = (currentY * width + currentX) * 4;
const rVal = sourceData[pixelIndex];
const gVal = sourceData[pixelIndex + 1];
const bVal = sourceData[pixelIndex + 2];
sumR += rVal;
sumG += gVal;
sumB += bVal;
// Luminance calculation (standard NTSC/PAL formula)
const luminance = 0.299 * rVal + 0.587 * gVal + 0.114 * bVal;
sumLum += luminance;
sumLumSq += luminance * luminance; // Sum of squares for variance
count++;
}
}
// Calculate mean color and luminance variance for the quadrant
// Note: count will be (radius+1)*(radius+1), so it's always > 0 if radius >=0.
meansR[i] = sumR / count;
meansG[i] = sumG / count;
meansB[i] = sumB / count;
const meanLum = sumLum / count;
// Variance = E[X^2] - (E[X])^2
variancesLum[i] = (sumLumSq / count) - (meanLum * meanLum);
}
// Find the quadrant with the minimum luminance variance
let minVariance = variancesLum[0];
let chosenQuadrantIndex = 0;
for (let i = 1; i < 4; i++) {
if (variancesLum[i] < minVariance) {
minVariance = variancesLum[i];
chosenQuadrantIndex = i;
}
}
// Set the output pixel to the mean color of the chosen (least variance) quadrant
const outputPixelIndex = (y * width + x) * 4;
outputData[outputPixelIndex] = meansR[chosenQuadrantIndex];
outputData[outputPixelIndex + 1] = meansG[chosenQuadrantIndex];
outputData[outputPixelIndex + 2] = meansB[chosenQuadrantIndex];
// Preserve original alpha channel (from the initial imageData 'data')
outputData[outputPixelIndex + 3] = data[outputPixelIndex + 3];
}
}
// 4. Put processed data back onto the canvas
const outputImageData = new ImageData(outputData, width, height);
ctx.putImageData(outputImageData, 0, 0);
// 5. Return the canvas with the watercolor effect applied
return canvas;
}
Free Image Tool Creator
Can't find the image tool you're looking for? Create one based on your own needs now!
The Image Watercolor Filter Application allows users to transform images into watercolor-style artworks. By processing an original image through a UV filter that simulates the soft blending and artistic look of watercolor painting, this tool provides an artistic transformation that can enhance images for presentations, social media posts, or personal projects. Users can adjust the intensity of the effect through parameters like kernel size and color levels, providing flexibility in achieving the desired artistic outcome.