You can edit the below JavaScript code to customize the image tool.
Apply Changes
/**
* 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]);
}
}
Apply Changes