Please bookmark this page to avoid losing your image tool!

PlayStation 1 Style Image Texture And Dithering 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.
/**
 * PlayStation 1 Style Image Texture and Dithering Converter
 * 
 * @param {HTMLImageElement} originalImg - The original image object.
 * @param {number|string} colorCount - Number of indexed colors (default: 32)
 * @param {number|string} ditherLevel - Intensity of the Bayer dithering (0 to 1+, default: 1)
 * @param {number|string} resolution - Max dimension mimicking low-res PS1 textures (default: 256)
 * @param {number|string} smoothRadius - Kuwahara radius for a digital painted look (default: 2)
 * @param {number|string} ditherSpread - Color offset multiplier for dithering (default: 48)
 * @returns {HTMLCanvasElement} - A canvas containing the PS1 stylized image.
 */
function processImage(originalImg, colorCount = 32, ditherLevel = 1, resolution = 256, smoothRadius = 2, ditherSpread = 48) {
    // Parse arguments
    colorCount = Math.max(2, Number(colorCount) || 32);
    ditherLevel = Number(ditherLevel);
    if (isNaN(ditherLevel)) ditherLevel = 1;
    resolution = Math.max(16, Number(resolution) || 256);
    smoothRadius = Math.max(0, Number(smoothRadius) || 0); // usually 0-4
    if (isNaN(smoothRadius)) smoothRadius = 2;
    ditherSpread = Number(ditherSpread);
    if (isNaN(ditherSpread)) ditherSpread = 48;

    // 1. Calculate resolution while maintaining aspect ratio
    let w = originalImg.width;
    let h = originalImg.height;
    if (w > resolution || h > resolution) {
        if (w > h) {
            h = Math.max(1, Math.round(h * (resolution / w)));
            w = resolution;
        } else {
            w = Math.max(1, Math.round(w * (resolution / h)));
            h = resolution;
        }
    }

    // 2. Draw image to offscreen canvas
    const canvas = document.createElement('canvas');
    canvas.width = w;
    canvas.height = h;
    const ctx = canvas.getContext('2d', { willReadFrequently: true });
    ctx.drawImage(originalImg, 0, 0, w, h);
    
    let imgData = ctx.getImageData(0, 0, w, h);
    let data = imgData.data;

    // 3. Image Cleanup & Painted Vibe: Apply Kuwahara Filter
    if (smoothRadius > 0) {
        data = applyKuwaharaFilter(data, w, h, smoothRadius);
    }

    // 4. Generate Indexed 15-bit Color Palette via K-Means Subsampling
    const palette = generatePS1Palette(data, colorCount);

    // 5. Bayer Matrix (4x4) Dithering Template
    const bayer4x4 = [
        [ 0,  8,  2, 10],
        [12,  4, 14,  6],
        [ 3, 11,  1,  9],
        [15,  7, 13,  5]
    ];

    // 6. Apply Dithering and Map to Palette
    for (let y = 0; y < h; y++) {
        for (let x = 0; x < w; x++) {
            let idx = (y * w + x) * 4;
            
            // PS1 usually has 1-bit alpha for standard texture mapping
            if (data[idx + 3] < 128) {
                data[idx + 3] = 0; 
                continue;
            }

            // Dithering Offset
            let bayerVal = (bayer4x4[y % 4][x % 4] / 15.0) - 0.5;
            let offset = bayerVal * ditherSpread * ditherLevel;

            let r = Math.max(0, Math.min(255, data[idx] + offset));
            let g = Math.max(0, Math.min(255, data[idx + 1] + offset));
            let b = Math.max(0, Math.min(255, data[idx + 2] + offset));

            // Find closest palette color
            let minDist = Infinity;
            let bestPal = palette[0];
            
            for (let c = 0; c < palette.length; c++) {
                let pR = palette[c][0];
                let pG = palette[c][1];
                let pB = palette[c][2];
                let dr = r - pR;
                let dg = g - pG;
                let db = b - pB;
                let dist = (dr * dr) + (dg * dg) + (db * db);
                if (dist < minDist) {
                    minDist = dist;
                    bestPal = palette[c];
                }
            }

            // Set matched color and enforce 15-bit RGB space hardware limits (Bitmask 248)
            data[idx]     = bestPal[0] & 248; 
            data[idx + 1] = bestPal[1] & 248;
            data[idx + 2] = bestPal[2] & 248;
            data[idx + 3] = 255; 
        }
    }

    // Put modified data back
    imgData.data.set(data);
    ctx.putImageData(imgData, 0, 0);

    // 7. Scale up the final render for visible crunchy pixels (Nearest-Neighbor)
    const outCanvas = document.createElement('canvas');
    const scale = Math.max(1, Math.floor(512 / Math.max(w, h))); 
    outCanvas.width = w * scale;
    outCanvas.height = h * scale;
    
    const outCtx = outCanvas.getContext('2d');
    outCtx.imageSmoothingEnabled = false; // Preserves pixel art aesthetic
    outCtx.drawImage(canvas, 0, 0, outCanvas.width, outCanvas.height);

    return outCanvas;

    // --- Helper Algorithms --- //

    // Kuwahara Filter: Gives images that solid/painted 'Spyro the Dragon' look
    function applyKuwaharaFilter(srcData, width, height, radius) {
        const out = new Uint8ClampedArray(srcData.length);
        
        for (let y = 0; y < height; y++) {
            for (let x = 0; x < width; x++) {
                let minVar = Infinity;
                let bestColor = [0, 0, 0, 255];
                
                // Define 4 quadrants (regions)
                const regions = [
                    [-radius, 0, -radius, 0], // Top-Left
                    [0, radius, -radius, 0],  // Top-Right
                    [-radius, 0, 0, radius],  // Bottom-Left
                    [0, radius, 0, radius]    // Bottom-Right
                ];

                for (let r = 0; r < 4; r++) {
                    const [dxMin, dxMax, dyMin, dyMax] = regions[r];

                    let sumR=0, sumG=0, sumB=0;
                    let sumSqR=0, sumSqG=0, sumSqB=0;
                    let count = 0;

                    for (let dy = dyMin; dy <= dyMax; dy++) {
                        let ny = y + dy;
                        if (ny < 0 || ny >= height) continue;
                        
                        for (let dx = dxMin; dx <= dxMax; dx++) {
                            let nx = x + dx;
                            if (nx < 0 || nx >= width) continue;

                            let i = (ny * width + nx) * 4;
                            let pR = srcData[i], pG = srcData[i+1], pB = srcData[i+2];
                            
                            sumR += pR; sumG += pG; sumB += pB;
                            sumSqR += pR * pR; sumSqG += pG * pG; sumSqB += pB * pB;
                            count++;
                        }
                    }

                    if (count === 0) continue;

                    let meanR = sumR / count;
                    let meanG = sumG / count;
                    let meanB = sumB / count;

                    let varR = (sumSqR / count) - (meanR * meanR);
                    let varG = (sumSqG / count) - (meanG * meanG);
                    let varB = (sumSqB / count) - (meanB * meanB);
                    let variance = varR + varG + varB;

                    if (variance < minVar) {
                        minVar = variance;
                        bestColor = [meanR, meanG, meanB, srcData[(y * width + x) * 4 + 3]];
                    }
                }
                
                let idx = (y * width + x) * 4;
                out[idx] = bestColor[0];
                out[idx+1] = bestColor[1];
                out[idx+2] = bestColor[2];
                out[idx+3] = bestColor[3];
            }
        }
        return out;
    }

    // Super fast K-Means to extract an indexed color palette
    function generatePS1Palette(srcData, k) {
        let pixels = [];
        // Subsample the image to roughly 2000-4000 limits to calculate fast
        let step = Math.max(4, Math.ceil(srcData.length / (4000 * 4)) * 4);
        
        for (let i = 0; i < srcData.length; i += step) {
            if (srcData[i+3] > 127) { 
                pixels.push([srcData[i], srcData[i+1], srcData[i+2]]);
            }
        }
        
        // Edge case fallback
        if (pixels.length === 0) return [[0,0,0]];
        if (k >= pixels.length) return pixels;

        // Randomly initialize cluster centers
        let centers = [];
        for (let i = 0; i < k; i++) {
            centers.push(pixels[Math.floor(Math.random() * pixels.length)].slice());
        }

        // Iterate K-Means
        let maxIterations = 8;
        for (let iter = 0; iter < maxIterations; iter++) {
            let sums = Array.from({length: k}, () => [0, 0, 0, 0]); // R, G, B, Count
            
            for (let p of pixels) {
                let minDist = Infinity;
                let minIdx = 0;
                for (let c = 0; c < k; c++) {
                    let dr = p[0] - centers[c][0];
                    let dg = p[1] - centers[c][1];
                    let db = p[2] - centers[c][2];
                    let dist = (dr*dr) + (dg*dg) + (db*db);
                    if (dist < minDist) {
                        minDist = dist;
                        minIdx = c;
                    }
                }
                sums[minIdx][0] += p[0];
                sums[minIdx][1] += p[1];
                sums[minIdx][2] += p[2];
                sums[minIdx][3]++;
            }

            let moved = false;
            for (let c = 0; c < k; c++) {
                if (sums[c][3] > 0) {
                    let nr = sums[c][0] / sums[c][3];
                    let ng = sums[c][1] / sums[c][3];
                    let nb = sums[c][2] / sums[c][3];
                    if (Math.abs(nr - centers[c][0]) > 1 || Math.abs(ng - centers[c][1]) > 1 || Math.abs(nb - centers[c][2]) > 1) {
                        moved = true;
                    }
                    centers[c] = [nr, ng, nb];
                }
            }
            if (!moved) break;
        }

        // Force all palette vectors into safe 15-bit RGB coordinate multiples
        return centers.map(c => [c[0] & 248, c[1] & 248, c[2] & 248]);
    }
}

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 modern images into retro-style textures reminiscent of the PlayStation 1 era. It achieves this aesthetic by reducing color depth through indexed palette generation, applying Bayer dithering for a classic grainy look, and downscaling resolution to simulate vintage hardware limitations. Additionally, it features a Kuwahara filter to provide a smooth, painted texture effect. This tool is ideal for game developers looking to create low-poly assets, digital artists aiming for a nostalgic pixel-art aesthetic, or anyone wanting to stylize photos with a retro gaming vibe.

Leave a Reply

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