307 lines
14 KiB
Python
307 lines
14 KiB
Python
"""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)
|