#!/usr/bin/env python # -*- coding: utf-8 -*- (""" Usage: img2txt.py [--maxLen=] [--fontSize=] [--color] [--ansi]""" """[--bgcolor=<#RRGGBB>] [--targetAspect=] [--antialias] [--dither] img2txt.py (-h | --help) Options: -h --help show this screen. --ansi output an ANSI rendering of the image --color output a colored HTML rendering of the image. --antialias causes any resizing of the image to use antialiasing --dither dither the colors to web palette. Useful when converting images to ANSI (which has a limited color palette) --fontSize= sets font size (in pixels) when outputting HTML, default: 7 --maxLen= resize image so that larger of width or height matches maxLen, default: 100px --bgcolor=<#RRGGBB> if specified, is blended with transparent pixels to produce the output. In ansi case, if no bgcolor set, a fully transparent pixel is not drawn at all, partially transparent pixels drawn as if opaque --targetAspect= resize image to this ratio of width to height. Default is 1.0 (no resize). For a typical terminal where height of a character is 2x its width, you might want to try 0.5 here """) import sys, os from docopt import docopt from PIL import Image sys.path.append(os.getcwd()) from graphics_util import alpha_blend def HTMLColorToRGB(colorstring): """ convert #RRGGBB to an (R, G, B) tuple """ colorstring = colorstring.strip() if colorstring[0] == '#': colorstring = colorstring[1:] if len(colorstring) != 6: raise ValueError( "input #{0} is not in #RRGGBB format".format(colorstring)) r, g, b = colorstring[:2], colorstring[2:4], colorstring[4:] r, g, b = [int(n, 16) for n in (r, g, b)] return (r, g, b) def generate_HTML_for_image(pixels, width, height): string = "" # first go through the height, otherwise will rotate for h in range(height): for w in range(width): rgba = pixels[w, h] # TODO - could optimize output size by keeping span open until we # hit end of line or different color/alpha # Could also output just rgb (not rgba) when fully opaque - if # fully opaque is prevalent in an image # those saved characters would add up string += ("" "▇").format( rgba[0], rgba[1], rgba[2], rgba[3] / 255.0) string += "\n" return string def generate_grayscale_for_image(pixels, width, height, bgcolor): # grayscale color = "MNHQ$OC?7>!:-;. " string = "" # first go through the height, otherwise will rotate for h in range(height): for w in range(width): rgba = pixels[w, h] # If partial transparency and we have a bgcolor, combine with bg # color if rgba[3] != 255 and bgcolor is not None: rgba = alpha_blend(rgba, bgcolor) # Throw away any alpha (either because bgcolor was partially # transparent or had no bg color) # Could make a case to choose character to draw based on alpha but # not going to do that now... rgb = rgba[:3] string += color[int(sum(rgb) / 3.0 / 256.0 * 16)] string += "\n" return string def load_and_resize_image(imgname, antialias, maxLen, aspectRatio): if aspectRatio is None: aspectRatio = 1.0 img = Image.open(imgname) # force image to RGBA - deals with palettized images (e.g. gif) etc. if img.mode != 'RGBA': img = img.convert('RGBA') # need to change the size of the image? if maxLen is not None or aspectRatio != 1.0: native_width, native_height = img.size new_width = native_width new_height = native_height # First apply aspect ratio change (if any) - just need to adjust one axis # so we'll do the height. if aspectRatio != 1.0: new_height = int(float(aspectRatio) * new_height) # Now isotropically resize up or down (preserving aspect ratio) such that # longer side of image is maxLen if maxLen is not None: rate = float(maxLen) / max(new_width, new_height) new_width = int(rate * new_width) new_height = int(rate * new_height) if native_width != new_width or native_height != new_height: img = img.resize((new_width, new_height), Image.ANTIALIAS if antialias else Image.NEAREST) return img def floydsteinberg_dither_to_web_palette(img): # Note that alpha channel is thrown away - if you want to keep it you need to deal with it yourself # # Here's how it works: # 1. Convert to RGB if needed - we can't go directly from RGBA because Image.convert will not dither in this case # 2. Convert to P(alette) mode - this lets us kick in dithering. # 3. Convert back to RGBA, which is where we want to be # # Admittedly converting back and forth requires more memory than just dithering directly # in RGBA but that's how the library works and it isn't worth writing it ourselves # or looking for an alternative given current perf needs. if img.mode != 'RGB': img = img.convert('RGB') img = img.convert(mode="P", matrix=None, dither=Image.FLOYDSTEINBERG, palette=Image.WEB, colors=256) img = img.convert('RGBA') return img def dither_image_to_web_palette(img, bgcolor): if bgcolor is not None: # We know the background color so flatten the image and bg color together, thus getting rid of alpha # This is important because as discussed below, dithering alpha doesn't work correctly. img = Image.alpha_composite(Image.new("RGBA", img.size, bgcolor), img) # alpha blend onto image filled with bgcolor dithered_img = floydsteinberg_dither_to_web_palette(img) else: """ It is not possible to correctly dither in the presence of transparency without knowing the background that the image will be composed onto. This is because dithering works by propagating error that is introduced when we select _available_ colors that don't match the _desired_ colors. Without knowing the final color value for a pixel, it is not possible to compute the error that must be propagated FROM it. If a pixel is fully or partially transparent, we must know the background to determine the final color value. We can't even record the incoming error for the pixel, and then later when/if we know the background compute the full error and propagate that, because that error needs to propagate into the original color selection decisions for the other pixels. Those decisions absorb error and are lossy. You can't later apply more error on top of those color decisions and necessarily get the same results as applying that error INTO those decisions in the first place. So having established that we could only handle transparency correctly at final draw-time, shouldn't we just dither there instead of here? Well, if we don't know the background color here we don't know it there either. So we can either not dither at all if we don't know the bg color, or make some approximation. We've chosen the latter. We'll handle it here to make the drawing code simpler. So what is our approximation? We basically just ignore any color changes dithering makes to pixels that have transparency, and prevent any error from being propagated from those pixels. This is done by setting them all to black before dithering (using an exact-match color in Floyd Steinberg dithering with a web-safe-palette will never cause a pixel to receive enough inbound error to change color and thus will not propagate error), and then afterwards we set them back to their original values. This means that transparent pixels are essentially not dithered - they ignore (and absorb) inbound error but they keep their original colors. We could alternately play games with the alpha channel to try to propagate the error values for transparent pixels through to when we do final drawing but it only works in certain cases and just isn't worth the effort (which involves writing the dithering code ourselves for one thing). """ # Force image to RGBA if it isn't already - simplifies the rest of the code if img.mode != 'RGBA': img = img.convert('RGBA') rgb_img = img.convert('RGB') orig_pixels = img.load() rgb_pixels = rgb_img.load() width, height = img.size for h in range(height): # set transparent pixels to black for w in range(width): if (orig_pixels[w, h])[3] != 255: rgb_pixels[w, h] = (0, 0, 0) # bashing in a new value changes it! dithered_img = floydsteinberg_dither_to_web_palette(rgb_img) dithered_pixels = dithered_img.load() # must do it again for h in range(height): # restore original RGBA for transparent pixels for w in range(width): if (orig_pixels[w, h])[3] != 255: dithered_pixels[w, h] = orig_pixels[w, h] # bashing in a new value changes it! return dithered_img def img2str(memePath): imgname = memePath maxLen = 100 clr = None fontSize = 7 bgcolor = None antialias = None dither = None target_aspect_ratio = .8 try: maxLen = float(maxLen) except: maxLen = 100.0 # default maxlen: 100px try: fontSize = int(fontSize) except: fontSize = 7 try: # add fully opaque alpha value (255) bgcolor = HTMLColorToRGB(bgcolor) + (255, ) except: bgcolor = None try: target_aspect_ratio = float(target_aspect_ratio) except: target_aspect_ratio = 1.0 # default target_aspect_ratio: 1.0 try: img = load_and_resize_image(imgname, antialias, maxLen, target_aspect_ratio) except IOError: exit("File not found: " + imgname) # Dither _after_ resizing if dither: img = dither_image_to_web_palette(img, bgcolor) # get pixels pixel = img.load() width, height = img.size if clr: # TODO - should handle bgcolor - probably by setting it as BG on # the CSS for the pre string = generate_HTML_for_image(pixel, width, height) else: string = generate_grayscale_for_image( pixel, width, height, bgcolor) # wrap with html template = """
%s
""" return string sys.stdout.flush()