"""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)}, )