Please bookmark this page to avoid losing your image tool!

Image To Watercolor Effect Converter

(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.
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;
}

Free Image Tool Creator

Can't find the image tool you're looking for?
Create one based on your own needs now!

Description

This tool transforms standard photographs into artistic watercolor paintings. By utilizing advanced filtering techniques, it flattens color regions to create a painterly look, enhances color vibrancy, and adds a simulated paper texture to mimic the appearance of liquid diffusion on watercolor paper. It is ideal for creators looking to turn personal photos into digital art, enhance social media posts with an aesthetic artistic flair, or generate unique textures for creative design projects.

Leave a Reply

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