Working Python version for Commodore.
This commit is contained in:
commit
2a48f52979
51 changed files with 3095 additions and 0 deletions
124
c64view/convert/base.py
Normal file
124
c64view/convert/base.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue