"""Shared machinery for every C64 display mode. The hard part of "make this photo look good on a C64" is that each screen cell may only show a handful of the 16 fixed colours. We solve that per cell with an exhaustive, vectorised search over every legal colour combination, scored by perceptual (CIELAB) reproduction error. The winning per-cell colour sets then drive a constrained dither (see ``dither.py``) to produce the final index image. """ from __future__ import annotations from dataclasses import dataclass, field from itertools import combinations import numpy as np @dataclass class Conversion: """Result of converting an image to one C64 display mode.""" mode: str width: int # logical pixel width (160 or 320) height: int # logical pixel height (200) pixel_aspect: float # on-screen width of one logical pixel index_image: np.ndarray # (H, W) uint8 palette indices, for preview data: bytes = b"" # picture block that must reside from data_addr up data_addr: int = 0x2000 # memory address where ``data`` must load preview_rgb: np.ndarray = None # optional explicit preview (e.g. interlace blend) extra_files: list = field(default_factory=list) # (cbm_name, full_prg_bytes) viewer: str = "" # viewer key (see viewer/assemble.py) error: float = 0.0 # mean per-pixel CIELAB error meta: dict = field(default_factory=dict) def cells_lab(img_lab: np.ndarray, cell_w: int, cell_h: int): """Reshape (H,W,3) -> (n_cells, cell_w*cell_h, 3) plus (rows, cols).""" H, W, _ = img_lab.shape rows, cols = H // cell_h, W // cell_w a = img_lab.reshape(rows, cell_h, cols, cell_w, 3) a = a.transpose(0, 2, 1, 3, 4).reshape(rows * cols, cell_h * cell_w, 3) return a, rows, cols def cell_distance(cells: np.ndarray, palette_lab: np.ndarray) -> np.ndarray: """Squared CIELAB distance from every cell pixel to every palette colour. cells: (n_cells, P, 3) -> (n_cells, P, 16) """ return np.sum((cells[:, :, None, :] - palette_lab[None, None, :, :]) ** 2, axis=-1) def best_global_color(img_lab: np.ndarray, palette_lab: np.ndarray) -> int: """Palette index closest, on average, to the whole image (good bg seed).""" flat = img_lab.reshape(-1, 3) d = np.sum((flat[:, None, :] - palette_lab[None, :, :]) ** 2, axis=-1) return int(np.argmin(d.mean(axis=0))) def select_cell_sets(dist: np.ndarray, available, n_free: int, fixed=()): """Pick, per cell, the ``n_free`` palette colours (plus any ``fixed`` ones) that minimise nearest-colour reproduction error. dist: (n_cells, P, 16) squared distances (from ``cell_distance``). Returns (sets, errors): sets is (n_cells, len(fixed)+n_free) palette indices, errors is (n_cells,) summed squared error of the winning set. """ n_cells = dist.shape[0] fixed = list(fixed) combos = list(combinations(sorted(available), n_free)) best_err = np.full(n_cells, np.inf) best_combo = np.zeros((n_cells, n_free), dtype=np.int64) if fixed: fixed_min = dist[:, :, fixed].min(axis=2) # (n_cells, P) for combo in combos: idx = list(combo) m = dist[:, :, idx].min(axis=2) # (n_cells, P) if fixed: m = np.minimum(m, fixed_min) err = m.sum(axis=1) better = err < best_err best_err = np.where(better, err, best_err) best_combo[better] = idx if fixed: fixed_arr = np.tile(np.array(fixed, dtype=np.int64), (n_cells, 1)) sets = np.concatenate([fixed_arr, best_combo], axis=1) else: sets = best_combo return sets, best_err def segment_distances(cells: np.ndarray, palette_lab: np.ndarray) -> np.ndarray: """Squared CIELAB distance from each cell pixel to every colour-pair *segment*. Unlike :func:`cell_distance` (distance to the nearest palette vertex), this credits what error-diffusion dithering actually achieves: blending two colours reproduces any shade on the line between them. Returns a (Q, Q, n_cells, P) array (Q = palette size) where ``[a, b]`` is the distance to segment a-b. """ n_cells, P, _ = cells.shape Q = palette_lab.shape[0] D = np.empty((Q, Q, n_cells, P), dtype=np.float64) for a in range(Q): ca = palette_lab[a] for b in range(a, Q): seg = palette_lab[b] - ca L = float(seg @ seg) + 1e-9 t = np.clip(((cells - ca) @ seg) / L, 0.0, 1.0) # (n,P) proj = ca + t[..., None] * seg d = np.sum((cells - proj) ** 2, axis=-1) # (n,P) D[a, b] = d D[b, a] = d return D def select_cell_sets_dither(cells, palette_lab, available, n_free, fixed=(), seg=None): """Like :func:`select_cell_sets` but scores each colour combination by the distance to the nearest pairwise *segment* among its colours, so the chosen colours best support error-diffusion dithering (smoother gradients). """ n_cells = cells.shape[0] fixed = list(fixed) if seg is None: seg = segment_distances(cells, palette_lab) combos = list(combinations(sorted(available), n_free)) best_err = np.full(n_cells, np.inf) best_combo = np.zeros((n_cells, n_free), dtype=np.int64) for combo in combos: colors = fixed + list(combo) pairs = list(combinations(colors, 2)) or [(colors[0], colors[0])] m = seg[pairs[0][0], pairs[0][1]] for a, b in pairs[1:]: m = np.minimum(m, seg[a, b]) err = m.sum(axis=1) better = err < best_err best_err = np.where(better, err, best_err) best_combo[better] = combo if fixed: fixed_arr = np.tile(np.array(fixed, dtype=np.int64), (n_cells, 1)) sets = np.concatenate([fixed_arr, best_combo], axis=1) else: sets = best_combo return sets, best_err def optimize_background_dither(cells, palette_lab, n_free, candidates=range(16)): """Dither-aware background search (see :func:`optimize_background`).""" seg = segment_distances(cells, palette_lab) best_total = np.inf best = None for bg in candidates: avail = [i for i in range(16) if i != bg] sets, errors = select_cell_sets_dither(cells, palette_lab, avail, n_free, fixed=(bg,), seg=seg) total = errors.sum() if total < best_total: best_total = total best = (bg, sets, errors) return best def optimize_background(dist: np.ndarray, n_free: int, candidates=range(16)): """Choose the single shared background colour (multicolor/FLI) that minimises total image error, returning (bg_index, sets, errors).""" best_total = np.inf best = None for bg in candidates: avail = [i for i in range(16) if i != bg] sets, errors = select_cell_sets(dist, avail, n_free, fixed=(bg,)) total = errors.sum() if total < best_total: best_total = total best = (bg, sets, errors) return best def per_pixel_allowed(sets: np.ndarray, rows: int, cols: int, cell_w: int, cell_h: int, H: int, W: int) -> np.ndarray: """Expand per-cell colour sets to an (H, W, K) per-pixel allowed-index table.""" yy, xx = np.indices((H, W)) cell_idx = (yy // cell_h) * cols + (xx // cell_w) return sets[cell_idx] def prg(load_addr: int, data: bytes) -> bytes: """Wrap raw bytes as a CBM PRG (little-endian load address prefix).""" return bytes([load_addr & 0xFF, (load_addr >> 8) & 0xFF]) + bytes(data) def mean_error(index_image: np.ndarray, img_lab: np.ndarray, palette_lab: np.ndarray) -> float: """Mean CIELAB delta-E between the chosen indices and the source image.""" chosen = palette_lab[index_image] return float(np.sqrt(np.sum((chosen - img_lab) ** 2, axis=-1)).mean()) # error-diffusion dithers benefit from dither-aware (segment) colour selection; # ordered ("bayer") and "none" must use plain nearest-colour selection. # "yliluoma" is ordered, not diffusion, but it likewise reproduces a cell's # *average* colour by mixing >2 entries, so it wants the same dither-aware # (segment) colour selection and perceptual (blurred) scoring as the diffusers. DIFFUSION_DITHERS = {"floyd", "atkinson", "stucki", "jarvis", "sierra", "sierra_lite", "burkes", "riemersma", "ostromoukhov", "yliluoma"} def _box_blur(a: np.ndarray, passes: int = 2) -> np.ndarray: """Cheap separable 3x3 box blur (approximates the eye averaging a dither).""" for _ in range(passes): p = np.pad(a, ((1, 1), (1, 1), (0, 0)), mode="edge") a = (p[:-2, :-2] + p[:-2, 1:-1] + p[:-2, 2:] + p[1:-1, :-2] + p[1:-1, 1:-1] + p[1:-1, 2:] + p[2:, :-2] + p[2:, 1:-1] + p[2:, 2:]) / 9.0 return a def luminance_ramp(plab: np.ndarray, neutral, base_color, siblings=None): """Build a luminance-sorted ramp of palette indices for monochrome rendering. With no/neutral base colour -> the platform's neutral (grey) ramp; otherwise a tinted ramp of black + the base colour (+ its lighter sibling, if any) + white, so the image becomes that hue's shades. ``neutral`` is the platform's grey ramp (e.g. [black, grey, white]); ``siblings`` maps a colour to a lighter variant. All indices are returned sorted by CIELAB lightness. """ neutral = list(neutral) if base_color is None or base_color in neutral: ramp = list(neutral) else: black, white = neutral[0], neutral[-1] ramp = {black, white, base_color} if siblings and base_color in siblings: ramp.add(siblings[base_color]) ramp = list(ramp) ramp.sort(key=lambda i: plab[i, 0]) return ramp def mono_render(img_rgb, plab, ramp, W, H, cell_w, cell_h, dither_mode, n_free=2): """Luminance-matched monochrome render shared by every 2-colour-per-cell platform: collapse image and palette to pure lightness, pick (per cell) the ramp levels that bracket the cell's luminance (dither-aware for error diffusion), then dither. Returns (index_image, sets, rows, cols, error).""" from .. import dither, palette as pal L = pal.srgb_to_lab(img_rgb)[..., 0] img_mono = np.zeros((H, W, 3)) img_mono[..., 0] = L plab_mono = np.zeros_like(plab) plab_mono[:, 0] = plab[:, 0] # The per-cell search runs on a COMPACT luminance-only sub-palette of just the # ramp colours (indices 0..len(ramp)-1), then maps back to real palette # indices. This stays small and correct even when the real palette has far # more than 16 colours (e.g. the TED's 128) -- segment_distances assumes a # small palette -- and is faster since only the ramp is considered. ramp = list(ramp) real = np.array(ramp, dtype=np.int64) cpal = np.zeros((len(ramp), 3)) cpal[:, 0] = plab[real, 0] cells, rows, cols = cells_lab(img_mono, cell_w, cell_h) avail = range(len(ramp)) n_free = min(n_free, len(ramp)) if n_free >= 2 and dither_mode in DIFFUSION_DITHERS: sets, _ = select_cell_sets_dither(cells, cpal, avail, n_free=n_free) else: dist = cell_distance(cells, cpal) sets, _ = select_cell_sets(dist, avail, n_free=max(n_free, 1)) sets = real[sets] # compact -> real palette indices if sets.shape[1] == 1: sets = np.concatenate([sets, sets], axis=1) allowed = per_pixel_allowed(sets, rows, cols, cell_w, cell_h, H, W) idx = dither.quantize(img_mono, allowed, plab_mono, dither_mode).astype(np.uint8) err = perceptual_error(idx, img_mono, plab_mono) return idx, sets, rows, cols, err def mono_codebook(bitmaps, k, iters=8): """Reduce per-cell binary patterns to a k-entry dictionary of REAL patterns (k-medoids under Hamming distance). Every dictionary entry is a genuine dithered pattern -- unlike k-means centroids, which threshold a cluster mean and can invent a near-solid 'average' pattern (block artefacts). Initialised from the k most frequent patterns, then refined by alternating nearest-Hamming assignment and medoid update so the entries spread to cover the pattern space. Returns (codebook (k, P) uint8, labels).""" P = bitmaps.shape[1] uniq, counts = np.unique(bitmaps, axis=0, return_counts=True) if len(uniq) <= k: code = np.zeros((k, P), np.uint8) code[:len(uniq)] = uniq lut = {tuple(p): i for i, p in enumerate(uniq)} labels = np.array([lut[tuple(b)] for b in bitmaps]) return code, labels order = np.argsort(-counts)[:k] code = uniq[order].copy() bm = bitmaps.astype(np.int16) labels = np.zeros(len(bitmaps), np.int64) for _ in range(iters): labels = (bm[:, None, :] ^ code[None].astype(np.int16)).sum(-1).argmin(1) moved = False for j in range(k): members = bm[labels == j] if len(members) > 1: intra = (members[:, None, :] ^ members[None, :, :]).sum(-1).sum(1) med = members[intra.argmin()].astype(np.uint8) if not np.array_equal(med, code[j]): code[j] = med moved = True if not moved: break labels = (bm[:, None, :] ^ code[None].astype(np.int16)).sum(-1).argmin(1) return code, labels def refine_mono_tiles(distL, tiles, labels, fg, bg, n_tiles, iters=25): """Fixed-colour vector quantisation of a two-tone tile dictionary (shared by the VIC-20 and Intellivision mono modes). With ink/paper fixed, alternately re-assign each cell to its best tile and re-cut every tile's shape (a pixel is ink where that lowers summed luminance error across the cells using the tile); empty tiles are reseeded from the highest-contrast cells so the whole budget is used. Returns (tiles, labels).""" n, P, _ = distL.shape dfg = distL[:, :, fg] # (n,P) error if pixel = ink dbg = distL[:, :, bg] # (n,P) error if pixel = paper worst = np.argsort(-(np.abs(dfg - dbg).sum(1))) best = (tiles, labels) best_err = np.inf for _ in range(iters): M = tiles.astype(np.float64) cost = np.einsum('tp,np->nt', M, dfg) + np.einsum('tp,np->nt', 1.0 - M, dbg) labels = cost.argmin(1) newt = np.zeros((n_tiles, P), np.uint8) wi = 0 for t in range(n_tiles): msk = labels == t if msk.any(): newt[t] = (dfg[msk].sum(0) < dbg[msk].sum(0)).astype(np.uint8) else: c = int(worst[wi % n]); wi += 1 newt[t] = (dfg[c] < dbg[c]).astype(np.uint8) tiles = newt err = float(cost[np.arange(n), labels].sum()) if err < best_err - 1e-6: best_err = err best = (tiles.copy(), labels.copy()) else: break return best def perceptual_error(index_image: np.ndarray, img_lab: np.ndarray, palette_lab: np.ndarray) -> float: """Delta-E after a light blur of both images -- credits dithering (the eye/CRT averages the dither) so dithered results are ranked by how they actually look, not penalised for per-pixel dither noise.""" chosen = _box_blur(palette_lab[index_image]) target = _box_blur(img_lab) return float(np.sqrt(np.sum((chosen - target) ** 2, axis=-1)).mean())