361 lines
15 KiB
Python
361 lines
15 KiB
Python
"""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())
|