First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
171
lenser/vic20/convert/hires.py
Normal file
171
lenser/vic20/convert/hires.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""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)},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue