DEV Community

SEN LLC
SEN LLC

Posted on

A Photo Mosaic Generator With Solid, Emoji, and ASCII Modes

A Photo Mosaic Generator With Solid, Emoji, and ASCII Modes

Drop an image, get three different mosaic versions: colored squares, emoji art (🟥🟧🟨🟩🟦🟪), and ASCII text. The underlying algorithm is the same — sample the image in a grid, compute the average color of each cell, pick the best match from a palette. Only the palette changes.

Photo mosaics look like they need complex algorithms but they really don't. Average the pixels in a grid cell, pick the closest color from a palette, render the palette entry in place of the cell. That's it. The variety comes from what you put in the palette.

🔗 Live demo: https://sen.ltd/portfolio/image-mosaic/
📦 GitHub: https://github.com/sen-ltd/image-mosaic

Screenshot

Features:

  • 3 rendering modes: solid squares, emoji, ASCII
  • Adjustable grid size
  • Side-by-side original vs mosaic
  • PNG download
  • Text copy (ASCII/emoji modes for sharing)
  • Japanese / English UI
  • Dark / light theme
  • Zero dependencies, 35 tests

Average color per cell

The core operation: given an ImageData and a bounding box, return the mean RGB:

export function computeAverageColor(imageData, x0, y0, w, h) {
  const { data, width } = imageData;
  let r = 0, g = 0, b = 0, count = 0;
  const x1 = Math.min(x0 + w, imageData.width);
  const y1 = Math.min(y0 + h, imageData.height);
  for (let y = y0; y < y1; y++) {
    for (let x = x0; x < x1; x++) {
      const i = (y * width + x) * 4;
      r += data[i];
      g += data[i + 1];
      b += data[i + 2];
      count++;
    }
  }
  return { r: r / count, g: g / count, b: b / count };
}
Enter fullscreen mode Exit fullscreen mode

Straightforward sum and divide. For a 32×32 grid on a 1024×1024 image, each cell averages 1024 pixels — instant on modern hardware.

Building the grid

export function buildMosaicGrid(imageData, gridSize) {
  const cellW = imageData.width / gridSize;
  const cellH = imageData.height / gridSize;
  const grid = [];
  for (let row = 0; row < gridSize; row++) {
    const line = [];
    for (let col = 0; col < gridSize; col++) {
      line.push(computeAverageColor(imageData, col * cellW, row * cellH, cellW, cellH));
    }
    grid.push(line);
  }
  return grid;
}
Enter fullscreen mode Exit fullscreen mode

The output is a 2D array of {r, g, b} objects. Every render mode starts here, so this function runs once per image load and is cached.

Emoji palette matching

const EMOJI_PALETTE = [
  { char: '', color: { r: 240, g: 240, b: 240 } },
  { char: '', color: { r: 30, g: 30, b: 30 } },
  { char: '🟥', color: { r: 220, g: 40, b: 40 } },
  { char: '🟧', color: { r: 240, g: 140, b: 30 } },
  { char: '🟨', color: { r: 240, g: 220, b: 40 } },
  { char: '🟩', color: { r: 60, g: 180, b: 60 } },
  { char: '🟦', color: { r: 40, g: 100, b: 220 } },
  { char: '🟪', color: { r: 140, g: 60, b: 200 } },
  { char: '🟫', color: { r: 140, g: 80, b: 40 } },
  { char: '🤍', color: { r: 255, g: 255, b: 255 } },
  { char: '🖤', color: { r: 10, g: 10, b: 10 } },
  // ... more
];

export function nearestEmoji(color) {
  let best = EMOJI_PALETTE[0];
  let bestDist = Infinity;
  for (const entry of EMOJI_PALETTE) {
    const d = colorDistance(color, entry.color);
    if (d < bestDist) {
      bestDist = d;
      best = entry;
    }
  }
  return best.char;
}
Enter fullscreen mode Exit fullscreen mode

The palette colors come from measuring rendered emoji in a font (Apple's Color Emoji). Different platforms render slightly differently, but the dominant color is usually close enough.

ASCII density

For ASCII mode, we use a density ramp — characters with progressively less ink:

const ASCII_DENSITY = ' .:-=+*#%@';

export function nearestChar(color) {
  const b = brightness(color); // 0-255
  const idx = Math.floor((b / 255) * (ASCII_DENSITY.length - 1));
  return ASCII_DENSITY[ASCII_DENSITY.length - 1 - idx]; // dark = @, light = space
}
Enter fullscreen mode Exit fullscreen mode

Bright pixels get space or period; dark pixels get @ or %. 10 levels give enough tonal range that most images become recognizable.

Why three modes

Solid squares look the best but aren't sharable as text. Emoji mosaics are fun in Discord/Slack/Twitter messages. ASCII is the most portable — you can paste it anywhere that accepts monospace text. Same algorithm, different aesthetics.

Series

This is entry #75 in my 100+ public portfolio series.

Top comments (0)