First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
1
lenser/vic20/__init__.py
Normal file
1
lenser/vic20/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Commodore VIC-20 target for lenser."""
|
||||
19
lenser/vic20/convert/__init__.py
Normal file
19
lenser/vic20/convert/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Commodore VIC-20 conversion dispatch."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ... import imageprep
|
||||
from . import hires, mono, multicolor
|
||||
|
||||
_MODULES = {"multicolor": multicolor, "hires": hires, "mono": mono}
|
||||
MODES = list(_MODULES.keys())
|
||||
|
||||
|
||||
def convert_image(path_or_img, mode="multicolor", palette_name="vic",
|
||||
dither_mode="floyd", intensive=False, prep_opt=None,
|
||||
base_color=None):
|
||||
mod = _MODULES.get(mode, multicolor)
|
||||
prep_opt = prep_opt or imageprep.PrepOptions()
|
||||
img_rgb = imageprep.prepare(path_or_img, mod.WIDTH, mod.HEIGHT,
|
||||
mod.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0))
|
||||
return mod.convert(img_rgb, palette_name, dither_mode, intensive,
|
||||
base_color=base_color)
|
||||
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)},
|
||||
)
|
||||
83
lenser/vic20/convert/mono.py
Normal file
83
lenser/vic20/convert/mono.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Commodore VIC-20 monochrome / tinted-mono mode.
|
||||
|
||||
176x184 hi-res matched by luminance: a single global background (black) and one
|
||||
foreground colour (white, or a tinted base) used by every cell, so the picture is
|
||||
carried entirely by the custom character shapes + dithering -- a clean two-tone
|
||||
image with no per-cell colour budget to spend. Reuses the hires char clustering,
|
||||
data layout and viewer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import palette as c64pal
|
||||
from ...convert import base
|
||||
from .. import palette as vpal
|
||||
from . import hires
|
||||
|
||||
WIDTH, HEIGHT, PIXEL_ASPECT = hires.WIDTH, hires.HEIGHT, hires.PIXEL_ASPECT
|
||||
|
||||
|
||||
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)
|
||||
|
||||
bg = 0 # black background
|
||||
fg = base_color if base_color in range(1, 8) else 1 # white (or tinted)
|
||||
ramp = sorted([bg, fg], key=lambda i: plab[i, 0])
|
||||
|
||||
idx, sets, rows, cols, err = base.mono_render(
|
||||
img_rgb, plab, ramp, hires.WIDTH, hires.HEIGHT,
|
||||
hires.CELL_W, hires.CELL_H, dither_mode, n_free=2)
|
||||
|
||||
# cell bitmaps (1 = foreground) reduced to the 256-char dictionary by a
|
||||
# frequency codebook -- keeps the dithered detail (k-means centroids would
|
||||
# invent near-solid 'average' chars -> block artefacts).
|
||||
bitmaps = np.zeros((hires.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).astype(np.uint8).reshape(-1)
|
||||
chars, labels = base.mono_codebook(bitmaps, hires.N_CHARS)
|
||||
|
||||
img_mono = np.zeros((hires.HEIGHT, hires.WIDTH, 3))
|
||||
img_mono[..., 0] = c64pal.srgb_to_lab(img_rgb)[..., 0]
|
||||
plab_mono = np.zeros_like(plab)
|
||||
plab_mono[:, 0] = plab[:, 0]
|
||||
|
||||
prev_idx = np.empty((hires.HEIGHT, hires.WIDTH), np.uint8)
|
||||
screen = np.zeros(hires.N_CELLS, np.uint8)
|
||||
color = np.zeros(hires.N_CELLS, np.uint8)
|
||||
for ci in range(hires.N_CELLS):
|
||||
cr, cc = divmod(ci, cols)
|
||||
ch = chars[labels[ci]].reshape(8, 8)
|
||||
prev_idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] = np.where(ch == 1, fg, bg)
|
||||
screen[ci] = labels[ci] & 0xFF
|
||||
color[ci] = fg & 0x07
|
||||
chardata = np.zeros(hires.N_CHARS * 8, np.uint8)
|
||||
for t in range(hires.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
|
||||
|
||||
data = {"chardata": chardata, "screen": screen, "color": color,
|
||||
"bg": int(bg), "border": 0, "aux": 0}
|
||||
|
||||
preview = prgb[prev_idx]
|
||||
disp_w = int(round(hires.WIDTH * hires.PIXEL_ASPECT))
|
||||
xs = (np.arange(disp_w) * hires.WIDTH) // disp_w
|
||||
preview = preview[:, xs]
|
||||
|
||||
return base.Conversion(
|
||||
mode="mono", width=hires.WIDTH, height=hires.HEIGHT,
|
||||
pixel_aspect=hires.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_mono, plab_mono),
|
||||
meta={"palette": "vic", "dither": dither_mode, "base_color": base_color},
|
||||
)
|
||||
241
lenser/vic20/convert/multicolor.py
Normal file
241
lenser/vic20/convert/multicolor.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"""VIC-20 multicolor image encoder.
|
||||
|
||||
Multicolor character mode (colour RAM bit 3 set) gives each 4x8 cell FOUR colours
|
||||
at half horizontal resolution. Three are GLOBAL registers and one is per-cell:
|
||||
|
||||
00 -> background ($900F bits 4-7, any of 16) global
|
||||
01 -> border ($900F bits 0-2, colours 0-7) global
|
||||
10 -> foreground (colour RAM low 3 bits, 0-7) per cell
|
||||
11 -> auxiliary ($900E bits 4-7, any of 16) global
|
||||
|
||||
So the warm tones (orange/pink, only available as colours 8-15) can be used as
|
||||
the global background/auxiliary -- exactly what hi-res can't do -- which makes
|
||||
this the strong photo mode. We pick the three globals by a dither-aware search
|
||||
(restricted to the image's relevant colours for speed), then the best per-cell
|
||||
foreground, dither each cell among its four colours, and cluster the resulting
|
||||
4x8 role patterns to a 256-character set.
|
||||
|
||||
Conversion.data: chardata(2048) screen(506) color(506) bg/border/aux globals.
|
||||
"""
|
||||
|
||||
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 = 88, 184 # 22x23 cells of 4x8 (double-wide pixels)
|
||||
PIXEL_ASPECT = 2.4 # logical pixel is 2x wide x the 1.2 cell aspect
|
||||
CELL_W, CELL_H = 4, 8
|
||||
N_COLS, N_ROWS = 22, 23
|
||||
N_CELLS = N_COLS * N_ROWS # 506
|
||||
N_CHARS = 256
|
||||
N_FG = 8 # per-cell foreground limited to colours 0-7
|
||||
|
||||
|
||||
def _relevant(cells, plab, k=8):
|
||||
"""The k palette colours nearest the most cell pixels (search-space prune)."""
|
||||
d = base.cell_distance(cells, plab) # (n, P, 16)
|
||||
nearest = d.reshape(-1, 16).argmin(1)
|
||||
counts = np.bincount(nearest, minlength=16)
|
||||
return list(np.argsort(-counts)[:k])
|
||||
|
||||
|
||||
def _global_shortlist(cells, plab, seg, dist, cand, dither_aware):
|
||||
"""Rank every (bg, aux, border) candidate by total per-cell selection error and
|
||||
return them sorted best-first as (bg, border, aux, sets) tuples. The segment
|
||||
metric is a fast proxy but mis-ranks the globals (it over-credits blends that
|
||||
actually muddy), so the caller re-scores the top few by real perceptual error."""
|
||||
bcand = [c for c in cand if c < 8] or list(range(8))
|
||||
out = []
|
||||
seen = set()
|
||||
for bg in cand:
|
||||
for aux in cand:
|
||||
for border in bcand:
|
||||
key = (bg, aux, border)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if dither_aware:
|
||||
sets, errors = base.select_cell_sets_dither(
|
||||
cells, plab, range(N_FG), n_free=1,
|
||||
fixed=(bg, border, aux), seg=seg)
|
||||
else:
|
||||
sets, errors = base.select_cell_sets(
|
||||
dist, range(N_FG), n_free=1, fixed=(bg, border, aux))
|
||||
out.append((errors.sum(), int(bg), int(border), int(aux), sets))
|
||||
out.sort(key=lambda t: t[0])
|
||||
return [(bg, border, aux, sets) for _, bg, border, aux, sets in out]
|
||||
|
||||
|
||||
def _role_sets(sets):
|
||||
"""Reorder selection [bg, border, aux, fg] -> role order [bg, border, fg, aux]
|
||||
(roles 0=bg 1=border 2=fg 3=aux for the 2-bit pixel code)."""
|
||||
return np.stack([sets[:, 0], sets[:, 1], sets[:, 3], sets[:, 2]], axis=1)
|
||||
|
||||
|
||||
def _role_dither(sets, rows, cols, img_lab, plab, dither_mode):
|
||||
"""Dither the image among each cell's four colours; returns (idx, role_sets)."""
|
||||
role_sets = _role_sets(sets)
|
||||
allowed = base.per_pixel_allowed(role_sets, rows, cols, CELL_W, CELL_H,
|
||||
HEIGHT, WIDTH)
|
||||
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
|
||||
return idx, role_sets
|
||||
|
||||
|
||||
def _nearest_perr(role_sets, rows, cols, img_lab, plab):
|
||||
"""Fast global-ranking proxy: perceptual error of the NEAREST-colour (no
|
||||
dithering) reconstruction. Vectorised, so all candidate globals can be ranked
|
||||
cheaply; floyd is too slow to run on every combo."""
|
||||
allowed = base.per_pixel_allowed(role_sets, rows, cols, CELL_W, CELL_H,
|
||||
HEIGHT, WIDTH)
|
||||
colors = plab[allowed]
|
||||
d = ((img_lab[:, :, None, :] - colors) ** 2).sum(-1)
|
||||
ch = d.argmin(-1)
|
||||
idx = np.take_along_axis(allowed, ch[..., None], axis=-1)[..., 0]
|
||||
return base.perceptual_error(idx, img_lab, plab)
|
||||
|
||||
|
||||
def _onehot(patterns):
|
||||
"""(n, 32) role codes 0..3 -> (n, 128) one-hot, so clustering compares role
|
||||
IDENTITY (0 vs 3 are equally different) rather than numeric code proximity."""
|
||||
n, m = patterns.shape
|
||||
oh = np.zeros((n, m, 4), np.float64)
|
||||
oh[np.arange(n)[:, None], np.arange(m)[None, :], patterns] = 1.0
|
||||
return oh.reshape(n, m * 4)
|
||||
|
||||
|
||||
def _cluster_chars(patterns, k=N_CHARS, iters=16, seed=0):
|
||||
"""k-means on one-hot role patterns -> k representative chars (codes 0..3)."""
|
||||
m = patterns.shape[1]
|
||||
uniq, counts = np.unique(patterns, axis=0, return_counts=True)
|
||||
if len(uniq) <= k:
|
||||
chars = np.zeros((k, m), 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 patterns])
|
||||
return chars, labels
|
||||
oh = _onehot(patterns) # (n, 128)
|
||||
oh_uniq = _onehot(uniq)
|
||||
order = np.argsort(-counts)[:k]
|
||||
cent = oh_uniq[order].copy()
|
||||
rng = np.random.default_rng(seed)
|
||||
for _ in range(iters):
|
||||
d = ((oh[:, None, :] - cent[None, :, :]) ** 2).sum(-1)
|
||||
labels = d.argmin(1)
|
||||
for j in range(k):
|
||||
msk = labels == j
|
||||
if msk.any():
|
||||
cent[j] = oh[msk].mean(0)
|
||||
else:
|
||||
cent[j] = oh[rng.integers(len(oh))]
|
||||
# representative char = argmax role per pixel
|
||||
chars = cent.reshape(k, m, 4).argmax(-1).astype(np.uint8)
|
||||
d = ((oh[:, None, :] - _onehot(chars)[None, :, :]) ** 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)
|
||||
k = 12 if intensive else 8
|
||||
cand = _relevant(cells, plab, k)
|
||||
dither_aware = dither_mode in base.DIFFUSION_DITHERS
|
||||
|
||||
dist = base.cell_distance(cells, plab)
|
||||
seg = base.segment_distances(cells, plab) if dither_aware else None
|
||||
shortlist = _global_shortlist(cells, plab, seg, dist, cand, dither_aware)
|
||||
|
||||
# Rank every candidate by a fast nearest-colour perceptual proxy (floyd is too
|
||||
# slow to run on all ~hundreds of global combos), then run the FULL pipeline
|
||||
# (floyd + char clustering + fg re-optimisation) on the most promising few and
|
||||
# keep the lowest actual post-cluster perceptual error.
|
||||
ranked = sorted(
|
||||
((_nearest_perr(_role_sets(s), rows, cols, img_lab, plab), bg, bo, au, s)
|
||||
for bg, bo, au, s in shortlist),
|
||||
key=lambda t: t[0])
|
||||
topn = 16 if intensive else 8
|
||||
best = None
|
||||
best_err = np.inf
|
||||
for _proxy, cbg, cborder, caux, csets in ranked[:topn]:
|
||||
idx, role_sets = _role_dither(csets, rows, cols, img_lab, plab, dither_mode)
|
||||
out = _build(idx, role_sets, cbg, cborder, caux, rows, cols, dist,
|
||||
img_lab, plab, iters=8)
|
||||
if out[-1] < best_err:
|
||||
best_err = out[-1]
|
||||
best = (cbg, cborder, caux, out)
|
||||
bg, border, aux, (prev_idx, screen, color, chardata, _err) = best
|
||||
|
||||
data = {"chardata": chardata, "screen": screen, "color": color,
|
||||
"bg": int(bg), "border": int(border), "aux": int(aux),
|
||||
"multicolor": True}
|
||||
|
||||
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="multicolor", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=prev_idx.astype(np.uint16), data=data, data_addr=0,
|
||||
viewer="multicolor", preview_rgb=preview, error=best_err,
|
||||
meta={"palette": "vic", "dither": dither_mode,
|
||||
"bg": int(bg), "border": int(border), "aux": int(aux)},
|
||||
)
|
||||
|
||||
|
||||
def _build(idx, role_sets, bg, border, aux, rows, cols, dist, img_lab, plab,
|
||||
iters=16):
|
||||
"""From a dithered image + per-cell colour roles, build the 256-char set and
|
||||
the screen/colour/char data; returns (prev_idx, screen, color, chardata,
|
||||
perceptual_error)."""
|
||||
fg = role_sets[:, 2].astype(np.int64)
|
||||
patterns = np.zeros((N_CELLS, CELL_W * CELL_H), np.uint8)
|
||||
for cr in range(rows):
|
||||
for cc in range(cols):
|
||||
ci = cr * cols + cc
|
||||
block = idx[cr * 8:cr * 8 + 8, cc * 4:cc * 4 + 4] # (8,4)
|
||||
r = role_sets[ci] # 4 colours
|
||||
code = np.zeros((8, 4), np.uint8)
|
||||
# first-match role for each pixel colour (handles duplicate colours)
|
||||
for role in (3, 2, 1, 0):
|
||||
code[block == r[role]] = role
|
||||
patterns[ci] = code.reshape(-1)
|
||||
|
||||
chars, labels = _cluster_chars(patterns, iters=iters)
|
||||
|
||||
# re-optimise per-cell fg for the final (clustered) pattern's fg pixels
|
||||
for ci in range(N_CELLS):
|
||||
P = (chars[labels[ci]] == 2)
|
||||
if P.any():
|
||||
fg[ci] = int(dist[ci][P][:, :N_FG].sum(0).argmin())
|
||||
|
||||
prev_idx = np.empty((HEIGHT, WIDTH), np.uint8)
|
||||
screen = np.zeros(N_CELLS, np.uint8)
|
||||
color = np.zeros(N_CELLS, np.uint8)
|
||||
chardata = np.zeros(N_CHARS * 8, np.uint8)
|
||||
for ci in range(N_CELLS):
|
||||
cr, cc = divmod(ci, cols)
|
||||
pat = chars[labels[ci]].reshape(8, 4)
|
||||
f = int(fg[ci])
|
||||
lut = np.array([bg, border, f, aux])
|
||||
prev_idx[cr * 8:cr * 8 + 8, cc * 4:cc * 4 + 4] = lut[pat]
|
||||
screen[ci] = labels[ci] & 0xFF
|
||||
color[ci] = (f & 0x07) | 0x08 # bit3 = multicolour
|
||||
for t in range(N_CHARS):
|
||||
rb = chars[t].reshape(8, 4)
|
||||
for r in range(8):
|
||||
byte = 0
|
||||
for x in range(4):
|
||||
byte = (byte << 2) | int(rb[r, x] & 3)
|
||||
chardata[t * 8 + r] = byte
|
||||
|
||||
return (prev_idx, screen, color, chardata,
|
||||
base.perceptual_error(prev_idx, img_lab, plab))
|
||||
18
lenser/vic20/exporter.py
Normal file
18
lenser/vic20/exporter.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""Build a Commodore VIC-20 autostart cartridge (.a0) from a conversion."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from .viewer import assemble
|
||||
|
||||
_EXTS = (".a0", ".crt", ".bin", ".rom")
|
||||
|
||||
|
||||
def export_a0(conv, output_path, source_path=None, display="forever",
|
||||
seconds=0, video="ntsc"):
|
||||
if not output_path.lower().endswith(_EXTS):
|
||||
output_path += ".a0"
|
||||
rom = assemble.build_cart(conv.data)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(rom)
|
||||
return output_path
|
||||
49
lenser/vic20/palette.py
Normal file
49
lenser/vic20/palette.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Commodore VIC-20 (VIC 6560/6561) 16-colour palette.
|
||||
|
||||
Calibrated against MAME's `vic20`: a display-setup cartridge cycled the global
|
||||
background ($900F bits 4-7) over all 16 values while a blank-character cell was
|
||||
sampled with the emulator's screen pixel reader (NORMAL mode -- $900F bit 3 = 1;
|
||||
with bit 3 = 0 the chip is in REVERSE mode and blank cells show the foreground).
|
||||
|
||||
The VIC has 16 colours but the per-cell *foreground* (colour RAM, 3 bits) is
|
||||
limited to the first 8; the global background / border / auxiliary may be any of
|
||||
the 16. Colours 8-15 are lighter variants of 0-7.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..palette import srgb_to_lab
|
||||
|
||||
VIC = np.array([
|
||||
(0x00, 0x00, 0x00), # 0 black
|
||||
(0xFF, 0xFF, 0xFF), # 1 white
|
||||
(0xF0, 0x00, 0x00), # 2 red
|
||||
(0x00, 0xF0, 0xF0), # 3 cyan
|
||||
(0x60, 0x00, 0x60), # 4 purple
|
||||
(0x00, 0xA0, 0x00), # 5 green
|
||||
(0x00, 0x00, 0xF0), # 6 blue
|
||||
(0xD0, 0xD0, 0x00), # 7 yellow
|
||||
(0xC0, 0xA0, 0x00), # 8 orange
|
||||
(0xFF, 0xA0, 0x00), # 9 light orange
|
||||
(0xF0, 0x80, 0x80), # 10 pink
|
||||
(0x00, 0xFF, 0xFF), # 11 light cyan
|
||||
(0xFF, 0x00, 0xFF), # 12 light purple
|
||||
(0x00, 0xFF, 0x00), # 13 light green
|
||||
(0x00, 0xA0, 0xFF), # 14 light blue
|
||||
(0xFF, 0xFF, 0x00), # 15 light yellow
|
||||
], dtype=np.float64)
|
||||
|
||||
# Foreground (colour RAM, 3-bit) is limited to the first 8 colours.
|
||||
FG_USABLE = list(range(8))
|
||||
# Background / border / auxiliary may be any of the 16.
|
||||
BG_USABLE = list(range(16))
|
||||
|
||||
|
||||
def get_palette() -> np.ndarray:
|
||||
return VIC
|
||||
|
||||
|
||||
def palette_lab() -> np.ndarray:
|
||||
return srgb_to_lab(VIC)
|
||||
1
lenser/vic20/viewer/__init__.py
Normal file
1
lenser/vic20/viewer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""VIC-20 6502 viewer (assembled by xa)."""
|
||||
82
lenser/vic20/viewer/assemble.py
Normal file
82
lenser/vic20/viewer/assemble.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Assemble the VIC-20 viewer with `xa` and lay out the 8K autostart cartridge.
|
||||
|
||||
The cartridge occupies $A000-$BFFF (8192 bytes). The KERNAL recognises the
|
||||
"A0CBM" signature at $A004 and jumps through the cold vector at $A000, so no 6502
|
||||
reset vector is needed. MAME's `vic20 -cart` wants a full 8K image; smaller .a0
|
||||
files fail with an I/O error.
|
||||
|
||||
ROM layout (fixed so the viewer can copy from constant addresses):
|
||||
$A000 header + viewer code
|
||||
$A800 CHARSRC character set (2048 bytes -> RAM $1400)
|
||||
$B000 SCRSRC screen ( 506 bytes -> RAM $1E00)
|
||||
$B200 COLSRC colour RAM ( 506 bytes -> RAM $9600)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
CART_BASE = 0xA000
|
||||
CART_SIZE = 0x2000
|
||||
CHARSRC = 0xA800
|
||||
SCRSRC = 0xB000
|
||||
COLSRC = 0xB200
|
||||
|
||||
|
||||
class AssemblerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def have_xa() -> bool:
|
||||
return shutil.which("xa") is not None
|
||||
|
||||
|
||||
def _assemble(bg: int, border: int, aux: int) -> bytes:
|
||||
if not have_xa():
|
||||
raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n"
|
||||
"Install it with: sudo apt install xa65")
|
||||
wrapper = (f"* = ${CART_BASE:04X}\n"
|
||||
f"#define CHARSRC ${CHARSRC:04X}\n"
|
||||
f"#define SCRSRC ${SCRSRC:04X}\n"
|
||||
f"#define COLSRC ${COLSRC:04X}\n"
|
||||
f"#define BG {bg & 15}\n"
|
||||
f"#define BORDER {border & 7}\n"
|
||||
f"#define AUX {aux & 15}\n"
|
||||
'#include "viewer.s"\n')
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out = os.path.join(td, "v.bin")
|
||||
fd, wrap = tempfile.mkstemp(suffix=".s", prefix="_wrap_", dir=VIEWER_DIR)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(wrapper)
|
||||
proc = subprocess.run(["xa", "-o", out, os.path.basename(wrap)],
|
||||
capture_output=True, text=True, cwd=VIEWER_DIR)
|
||||
if proc.returncode != 0:
|
||||
raise AssemblerError(f"xa failed:\n{proc.stdout}{proc.stderr}")
|
||||
with open(out, "rb") as f:
|
||||
code = f.read()
|
||||
finally:
|
||||
os.unlink(wrap)
|
||||
return code
|
||||
|
||||
|
||||
def build_cart(data: dict) -> bytes:
|
||||
"""data carries chardata(2048), screen(506), color(506) and bg/border/aux."""
|
||||
code = _assemble(data["bg"], data["border"], data["aux"])
|
||||
if len(code) > (CHARSRC - CART_BASE):
|
||||
raise AssemblerError(f"viewer code is {len(code)} bytes, overruns CHARSRC")
|
||||
chardata = bytes(bytearray(data["chardata"]))
|
||||
screen = bytes(bytearray(data["screen"]))
|
||||
color = bytes(bytearray(data["color"]))
|
||||
|
||||
rom = bytearray(b"\x00" * CART_SIZE)
|
||||
rom[0:len(code)] = code
|
||||
rom[CHARSRC - CART_BASE:CHARSRC - CART_BASE + len(chardata)] = chardata
|
||||
rom[SCRSRC - CART_BASE:SCRSRC - CART_BASE + len(screen)] = screen
|
||||
rom[COLSRC - CART_BASE:COLSRC - CART_BASE + len(color)] = color
|
||||
return bytes(rom)
|
||||
95
lenser/vic20/viewer/viewer.s
Normal file
95
lenser/vic20/viewer/viewer.s
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
; VIC-20 image viewer (autostart 8K cartridge at $A000).
|
||||
;
|
||||
; The KERNAL does NOT initialise the VIC before launching an autostart cart, so
|
||||
; this code programs every VIC register itself, copies the character set, screen
|
||||
; and colour data from the cartridge ROM into RAM, then loops forever showing the
|
||||
; picture.
|
||||
;
|
||||
; Layout in unexpanded RAM -- char set $1400-$1BFF (256 chars), screen $1E00,
|
||||
; colour RAM $9600 (fixed). $9005 = $FD selects screen $1E00 + char base $1400.
|
||||
;
|
||||
; The build (assemble.py) appends the data blocks at the fixed ROM addresses
|
||||
; CHARSRC / SCRSRC / COLSRC and fills in the BG / BORDER / AUX colour defines.
|
||||
|
||||
.word cold ; $A000 cold-start vector
|
||||
.word cold ; $A002 warm-start vector
|
||||
.byte $41,$30,$C3,$C2,$CD ; "A0CBM" autostart signature
|
||||
|
||||
; zero-page scratch (KERNAL-safe temporaries)
|
||||
src = $fb ; $fb/$fc copy source pointer
|
||||
dst = $fd ; $fd/$fe copy destination pointer
|
||||
|
||||
cold:
|
||||
sei
|
||||
cld
|
||||
ldx #$ff
|
||||
txs
|
||||
|
||||
; --- copy the 256-char set (2048 bytes = 8 pages) ROM -> $1400 ---
|
||||
lda #<CHARSRC
|
||||
sta src
|
||||
lda #>CHARSRC
|
||||
sta src+1
|
||||
lda #$00
|
||||
sta dst
|
||||
lda #$14
|
||||
sta dst+1
|
||||
ldx #8 ; pages to copy
|
||||
jsr copypages
|
||||
|
||||
; --- copy the screen (506 bytes -> 2 pages) ROM -> $1E00 ---
|
||||
lda #<SCRSRC
|
||||
sta src
|
||||
lda #>SCRSRC
|
||||
sta src+1
|
||||
lda #$00
|
||||
sta dst
|
||||
lda #$1e
|
||||
sta dst+1
|
||||
ldx #2
|
||||
jsr copypages
|
||||
|
||||
; --- copy colour RAM (506 bytes -> 2 pages) ROM -> $9600 ---
|
||||
lda #<COLSRC
|
||||
sta src
|
||||
lda #>COLSRC
|
||||
sta src+1
|
||||
lda #$00
|
||||
sta dst
|
||||
lda #$96
|
||||
sta dst+1
|
||||
ldx #2
|
||||
jsr copypages
|
||||
|
||||
; --- program the VIC ---
|
||||
lda #$05
|
||||
sta $9000 ; horizontal origin
|
||||
lda #$19
|
||||
sta $9001 ; vertical origin
|
||||
lda #$96
|
||||
sta $9002 ; 22 columns + screen address bit 9
|
||||
lda #$2e
|
||||
sta $9003 ; 23 rows, 8x8 chars
|
||||
lda #$fd
|
||||
sta $9005 ; screen $1E00 + char base $1400
|
||||
lda #(AUX<<4)
|
||||
sta $900e ; auxiliary colour (bits 4-7), volume 0
|
||||
lda #((BG<<4)|$08|BORDER)
|
||||
sta $900f ; bg (bits 4-7) | normal mode (bit3) | border (0-2)
|
||||
|
||||
loop:
|
||||
jmp loop ; hold the picture forever
|
||||
|
||||
; copy X pages of 256 bytes from (src) to (dst)
|
||||
copypages:
|
||||
ldy #$00
|
||||
cp1:
|
||||
lda (src),y
|
||||
sta (dst),y
|
||||
iny
|
||||
bne cp1
|
||||
inc src+1
|
||||
inc dst+1
|
||||
dex
|
||||
bne cp1
|
||||
rts
|
||||
Loading…
Add table
Add a link
Reference in a new issue