diff --git a/README.md b/README.md index 78eca9e..3451b6c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ Designed to be portable, there is no API key needed, no local art files needed, ## Dependencies * [python](https://www.python.org/) * [chardet](https://pypi.org/project/chardet/) *(`pip install chardet`)* -* [pillow](https://pypi.org/project/pillow/) *(`pip install pillow`)* +* [numpy](https://pypi.org/project/numpy/) *(`pip install numpy`)* +* [opencv-python](https://pypi.org/project/opencv-python/) *(`pip install opencv-python`)* ## Commands | Command | Description | @@ -32,21 +33,17 @@ Designed to be portable, there is no API key needed, no local art files needed, **NOTE**: The sync & settings commands are admin only! `admin` is a *nick!user@host* mask defined in [scroll.py](https://github.com/ircart/scroll/blob/master/scroll.py) ## Settings -| Setting | Type | Description | -| ---------------- | ------------ | -------------------------------------------------------------------------------------------- | -| `flood` | int or float | delay between each command | -| `ignore` | str | directories to ignore in `.ascii random` *(comma seperated list, no spaces)* | -| `lines` | int | max lines outside of #scroll | -| `msg` | int or float | delay between each message sent | -| `paste` | boolean | enable or disable `.ascii play` | -| `png_brightness` | int or float | increase or decrease brightness for `.ascii img` output | -| `png_contrast` | int or float | increase or decrease contrast for `.ascii img` output | -| `png_effect` | str | change the effect for `.ascii img` output *(blackwhite, blue, greyscale, invert, or smooth)* | -| `png_palette` | str | palette option for `.ascii img` output *(RGB99 or RGB88)* | -| `png_width` | int | maximum width for `.ascii img` output | -| `results` | int | max results to return in `.ascii search` | - -**NOTE**: Setting **0** to `png_brightness`, `png_contrast`, or `png_effect` will disable the setting. +| Setting | Type | Description | +| --------------------- | ------------ | -------------------------------------------------------------------------------------------- | +| `flood` | int or float | delay between each command | +| `ignore` | str | directories to ignore in `.ascii random` *(comma seperated list, no spaces)* | +| `lines` | int | max lines outside of #scroll | +| `msg` | int or float | delay between each message sent | +| `paste` | boolean | enable or disable `.ascii play` | +| `png_quantize_colors` | int | quantize color option for `.ascii img` output | +| `png_palette` | str | palette option for `.ascii img` output *(RGB99 or RGB88)* | +| `png_width` | int | maximum width for `.ascii img` output | +| `results` | int | max results to return in `.ascii search` | ## Preview @@ -59,7 +56,7 @@ Come pump with us in **#scroll** on [irc.supernets.org](ircs://irc.supernets.org ## Todo - git integration to `git clone` the [ircart](https://github.com/ircart/ircart) repository & `git pull` on `.ascii sync` *(Load art files into RAM for faster pumping)* - `.ascii scroll` command to loop playing random art files *(Stopped with `.ascii stop`)* -- Add arguments to `.ascii img` for contrast, brightness, * effects *(Take them out of self.settings)* +- Add arguments to `.ascii img` for palette, width, & other options - Setting to auto convert any image link to IRC art *(Emulate link previews like on Discord LOL)* - `.ascii record` to record lines from the senders nick for uploads *(Stopped with `.ascii stop`)* - Improve randomness with `.ascii random` diff --git a/__pycache__/img2irc.cpython-39.pyc b/__pycache__/img2irc.cpython-39.pyc new file mode 100644 index 0000000..18190c2 Binary files /dev/null and b/__pycache__/img2irc.cpython-39.pyc differ diff --git a/img2irc.py b/img2irc.py index cfaf6a1..8491110 100644 --- a/img2irc.py +++ b/img2irc.py @@ -2,30 +2,25 @@ # Scroll IRC Art Bot - Developed by acidvegas in Python (https://git.acid.vegas/scroll) ''' -Pull Request: - - https://github.com/ircart/scroll/pull/3 - - Props: - - forked idea from malcom's img2irc (https://github.com/waveplate/img2irc) - - big props to wrk (wr34k) for forking this one - - brightness/contrast/effects & more added by acidvegas - -Interesting: - - https://pythonexamples.org/pillow-image-blend/ - - https://pythonexamples.org/pillow-access-rgb-channels-of-image/ +Props: + - forked idea from malcom's img2irc (https://github.com/waveplate/img2irc) + - big props to wrk (wr34k) for forking this + opencv implementation ''' -import io - try: - from PIL import Image, ImageEnhance, ImageFilter, ImageOps + import cv2 except ImportError: - raise SystemExit('missing required \'pillow\' library (https://pypi.org/project/pillow/)') + raise SystemExit('missing required \'opencv-python\' library (https://pypi.org/project/opencv-python/)') +try: + import numpy as np +except ImportError: + raise SystemExit('missing required \'numpy\' library (https://pypi.org/project/numpy/)') -effects = ('blackwhite', 'blur', 'greyscale', 'invert', 'smooth') palettes = { + 'RGB16': [0xffffff, 0x000000, 0x00007f, 0x009300, 0xff0000, 0x7f0000, 0x9c009c, 0xfc7f00, + 0xffff00, 0x00fc00, 0x009393, 0x00ffff, 0x0000fc, 0xff00ff, 0x0, 0x0], 'RGB88': [0xffffff, 0x000000, 0x00007f, 0x009300, 0xff0000, 0x7f0000, 0x9c009c, 0xfc7f00, - 0xffff00, 0x00fc00, 0x009393, 0x00ffff, 0x0000fc, 0xff00ff, 0x0, 0x0, + 0xffff00, 0x00fc00, 0x009393, 0x00ffff, 0x0000fc, 0xff00ff, 0x0, 0x0, 0x470000, 0x472100, 0x474700, 0x324700, 0x004700, 0x00472c, 0x004747, 0x002747, 0x000047, 0x2e0047, 0x470047, 0x47002a, 0x740000, 0x743a00, 0x747400, 0x517400, 0x007400, 0x007449, 0x007474, 0x004074, 0x000074, 0x4b0074, 0x740074, 0x740045, @@ -35,7 +30,6 @@ palettes = { 0xff5959, 0xffb459, 0xffff71, 0xcfff60, 0x6fff6f, 0x65ffc9, 0x6dffff, 0x59b4ff, 0x5959ff, 0xc459ff, 0xff66ff, 0xff59bc, 0xff9c9c, 0xffd39c, 0xffff9c, 0xe2ff9c, 0x9cff9c, 0x9cffdb, 0x9cffff, 0x9cd3ff, 0x9c9cff, 0xdc9cff, 0xff9cff, 0xff94d3], - 'RGB99': [0xffffff, 0x000000, 0x00007f, 0x009300, 0xff0000, 0x7f0000, 0x9c009c, 0xfc7f00, 0xffff00, 0x00fc00, 0x009393, 0x00ffff, 0x0000fc, 0xff00ff, 0x7f7f7f, 0xd2d2d2, 0x470000, 0x472100, 0x474700, 0x324700, 0x004700, 0x00472c, 0x004747, 0x002747, @@ -51,50 +45,31 @@ palettes = { 0xbcbcbc, 0xe2e2e2, 0xffffff] } -def convert(data, max_line_len, img_width=80, palette='RGB99', brightness=False, contrast=False, effect=None): +def convert(data, max_line_len, img_width=80, palette='RGB99', quantize_colors=None): if palette not in palettes: raise Exception('invalid palette option') - if effect and effect not in effects: - raise Exception('invalid effect option') palette = palettes[palette] - image = Image.open(io.BytesIO(data)) + np_arr = np.asarray(bytearray(data), dtype="uint8") + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) del data - if brightness: - image = ImageEnhance.Brightness(im).enhance(brightness) - if contrast: - image = ImageEnhance.Contrast(image).enhance(contrast) - if effect == 'blackwhite': - image = image.convert("1") - elif effect == 'blur': - image - image.filter(ImageFilter.BLUR) - elif effect == 'greyscale': - image = image.convert("L") - elif effect == 'invert': - image = ImageOps.invert(image) - elif effect == 'smooth': - image = image.filter(ImageFilter.SMOOTH_MORE) - return convert_image(image, max_line_len, img_width, palette) + return convert_image(image, quantize_colors, max_line_len, img_width, palette) -def convert_image(image, max_line_len, img_width, palette): - (width, height) = image.size - img_height = img_width / width * height - del height, width - image.thumbnail((img_width, img_height), Image.Resampling.LANCZOS) - del img_height +def convert_image(orig_image, quantize_colors, max_line_len, img_width, palette): + image = ircize(orig_image, img_width, quantize_colors) CHAR = '\u2580' buf = list() - for i in range(0, image.size[1], 2): - if i+1 >= image.size[1]: - bitmap = [[rgb_to_hex(image.getpixel((x, i))) for x in range(image.size[0])]] - bitmap += [[0 for _ in range(image.size[0])]] + for i in range(0, image.shape[0], 2): + if i+1 >= image.shape[0]: + bitmap = [[bgr_to_hex(image[i, x]) for x in range(image.shape[1])]] + bitmap += [[0 for _ in range(image.shape[1])]] else: - bitmap = [[rgb_to_hex(image.getpixel((x, y))) for x in range(image.size[0])] for y in [i, i+1]] + bitmap = [[bgr_to_hex(image[y, x]) for x in range(image.shape[1])] for y in [i, i+1]] top_row = [AnsiPixel(px, palette) for px in bitmap[0]] bottom_row = [AnsiPixel(px, palette) for px in bitmap[1]] buf += [""] last_fg = last_bg = -1 ansi_row = list() - for j in range(image.size[0]): + for j in range(image.shape[1]): top_pixel = top_row[j] bottom_pixel = bottom_row[j] pixel_pair = AnsiPixelPair(top_pixel, bottom_pixel) @@ -112,32 +87,56 @@ def convert_image(image, max_line_len, img_width, palette): last_fg = fg last_bg = bg if len(buf[-1].encode('utf-8', 'ignore')) > max_line_len: - if img_width - 5 < 10: - raise Exception('internal error') - return convert_image(image, max_line_len, img_width-5, palette) + if img_width - 5 < 5: + raise Exception('image would get too small') + return convert_image(orig_image, quantize_colors, max_line_len, img_width-5, palette) return buf +def ircize(image, img_width, quantize_colors): + (height, width, _) = image.shape + img_height = img_width / width * height + image = cv2.resize(image, (int(img_width), int(img_height)), interpolation=cv2.INTER_AREA) + brightness = np.sum(image) / (255 * image.shape[0] * image.shape[1]) + minimum_brightness = 0.72 + ratio = brightness / minimum_brightness + if ratio < 1: + image = cv2.convertScaleAbs(image, alpha = 1 / ratio, beta = 0) + imgf = np.float32(image).reshape(-1, 3) + criteria = (cv2.TERM_CRITERIA_EPS+cv2.TERM_CRITERIA_MAX_ITER,20,2.0) + compactness, label, center = cv2.kmeans(imgf, quantize_colors, None, criteria, 10, cv2.KMEANS_RANDOM_CENTERS) + center = np.uint8(center) + final_img = center[label.flatten()] + image = final_img.reshape(image.shape) + return image + def hex_to_rgb(color): - r = color >> 16 - g = (color >> 8) % 256 - b = color % 256 + r = (color >> 16) & 255 + g = (color >> 8) & 255 + b = color & 255 return (r,g,b) def rgb_to_hex(rgb): - r = rgb[0] - g = rgb[1] - b = rgb[2] + if len(list(rgb)) < 3: + r = g = b = rgb[0] + else: + r = rgb[0] + g = rgb[1] + b = rgb[2] + return (r << 16) + (g << 8) + b +def bgr_to_hex(bgr): + return rgb_to_hex((bgr[0], 0, 0)) if len(list(bgr)) < 3 else rgb_to_hex((bgr[2], bgr[1], bgr[0])) + def color_distance_squared(c1, c2): - dr = c1[0] - c2[0] - dg = c1[1] - c2[1] - db = c1[2] - c2[2] - return dr * dr + dg * dg + db * db + d1 = abs(c1[0] - c2[0]) + d2 = abs(c1[1] - c2[1]) + d3 = abs(c1[2] - c2[2]) + return d1 * d1 + d2 * d2 + d3 * d3 class AnsiPixel: def __init__(self, pixel_u32, palette): - self.irc = self.nearest_hex_color(pixel_u32, palette) + self.irc = self.nearest_hex_color(pixel_u32, palette) def nearest_hex_color(self, pixel_u32, hex_colors): rgb_colors = [hex_to_rgb(color) for color in hex_colors] diff --git a/scroll.py b/scroll.py index 7621dd9..e7f7945 100644 --- a/scroll.py +++ b/scroll.py @@ -7,6 +7,7 @@ import json import random import re import ssl +import sys import time import urllib.request @@ -15,7 +16,7 @@ class connection: port = 6697 ipv6 = False ssl = True - vhost = None + vhost = None # Must in ('ip', port) format channel = '#chats' key = None modes = 'BdDg' @@ -85,17 +86,15 @@ class Bot(): self.host = '' self.playing = False self.settings = { - 'flood' : 1, - 'ignore' : 'big,birds,doc,gorf,hang,nazi,pokemon', - 'lines' : 500, - 'msg' : 0.03, - 'paste' : True, - 'png_brightness' : 0, - 'png_contrast' : 0, - 'png_effect' : None, - 'png_palette' : 'RGB99', - 'png_width' : 80, - 'results' : 25} + 'flood' : 1, + 'ignore' : 'big,birds,doc,gorf,hang,nazi,pokemon', + 'lines' : 500, + 'msg' : 0.03, + 'paste' : True, + 'png_palette' : 'RGB99', + 'png_quantize_colors' : 99, + 'png_width' : 80, + 'results' : 25} self.slow = False self.reader = None self.writer = None @@ -163,27 +162,47 @@ class Bot(): finally: self.db = cache - async def play(self, chan, name, paste=None): try: - if paste: + content = get_url(url).read() + except Exception as ex: + await self.irc_error(chan, 'failed to convert image', ex) + else: + if ascii: + if len(ascii) <= self.settings['lines']: + for line in ascii: + await self.sendmsg(chan, line) + await asyncio.sleep(self.settings['msg']) + else: + await self.irc_error('image is too big', 'take it to #scroll') + + + async def play(self, chan, name, img=False, paste=False): + try: + if img or paste: ascii = get_url(name) else: ascii = get_url(f'https://raw.githubusercontent.com/ircart/ircart/master/ircart/{name}.txt') if ascii.getcode() == 200: - ascii = ascii.readlines() + if img: + ascii = img2irc.convert(ascii.read(), img, int(self.settings['png_width']), self.settings['png_palette'], int(self.settings['png_quantize_colors'])) + else: + ascii = ascii.readlines() if len(ascii) > int(self.settings['lines']) and chan != '#scroll': await self.irc_error(chan, 'file is too big', f'take those {len(ascii):,} lines to #scroll') else: - await self.action(chan, 'the ascii gods have chosen... ' + color(name, cyan)) + if not img and not paste: + await self.action(chan, 'the ascii gods have chosen... ' + color(name, cyan)) for line in ascii: - try: - line = line.decode() - except: - line = line.encode(chardet.detect(line)['encoding']).decode() # Get fucked UTF-16 - await self.sendmsg(chan, line.replace('\n','').replace('\r','') + reset) + if type(line) == bytes: + try: + line = line.decode() + except UnicodeError: + line = line.decode(chardet.detect(line)['encoding']).encode().decode() # TODO: Do we need to re-encode/decode in UTF-8? + line = line.replace('\n','').replace('\r','') + await self.sendmsg(chan, line + reset) await asyncio.sleep(self.settings['msg']) else: - await self.irc_error(chan, 'invalid name', name) + await self.irc_error(chan, 'invalid name', name) if not img and not paste else await self.irc_error(chan, 'invalid url', name) except Exception as ex: try: await self.irc_error(chan, 'error in play function', ex) @@ -263,28 +282,25 @@ class Bot(): await asyncio.sleep(self.settings['msg']) elif args[1] == 'img' and len(args) == 3: url = args[2] - width = 512 - len(line.split(' :')[0])+4 if url.startswith('https://') or url.startswith('http://'): - try: - content = get_url(url).read() - ascii = img2irc.convert(content, 512 - len(f":{identity.nickname}!{identity.username}@{self.host} PRIVMSG {chan} :\r\n"), int(self.settings['png_width']), self.settings['png_palette'], self.settings['png_brightness'], self.settings['png_contrast'], self.settings['png_effect']) - except Exception as ex: - await self.irc_error(chan, 'failed to convert image', ex) - else: - if ascii: - if len(ascii) <= self.settings['lines']: - for line in ascii: - await self.sendmsg(chan, line) - await asyncio.sleep(self.settings['msg']) - else: - await self.irc_error('image is too big', 'take it to #scroll') + self.playing = True + width = 512 - len(line.split(' :')[0])+4 + self.loops[chan] = asyncio.create_task(self.play(chan, url, img=width)) elif msg == '.ascii list': await self.sendmsg(chan, underline + color('https://raw.githubusercontent.com/ircart/ircart/master/ircart/.list', light_blue)) - elif msg == '.ascii random': - self.playing = True - dir = random.choice([item for item in self.db if item not in self.settings['ignore']]) - ascii = f'{dir}/{random.choice(self.db[dir])}' - self.loops[chan] = asyncio.create_task(self.play(chan, ascii)) + elif args[1] == 'random' and len(args) in (2,3): + if len(args) == 3: + dir = args[2] + else: + random.seed(random.randrange(sys.maxsize)) + random.choice([item for item in self.db if item not in self.settings['ignore']]) + if dir in self.db: + random.seed(random.randrange(sys.maxsize)) + ascii = f'{dir}/{random.choice(self.db[dir])}' + self.playing = True + self.loops[chan] = asyncio.create_task(self.play(chan, ascii)) + else: + await self.irc_error(chan, 'invalid directory name', dir) elif msg == '.ascii sync' and is_admin(ident): await self.sync() await self.sendmsg(chan, bold + color('database synced', light_green)) @@ -294,14 +310,6 @@ class Bot(): self.loops[chan] = asyncio.create_task(self.play(chan, url, paste=True)) else: await self.irc_error(chan, 'invalid pastebin url', paste) - elif args[1] == 'random' and len(args) == 3: - dir = args[2] - if dir in self.db: - self.playing = True - ascii = f'{dir}/{random.choice(self.db[dir])}' - self.loops[chan] = asyncio.create_task(self.play(chan, ascii)) - else: - await self.irc_error(chan, 'invalid directory name', dir) elif args[1] == 'search' and len(args) == 3: query = args[2] results = [{'name':ascii,'dir':dir} for dir in self.db for ascii in self.db[dir] if query in ascii] @@ -322,7 +330,7 @@ class Bot(): setting = args[2] option = args[3] if setting in self.settings: - if setting in ('flood','lines','msg','png_brightness','png_contrast','png_width','results'): + if setting in ('flood','lines','msg','png_quantize_colors','png_width','results'): try: option = float(option) self.settings[setting] = option @@ -338,11 +346,6 @@ class Bot(): await self.sendmsg(chan, color('OK', light_green)) else: await self.irc_error(chan, 'invalid option', 'must be on or off') - elif setting == 'png_effect' and option in ('false','none','off','0'): - self.settings[setting] = None - else: - self.settings[setting] = option - await self.sendmsg(chan, color('OK', light_green)) else: await self.irc_error(chan, 'invalid setting', setting) elif len(args) == 2: @@ -378,4 +381,5 @@ try: import img2irc except ImportError: raise SystemExit('missing required \'img2irc\' file (https://github.com/ircart/scroll/blob/master/img2irc.py)') + pass asyncio.run(Bot().connect())