From 6e67a63d739f3cecbc1d2b8f6ff1a7baa079766e Mon Sep 17 00:00:00 2001 From: iou1name Date: Sun, 3 Dec 2017 20:46:10 -0500 Subject: [PATCH] added brail and animated gif support --- modules/ascii.py | 655 ++++++++++++++++++++++++++--------------------- 1 file changed, 361 insertions(+), 294 deletions(-) diff --git a/modules/ascii.py b/modules/ascii.py index 5e180a9..7303c49 100755 --- a/modules/ascii.py +++ b/modules/ascii.py @@ -1,294 +1,361 @@ -#! /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) +#! /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 = "$@%#*+=-:. " +BRAIL_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, scale="ascii"): + """ + Maps each pixel to an ascii char based on where it falls in the range + 0-255 normalized to the length of ASCII_CHARS. + """ + scales = {"ascii": ASCII_CHARS, + "ascii_reverse": "".join(list(reversed(ASCII_CHARS))), + "brail": BRAIL_CHARS, + "brail_reverse": "".join(list(reversed(BRAIL_CHARS)))} + range_width = int(255 / len(scales[scale])) + (255 % len(scales[scale]) > 0) + + pixels = list(image.getdata()) + chars = [] + for pixel in pixels: + index = int(pixel/range_width) + chars.append(scales[scale][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 alpha_composite(front, back): + """Alpha composite two RGBA images. + + Source: http://stackoverflow.com/a/9166671/284318 + + Keyword Arguments: + front -- PIL RGBA Image object + back -- PIL RGBA Image object + + """ + front = np.asarray(front) + back = np.asarray(back) + result = np.empty(front.shape, dtype='float') + alpha = np.index_exp[:, :, 3:] + rgb = np.index_exp[:, :, :3] + falpha = front[alpha] / 255.0 + balpha = back[alpha] / 255.0 + result[alpha] = falpha + balpha * (1 - falpha) + old_setting = np.seterr(invalid='ignore') + result[rgb] = (front[rgb] * falpha + back[rgb] * balpha * (1 - falpha)) / result[alpha] + np.seterr(**old_setting) + result[alpha] *= 255 + np.clip(result, 0, 255) + # astype('uint8') maps np.nan and np.inf to 0 + result = result.astype('uint8') + result = Image.fromarray(result, 'RGBA') + return result + + +def alpha_composite_with_color(image, color=(255, 255, 255)): + """Alpha composite an RGBA image with a single color image of the + specified color and the same size as the original image. + + Keyword Arguments: + image -- PIL RGBA Image object + color -- Tuple r, g, b (default 255, 255, 255) + + """ + back = Image.new('RGBA', size=image.size, color=color + (255,)) + return alpha_composite(image, back) + + +def image_to_ascii(image, reverse=False, colors=None, brail=False): + """ + Reads an image file and converts it to ascii art. Returns a + newline-delineated string. If reverse is True, the ascii scale is + reversed. + """ + if image.mode == "RGBA": + image = alpha_composite_with_color(image).convert("RGB") + + image = scale_image(image) + image_grey = image.convert('L') # convert to grayscale + + if reverse: + if brail: + scale = "brail_reverse" + else: + scale = "ascii_reverse" + else: + if brail: + scale = "brail" + else: + scale = "ascii" + chars = pixels_to_chars(image_grey, scale) + + 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. + """ + if not trigger.group(2): + return bot.say() + parser = argparse.ArgumentParser() + parser.add_argument("imagePath") + parser.add_argument("-r", "--reverse", action="store_true") + parser.add_argument("-c", "--color", action="store_true") + parser.add_argument("-b", "--brail", action="store_true") + parser.add_argument("-a", "--animated", action="store_true") + 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) + if args.animated: + handle_gif(image, "temp.png", args.reverse) + file = {"file": open("temp.png", "rb")} + res = requests.post("https://uguu.se/api.php?d=upload-tool", files=file) + # print(res.text) + bot.say(res.text) + else: + image_ascii = image_to_ascii(image, args.reverse, args.color, args.brail) + 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)