Asciify

Most images nowadays are represented using pixels. They are square, often relatively small and numerous, come in (2^8)^3 different colors and thereby do a good job being the fundamental building block of images. But one can imagine more coarse-grained and differently shaped pixels.
An interesting fact is, that in most monotype fonts two characters placed right next to each other (for example ‘$$’) occupy roughly a square area. So simple ASCII characters can indeed be used to approximately describe any ordinary image.
Asciify does exactly this; it takes in an image and some optional parameters and maps the pixels’ intensity onto a character set. Both the large and small default character sets are taken from a post by Paul Bourke.

Asciified kirby
Kirby grafitti

In conjunction with asciify.py, I wrote index.py, which asciifies a bunch of images and results in their html form; it also creates an index. All images asciified for this post can be viewed through this index.

Converting an image to its asciified form works best when there is a lot of contrast in the image. Because of this, some pre-processing of the image may be required for best results (all images shown where only cropped or rotated). The built-in color functionality also only knows of 8 colors, so bright and different colors look the best, as they interestingly differentiate from one another. The asciified image’s size also plays a role, the larger it is, the better the characters blend into one and appear to be one image.

Asciified cube
AoLong 3^3 cube

Asciify is operated on a command prompt; python asciify.py img.png. To parse arguments, the built-in python module argparse is used. The images are opened and read using the Python Imaging Library module PIL, which needs to be installed for this program to work.
Optional arguments include --size N, where the maximum size can be specified, --invert and --smallcharset, which can sometimes increase the asciified image’s visual appeal and --html, which will output an html file to be viewed in a browser. To see the program’s full potential, simply run python asciify.py --help.
Source code for both asciify.py and index.py can be downloaded, the first is also listed below.

The two examples above use the color mode, though certain images also work in default black and white mode, such as this spider I photographed.

Asciified spider
Spider

Then again, the colored text also has its charm, especially when the source image has bright colors and a lot of contrast.

Asciified boat
Sailboat

# Python 2.7 Code
# Jonathan Frech 3rd, 4th of March 2017
#      rewritten 12th of April 2017
#         edited 13th of April, 13th, 14th, 15th of July 2017

# Type 'python asciify.py -h' for help.

# import
import os, argparse, sys, cgi

# turn main function's output into an html page
def html(img, parsed):
	# encoding list (translate color)
	clrenc = {
		"\033[0m"   :"</span>",
		"\033[1;30m":"<span style=\"color:gray\">",
		"\033[1;31m":"<span style=\"color:red\">",
		"\033[1;32m":"<span style=\"color:green\">",
		"\033[1;33m":"<span style=\"color:yellow\">",
		"\033[1;34m":"<span style=\"color:blue\">",
		"\033[1;35m":"<span style=\"color:magenta\">",
		"\033[1;36m":"<span style=\"color:cyan\">",
		"\033[1;37m":"<span style=\"color:white\">"
	}
	
	# escape html special characters
	img = cgi.escape(img)
	
	# replace ANSI escape codes with html <span> tags
	for enc in clrenc: img = img.replace(enc, clrenc[enc])
	
	# write command beneath image
	cmd = "python %s" % sys.argv[0]
	if parsed.img         : cmd += " \"%s\""           % parsed.img.replace("\"", "\\\"") if not parsed.htmlx else " <a href=\"%s\" target=\"_blank\" title=\"View original pixel image\">\"%s\"<a/>" % (parsed.img.replace("\"", "\\\""), parsed.img.replace("\"", "\\\""))
	if parsed.size        : cmd += " --size %d"        % parsed.size
	if parsed.charset     : cmd += " --charset \"%s\"" % parsed.charset.replace("\"", "\\\"")
	if parsed.smallcharset: cmd += " --smallcharset"
	if parsed.cut         : cmd += " --cut"
	if parsed.color       : cmd += " --color"
	else: img = "<span style=\"color:white\">%s</span>" % img
	if parsed.invert      : cmd += " --invert"
	if parsed.fullsize    : cmd += " --fullsize"
	if parsed.html        : cmd += " --html"
	if parsed.htmlx       : cmd += " --htmlx"
	if parsed.link        : cmd += " --link"
	
	# generate link
	if parsed.link: cmd += "\n \n<a href=\"index.html\" title=\"Full list of asciified images\"><-</a>"
	
	# html page
	html = "<!DOCTYPE html>\n\t<meta charset=\"UTF-8\">\n\t<html>\n\t\t<head>\n\t\t\t<title>Asciify</title>\n\t\t\t<center><h1 style=\"color:white\">Asciify</h1></center>\n\t\t</head>\n\t\t\n\t\t<body bgcolor=\"#000000\">\n\t\t\t<center><pre><code>%s\n \n<span style=\"color:white\">%s</span></code></pre></center>\n\t\t</body>\n\t</html>" % (img, cmd)
	
	# return finished html page
	return html

