ASCII Fun, and writing performant Clojure

April 4, 2014 —

Ever created some silly border-line pointless libraries just for fun? Well, I have two more to add to that particular pile.

clj-figlet

This library allows you to load FIGlet fonts (for which there is a large database of them available) and render any string of text. So, you can do stuff like:

(use 'clj-figlet.core)

(println 
  (render-to-string 
    (load-flf "/Users/gered/standard.flf")
    "Hello, world!"))

And get output like:

  _   _          _   _                                           _       _   _ 
 | | | |   ___  | | | |   ___         __      __   ___    _ __  | |   __| | | |
 | |_| |  / _ \ | | | |  / _ \        \ \ /\ / /  / _ \  | '__| | |  / _` | | |
 |  _  | |  __/ | | | | | (_) |  _     \ V  V /  | (_) | | |    | | | (_| | |_|
 |_| |_|  \___| |_| |_|  \___/  ( )     \_/\_/    \___/  |_|    |_|  \__,_| (_)
                                |/        

And that's pretty much all there is to it!

Right now clj-figlet only supports the "Full Size" style of rendering FIGlets. The other "smushing" style will be added in an upcoming release.

clj-image2ascii

This library was inspired by Claskii. The author also has a great post detailing it's development here.

So, why would you want to use this library? Well, what a silly question. I mean, who among us has not ever wanted to change this:

Troll face

into this:

.................................................................................................... .................................................................................................... .................................................................................................... .................................................................................................... .................................................................................................... .................................................................................................... .................................................................................................... .................................................................................................... .................................................................................................... .................................................................................................... ........................................$###########################:...$##:........................ ................................A#############################################...................... ...........................*################$.............................=####..................... ........................###########............#####+.....###................###.................... .....................#######=:*@####@A######@.................##..............###................... ...................#####.........................................##............###.................. .................####....+##A................A###,.......####......#+...........###................. ................###,....%.....:#############%.................##.....#...........###................ ..............####...............................................##...##..........##................ .............####..........................................A##@....#%...#.........###............... ............@##%..........##=.....###*.............#............##...#...:=........##............... ............A#A.........#.........................#................#..#....#.......###.............. ............##.........#.........................#..................#...#...........##.............. ............##........A.............#............#.......................#..........##%............. ............##........,..........................@........................$..........##............. ...........###..........................................#########....................##A............ ...........##,.......................................###############..................##............ ..........###.............#####=...................#####..###########.................###........... .........###............###########...............###.....############.................###.......... ........###............###....#######............###.......#######...##..#........#########......... .......###...#####A##..###############..........###....################....##....#####A..###,....... ......###,.A#:...........#################......###..######+......#####.................#..##A...... ......###.#A.....................:#######........#######.....#...............#######......#.###..... .....###.#...#......................###...........###........###...........###########.......##..... .....###.A.#....$#..................@#.......................,###........######.....###.......##.... .....##.#..+..########..............@#........................#####=..#######...#.....##......###... .....##.#....###########..@#........@#..........................##########$....##......##...:..##... .....##..............#######........##.........................................##......##...#..##... .....##...........#..%#####=......+###........................................###@......##..#..##@.. .....##...........##.............####.............###...,..................*#######.....##..#..+##.. .....##.+.........##............###..............######..................#####@.####....##..#..%##.. ......###..#,....###...........###..................,##.$######........#####....######..##..,..###.. ......###....#...###.........#####...................##.............A#####......##.A##.##......##... ......A###......####........#.#####.........#######..##...........######%......###.....##......##... .......##..##...#####.....#......###........#######.###........########.......*##......#...#..###... .......###...#..##.###............###...............##=.....#######*.##......####..........#..##.... ........##=....########............###.##................#######.....##.....#####.........#..###.... ........###....#####.####...........####@............#######%.......*##..#######........#...###..... .........##....#####.#######..........##.........########...........###########............###...... .........##....####..##.########%.........+###########%##..........#######..###..........####....... .........%##...####..##...#######################......###.......#######...###...........###........ ..........##...####..##...##..,##########$..##..........##...$#########....##...........###......... ..........##...####.###...##.......##.......##..........##@#########.##...##A..........###.......... ..........##...########..###.......##.......##..........##########..##A..###...........##........... ..........##...##########.##.......##.......##......############.....##.###...........###........... ..........##...##############################################........#####............##............ ..........##...############################################..........%####............##............ ..........##...:#######################################..##..........####............##............. ..........##....####################################.....##.........####.............##............. ..........##....##.############################*.........###......####@.............##.............. ..........##....######.####################=..#...........##.....####..............###.............. ..........##.....##.##.###..+##....###........#,..........###..#####..............###............... ..........#@.....##A##..##...##.....##.......,##...........##+####...............###................ .........##......*####..###..###....##.......+##...........######...............###................. .........##.......####...##...##.....##.......##........#######......#=...##...###.................. .........##........########...##*....##.......##.....########.....$#....##...A###................... .........##..........#############,..##.....+#############......##....##%...####.................... .........##.............###############################.......##....##%...####...................... .........##...................=################%............##....##....A####....................... .........##...............................................##....##....=####......................... .........##.........#,.........@@@@@@@$::A###...........##...##@....@####........................... .........##..........##..............................##...A##.....#####............................. ........,##....#.......###.......................###...###......#####............................... ........=##.....#.........,#####%==%###+............##........#####................................. ........=##......##............................,##..........#####................................... ........,##........##A....................###.............####+..................................... .........##...........=#############=...................####,....................................... .........##...........................................####.......................................... ..........##........................................####=........................................... ..........###................................#########$............................................. ...........###............................A#########................................................ ............####.......................##########................................................... .............:#####...............#########......................................................... ...............########################............................................................. ..................################.................................................................. .................................................................................................... .................................................................................................... .................................................................................................... .................................................................................................... .................................................................................................... ....................................................................................................

Seems like a real no-brainer to me.

clj-image2ascii can also convert animated GIFs to a set of ASCII frames with frame delay information so you can set up your own animation of ASCII images.

Performance

Working on this library was a nice exercise in writing performant Clojure code for me. I've been working with Clojure now for about a year on mostly just CRUD web apps where writing super performant code is not that important. After fiddling around with the original Claskii code, I decided to benchmark it (using Criterium mostly) and found that it was quite slow. Which isn't all that surprising if you take a look at the code.

For one, there are no type hints anywhere, so Clojure will end up doing quite a bit of reflection lookups at runtime. From what I've seen in other bits of code before working on this project, adding type hints -- even just by itself -- can make quite a significant difference in performance. However, we can still do better.

For example, Claskii is doing this for each pixel it reads from the source image it is converting:

(get-properties
  (Color. (.getRGB img x y))
  .getRed .getGreen .getBlue)

get-properties is a macro the author had which just returns a vector of the values from each of the property getters listed. Essentially this is creating a new Color object for each pixel (obtained via BufferedImage.getRGB which returns a 32bit packed ARGB integer). Obviously creating a new object for each pixel is not performant.

Later on, to convert the pixel to an ASCII character with HTML representation including color information, it does this:

(html [:span {:style (format "color: rgb(%s,%s,%s);" red green blue)} output])

Which is using hiccup to generate an HTML string from Clojure data structures, and of course that call to format which internally uses String.format which performs memory allocations and is somewhat slow to be calling in an inner loop.

There are a few more examples, but you get the idea. My intention is not to pick apart Claskii. It does work, and it works quite well. It could just be faster, and I figured what the heck, lets give it a shot.

Eventually I ended up with the following set of functions:

(defn add-pixel [ argb  sb color?]
  (let [r          (bit-shift-right (bit-and 0x00ff0000 argb) 16)
        g          (bit-shift-right (bit-and 0x0000ff00 argb) 8)
        b          (bit-and 0x000000ff argb)
        peak       (int
                     (Math/sqrt
                       (+ (* r r 0.241)
                          (* g g 0.691)
                          (* b b 0.068))))
        char-index (if (zero? peak)
                     (dec num-ascii-chars)
                     (dec (int (* num-ascii-chars (/ peak 255)))))
        pixel-char (nth ascii-chars (if (pos? char-index) char-index 0))]
    (if color?
      (doto sb
        (.append "<span style=\"color: rgb(")
        (.append r)
        (.append ",")
        (.append g)
        (.append ",")
        (.append b)
        (.append ");\">")
        (.append pixel-char)
        (.append "</span>"))
      pixel-char)))

(defn pixels->ascii [ image color?]
  (let [width        (.getWidth image)
        height       (.getHeight image)
        sb           (StringBuilder. (+ (* width height 47) (* height 4)))
         pixels (.getRGB image 0 0 width height nil 0 width)]
    (dotimes [y height]
      (dotimes [x width]
        (add-pixel (aget pixels (+ x (* y width))) sb color?))
      (.append sb (if color? "<br>" \newline)))
    (.toString sb)))

I arrived at this after doing quite a bit of reading into writing fast Clojure code. It's probably not the fastest or best code, but it is still very fast. Key factors that make this performant:

  • Type hints. (set! *warn-on-reflection* true) is your friend!
  • Using a StringBuilder, and initializing it with a capacity that will be large enough to hold the entire string we will be building up.
  • Accessing the raw pixels in BufferedImage via its getRGB method which returns the pixels as an int[].
  • Bit shifts to decompose the packed 32-bit ARGB integer used for each pixel in the image.

Using Criterium for benchmarking on my computer, I found this had a mean execution time of about 86ms when converting a 300x300 24-bit color PNG with color information being included in the output ASCII string. Very good, considering my first unoptimized version of Claskii's algorithm ran with a mean execution time of over 800ms!

But the question left in my mind was, if I wrote this in pure Java, would it be faster? So, I went ahead and wrote a Java version:

public class ImageToAscii {
	static final char[] asciiChars = {'#', 'A', '@', '%', '$', '+', '=', '*', ':', ',', '.', ' '};
	static final int spanLength = "<span style=\"color:rgb(255,255,255);\">X</span>".length();
	static final int lineTerminatorLength = "<br>".length();

	public static String convert(BufferedImage image, boolean useColor) {
		int width = image.getWidth();
		int height = image.getHeight();

		int maxLength = (useColor ?
		                 (width * height * spanLength) + (height * lineTerminatorLength) :
		                 (width * height) + height);

		StringBuilder sb = new StringBuilder(maxLength);

		int[] pixels = image.getRGB(0, 0, width, height, null, 0, width);
		for (int y = 0; y < height; ++y) {
			for (int x = 0; x < width; ++x) {
				int argb = pixels[(y * width) + x];
				int r = (0x00ff0000 & argb) >> 16;
				int g = (0x0000ff00 & argb) >> 8;
				int b = (0x000000ff & argb);
				int brightness = (int)Math.sqrt((r * r * 0.241f) +
				                                (g * g * 0.691f) +
				                                (b * b * 0.068f));
				int charIndex;
				if (brightness == 0.0f)
					charIndex = asciiChars.length - 1;
				else
					charIndex = (int)((brightness / 255.0f) * asciiChars.length) - 1;

				char pixelChar = asciiChars[charIndex > 0 ? charIndex : 0];

				if (useColor) {
					sb.append("<span style=\"color:rgb(");
					sb.append(r);
					sb.append(',');
					sb.append(g);
					sb.append(',');
					sb.append(b);
					sb.append(");\">");
					sb.append(pixelChar);
					sb.append("</span>");
				} else
					sb.append(pixelChar);
			}
			if (useColor)
				sb.append("<br>");
			else
				sb.append('\n');
		}

		return sb.toString();
	}
}

The same benchmark (with Criterium, using a Clojure wrapper function that called this Java version) showed me that yes, indeed Java was still faster for raw performance: mean execution time of 16ms.

I'm honestly not sure how much faster I could get the pure Clojure version to run, but I suspect any possible speed improvements (if there are any) would end up making the code really un-idiomatic and harder to read. I don't find the Java version hard to read at all, so I'm happy arriving at the conclusion that for raw performance, it does make sense to at least consider using bits of Java in a predominantly Clojure project, especially given that interop between Java and Clojure is extremely easy.