First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
10
lenser/ansi/__init__.py
Normal file
10
lenser/ansi/__init__.py
Normal 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
307
lenser/ansi/convert.py
Normal 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
BIN
lenser/ansi/cp437_8x16.bin
Normal file
Binary file not shown.
19
lenser/ansi/exporter.py
Normal file
19
lenser/ansi/exporter.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue