Pixel art is everywhere — indie games, NFT avatars, social media profiles, retro-themed designs. But there's a huge gap between "pixelating an image" (which looks terrible) and actual pixel art (which looks intentional and beautiful).
I built a free Pixel Art Converter that bridges this gap using 5 classic computer science algorithms, all running in the browser via the Canvas API. No server uploads, no AI — just math.
Here's exactly how it works under the hood.
The Problem: Pixelation ≠ Pixel Art
If you just shrink an image and scale it back up, you get a blurry mosaic. Real pixel art has:
- Limited color palettes (4–64 colors)
- Dithering patterns that simulate smooth gradients
- Dark outlines around shapes
- Vibrant, saturated colors
My converter applies all four of these automatically. Let me walk through each algorithm.
Algorithm 1: Downsampling
The simplest step. I draw the source image onto a tiny canvas (e.g., 64px wide), then scale it back up using imageSmoothingEnabled = false to preserve hard pixel edges.
// Draw source onto tiny canvas
const smallCanvas = document.createElement('canvas');
smallCanvas.width = pixelRes; // e.g., 64
smallCanvas.height = Math.round(img.height * (pixelRes / img.width));
const ctx = smallCanvas.getContext('2d');
ctx.drawImage(img, 0, 0, smallCanvas.width, smallCanvas.height);
// Scale back up with nearest-neighbor interpolation
outputCtx.imageSmoothingEnabled = false;
outputCtx.drawImage(smallCanvas, 0, 0, outputWidth, outputHeight);
Resolution recommendations:
- 16–32px → Game sprites, tiny icons
- 64px → Sweet spot for most images
- 128–256px → Detailed portraits, wallpapers
Algorithm 2: Median Cut Color Quantization
This is where it gets interesting. Real pixel art uses limited palettes. I use the median cut algorithm to intelligently reduce colors.
The idea: treat every pixel as a point in 3D RGB space. Recursively split the color space into buckets by finding the channel (R, G, or B) with the widest range and splitting at the median.
function medianCut(pixels, numColors) {
let buckets = [pixels];
while (buckets.length < numColors) {
// Find the bucket with the widest color range
let targetBucket = findWidestBucket(buckets);
// Find which channel (R, G, B) has the widest range
let channel = findWidestChannel(targetBucket);
// Sort by that channel and split at the median
targetBucket.sort((a, b) => a[channel] - b[channel]);
let mid = Math.floor(targetBucket.length / 2);
buckets.push(targetBucket.slice(0, mid));
buckets.push(targetBucket.slice(mid));
}
// Each bucket's average color becomes one palette entry
return buckets.map(bucket => averageColor(bucket));
}
After building the palette, each pixel gets mapped to its closest palette color using Euclidean distance in RGB space.
Fewer colors = more retro:
- 4 colors → Game Boy style
- 16 colors → NES/CGA era
- 32–64 colors → Best balance for most photos
Algorithm 3: Floyd-Steinberg Dithering
This is what separates a real pixel art converter from a cheap pixelator. Dithering distributes quantization error to neighboring pixels, creating the illusion of more colors through dot patterns.
Without dithering: harsh flat color blocks (the "mosaic" look).
With dithering: smooth gradients made of alternating dots — exactly like 8-bit and 16-bit era graphics.
function floydSteinbergDither(imageData, palette) {
const { data, width, height } = imageData;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let idx = (y * width + x) * 4;
let oldR = data[idx], oldG = data[idx+1], oldB = data[idx+2];
// Find nearest palette color
let [newR, newG, newB] = findClosest(oldR, oldG, oldB, palette);
data[idx] = newR; data[idx+1] = newG; data[idx+2] = newB;
// Calculate error
let errR = oldR - newR, errG = oldG - newG, errB = oldB - newB;
// Distribute error to neighbors
distributeError(data, x+1, y, width, height, errR, errG, errB, 7/16);
distributeError(data, x-1, y+1, width, height, errR, errG, errB, 3/16);
distributeError(data, x, y+1, width, height, errR, errG, errB, 5/16);
distributeError(data, x+1, y+1, width, height, errR, errG, errB, 1/16);
}
}
}
The 7/16, 3/16, 5/16, 1/16 distribution creates a natural, organic pattern that avoids visual banding.
Algorithm 4: Sobel Edge Detection
Hand-drawn pixel art almost always has dark outlines. I use the Sobel operator to automatically detect and darken edges.
The Sobel operator uses two 3×3 convolution kernels to calculate horizontal and vertical gradients:
const sobelX = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]];
const sobelY = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]];
function sobelEdgeDetect(imageData) {
// Convert to grayscale first
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
let gx = convolve(imageData, x, y, sobelX);
let gy = convolve(imageData, x, y, sobelY);
let magnitude = Math.sqrt(gx * gx + gy * gy);
if (magnitude > threshold) {
// Darken this pixel proportionally
darkenPixel(imageData, x, y, magnitude);
}
}
}
}
Where the gradient magnitude exceeds a threshold, pixels get darkened — creating outlines that naturally follow contours.
Algorithm 5: HSL Saturation Boost
Pixel art uses bold, vibrant colors. Before quantization, I convert each pixel to HSL, boost saturation (default 130%), and convert back to RGB.
function boostSaturation(r, g, b, factor) {
let [h, s, l] = rgbToHsl(r, g, b);
s = Math.min(1, s * factor); // Boost but clamp at 1.0
return hslToRgb(h, s, l);
}
150–200% saturation gives that vibrant retro game aesthetic.
Retro Console Presets
For instant results, I built presets that emulate real gaming hardware:
| Preset | Colors | Resolution | Special |
|---|---|---|---|
| Game Boy | 4 green shades | 48px | Hardcoded 1989 palette |
| NES | 54 colors | 64px | Authentic NES color table |
| SNES | 128 colors | 128px | Median cut quantization |
| CGA | 16 colors | 64px | IBM PC palette |
The Game Boy and NES presets use exact hardware palettes rather than median cut — every color matches what the original console could display.
The Pipeline
Here's the full processing order:
Source Image
→ Saturation boost (HSL)
→ Downsample to pixel grid (Canvas)
→ Median cut quantization (or preset palette)
→ Floyd-Steinberg dithering (optional)
→ Sobel edge detection + outline darkening (optional)
→ Scale up with nearest-neighbor
→ Output
Everything runs on a single <canvas> element using getImageData() and putImageData(). No WebGL, no Web Workers, no external libraries.
Privacy: Zero Uploads
The entire tool runs in the browser. Your image never touches a server. Close the tab and all data is gone. This is possible because the Canvas API gives us direct pixel-level access — we don't need any server-side image processing.
Try It
→ Pixel Art Converter — Free, No Sign-up
Drop any image, adjust resolution and colors, toggle dithering and outlines, try the Game Boy preset — and download your pixel art as PNG.
Built as part of ToolKnit — 33+ free browser-based tools for PDF, image, video, audio, and more.
What algorithms would you add? I'm considering palette import (load a .pal file) and animated GIF pixel art conversion. Let me know in the comments!
Top comments (1)
CGA only has 4 colours in standard graphics modes. Your preset is EGA