""" Sopel plugin: salvador Converts an image URL to mIRC colour art using half-block characters. !draw - render at default height (6 lines) !draw - render at custom height (4-12 lines) """ import time import urllib.request from io import BytesIO from PIL import Image, ImageFile ImageFile.LOAD_TRUNCATED_IMAGES = True from sopel import plugin MAX_WIDTH = 60 DEFAULT_HEIGHT = 6 MIN_HEIGHT = 4 MAX_HEIGHT = 12 FLOOD_DELAY = 0.4 MAX_BYTES = 5 * 1024 * 1024 # 5 MB # 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 USAGE = f'usage: salvador [] (lines: {MIN_HEIGHT}-{MAX_HEIGHT}, default {DEFAULT_HEIGHT})' # Full mIRC 99-colour palette (indices 0-98) 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 _nearest(r, g, b): # Perceptually-weighted RGB distance 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': 'salvador/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 char_aspect = max_w / max_h if aspect >= char_aspect: 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=DEFAULT_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): args = (trigger.group(2) or "").split(None, 1) if not args: bot.say(USAGE, 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.say(USAGE, trigger.nick) return height = n url = args[1] else: url = args[0] url = url.rstrip('.,)>') if not url.startswith(('http://', 'https://')): bot.say(USAGE, trigger.nick) return try: lines = _img_to_irc(url, height=height) except Exception as e: bot.say(str(e)) return for line in lines: bot.say(line) time.sleep(FLOOD_DELAY)