171 lines
6.5 KiB
Python
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)},
|
|
)
|