You can edit the below JavaScript code to customize the image tool.
Apply Changes
function processImage(originalImg, pixelSize = "auto", ditherStrength = "0", contrast = "15") {
// Determine pixel block size. If "auto", scale dynamically to emulate NES 256 pixel width resolution.
const pSize = pixelSize === "auto"
? Math.max(1, Math.round(originalImg.width / 256))
: Math.max(1, parseInt(pixelSize, 10) || 4);
const dStrength = parseInt(ditherStrength, 10) || 0;
const cStrength = parseInt(contrast, 10) || 0;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = originalImg.width;
canvas.height = originalImg.height;
// Draw original image to extract its pixel data
ctx.drawImage(originalImg, 0, 0);
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imgData.data;
const width = canvas.width;
const height = canvas.height;
// NES 54-Color Palette Definition
const nesPalette = [
[124,124,124], [0,0,252], [0,0,188], [68,40,188], [148,0,132], [168,0,32],
[168,16,0], [136,20,0], [80,48,0], [0,120,0], [0,104,0], [0,88,0], [0,64,88], [0,0,0],
[188,188,188], [0,120,248], [0,88,248], [104,68,252], [216,0,204], [228,0,88],
[248,56,0], [228,92,16], [172,124,0], [0,184,0], [0,168,0], [0,168,68], [0,136,136],
[248,248,248], [60,188,252], [104,136,252], [152,120,248], [248,120,248], [248,88,152],
[248,120,88], [252,160,68], [248,184,0], [184,248,24], [88,216,84], [88,248,152], [0,232,216], [120,120,120],
[252,252,252], [164,228,252], [184,184,248], [216,184,248], [248,184,248], [248,164,192],
[240,208,176], [252,224,168], [248,216,120], [216,248,120], [184,248,184], [184,248,216], [0,252,252], [248,216,248]
];
// Basic Bayer matrix for optional dithering
const bayerMatrix = [
[ 0, 8, 2, 10],
[12, 4, 14, 6],
[ 3, 11, 1, 9],
[15, 7, 13, 5]
];
// Map RGB values to the nearest matching NES palette color using Euclidean distance
function getClosestColor(r, g, b) {
let minDistance = Infinity;
let closest = nesPalette[0];
for (let i = 0; i < nesPalette.length; i++) {
let color = nesPalette[i];
let dr = r - color[0];
let dg = g - color[1];
let db = b - color[2];
let distance = dr*dr + dg*dg + db*db;
if (distance < minDistance) {
minDistance = distance;
closest = color;
}
}
return closest;
}
const outData = new Uint8ClampedArray(data.length);
// Pre-calculate contrast enhancement factor if set
const contrastFactor = cStrength !== 0 ? (259 * (cStrength + 255)) / (255 * (259 - cStrength)) : 1;
// Process the image in grid blocks based on the pixel size
for (let y = 0; y < height; y += pSize) {
for (let x = 0; x < width; x += pSize) {
let sumR = 0, sumG = 0, sumB = 0, sumAlpha = 0, count = 0;
// Calculate the average color for this entire block
for (let by = 0; by < pSize; by++) {
for (let bx = 0; bx < pSize; bx++) {
let px = x + bx;
let py = y + by;
if (px < width && py < height) {
let offset = (py * width + px) * 4;
sumR += data[offset];
sumG += data[offset+1];
sumB += data[offset+2];
sumAlpha += data[offset+3];
count++;
}
}
}
let avgAlpha = sumAlpha / count;
// Keep mostly invisible elements as fully transparent like retro hard-edge sprites
if (avgAlpha < 128) {
for (let by = 0; by < pSize; by++) {
for (let bx = 0; bx < pSize; bx++) {
let px = x + bx;
let py = y + by;
if (px < width && py < height) {
let offset = (py * width + px) * 4;
outData[offset] = 0;
outData[offset+1] = 0;
outData[offset+2] = 0;
outData[offset+3] = 0;
}
}
}
continue;
}
let avgR = sumR / count;
let avgG = sumG / count;
let avgB = sumB / count;
// Apply contrast mapping
if (cStrength !== 0) {
avgR = contrastFactor * (avgR - 128) + 128;
avgG = contrastFactor * (avgG - 128) + 128;
avgB = contrastFactor * (avgB - 128) + 128;
}
// Apply structural Bayer dithering formula conditionally if enabled
if (dStrength > 0) {
let bX = Math.floor(x / pSize) % 4;
let bY = Math.floor(y / pSize) % 4;
let factor = (bayerMatrix[bY][bX] / 16) - 0.5;
avgR += factor * dStrength;
avgG += factor * dStrength;
avgB += factor * dStrength;
}
// Ensure exact limits before color distance check
avgR = Math.max(0, Math.min(255, avgR));
avgG = Math.max(0, Math.min(255, avgG));
avgB = Math.max(0, Math.min(255, avgB));
let closestColor = getClosestColor(avgR, avgG, avgB);
// Fill original size mapping output with the synthesized classic NES colors
for (let by = 0; by < pSize; by++) {
for (let bx = 0; bx < pSize; bx++) {
let px = x + bx;
let py = y + by;
if (px < width && py < height) {
let offset = (py * width + px) * 4;
outData[offset] = closestColor[0];
outData[offset+1] = closestColor[1];
outData[offset+2] = closestColor[2];
outData[offset+3] = 255;
}
}
}
}
}
// Map our calculated new 8-bit visual array to actual imagery on Canvas
const finalImgData = new ImageData(outData, width, height);
ctx.putImageData(finalImgData, 0, 0);
return canvas;
}
Apply Changes