devwithcoffee

How to Convert an Image to ASCII Art in JavaScript

Shrink the image until each pixel is one character, then pick a character by brightness. That takes 20 lines. Making the result look good takes three more fixes.

These notes come from building the image to ASCII art converter on this site. You can try every fix below there, with a photo or your live camera.

The whole idea in 20 lines

A browser canvas does the hard part. Drawing an image into a tiny canvas resizes it, and getImageData hands back the pixels. Each pixel takes four slots in the array: red, green, blue and alpha.

const RAMP = " .:-=+*#%@"; // empty to dense

function imageToAscii(img, cols) {
  const rows = Math.round(cols * (img.height / img.width) * 0.5);

  const canvas = document.createElement("canvas");
  canvas.width = cols;
  canvas.height = rows;
  const ctx = canvas.getContext("2d");
  ctx.drawImage(img, 0, 0, cols, rows);
  const { data } = ctx.getImageData(0, 0, cols, rows);

  let out = "";
  for (let y = 0; y < rows; y++) {
    for (let x = 0; x < cols; x++) {
      const i = (y * cols + x) * 4;
      const brightness =
        0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
      out += RAMP[Math.round((brightness / 255) * (RAMP.length - 1))];
    }
    out += "\n";
  }
  return out;
}

The three weights are the standard Rec. 709 luma numbers. Green counts for most of the total because our eyes are most sensitive to it. A plain average of red, green and blue makes greens too dark and blues too bright.

The ramp order assumes light text on a dark background, where a dense character reads as bright. Here is that loop run on a shaded ball, 44 characters wide:

                #######***+=
            %%%%%%%%%%####**++=-
         %%@@@@@@@%%%%%###***++==-:
       %%@@@@@@@@@%%%%%####**+++==-:.
      %%@@@@@@@@@%%%%%%###***+++==--:.
    #%%@@@@@@@@@%%%%%%####***++===--:.
   *%%%%@@@@@%%%%%%%#####***+++==--::..
   #%%%%%%%%%%%%%%#####****+++==---::.
  *##%%%%%%%%%%######****++++==---::..
  **###############****++++===---::..
  +**##########******++++====--:::..
  =+**************+++++====---::...
   =++++****++++++++====----:::..
   -===++++++++======----::::..
    :--==========-----::::...
      ::---------::::::...
       ..::::::::......
            ....

Problem 1: every face comes out stretched

A monospace character is about twice as tall as it is wide. If a 200 by 200 image becomes 100 columns and 100 rows, the text version is twice as tall as it should be. That is what the * 0.5 in the rows line fixes. Half the rows gives each character a cell the right shape.

The exact ratio depends on the font. 0.5 is close enough for the usual ones, such as Consolas, Menlo and Courier New.

Problem 2: real photos turn into a grey smear

The first version works on bright, high contrast images. A dim phone photo might only use brightness 40 to 150 out of 255. That squeezes the picture into a few characters in the middle of the ramp, and the subject disappears.

The fix is the auto levels step from photo editors. Build a histogram, find the brightness at the darkest and brightest 1% of pixels, and stretch that range to fill the ramp. Using percentiles instead of the true minimum and maximum means one dead pixel or one tiny reflection can’t ruin the stretch.

function levels(brightness) {
  const histogram = new Uint32Array(256);
  for (const b of brightness) histogram[b | 0]++;

  const edge = brightness.length * 0.01;
  let lo = 0;
  let hi = 255;
  for (let seen = 0; lo < 255; lo++) {
    seen += histogram[lo];
    if (seen > edge) break;
  }
  for (let seen = 0; hi > 0; hi--) {
    seen += histogram[hi];
    if (seen > edge) break;
  }
  // A flat image has no range to stretch.
  return hi - lo < 8 ? [0, 255] : [lo, hi];
}

// Then, for each pixel:
const value = Math.min(1, Math.max(0, (brightness - lo) / (hi - lo)));

Auto levels has a side effect: it finds detail you didn’t want. While making the sample image for the tool, a soft glow behind a coffee cup turned into a solid sphere that hid the cup. The fix was a plain black background in the sample. Stretching amplifies whatever is already in the image, so a clean background still matters.

Problem 3: it falls apart when you paste it

ASCII art needs a font where every character has the same width. Most chat apps and documents use proportional fonts, so the columns slide out of line. Wrap the art in triple backticks in Discord, Slack or GitHub. In Word or Google Docs, set a monospace font and turn off line wrapping.

The background can catch you out too. The ramp above assumes a dark background, so on a white page the picture reads as a negative. To fix that, flip the value before you pick a character: value = 1 - value. Also trim the spaces at the end of each line. They are invisible and they bloat the paste.

Picking a ramp

More characters give smoother tones, but only if you have the width to show them. A 10 character ramp survives at small sizes. A 70 character ramp works best at 100 columns or more.

You can also use text instead of a ramp, such as someone’s name. A word has no density to fall back on, so the tool leaves any cell below 12% brightness blank and draws the rest. Those blank cells are what keep the subject readable.

Going live with the camera

The same approach works on video, because drawImage accepts a <video> element. Call it inside requestAnimationFrame and read the size from videoWidth and videoHeight. In the tool, the frames stay in the tab and never touch a server.


Want the result without the code? Use the free image to ASCII art converter. More notes are on the blog.

Written by Himanshu Srivastava, a backend engineer in Bengaluru.