You can edit the below JavaScript code to customize the image tool.
Apply Changes
function processImage(originalImg, maxTextureSize = "256", ditherStrength = "48", saturationBoost = "1.3") {
// Parse parameters
const maxSize = parseInt(maxTextureSize, 10);
const strength = parseFloat(ditherStrength);
const boost = parseFloat(saturationBoost);
// Keep aspect ratio but cap at maxSize for the true PS1 texture resolution
let w = originalImg.width;
let h = originalImg.height;
if (w > h) {
if (w > maxSize) {
h = Math.floor(h * (maxSize / w));
w = maxSize;
}
} else {
if (h > maxSize) {
w = Math.floor(w * (maxSize / h));
h = maxSize;
}
}
// Step 1: Create a low-res canvas to simulate texture
const lowResCanvas = document.createElement('canvas');
lowResCanvas.width = w;
lowResCanvas.height = h;
const ctx = lowResCanvas.getContext('2d');
ctx.drawImage(originalImg, 0, 0, w, h);
const imgData = ctx.getImageData(0, 0, w, h);
const data = imgData.data;
// Step 2: Apply vibrant saturation boost (Spyro style textures are highly saturated)
for (let i = 0; i < data.length; i += 4) {
let r = data[i], g = data[i+1], b = data[i+2];
let lum = 0.299 * r + 0.587 * g + 0.114 * b;
data[i] = Math.min(255, Math.max(0, lum + (r - lum) * boost));
data[i+1] = Math.min(255, Math.max(0, lum + (g - lum) * boost));
data[i+2] = Math.min(255, Math.max(0, lum + (b - lum) * boost));
}
// Step 3: Extract a 16-color palette using K-Means++
const k = 16;
let pixels = [];
// Subsample the image for faster clustering
let step = Math.max(4, Math.floor(data.length / (2000 * 4)) * 4);
for (let i = 0; i < data.length; i += step) {
if (data[i+3] > 0) { // Ignore fully transparency for color selection
pixels.push([data[i], data[i+1], data[i+2]]);
}
}
if (pixels.length === 0) pixels.push([0, 0, 0]);
const distSq = (c1, c2) => (c1[0]-c2[0])**2 + (c1[1]-c2[1])**2 + (c1[2]-c2[2])**2;
// K-Means++ centroid initialization
let centroids = [pixels[Math.floor(Math.random() * pixels.length)]];
for (let i = 1; i < k; i++) {
let maxDist = -1;
let bestPixel = pixels[0];
for (let p of pixels) {
let minDist = Infinity;
for (let c of centroids) {
let d = distSq(p, c);
if (d < minDist) minDist = d;
}
if (minDist > maxDist) {
maxDist = minDist;
bestPixel = p;
}
}
centroids.push(bestPixel);
}
// K-Means iterations to refine 16 colors
for (let iter = 0; iter < 10; iter++) {
let clusters = Array.from({length: k}, () => []);
let sums = Array.from({length: k}, () => [0,0,0]);
for (let p of pixels) {
let minDist = Infinity;
let minIdx = 0;
for (let i = 0; i < k; i++) {
let d = distSq(p, centroids[i]);
if (d < minDist) { minDist = d; minIdx = i; }
}
clusters[minIdx].push(p);
sums[minIdx][0] += p[0];
sums[minIdx][1] += p[1];
sums[minIdx][2] += p[2];
}
let moved = false;
for (let i = 0; i < k; i++) {
if (clusters[i].length > 0) {
let newC = [
sums[i][0] / clusters[i].length,
sums[i][1] / clusters[i].length,
sums[i][2] / clusters[i].length
];
if (distSq(newC, centroids[i]) > 1) moved = true;
centroids[i] = newC;
} else {
centroids[i] = pixels[Math.floor(Math.random() * pixels.length)];
moved = true;
}
}
if (!moved) break; // Convergence
}
// Step 4: Apply 4x4 Bayer Dithering targeting our custom 16 colors
// Standard 4x4 matrix normalized to -0.5 .. ~0.4375 range
const bayer = [
[ -0.5, 0, -0.375, 0.125 ],
[ 0.25, -0.25, 0.375, -0.125 ],
[ -0.3125, 0.1875, -0.4375, 0.0625 ],
[ 0.4375, -0.0625, 0.3125, -0.1875 ]
];
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
let idx = (y * w + x) * 4;
if (data[idx+3] === 0) continue; // Skip transparency bounds
// Perturb the original color with the Bayer matrix
let offset = bayer[y % 4][x % 4] * strength;
let r = data[idx] + offset;
let g = data[idx+1] + offset;
let b = data[idx+2] + offset;
// Find closest palette color
let minDist = Infinity;
let closest = centroids[0];
for (let c of centroids) {
let d = distSq([r, g, b], c);
if (d < minDist) {
minDist = d;
closest = c;
}
}
// Assign dithered 16-indexed color
data[idx] = closest[0];
data[idx+1] = closest[1];
data[idx+2] = closest[2];
// PS1 typically used 1-bit alpha (cut-out) for textures
data[idx+3] = data[idx+3] > 128 ? 255 : 0;
}
}
// Apply modified pixels to low-res canvas
ctx.putImageData(imgData, 0, 0);
// Step 5: Upscale nearest-neighbor back to original image size for chunky aesthetics
const finalCanvas = document.createElement('canvas');
finalCanvas.width = originalImg.width;
finalCanvas.height = originalImg.height;
const fnCtx = finalCanvas.getContext('2d');
fnCtx.imageSmoothingEnabled = false; // Nearest-neighbor scaling
fnCtx.drawImage(lowResCanvas, 0, 0, finalCanvas.width, finalCanvas.height);
return finalCanvas;
}
Apply Changes