aboutsummaryrefslogtreecommitdiffstats
path: root/jeeves/art.py
diff options
context:
space:
mode:
Diffstat (limited to 'jeeves/art.py')
-rw-r--r--jeeves/art.py188
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)