# main function
def main():
	# import PIL (python imaging library)
	try:
		from PIL import Image
	except ImportError:
		return "Failed to import PIL. Is it installed?\nsudo apt-get install python-pil"

	# parse command line arguments
	parser = argparse.ArgumentParser(description = "Asciify an image; turn pixels into characters.")
	parser.add_argument("img"           ,       type = str               , help = "image file to asciify"                   )
	parser.add_argument("--size"        , "-s", type = int, metavar = "N", help = "image's size in characters"              )
	parser.add_argument("--charset"     ,       type = str, metavar = "C", help = "character set used to interpret image"   )
	parser.add_argument("--smallcharset",       action = "store_true"    , help = "use smaller character set than default"  )
	parser.add_argument("--cut"         ,       action = "store_true"    , help = "cut away white border"                   )
	parser.add_argument("--color"       , "-c", action = "store_true"    , help = "display image's colors"                  )
	parser.add_argument("--invert"      , "-i", action = "store_true"    , help = "invert charset"                          )
	parser.add_argument("--fullsize"    , "-f", action = "store_true"    , help = "use image's full size (default is 100px)")
	parser.add_argument("--html"        ,       action = "store_true"    , help = "format output to html page"              )
	parser.add_argument("--htmlx"       ,       action = "store_true"    , help = "add image hyperlink to html output"      )
	parser.add_argument("--link"        ,       action = "store_true"    , help = "link to index.html in html output"       )
	parsed = parser.parse_args()
	
	# you need hyperlink technology to link
	if parsed.link and not (parsed.html or parsed.htmlx):
		parser.error("Cannot link when not outputting html page.")
	
	# try to open image
	imgname = os.path.abspath(parsed.img)
	try:
		img = Image.open(imgname).convert("RGB")
		w, h = img.size
	except:
		parser.error("Could not open image file.")

	# crop image if too big
	s = parsed.size or 100
	if not parsed.fullsize:
		k = float(w)/h
		# width larger
		if w > h:
			w = s
			h = int(w/k)

		# height larger or equal
		else:
			h = s
			w = int(h*k)

		# a character is twice as high as wide
		h //= 2

		# resize (minimum size is 1x1)
		img = img.resize((max(w, 1), max(h, 1)))

	# special character set
	if parsed.charset is not None:
		charset = parsed.charset
	else:
		# smaller character set
		if parsed.smallcharset:
			charset = "@%#*+=-:. "

		# default character set
		else:
			charset = "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. "

	# invert charset
	if parsed.invert:
		charset = charset[::-1]

	# load pixels
	_pix = img.load()
	pix = [[_pix[x, y] for x in range(w)] for y in range(h)]

	# calculate black and white values
	k = [[sum(pix[y][x])/3. for x in range(w)] for y in range(h)]

	# determine darkest and brightest color
	kmin, kmax = 255, 0
	for y in range(h):
		for x in range(w):
			v = k[y][x]
			if v < kmin: kmin = v
			if v > kmax: kmax = v

	# map color value to character set
	kdelta = kmax-kmin
	l = len(charset)-1
	k = [[int((k[y][x]-kmin)/kdelta*l) for x in range(w)] for y in range(h)]

	# cut whitespace from all four sides
	if parsed.cut:
		empty = lambda array: map(lambda x: charset[x], array).count(" ") == len(array)
		for _ in (-1, 0):
			# cut top and bottom
			while len(k) > 0 and empty(k[_]):
				k.pop(_)
				pix.pop(_)
				h -= 1
			# cut left and right
			while len(k[0]) > 0 and empty([k[y][_] for y in range(h)]):
				for y in range(h):
					k[y].pop(_)
					pix[y].pop(_)
				w -= 1

	# output in color
	if parsed.color:
		# color encodings
		end, gray, red, green, yellow, blue, magenta, cyan, white = "\033[0m", "\033[1;30m", "\033[1;31m", "\033[1;32m", "\033[1;33m", "\033[1;34m", "\033[1;35m", "\033[1;36m", "\033[1;37m"

		# color array, so that c[r][g][b] is the desired encoding for r, g, b in [0, 1]
		c = [[[gray, blue], [green, cyan]], [[red, magenta], [yellow, white]]]

		# determine min and max color values
		rmin, rmax = 255, 0
		gmin, gmax = 255, 0
		bmin, bmax = 255, 0
		for y in range(h):
			for x in range(w):
				rv, gv, bv = pix[y][x]
				if rv < rmin: rmin = rv
				if rv > rmax: rmax = rv
				if gv < gmin: gmin = gv
				if gv > gmax: gmax = gv
				if bv < bmin: bmin = bv
				if bv > bmax: bmax = bv
		rdelta, gdelta, bdelta = rmax-rmin, gmax-gmin, bmax-bmin
		rmiddle, gmiddle, bmiddle = rmin+rdelta//2, gmin+gdelta//2, bmin+bdelta//2

		# old one-liner (ineffective, as characters sharing color and position get their own color tags)
		# img = "\n".join(["".join([c[pix[y][x][0]>rmiddle][pix[y][x][1]>gmiddle][pix[y][x][2]>bmiddle] + charset[k[y][x]] + end for x in range(w)]) for y in range(h)])
		
		# image string, current color
		img, cclr = "", None
		
		# loop through pixels
		for y in range(h):
			for x in range(w):
				# determine character's color
				clr = c[pix[y][x][0]>rmiddle][pix[y][x][1]>gmiddle][pix[y][x][2]>bmiddle]
				
				# check if color changed, act accordingly
				if clr != cclr: img += end + clr; cclr = clr
				
				# add character
				img += charset[k[y][x]]
			
			# new line
			img += "\n"
		
		# remove last new line
		img = img[:-1]
		
		# add end if not yet present
		if img[-len(end):] != end: img += end
		
	# output in standard black and white
	else:
		img = "\n".join(["".join([charset[k[y][x]] for x in range(w)]) for y in range(h)])

	# return as html page
	if parsed.html or parsed.htmlx:
		return html(img, parsed)
	
	# return as normal text (color is encoded using ANSI escape codes)
	else:
		return img

# run if called as __main__
if __name__ == "__main__":
	print main()
Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s