sopel/modules/ascii.py

345 lines
9.8 KiB
Python
Raw Normal View History

2018-01-18 23:43:27 -05:00
#!/usr/bin/env python3
2017-12-03 20:46:10 -05:00
"""
ASCII
"""
from io import BytesIO
import argparse
2017-12-03 20:46:10 -05:00
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 = "⠿⠾⠼⠸⠰⠠ "
2017-12-09 01:12:35 -05:00
HEADERS = {'User-Agent': 'Gimme ascii.'}
2017-12-03 20:46:10 -05:00
2017-12-09 01:12:35 -05:00
def scale_image(image, maxDim=100):
2017-12-03 20:46:10 -05:00
"""
Resizes an image while preserving the aspect ratio. Chooses the
dimension to scale by based on whichever is larger, ensuring that
2017-12-09 01:12:35 -05:00
neither width or height is ever larger than the maxDim argument.
2017-12-03 20:46:10 -05:00
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
2017-12-09 01:12:35 -05:00
if original_width <= maxDim and original_height <= maxDim:
new_width, new_height = image.size
elif original_width > original_height:
new_width = maxDim
aspect_ratio = original_height/float(original_width)
new_height = int(aspect_ratio * new_width)
2017-12-03 20:46:10 -05:00
else:
2017-12-09 01:12:35 -05:00
new_height = maxDim
aspect_ratio = original_width/float(original_height)
new_width = int(aspect_ratio * new_height)
2017-12-11 20:03:11 -05:00
image = image.resize((new_width, new_height), Image.ANTIALIAS)
2017-12-03 20:46:10 -05:00
return image
2017-12-09 01:12:35 -05:00
def pixels_to_chars(image, scale="ascii", color_code=None):
2017-12-03 20:46:10 -05:00
"""
Maps each pixel to an ascii char based on where it falls in the range
2017-12-09 01:12:35 -05:00
0-255 normalized to the length of the chosen scale.
2017-12-03 20:46:10 -05:00
"""
scales = {"ascii": ASCII_CHARS,
2017-12-09 01:12:35 -05:00
"ascii_reverse": "".join(reversed(ASCII_CHARS)),
2017-12-03 20:46:10 -05:00
"brail": BRAIL_CHARS,
2017-12-09 01:12:35 -05:00
"brail_reverse": "".join(reversed(BRAIL_CHARS))}
color_prefix = {"irc": "\03", "ansi":"\033"}
2017-12-03 20:46:10 -05:00
range_width = int(255 / len(scales[scale])) + (255 % len(scales[scale]) > 0)
pixels = list(image.getdata())
2017-12-09 01:12:35 -05:00
pixels = [pixels[i:i + image.size[0]] for i in range(0, len(pixels),
2017-12-03 20:46:10 -05:00
image.size[0])]
2017-12-09 01:12:35 -05:00
chars = []
for row in pixels:
new_row = ""
for pixel in row:
R, G, B = pixel
L = R * 299/1000 + G * 587/1000 + B * 114/1000
index = int(L/range_width)
char = scales[scale][index]
if color_code and char is not " ":
prefix = color_prefix[color_code] + char_color(pixel,color_code)
char = prefix + char
new_row += char
chars.append(new_row)
2017-12-03 20:46:10 -05:00
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)
2017-12-09 01:12:35 -05:00
if res.status_code == 404:
return "404: file not found."
2017-12-03 20:46:10 -05:00
res.raise_for_status()
image = Image.open(BytesIO(res.content))
else:
image = Image.open(imagePath)
2017-12-09 01:12:35 -05:00
except FileNotFoundError:
2017-12-03 20:46:10 -05:00
return f"File not found: {imagePath}"
except Exception as e:
return(f"Error opening image: {imagePath}\n{e}")
return image
def alpha_composite(image, color=(255, 255, 255)):
2017-12-03 20:46:10 -05:00
"""
Alpha composite an RGBA Image with a specified color.
Source: http://stackoverflow.com/a/9166671/284318
2017-12-03 20:46:10 -05:00
"""
image.load() # needed for split()
background = Image.new('RGB', image.size, color)
background.paste(image, mask=image.split()[3]) # 3 is the alpha channel
return background
2017-12-03 20:46:10 -05:00
2017-12-09 01:12:35 -05:00
def image_to_ascii(image=None, reverse=False, brail=False, color=None,**kwargs):
2017-12-03 20:46:10 -05:00
"""
Reads an image file and converts it to ascii art. Returns a
newline-delineated string. If reverse is True, the ascii scale is
reversed.
"""
2017-12-09 01:12:35 -05:00
if not image:
image = open_image(kwargs["imagePath"])
if image.mode == "P":
image = image.convert(image.palette.mode)
2017-12-03 20:46:10 -05:00
if image.mode == "RGBA":
image = alpha_composite(image).convert("RGB")
if image.mode == "L":
image = image.convert("RGB")
2017-12-03 20:46:10 -05:00
image = scale_image(image)
2017-12-09 01:12:35 -05:00
if brail:
scale = "brail"
2017-12-03 20:46:10 -05:00
else:
2017-12-09 01:12:35 -05:00
scale = "ascii"
if reverse:
scale += "_reverse"
chars = pixels_to_chars(image, scale, color)
2017-12-03 20:46:10 -05:00
image.close()
del(image)
return chars
def ascii_to_image(image_ascii):
"""
Creates a plain image and draws text on it.
"""
2017-12-09 01:12:35 -05:00
# TODO: make font type, size and color non-fixed
2017-12-03 20:46:10 -05:00
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
2017-12-09 01:12:35 -05:00
def handle_gif(imagePath, **kwargs):
2017-12-03 20:46:10 -05:00
"""
Handle gifs seperately.
"""
2017-12-09 01:12:35 -05:00
image = open_image(imagePath)
2017-12-03 20:46:10 -05:00
ascii_seq = []
2017-12-09 01:12:35 -05:00
new_image = ascii_to_image(image_to_ascii(image, **kwargs))
2017-12-03 20:46:10 -05:00
image.seek(1)
while True:
try:
2017-12-09 01:12:35 -05:00
im = ascii_to_image(image_to_ascii(image, **kwargs))
2017-12-03 20:46:10 -05:00
ascii_seq.append(im)
image.seek(image.tell()+1)
except EOFError:
break # end of sequence
2017-12-09 01:12:35 -05:00
# new_image.save(output, save_all=True, append_images=ascii_seq,
# duration=60, loop=0, optimize=True)
2017-12-03 20:46:10 -05:00
ascii_seq = [new_image] + ascii_seq
np_ascii_seq = [np.array(im) for im in ascii_seq]
2017-12-09 01:12:35 -05:00
with open(kwargs["output"], "wb") as file:
2017-12-03 20:46:10 -05:00
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()
2017-12-09 01:12:35 -05:00
parser = argparse.ArgumentParser(add_help=False)
2017-12-03 20:46:10 -05:00
parser.add_argument("imagePath")
2017-12-09 01:12:35 -05:00
parser.add_argument("-r", "--reverse", action="store_true", help="Reverse.")
2017-12-03 20:46:10 -05:00
parser.add_argument("-c", "--color", action="store_true")
parser.add_argument("-b", "--brail", action="store_true")
parser.add_argument("-a", "--animated", action="store_true")
2017-12-09 01:12:35 -05:00
parser.add_argument("-h", "--help", action="store_true")
2017-12-03 20:46:10 -05:00
args = parser.parse_args(trigger.group(2).split())
2017-12-09 01:12:35 -05:00
if args.help:
return bot.say(parser.print_help())
2017-12-03 20:46:10 -05:00
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
if args.animated:
2017-12-09 01:12:35 -05:00
args.output = "temp.png"
handle_gif(**vars(args))
2017-12-03 20:46:10 -05:00
file = {"file": open("temp.png", "rb")}
res = requests.post("https://uguu.se/api.php?d=upload-tool", files=file)
bot.say(res.text)
else:
2017-12-09 01:12:35 -05:00
image_ascii = image_to_ascii(None, **vars(args))
2017-12-03 20:46:10 -05:00
bot.say(image_ascii)
if __name__=='__main__':
2017-12-09 01:12:35 -05:00
import argparse
2017-12-03 20:46:10 -05:00
parser = argparse.ArgumentParser(
description="Converts an image file to ascii art.")
parser.add_argument(
"imagePath",
2017-12-09 01:12:35 -05:00
help="The path to the image file. May be a local path or internet URL.")
2017-12-03 20:46:10 -05:00
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",
2017-12-09 01:12:35 -05:00
dest="drawImage",
2017-12-03 20:46:10 -05:00
action="store_true",
2017-12-09 01:12:35 -05:00
help="Outputs the ascii art as an image rather than text. Requires \
--output.")
2017-12-03 20:46:10 -05:00
parser.add_argument(
"-a",
"--animated",
action="store_true",
help="Handles animated GIFs. Includes --image.")
parser.add_argument(
"-c",
"--color",
type=str,
2017-12-09 01:12:35 -05:00
help="Colorizes the ascii matrix. Currently supported modes are 'irc' \
and 'ansi' for generating color codes compliant with those standards.")
parser.add_argument(
"--ansi",
dest="color",
action="store_const",
const="ansi",
help="Shortcut for '--color ansi'.")
parser.add_argument(
"--irc",
dest="color",
action="store_const",
const="irc",
help="Shortcut for '--color irc'.")
parser.add_argument(
"-b",
"--brail",
action="store_true",
help="Uses brail unicode characters instead of ascii characters.")
2017-12-03 20:46:10 -05:00
args = parser.parse_args()
if args.animated: # --animated includes --image
2017-12-09 01:12:35 -05:00
args.drawImage = True
if args.drawImage: # --image requires --output
2017-12-03 20:46:10 -05:00
if not args.output:
parser.error("--image requires --output")
if args.animated:
2017-12-09 01:12:35 -05:00
handle_gif(**vars(args))
2017-12-03 20:46:10 -05:00
exit()
2017-12-09 01:12:35 -05:00
image_ascii = image_to_ascii(None, **vars(args))
if args.drawImage:
2017-12-03 20:46:10 -05:00
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)