diff options
Diffstat (limited to 'jeeves/art.py')
| -rw-r--r-- | jeeves/art.py | 188 |
1 files changed, 188 insertions, 0 deletions
diff --git a/jeeves/art.py b/jeeves/art.py new file mode 100644 index 0000000..274bac5 --- /dev/null +++ b/jeeves/art.py @@ -0,0 +1,188 @@ +""" +Sopel plugin: jeeves art + +Converts an image URL to mIRC colour art using half-block characters. + + !draw [<lines>] <url> - render image (lines: 4-12, default from !set art_height) + +Default height is configurable via !set art_height <N>. +Requires Pillow (pip install Pillow). Gracefully disabled if not installed. +""" + +import os as _os, sys as _sys +_d = _os.path.dirname(_os.path.abspath(__file__)) +if _d not in _sys.path: + _sys.path.insert(0, _d) +import jv_core as jv + +import time +import urllib.request + +from sopel import plugin + +try: + from io import BytesIO + from PIL import Image, ImageFile + ImageFile.LOAD_TRUNCATED_IMAGES = True + _PILLOW = True +except ImportError: + _PILLOW = False + + +MAX_WIDTH = 60 +MIN_HEIGHT = 4 +MAX_HEIGHT = 12 +FLOOD_DELAY = 0.4 +MAX_BYTES = 5 * 1024 * 1024 + +# IRC monospace character cells are approximately twice as tall as they are wide. +# _fit multiplies the image aspect ratio by this factor so the rendered output +# uses enough character columns to compensate for the tall cells. +CHAR_RATIO = 2.0 + + +MIRC = [ + (0xff,0xff,0xff),(0x00,0x00,0x00),(0x00,0x00,0x7f),(0x00,0x93,0x00), + (0xff,0x00,0x00),(0x7f,0x00,0x00),(0x9c,0x00,0x9c),(0xfc,0x7f,0x00), + (0xff,0xff,0x00),(0x00,0xfc,0x00),(0x00,0x93,0x93),(0x00,0xff,0xff), + (0x00,0x00,0xfc),(0xff,0x00,0xff),(0x55,0x55,0x55),(0xaa,0xaa,0xaa), + (0x47,0x00,0x00),(0x47,0x21,0x00),(0x47,0x47,0x00),(0x32,0x47,0x00), + (0x00,0x47,0x00),(0x00,0x47,0x2c),(0x00,0x47,0x47),(0x00,0x27,0x47), + (0x00,0x00,0x47),(0x2e,0x00,0x47),(0x47,0x00,0x47),(0x47,0x00,0x2a), + (0x74,0x00,0x00),(0x74,0x3a,0x00),(0x74,0x74,0x00),(0x51,0x74,0x00), + (0x00,0x74,0x00),(0x00,0x74,0x49),(0x00,0x74,0x74),(0x00,0x40,0x74), + (0x00,0x00,0x74),(0x4b,0x00,0x74),(0x74,0x00,0x74),(0x74,0x00,0x45), + (0xb5,0x00,0x00),(0xb5,0x63,0x00),(0xb5,0xb5,0x00),(0x7d,0xb5,0x00), + (0x00,0xb5,0x00),(0x00,0xb5,0x71),(0x00,0xb5,0xb5),(0x00,0x63,0xb5), + (0x00,0x00,0xb5),(0x75,0x00,0xb5),(0xb5,0x00,0xb5),(0xb5,0x00,0x6b), + (0xff,0x00,0x00),(0xff,0x8c,0x00),(0xff,0xff,0x00),(0xb2,0xff,0x00), + (0x00,0xff,0x00),(0x00,0xff,0xa0),(0x00,0xff,0xff),(0x00,0x8c,0xff), + (0x00,0x00,0xff),(0xa5,0x00,0xff),(0xff,0x00,0xff),(0xff,0x00,0x98), + (0xff,0x59,0x59),(0xff,0xb4,0x59),(0xff,0xff,0x71),(0xcf,0xff,0x60), + (0x6f,0xff,0x6f),(0x65,0xff,0xc9),(0x6d,0xff,0xff),(0x59,0xb4,0xff), + (0x59,0x59,0xff),(0xc4,0x59,0xff),(0xff,0x66,0xff),(0xff,0x59,0xbc), + (0xff,0x9c,0x9c),(0xff,0xd3,0x9c),(0xff,0xff,0x9c),(0xe2,0xff,0x9c), + (0x9c,0xff,0x9c),(0x9c,0xff,0xdb),(0x9c,0xff,0xff),(0x9c,0xd3,0xff), + (0x9c,0x9c,0xff),(0xdc,0x9c,0xff),(0xff,0x9c,0xff),(0xff,0x94,0xd3), + (0x00,0x00,0x00),(0x13,0x13,0x13),(0x28,0x28,0x28),(0x36,0x36,0x36), + (0x4d,0x4d,0x4d),(0x65,0x65,0x65),(0x81,0x81,0x81),(0x9f,0x9f,0x9f), + (0xbc,0xbc,0xbc),(0xe2,0xe2,0xe2),(0xff,0xff,0xff), +] + + +def setup(bot): + jv.ensure_setup(bot) + + +def _nearest(r, g, b): + rmean = (r + 128) / 2 + best, best_dist = 0, float("inf") + for i, (cr, cg, cb) in enumerate(MIRC): + dr, dg, db = r - cr, g - cg, b - cb + dist = (2 + rmean / 256) * dr*dr + 4*dg*dg + (2 + (255 - rmean) / 256) * db*db + if dist < best_dist: + best_dist = dist + best = i + return best + + +def _fetch(url): + req = urllib.request.Request(url, headers={"User-Agent": "jeeves/1.0"}) + with urllib.request.urlopen(req, timeout=10) as resp: + content_type = resp.headers.get("Content-Type", "") + if "image" not in content_type: + raise ValueError(f"not an image ({content_type})") + return resp.read(MAX_BYTES) + + +def _fit(img_w, img_h, max_w, max_h): + aspect = (img_w / img_h) * CHAR_RATIO + if aspect >= max_w / max_h: + w = max_w + h = max(1, round(max_w / aspect)) + else: + h = max_h + w = max(1, round(max_h * aspect)) + return w, h + + +def _dither(img, w, h): + buf = [[list(map(float, img.getpixel((x, y)))) for x in range(w)] for y in range(h)] + idx = [[0] * w for _ in range(h)] + for y in range(h): + for x in range(w): + r, g, b = (max(0, min(255, int(v))) for v in buf[y][x]) + c = _nearest(r, g, b) + idx[y][x] = c + cr, cg, cb = MIRC[c] + er, eg, eb = r - cr, g - cg, b - cb + for dx, dy, f in ((1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)): + nx, ny = x + dx, y + dy + if 0 <= nx < w and 0 <= ny < h: + buf[ny][nx][0] += er * f / 16 + buf[ny][nx][1] += eg * f / 16 + buf[ny][nx][2] += eb * f / 16 + return idx + + +def _img_to_irc(url, height): + data = _fetch(url) + img = Image.open(BytesIO(data)).convert("RGB") + char_w, char_h = _fit(img.width, img.height, MAX_WIDTH, height) + img = img.resize((char_w, char_h * 2), Image.LANCZOS) + idx = _dither(img, char_w, char_h * 2) + lines = [] + for row in range(char_h): + line = "" + last_fg = last_bg = None + for col in range(char_w): + bg = idx[row * 2][col] + fg = idx[row * 2 + 1][col] + if fg != last_fg or bg != last_bg: + line += f"\x03{fg:02d},{bg:02d}" + last_fg, last_bg = fg, bg + line += "▄" + line += "\x0f" + lines.append(line) + return lines + + +@plugin.command("draw") +@plugin.require_chanmsg +def cmd_draw(bot, trigger): + if not _PILLOW: + bot.notice("!draw requires Pillow (pip install Pillow) — not installed", trigger.nick) + return + + args = (trigger.group(2) or "").split(None, 1) + default_height = jv.cfg_val(bot, "art_height") + + if not args: + bot.notice(f"usage: !draw [<lines>] <url> (lines: {MIN_HEIGHT}-{MAX_HEIGHT}, default {default_height})", trigger.nick) + return + + height = default_height + if len(args) == 2 and args[0].isdigit(): + n = int(args[0]) + if not (MIN_HEIGHT <= n <= MAX_HEIGHT): + bot.notice(f"lines must be between {MIN_HEIGHT} and {MAX_HEIGHT}", trigger.nick) + return + height = n + url = args[1] + else: + url = args[0] + + url = url.rstrip(".,)>") + if not url.startswith(("http://", "https://")): + bot.notice(f"usage: !draw [<lines>] <url> (lines: {MIN_HEIGHT}-{MAX_HEIGHT}, default {default_height})", trigger.nick) + return + + try: + lines = _img_to_irc(url, height=height) + except Exception as e: + bot.say(str(e), trigger.sender) + return + + for line in lines: + bot.say(line, trigger.sender) + time.sleep(FLOOD_DELAY) |
