#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ ASCII """ from io import BytesIO import argparse import requests from PIL import Image, ImageFont, ImageDraw #import imageio import numpy as np import numpngw import module from tools import web ASCII_CHARS = "$@%#*+=-:. " HEADERS = {'User-Agent': 'Gimme the ascii.'} def scale_image(image, size=(100,100)): """ Resizes an image while preserving the aspect ratio. Chooses the dimension to scale by based on whichever is larger, ensuring that neither width or height is ever larger than the accepted size tuple. Because text characters are typically pretty close to a 1:2 rectangle, we weight the width twice as much. """ original_width, original_height = image.size original_width = original_width * 2 if original_width > original_height: if original_width > size[0]: new_width = size[0] aspect_ratio = original_height/float(original_width) new_height = int(aspect_ratio * new_width) else: new_width, new_height = image.size else: if original_height > size[1]: new_height = size[1] aspect_ratio = original_width/float(original_height) new_width = int(aspect_ratio * new_height) else: new_width, new_height = image.size image = image.resize((new_width, new_height)) return image def pixels_to_chars(image, reverse=False): """ Maps each pixel to an ascii char based on where it falls in the range 0-255 normalized to the length of ASCII_CHARS. """ range_width = int(255 / len(ASCII_CHARS)) + (255 % len(ASCII_CHARS) > 0) pixels = list(image.getdata()) chars = [] for pixel in pixels: if reverse: index = -int(pixel/range_width)-1 else: index = int(pixel/range_width) chars.append(ASCII_CHARS[index]) chars = "".join(chars) chars = [chars[i:i + image.size[0]] for i in range(0, len(chars), image.size[0])] return "\n".join(chars) def char_color(pixel, code="irc"): """ Maps a color to a character based on the original pixel color. Calculates the distance from the provided color to each of the 16 colors in the IRC and ANSI color codes and selects the closest match. """ colors_index = ["white", "black", "blue", "green", "red", "brown", "purple", "orange", "yellow", "light green", "teal", "cyan", "light blue", "pink", "grey", "silver"] colors_rgb = {"white": (0,0,0), "black": (255,255,255), "blue": (0,0,255), "green": (0,255,0), "red": (255,0,0), "brown": (150,75,0), "purple": (128,0,128), "orange": (255,128,0), "yellow": (255,255,0), "light green": (191,255,0), "teal": (0,128,128), "cyan": (0,255,255), "light blue": (65,105,225), "pink":(255,192,203), "grey": (128,128,128), "silver": (192,192,192)} colors_irc = {"white": "0", "black": "1", "blue": "2", "green": "3", "red": "4", "brown": "5", "purple": "6", "orange": "7", "yellow": "8", "light green": "9", "teal": "10", "cyan": "11", "light blue": "12", "pink": "13", "grey": "14", "silver": "15"} colors_ansi = {"white": "[1;37m", "black": "[0;30m", "blue": "[0;34m", "green": "[0;32m", "red": "[0;31m", "brown": "[0;33m", "purple": "[0;35m", "orange": "[1;31m", "yellow": "[1;33m", "light green": "[1;32m", "teal": "[0;36m", "cyan": "[1;36m", "light blue": "[1;34m", "pink": "[1;35m", "grey": "[1;30m", "silver": "[0;37m"} dist = [(abs(pixel[0] - colors_rgb[color][0])**2 + abs(pixel[1] - colors_rgb[color][1])**2 + abs(pixel[2] - colors_rgb[color][2])**2)**0.5 for color in colors_index] color = colors_index[dist.index(min(dist))] if code == "irc": return colors_irc[color] elif code == "ansi": return colors_ansi[color] def open_image(imagePath): """ Opens the image at the supplied file path in PIL. If an internet URL is supplied, it will download the image and then open it. Returns a PIL image object. """ try: if imagePath.startswith("http"): res = requests.get(imagePath, headers=HEADERS, verify=True, timeout=20) res.raise_for_status() image = Image.open(BytesIO(res.content)) else: image = Image.open(imagePath) except FileNotFoundError as e: return f"File not found: {imagePath}" except Exception as e: return(f"Error opening image: {imagePath}\n{e}") return image def colorize(chars, image, code): """ Colorizes the ascii matrix. """ prefix = {"irc": "\03", "ansi":"\033"} chars = chars.split("\n") for j in range(0, image.size[1]): new_row = "" for k in range(0, image.size[0]): new_row += prefix[code] + char_color(image.getpixel((k,j)), code) new_row += chars[j][k] chars[j] = new_row return "\n".join(chars) def image_to_ascii(image, reverse=False, colors=None): """ Reads an image file and converts it to ascii art. Returns a newline-delineated string. If reverse is True, the ascii scale is reversed. """ image = scale_image(image) image_grey = image.convert('L') # convert to grayscale chars = pixels_to_chars(image_grey, reverse) if colors: chars = colorize(chars, image, colors) image.close() image_grey.close() del(image) del(image_grey) return chars def ascii_to_image(image_ascii): """ Creates a plain image and draws text on it. """ # TODO: make font type and size non-fixed width = len(image_ascii[:image_ascii.index("\n")]) * 8 height = (image_ascii.count("\n")+1) * 12 + 4 font = ImageFont.truetype("LiberationMono-Regular.ttf", 14) image = Image.new("RGB", (width, height), (255,255,255)) draw = ImageDraw.Draw(image) draw.text((0,0), image_ascii, (0,0,0), font=font, spacing=0) return image def handle_gif(image, output, reverse=False): """ Handle gifs seperately. """ image = open_image(args.imagePath) ascii_seq = [] new_image = ascii_to_image(image_to_ascii(image, reverse)) image.seek(1) while True: try: im = ascii_to_image(image_to_ascii(image, reverse)) ascii_seq.append(im) image.seek(image.tell()+1) except EOFError: break # end of sequence #new_image.save(output, save_all=True, append_images=ascii_seq, # duration=60, loop=0, optimize=True) ascii_seq = [new_image] + ascii_seq np_ascii_seq = [np.array(im) for im in ascii_seq] with open(output, "wb") as file: numpngw.write_apng(file, np_ascii_seq) @module.rate(user=60) @module.require_chanmsg(message="It's impolite to whisper.") @module.commands('ascii') @module.example('.ascii [-rc] https://www.freshports.org/images/freshports.jpg') def ascii(bot, trigger): """ Downloads an image and converts it to ascii. """ parser = argparse.ArgumentParser() parser.add_argument("imagePath") parser.add_argument("-r", "--reverse", action="store_true") parser.add_argument("-c", "--color", action="store_true") parser.set_defaults(reverse=False, color=False) args = parser.parse_args(trigger.group(2).split()) if args.color: args.color = "irc" if not web.secCheck(bot, args.imagePath): return bot.reply("Known malicious site. Ignoring.") if not args.imagePath.startswith("http"): bot.reply("Internet requests only.") return image = open_image(args.imagePath) image_ascii = image_to_ascii(image, args.reverse, args.color) bot.say(image_ascii) if __name__=='__main__': parser = argparse.ArgumentParser( description="Converts an image file to ascii art.") parser.add_argument( "imagePath", help="The full path to the image file.") parser.add_argument( "-r", "--reverse", action="store_true", help="Reverses the ascii scale.") parser.add_argument( "-o", "--output", help="Outputs the ascii art into a file at the specified path.") parser.add_argument( "-i", "--image", action="store_true", help="Outputs the ascii art as an image rather than plain text. \ Requires --output.") parser.add_argument( "-a", "--animated", action="store_true", help="Handles animated GIFs. Includes --image.") parser.add_argument( "-c", "--color", type=str, help="Colorizes the ascii matrix.") parser.set_defaults(reverse=False, image=False, animated=False) args = parser.parse_args() if args.animated: # --animated includes --image args.image = True if args.image: # --image requires --output if not args.output: parser.error("--image requires --output") image = open_image(args.imagePath) if args.animated: handle_gif(image, args.output, args.reverse) exit() image_ascii = image_to_ascii(image, args.reverse, args.color) if args.image: image = ascii_to_image(image_ascii) image.save(args.output, "PNG") elif args.output: with open(args.output, "w+") as file: file.write(image_ascii) else: print(image_ascii)