First public commit.

This commit is contained in:
The Dust Council 2026-07-03 19:35:35 -07:00
parent 2a48f52979
commit 4bac9d83ed
288 changed files with 18417 additions and 1076 deletions

10
lenser/ansi/__init__.py Normal file
View file

@ -0,0 +1,10 @@
"""ANSI / CP437 "BBS art" output.
Not a real machine -- this renders the image as classic 16-colour ANSI text art
suitable for display on a bulletin board system (or any ANSI/CP437 viewer). Each
character cell is the CP437 upper-half-block (``0xDF``) with the foreground colour
painting the top pixel and the background colour the bottom pixel, so one 80-column
row of text is two rows of freely-coloured pixels. Sixteen EGA/VGA colours are
available for both halves (bright backgrounds use "iCE colours"), so the picture is
just a free 16-colour image at 80 x (2*rows).
"""

307
lenser/ansi/convert.py Normal file
View file

@ -0,0 +1,307 @@
"""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)

BIN
lenser/ansi/cp437_8x16.bin Normal file

Binary file not shown.

19
lenser/ansi/exporter.py Normal file
View file

@ -0,0 +1,19 @@
"""Write an ANSI/CP437 conversion to a ``.ANS`` file."""
from __future__ import annotations
import os
def export_ans(conv, path, source_path=None, display="key", seconds=0,
video="pal", layout="unified"):
"""Write the conversion's ANSI byte stream to ``path`` (forcing a .ans suffix).
The extra keyword arguments (display / seconds / video / layout) exist only so
ANSI shares the platform export interface; a static text file ignores them.
"""
if not str(path).lower().endswith(".ans"):
path = os.path.splitext(path)[0] + ".ans"
with open(path, "wb") as f:
f.write(conv.data)
return path