8bitlenser/lenser/vic20/convert/hires.py
2026-07-03 19:35:35 -07:00

171 lines
6.5 KiB
Python

"""VIC-20 hi-res image encoder.
The VIC-20 has no bitmap mode -- images are drawn from a programmable character
set. In hi-res text mode each 8x8 cell shows a GLOBAL background colour (the
0-bits) and one per-cell FOREGROUND colour (the 1-bits, from colour RAM, limited
to colours 0-7). A screen of 22x23 = 506 cells (176x184 px) needs at most 256
distinct 8x8 patterns (a char index is one byte), so the cell bitmaps are
clustered to 256 representative characters (k-means), exactly like the
Intellivision GRAM dictionary.
Model: global bg (0-15) + per-cell fg (0-7) + 256-char dictionary. For
error-diffusion dithers the colour choices are dither-aware (segment metric), so
bg and fg bracket each cell and dithering blends to the true shade.
Conversion.data carries the bytes the cartridge builder needs:
chardata 2048 bytes (256 chars x 8 rows, copied to $1400)
screen 506 bytes (char indices, copied to $1E00)
color 506 bytes (colour RAM nibbles, fg in low 3 bits, copied to $9600)
bg, border, aux global colour registers
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert import base
from .. import palette as vpal
WIDTH, HEIGHT = 176, 184
PIXEL_ASPECT = 1.2 # 176x184 on a ~4:3 screen -> pixels a touch tall
CELL_W, CELL_H = 8, 8
N_COLS, N_ROWS = 22, 23
N_CELLS = N_COLS * N_ROWS # 506
N_CHARS = 256
N_FG = 8 # foreground limited to colours 0-7
def _select_global_bg(cells, plab, seg, candidates):
"""Choose the global background (0-15) + per-cell fg (0-7) that minimise total
dither-aware error. Returns (bg, sets) where sets[ci] = [bg, fg]."""
best_total = np.inf
best = None
for bg in candidates:
avail = [c for c in range(N_FG) if c != bg]
sets, errors = base.select_cell_sets_dither(
cells, plab, avail, n_free=1, fixed=(bg,), seg=seg)
total = errors.sum()
if total < best_total:
best_total = total
best = (bg, sets)
return best
def _select_global_bg_nearest(dist, candidates):
"""Nearest-colour variant (ordered/none dithers)."""
best_total = np.inf
best = None
for bg in candidates:
avail = [c for c in range(N_FG) if c != bg]
sets, errors = base.select_cell_sets(dist, avail, n_free=1, fixed=(bg,))
total = errors.sum()
if total < best_total:
best_total = total
best = (bg, sets)
return best
def _cluster_chars(bitmaps, k=N_CHARS, iters=16, seed=0):
"""k-means on 64-dim {0,1} cell bitmaps -> k binary representative chars."""
pats = bitmaps.astype(np.float64)
uniq, counts = np.unique(bitmaps, axis=0, return_counts=True)
if len(uniq) <= k:
chars = np.zeros((k, 64), np.uint8)
chars[:len(uniq)] = uniq
lut = {tuple(p): i for i, p in enumerate(uniq)}
labels = np.array([lut[tuple(b)] for b in bitmaps])
return chars, labels
order = np.argsort(-counts)[:k]
cent = uniq[order].astype(np.float64)
rng = np.random.default_rng(seed)
for _ in range(iters):
d = ((pats[:, None, :] - cent[None, :, :]) ** 2).sum(-1)
labels = d.argmin(1)
for j in range(k):
msk = labels == j
if msk.any():
cent[j] = pats[msk].mean(0)
else:
cent[j] = pats[rng.integers(len(pats))]
chars = (cent >= 0.5).astype(np.uint8)
d = ((pats[:, None, :] - chars[None, :, :].astype(np.float64)) ** 2).sum(-1)
labels = d.argmin(1)
return chars, labels
def convert(img_rgb, palette_name="vic", dither_mode="floyd",
intensive=False, base_color=None):
plab = vpal.palette_lab()
prgb = vpal.get_palette().astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H)
dist = base.cell_distance(cells, plab)
bg_candidates = range(16) if intensive else vpal.BG_USABLE
if dither_mode in base.DIFFUSION_DITHERS:
seg = base.segment_distances(cells, plab)
bg, sets = _select_global_bg(cells, plab, seg, bg_candidates)
else:
bg, sets = _select_global_bg_nearest(dist, bg_candidates)
fg = sets[:, 1].astype(np.int64)
# dither each cell between the global bg and its fg (order [bg, fg])
allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
bitmaps = np.zeros((N_CELLS, 64), np.uint8)
for cr in range(rows):
for cc in range(cols):
ci = cr * cols + cc
block = idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8]
bitmaps[ci] = (block == fg[ci]).astype(np.uint8).reshape(-1)
chars, labels = _cluster_chars(bitmaps)
# Re-optimise the per-cell fg for the FINAL (clustered) pattern -- the shared
# char's fg pixels may want a different colour than the pre-clustering pick.
for ci in range(N_CELLS):
P = chars[labels[ci]].astype(bool) # 64 px, True = fg
if P.any():
fg[ci] = int(dist[ci][P][:, :N_FG].sum(0).argmin())
# build outputs + an exact preview
prev_idx = np.empty((HEIGHT, WIDTH), np.uint8)
screen = np.zeros(N_CELLS, np.uint8)
color = np.zeros(N_CELLS, np.uint8)
for ci in range(N_CELLS):
cr, cc = divmod(ci, cols)
ch = chars[labels[ci]].reshape(8, 8)
f = int(fg[ci])
prev_idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] = np.where(ch == 1, f, bg)
screen[ci] = labels[ci] & 0xFF
color[ci] = f & 0x07
chardata = np.zeros(N_CHARS * 8, np.uint8)
for t in range(N_CHARS):
rb = chars[t].reshape(8, 8)
for r in range(8):
byte = 0
for x in range(8):
byte = (byte << 1) | int(rb[r, x])
chardata[t * 8 + r] = byte
border = bg if bg < 8 else 0
data = {"chardata": chardata, "screen": screen, "color": color,
"bg": int(bg), "border": int(border), "aux": 0}
preview = prgb[prev_idx]
disp_w = int(round(WIDTH * PIXEL_ASPECT))
xs = (np.arange(disp_w) * WIDTH) // disp_w
preview = preview[:, xs]
return base.Conversion(
mode="hires", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=prev_idx.astype(np.uint16), data=data, data_addr=0,
viewer="hires", preview_rgb=preview,
error=base.perceptual_error(prev_idx, img_lab, plab),
meta={"palette": "vic", "dither": dither_mode, "bg": int(bg)},
)