Please bookmark this page to avoid losing your image tool!

Image Watercolor Filter Application

(Free & Supports Bulk Upload)

Drag & drop your images here or

The result will appear here...
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!

Description

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.

Leave a Reply

Your email address will not be published. Required fields are marked *