"""Convert an image to 16-colour ANSI/CP437 BBS art. Two encoders share this module: * **half-block** (fast, the default when ``intensive`` is off) -- every cell is the CP437 upper-half-block ``0xDF`` with fg = top pixel, bg = bottom pixel, so the picture is a free 16-colour dither on an 80 x (2*rows) grid (no cell clash). * **full glyph** (``intensive`` on) -- every 8x16 cell is matched to the best of the whole CP437 repertoire (letters, punctuation, line- and block-drawing) together with an optimal foreground/background colour pair, minimising perceptual (CIELAB) reproduction error. Using the actual glyph shapes -- not just blocks -- reproduces edges, texture and gradients far better, at the cost of a per-cell search. Both choose their two colours per cell from the 16 EGA/VGA colours (bright backgrounds via iCE colours); "mono" restricts them to a grey/tinted ramp. """ from __future__ import annotations import os import numpy as np from .. import dither, imageprep, palette as pal from ..convert import base # Standard 16-colour EGA/VGA text palette (indices 0..15 = the ANSI colour order: # black, blue, green, cyan, red, magenta, brown, light-grey, then their bright # variants). Foreground index -> SGR 30..37 (+bold for 8..15); background index -> # SGR 40..47 (+"iCE" blink for 8..15). VGA = np.array([ (0x00, 0x00, 0x00), (0x00, 0x00, 0xAA), (0x00, 0xAA, 0x00), (0x00, 0xAA, 0xAA), (0xAA, 0x00, 0x00), (0xAA, 0x00, 0xAA), (0xAA, 0x55, 0x00), (0xAA, 0xAA, 0xAA), (0x55, 0x55, 0x55), (0x55, 0x55, 0xFF), (0x55, 0xFF, 0x55), (0x55, 0xFF, 0xFF), (0xFF, 0x55, 0x55), (0xFF, 0x55, 0xFF), (0xFF, 0xFF, 0x55), (0xFF, 0xFF, 0xFF), ], dtype=np.uint8) # Canvas sizes, keyed "COLSxROWS" (character cells). 80x25 is the classic one-screen # BBS canvas, 80x50 is a taller (scrolling) canvas with twice the vertical detail. # "mono" is a greyscale (or hue-tinted) 80x25 canvas -- the monochrome mode every # platform provides. _DIMS = {"80x25": (80, 25), "80x50": (80, 50), "mono": (80, 25)} MODES = list(_DIMS.keys()) # The four VGA greys, darkest-to-lightest, used as the monochrome ramp. _GREY_RAMP = [0, 8, 7, 15] # black, dark grey, light grey, white UPPER_HALF_BLOCK = 0xDF # CP437 "▀": top half = fg colour, bottom half = bg PREVIEW_ZOOM = 4 # widen the 80-wide half-block preview for the GUI GLYPH_W, GLYPH_H = 8, 16 # CP437 text cell (pixels) _FONT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cp437_8x16.bin") def _sgr(fg: int, bg: int) -> bytes: """SGR escape selecting foreground ``fg`` and background ``bg`` (0..15 each). Bright foregrounds (8..15) use bold (``1``); bright backgrounds use the blink bit (``5``) interpreted as high-intensity ("iCE colours"), which every modern ANSI viewer and most BBS terminals honour. """ parts = ["0"] if fg >= 8: parts.append("1") if bg >= 8: parts.append("5") parts.append(str(30 + (fg & 7))) parts.append(str(40 + (bg & 7))) return b"\x1b[" + ";".join(parts).encode("ascii") + b"m" def _mono_ramp(base_color) -> list[int]: """Luminance-sorted VGA indices for the mono ramp. With no base colour this is the four greys; with one it is black + the nearest VGA hue (+ white), so the picture becomes that colour's shades -- the app's tinted-mono behaviour.""" plab = pal.srgb_to_lab(VGA) if base_color is None: ramp = set(_GREY_RAMP) else: # base_color is a colodore palette index; tint toward its nearest VGA hue. rgb = pal.get_palette("colodore")[int(base_color)].astype(np.int64) hue = int(np.argmin(((VGA.astype(np.int64) - rgb) ** 2).sum(axis=1))) ramp = {0, hue, 15} return sorted(ramp, key=lambda i: plab[i, 0]) # --------------------------------------------------------------------------- # # half-block encoder (fast path) # --------------------------------------------------------------------------- # def encode_ansi(index_image: np.ndarray, cols: int, rows: int) -> bytes: """Encode a (2*rows, cols) index image as a CP437 half-block ANSI byte stream. Each output row pairs two pixel rows into one text row of ``0xDF`` cells, emitting a colour escape only when the (fg, bg) pair changes, and resetting at each line end so a coloured background never bleeds past column 80. Lines end in CRLF -- on the deferred-wrap ("magic margin") terminals BBSes use, an exact 80-column line plus CRLF advances one row with no blank line. """ out = bytearray(b"\x1b[0m") for r in range(rows): top = index_image[2 * r] bot = index_image[2 * r + 1] last = None for c in range(cols): pair = (int(top[c]), int(bot[c])) if pair != last: out += _sgr(*pair) last = pair out.append(UPPER_HALF_BLOCK) out += b"\x1b[0m\r\n" return bytes(out) def _convert_halfblock(rgb, mode, cols, rows, dither_mode, ramp): W, H = cols, rows * 2 img_lab = pal.srgb_to_lab(rgb) plab = pal.srgb_to_lab(VGA) allowed = np.tile(np.asarray(ramp, np.int64), (H, W, 1)) idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8) preview = np.repeat(np.repeat(VGA[idx], PREVIEW_ZOOM, 0), PREVIEW_ZOOM, 1) return base.Conversion( mode=mode, width=W, height=H, pixel_aspect=1.0, index_image=idx, data=encode_ansi(idx, cols, rows), data_addr=0, preview_rgb=preview, viewer="", error=base.mean_error(idx, img_lab, plab), meta={"palette": "vga", "dither": dither_mode, "cols": cols, "rows": rows, "encoding": "halfblock"}) # --------------------------------------------------------------------------- # # full-glyph encoder (intensive path) # --------------------------------------------------------------------------- # _glyph_cache = None def _glyphs(): """Load the bundled CP437 8x16 font once and return (masks, codes). ``masks`` is (Ng, 128) float32 -- one row per usable, de-duplicated glyph, a pixel being 1.0 where the glyph is foreground (pixel order y*8+x, x MSB-first, matching how cells are flattened). ``codes`` is the CP437 byte to emit for each. Control bytes (0x00-0x1F, 0x7F) are excluded so the stream is always safe to send to a terminal, and glyphs with identical bitmaps collapse to one entry. """ global _glyph_cache if _glyph_cache is not None: return _glyph_cache font = np.frombuffer(open(_FONT_PATH, "rb").read(), np.uint8).reshape(256, GLYPH_H) bits = np.unpackbits(font, axis=1).astype(np.float32) # (256, 128) masks, codes, seen = [], [], set() for code in range(0x20, 0x100): if code == 0x7F: continue key = bits[code].tobytes() if key in seen: continue seen.add(key) masks.append(bits[code]) codes.append(code) _glyph_cache = (np.stack(masks), np.array(codes, np.uint8)) return _glyph_cache def _match_cells(cells_lab, csub_lab, csub_idx): """Best (glyph, fg, bg) per cell by minimum summed CIELAB error. ``cells_lab`` is (n, 128, 3); ``csub_lab``/``csub_idx`` are the allowed colours in CIELAB and as VGA indices. For a glyph's fg (bg) pixel set the optimal palette colour is the one nearest that set's mean, whose summed squared error is ``k*|c|^2 - 2 c.sum + sum|p|^2`` -- computed here for every glyph and colour at once, then reduced. Returns (glyph_row, fg_idx, bg_idx, err) arrays, length n. """ G, _ = _glyphs() Ng = G.shape[0] k1 = G.sum(1) # (Ng,) fg pixel counts k0 = float(G.shape[1]) - k1 Csq = (csub_lab ** 2).sum(1) # (m,) grow = np.empty(len(cells_lab), np.int64) fgi = np.empty(len(cells_lab), np.int64) bgi = np.empty(len(cells_lab), np.int64) err = np.empty(len(cells_lab), np.float64) # Batch cells so the (Ng, batch, m) error tensors stay modest in memory. step = max(1, 2_000_000 // (Ng * max(1, len(csub_idx)))) for s in range(0, len(cells_lab), step): P = cells_lab[s:s + step] # (nb, 128, 3) nb = P.shape[0] sq = (P ** 2).sum(2) # (nb, 128) tot = P.sum(1) # (nb, 3) totsq = sq.sum(1) # (nb,) sum_fg = np.einsum("gp,npc->gnc", G, P) # (Ng, nb, 3) msq_fg = G @ sq.T # (Ng, nb) # error of assigning each glyph's fg pixels to each candidate colour efg = (k1[:, None, None] * Csq[None, None, :] - 2 * np.einsum("gnc,mc->gnm", sum_fg, csub_lab) + msq_fg[:, :, None]) # (Ng, nb, m) best_fg = efg.min(2) sel_fg = efg.argmin(2) ebg = (k0[:, None, None] * Csq[None, None, :] - 2 * np.einsum("gnc,mc->gnm", tot[None] - sum_fg, csub_lab) + (totsq[None, :] - msq_fg)[:, :, None]) best_bg = ebg.min(2) sel_bg = ebg.argmin(2) total = best_fg + best_bg # (Ng, nb) g = total.argmin(0) # (nb,) r = np.arange(nb) grow[s:s + nb] = g fgi[s:s + nb] = csub_idx[sel_fg[g, r]] bgi[s:s + nb] = csub_idx[sel_bg[g, r]] err[s:s + nb] = total[g, r] return grow, fgi, bgi, err def encode_ansi_glyph(codes, fg, bg, cols, rows): """Encode per-cell CP437 ``codes`` with ``fg``/``bg`` (all (rows, cols)) as ANSI. Same colour-run and line-reset discipline as the half-block encoder, but each cell emits its matched glyph byte instead of a fixed half-block. """ out = bytearray(b"\x1b[0m") for r in range(rows): last = None for c in range(cols): pair = (int(fg[r, c]), int(bg[r, c])) if pair != last: out += _sgr(*pair) last = pair out.append(int(codes[r, c])) out += b"\x1b[0m\r\n" return bytes(out) def _convert_glyph(rgb, mode, cols, rows, ramp, dither_mode): G, gcodes = _glyphs() img_lab = pal.srgb_to_lab(rgb) # (rows*16, cols*8, 3) plab = pal.srgb_to_lab(VGA) csub_idx = np.asarray(ramp, np.int64) csub_lab = plab[csub_idx] # Match against a pre-dithered copy so smooth gradients become shade characters # (two blended colours) instead of banding into flat cells. A FAST vectorised # ordered dither is used -- error-diffusion dithers are far too slow at # 8x16-per-cell resolution and the glyph matcher re-approximates the local mix # anyway. "none" matches the continuous image (crispest flats, but visible # gradient bands); every diffusion choice maps to blue-noise (the best-looking). pd = {"none": None, "bayer": "bayer", "bluenoise": "bluenoise"}.get( dither_mode, "bluenoise") if pd is not None: allowed = np.tile(csub_idx, (img_lab.shape[0], img_lab.shape[1], 1)) target = plab[dither.quantize(img_lab, allowed, plab, pd)] else: target = img_lab cells = (target.reshape(rows, GLYPH_H, cols, GLYPH_W, 3) .transpose(0, 2, 1, 3, 4).reshape(rows * cols, GLYPH_H * GLYPH_W, 3)) grow, fg, bg, _ = _match_cells(cells, csub_lab, csub_idx) # render the matched glyphs to an RGB preview at full 8x16 cell resolution masks = G[grow] # (n, 128) fg_rgb = VGA[fg].astype(np.float32) bg_rgb = VGA[bg].astype(np.float32) cellpix = masks[:, :, None] * fg_rgb[:, None, :] + (1 - masks[:, :, None]) * bg_rgb[:, None, :] preview = (cellpix.reshape(rows, cols, GLYPH_H, GLYPH_W, 3) .transpose(0, 2, 1, 3, 4).reshape(rows * GLYPH_H, cols * GLYPH_W, 3) .astype(np.uint8)) codes = gcodes[grow].reshape(rows, cols) fg2d, bg2d = fg.reshape(rows, cols), bg.reshape(rows, cols) # perceptual error of the rendered result against the ORIGINAL (undithered) image rms = float(np.sqrt(((pal.srgb_to_lab(preview) - img_lab) ** 2).sum(-1)).mean()) return base.Conversion( mode=mode, width=cols * GLYPH_W, height=rows * GLYPH_H, pixel_aspect=1.0, index_image=None, data=encode_ansi_glyph(codes, fg2d, bg2d, cols, rows), data_addr=0, preview_rgb=preview, viewer="", error=rms, meta={"palette": "vga", "dither": dither_mode, "cols": cols, "rows": rows, "encoding": "glyph"}) # --------------------------------------------------------------------------- # def convert_image(path_or_img, mode="80x25", palette_name="vga", dither_mode="floyd", intensive=False, prep_opt=None, base_color=None): """Convert an image to ANSI BBS art. ``mode`` picks the canvas ("80x25" / "80x50", full 16-colour) or "mono" (greyscale, or a hue tint via ``base_color``). With ``intensive`` set, every 8x16 cell is matched to the best CP437 glyph + colour pair (highest quality); otherwise the fast half-block encoder runs and ``dither_mode`` chooses its dither. The returned Conversion's ``data`` is the ready-to-write ``.ANS`` byte stream and ``preview_rgb`` the rendered picture for the GUI. """ if prep_opt is None: prep_opt = imageprep.PrepOptions() cols, rows = _DIMS.get(mode, _DIMS["80x25"]) ramp = _mono_ramp(base_color) if mode == "mono" else list(range(16)) if intensive: rgb = imageprep.prepare(path_or_img, cols * GLYPH_W, rows * GLYPH_H, 1.0, prep_opt, border_rgb=(0, 0, 0)) return _convert_glyph(rgb, mode, cols, rows, ramp, dither_mode) rgb = imageprep.prepare(path_or_img, cols, rows * 2, 1.0, prep_opt, border_rgb=(0, 0, 0)) return _convert_halfblock(rgb, mode, cols, rows, dither_mode, ramp)