"""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 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())