Given a black-and-white image in any reasonable lossless format as input, output ASCII art that is as close to the input image as possible.
Rules
- Only linefeeds and ASCII bytes 32-127 may be used.
- The input image will be cropped so that there is no extraneous whitespace surrounding the image.
- Submissions must be able to complete the entire scoring corpus in under 5 minutes.
- Only raw text is acceptable; no rich text formats.
- The font used in the scoring is 20-pt Linux Libertine.
- The output text file, when converted to an image as described below, must be the same dimensions as the input image, within 30 pixels in either dimension.
Scoring
These images will be used for scoring:
You can download a zipfile of the images here.
Submissions should not be optimized for this corpus; rather, they should work for any 8 black-and-white images of similar dimensions. I reserve the right to change the images in the corpus if I suspect submissions are being optimized for these specific images.
The scoring will be performed via this script:
#!/usr/bin/env python
from __future__ import print_function
from __future__ import division
# modified from http://stackoverflow.com/a/29775654/2508324
# requires Linux Libertine fonts - get them at https://sourceforge.net/projects/linuxlibertine/files/linuxlibertine/5.3.0/
# requires dssim - get it at https://github.com/pornel/dssim
import PIL
import PIL.Image
import PIL.ImageFont
import PIL.ImageOps
import PIL.ImageDraw
import pathlib
import os
import subprocess
import sys
PIXEL_ON = 0 # PIL color to use for "on"
PIXEL_OFF = 255 # PIL color to use for "off"
def dssim_score(src_path, image_path):
out = subprocess.check_output(['dssim', src_path, image_path])
return float(out.split()[0])
def text_image(text_path):
"""Convert text file to a grayscale image with black characters on a white background.
arguments:
text_path - the content of this file will be converted to an image
"""
grayscale = 'L'
# parse the file into lines
with open(str(text_path)) as text_file: # can throw FileNotFoundError
lines = tuple(l.rstrip() for l in text_file.readlines())
# choose a font (you can see more detail in my library on github)
large_font = 20 # get better resolution with larger size
if os.name == 'posix':
font_path = '/usr/share/fonts/linux-libertine/LinLibertineO.otf'
else:
font_path = 'LinLibertine_DRah.ttf'
try:
font = PIL.ImageFont.truetype(font_path, size=large_font)
except IOError:
print('Could not use Libertine font, exiting...')
exit()
# make the background image based on the combination of font and lines
pt2px = lambda pt: int(round(pt * 96.0 / 72)) # convert points to pixels
max_width_line = max(lines, key=lambda s: font.getsize(s)[0])
# max height is adjusted down because it's too large visually for spacing
test_string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
max_height = pt2px(font.getsize(test_string)[1])
max_width = pt2px(font.getsize(max_width_line)[0])
height = max_height * len(lines) # perfect or a little oversized
width = int(round(max_width + 40)) # a little oversized
image = PIL.Image.new(grayscale, (width, height), color=PIXEL_OFF)
draw = PIL.ImageDraw.Draw(image)
# draw each line of text
vertical_position = 5
horizontal_position = 5
line_spacing = int(round(max_height * 0.8)) # reduced spacing seems better
for line in lines:
draw.text((horizontal_position, vertical_position),
line, fill=PIXEL_ON, font=font)
vertical_position += line_spacing
# crop the text
c_box = PIL.ImageOps.invert(image).getbbox()
image = image.crop(c_box)
return image
if __name__ == '__main__':
compare_dir = pathlib.PurePath(sys.argv[1])
corpus_dir = pathlib.PurePath(sys.argv[2])
images = []
scores = []
for txtfile in os.listdir(str(compare_dir)):
fname = pathlib.PurePath(sys.argv[1]).joinpath(txtfile)
if fname.suffix != '.txt':
continue
imgpath = fname.with_suffix('.png')
corpname = corpus_dir.joinpath(imgpath.name)
img = text_image(str(fname))
corpimg = PIL.Image.open(str(corpname))
img = img.resize(corpimg.size, PIL.Image.LANCZOS)
corpimg.close()
img.save(str(imgpath), 'png')
img.close()
images.append(str(imgpath))
score = dssim_score(str(corpname), str(imgpath))
print('{}: {}'.format(corpname, score))
scores.append(score)
print('Score: {}'.format(sum(scores)/len(scores)))
The scoring process:
- Run the submission for each corpus image, outputting the results to
.txt
files with the same stem as the corpus file (done manually). - Convert each text file to a PNG image, using 20-point font, cropping out whitespace.
- Resize the result image to the dimensions of the original image using Lanczos resampling.
- Compare each text image with the original image using
dssim
. - Output the dssim score for each text file.
- Output the average score.
Structural Similarity (the metric by which dssim
calculates scores) is a metric based on human vision and object identification in images. To put it plainly: if two images look similar to humans, they will (probably) have a low score from dssim
.
The winning submission will be the submission with the lowest average score.
1 Answer 1
Java, score 0.57058675
This is actually my first time doing image manipulation so it's kind of awkward but I think it turned out OK.
I couldn't get dssim to work on my machine, but I was able to make images using PIL.
Interestingly, the font tells me in Java that each of the characters I'm using are width 6
. You can see that in my program FontMetrics::charWidth
is 6
for all characters that I have used. The {}
logo looks pretty decent in a monospace font. But for some reason the lines do not actually line up in the full text file. I blame ligatures. (And yes, I should be using the correct font.)
In monospaced font:
.
.,:ff:, ,:fff::,.
,ff .fIIIIIf, .:fIIIIIf.:f:.
.,:III: ,ff:: ..,, ,,.. ,:fff, IIII.,
:IIf,f:,:fff:, .:fIIIIIII. .IIIIIIIf:. .,:fff:,ff IIf,
,.fIIIf,:ffff, ,IIIIIII:,,. .,,:IIIIIII. .:ffff:,IIII,:.
,III.::.,,,,,. IIIIII: ,IIIIII ,,,,,.,:,:IIf
IIIII :ffIIf, IIIIII, .IIIIII :IIIf:,.IIIIf.
,II,fIf.:::,.. IIIIII, .IIIIII ..,:::,,If::II
IIIIf. ,:fII: .IIIIII, .IIIIII. IIff:. :IIII:
::IIIIf:IIIf: . ,::fIIIIIII, ,fIIIIIIf::, ,ffIII,IIIIf,,
:IIf::: .,fI: IIIIIIIII: :IIIIIIIIf If:, .::fIIf
IIIIII, :IIIIf .,:IIIIIIf fIIIIII:,. ,IIIII. fIIIII:
,:IIIII ff:, f, IIIIII, .IIIIII f. .::f::IIIIf,.
fIf::,, ,fIII IIIIII, .IIIIII :III: ,,:fII.
fIIIIIIf, :IIIIf , IIIIII, .IIIIII ., ,IIIII. :fIIIIII,
.:IIIIIII,ff, :II: IIIIIIf fIIIIII .fII. .:ff:IIIIIIf,
:fffff:, IIIIIf , :IIIIIIIfff fffIIIIIII: .. IIIII: ::fffff,
.fIIIIIIIf:, fIIII, ,IIf, ,:ffIIII. .IIIIff:, .:fII fIIII,.:ffIIIIIII:
,fIIIIIIIIIf:, ,IIIII: .,::, .,::, .IIIIII ::fIIIIIIIIf:.
:fffffff, .fIIIII, .IIIIIf: ,:fIIII: IIIIII: :fffffff,
.:fIIIIIIIIIIIIffffI: IIIIIIII. :IIIIIII: .fIffffIIIIIIIIIIII:,
,:fIIIIIIIIIIIf, .:fIIIII ,IIIIIf, :IIIIIIIIIIIff,.
.:ffffffffIIIIIIIIIIIfff:. ,ffffIIIIIIIIIIIfffffff:,
.,:ffIIIIIIIIIIIIIIIIf, .,,,,. .:fIIIIIIIIIIIIIIIIff:,.
....... .,,:fffff:.,:fffff:,. .......
..,,:fffIIIIf:,. .,:fIIIIff::,,..
.IIIIIf:,. .,:fIIIII
f, ,f
After running it through the image tool:
Anyway, here's the actual code.
//package cad97;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Rectangle;
import java.awt.Toolkit;
import java.awt.image.BufferedImage;
import java.awt.image.Raster;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import javax.imageio.ImageIO;
public final class AsciiArt {
private static final Font LINUX_LIBERTINE = new Font("LinLibertine_DRah", Font.PLAIN, 20);
private static final FontMetrics LL_METRICS = Toolkit.getDefaultToolkit().getFontMetrics(LINUX_LIBERTINE);
// Toolkit::getFontMetrics is deprecated, but that's the only way to get FontMetrics without an explicit Graphics environment.
// If there's a better way to get the widths of characters, please tell me.
public static void main(String[] args) throws IOException {
File jar = new java.io.File(AsciiArt.class.getProtectionDomain().getCodeSource().getLocation().getPath());
if (args.length != 1) {
String jarName = jar.getName();
System.out.println("Usage: java -jar " + jarName + " file");
} else {
File image = new File(args[0]);
try (InputStream input = new FileInputStream(image)) {
String art = createAsciiArt(ImageIO.read(input), LINUX_LIBERTINE, LL_METRICS);
System.out.print(art); // If you want to save as a file, change this.
} catch (FileNotFoundException fnfe) {
System.out.println("Unable to find file " + image + ".");
System.out.println("Please note that you need to pass the full file path.");
}
}
}
private static String createAsciiArt(BufferedImage image, Font font, FontMetrics metrics) {
final int height = metrics.getHeight();
final Map<Character,Integer> width = new HashMap<>();
for (char c=32; c<127; c++) { width.put(c, metrics.charWidth(c)); }
StringBuilder art = new StringBuilder();
for (int i=0; i<=image.getHeight(); i+=height) {
final int tempHeight = Math.min(height, image.getHeight()-i);
art.append(createAsciiLine(image.getSubimage(0, i, image.getWidth(), tempHeight), width));
}
return art.toString();
}
private static String createAsciiLine(BufferedImage image, Map<Character,Integer> charWidth) {
if (image.getWidth()<6) return "\n";
/*
I'm passing in the charWidth Map because I could use it, and probably a later revision if I
come back to this will actually use non-6-pixel-wide characters. As is, I'm only using the
6-pixel-wide characters for simplicity. They are those in this set: { !,./:;I[\]ft|}
*/
assert charWidth.get(' ') == 6; assert charWidth.get('!') == 6;
assert charWidth.get(',') == 6; assert charWidth.get('.') == 6;
assert charWidth.get('/') == 6; assert charWidth.get(':') == 6;
assert charWidth.get(';') == 6; assert charWidth.get('I') == 6;
assert charWidth.get('[') == 6; assert charWidth.get('\\') == 6;
assert charWidth.get(']') == 6; assert charWidth.get('f') == 6;
assert charWidth.get('t') == 6; assert charWidth.get('|') == 6;
// Measure whiteness of 6-pixel-wide sample
Raster sample = image.getData(new Rectangle(6, image.getHeight()));
int whiteCount = 0;
for (int x=sample.getMinX(); x<sample.getMinX()+sample.getWidth(); x++) {
for (int y=sample.getMinY(); y<sample.getMinY()+sample.getHeight(); y++) {
int pixel = sample.getPixel(x, y, new int[1])[0];
whiteCount += pixel==1?0:1;
}
}
char next;
int area = sample.getWidth()*sample.getHeight();
if (whiteCount > area*0.9) {
next = ' ';
} else if (whiteCount > area*0.8) {
next = '.';
} else if (whiteCount > area*0.65) {
next = ',';
} else if (whiteCount > area*0.5) {
next = ':';
} else if (whiteCount > area*0.3) {
next = 'f';
} else {
next = 'I';
}
return next + createAsciiLine(image.getSubimage(charWidth.get(','), 0, image.getWidth()-sample.getWidth(), image.getHeight()), charWidth);
}
}
Compile:
- Make sure you have the JDK installed
- Make sure that the JDK bin is on your PATH (for me it's
C:\Program Files\Java\jdk1.8.0_91\bin
) - Save the file as
AsciiArt.java
javac AsciiArt.java
jar cvfe WhateverNameYouWant.jar AsciiArt AsciiArt.class
Usage: java -jar WhateverNameYouWant.jar C:\full\file\path.png
, prints to STDOUT
REQUIRES the source file to be saved with 1-bit depth and the sample for a white pixel to be 1
.
Scoring output:
corp/board.png: 0.6384
corp/Doppelspalt.png: 0.605746
corp/down.png: 1.012326
corp/img2.png: 0.528794
corp/pcgm.png: 0.243618
corp/peng.png: 0.440982
corp/phi.png: 0.929552
corp/text2image.png: 0.165276
Score: 0.57058675
-
1\$\begingroup\$ Run with
-ea
to enable assertions. It won't change the behavior (except maybe slow it down a small amount) because assertions work by failing the program when they evaluate tofalse
and all these assertions pass. \$\endgroup\$CAD97– CAD972016年06月23日 21:32:09 +00:00Commented Jun 23, 2016 at 21:32 -
\$\begingroup\$ Ahh, I missed that you removed the package declaration. It works now. I'll score it when I get a few minutes today. \$\endgroup\$user45941– user459412016年06月23日 21:34:38 +00:00Commented Jun 23, 2016 at 21:34
-
\$\begingroup\$ The output for board.png is only 4 lines long for some reason: gist.github.com/Mego/75eccefe555a81bde6022d7eade1424f. In fact, all of the output seems to be prematurely truncated when I run it, with the exception of the PPCG logo. \$\endgroup\$user45941– user459412016年06月23日 22:11:01 +00:00Commented Jun 23, 2016 at 22:11
-
\$\begingroup\$ @Mego I think it has to do with the height of the font (24 px by the FontMetrics report). I changed the line loop so it errs on the side of one too many lines rather than one too few, and it should work now. (board is 5 lines) \$\endgroup\$CAD97– CAD972016年06月23日 22:36:25 +00:00Commented Jun 23, 2016 at 22:36
-
\$\begingroup\$ Just as a rule this algorithm struggles with the smaller images, since (it thinks) all of the characters are 6px wide and 24px tall, and all it looks at are how many pixels are turned on in that super-pixel. \$\endgroup\$CAD97– CAD972016年06月23日 22:39:26 +00:00Commented Jun 23, 2016 at 22:39
.txt
files"? Should the program output text that will get piped to a file or should we output a file directly? \$\endgroup\$