"""FLI (Flexible Line Interpretation) multicolor mode. A stable raster routine re-points the VIC video matrix every scanline, so the two screen-RAM-derived colours gain per-line (4x1) resolution while the colour-RAM colour stays per-cell (4x8) and the background is global. Per 4x1 strip the displayable colours are therefore {background, colourRAM(cell), screen01(line), screen10(line)} -- four colours that refresh every line, far more than plain multicolor. Memory layout of the appended data block (loads from $4000), matched to viewer/fli.s: $4000+L*$400 screen RAM for line L of each char row (L=0..7), 1000 bytes each $6000 bitmap 8000 (VIC reads here, offset $2000 in bank 1) $8000 colour RAM 1000 (viewer copies to $D800) $83E8 background 1 """ from __future__ import annotations from itertools import combinations import numpy as np from .. import dither, palette as pal from . import base WIDTH, HEIGHT = 160, 200 CELL_W, CELL_H = 4, 8 PIXEL_ASPECT = 2.0 DATA_ADDR = 0x4000 N_COLS, N_ROWS = 40, 25 N_CELLS = N_COLS * N_ROWS def convert(img_rgb, palette_name="colodore", dither_mode="bayer", intensive=False): plab = pal.palette_lab(palette_name) img_lab = pal.srgb_to_lab(img_rgb) # (n_cells, 8 rows, 4 px, 3): one 4x1 strip per (cell, line). a = img_lab.reshape(N_ROWS, CELL_H, N_COLS, CELL_W, 3) a = a.transpose(0, 2, 1, 3, 4).reshape(N_CELLS, CELL_H, CELL_W, 3) dist = np.sum((a[:, :, :, None, :] - plab[None, None, None, :, :]) ** 2, axis=-1) # dist: (n_cells, 8, 4, 16) aware = dither_mode in base.DIFFUSION_DITHERS # dither-aware (segment) scoring strip = a if aware else None bg_candidates = range(16) if intensive else [base.best_global_color(img_lab, plab)] best = None for bg in bg_candidates: c11, c01, c10, err = _solve(dist, bg, plab, strip) if best is None or err < best[-1]: best = (bg, c11, c01, c10, err) bg, c11, c01, c10, _ = best index_image = _quantize(img_lab, plab, bg, c11, c01, c10, dither_mode) data = _encode(index_image, bg, c11, c01, c10) conv = base.Conversion( mode="fli", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, index_image=index_image, data=data, data_addr=DATA_ADDR, viewer="fli", error=base.perceptual_error(index_image, img_lab, plab), meta={"palette": palette_name, "dither": dither_mode, "background": int(bg)}, ) return conv def _seg(strip, ca, cb): """Squared distance from each strip pixel to segment ca-cb (Lab points); ca may be (3,) or (n,1,1,3) for a per-cell endpoint, cb is (3,).""" seg = cb - ca L = np.sum(seg * seg, axis=-1, keepdims=True) + 1e-9 t = np.clip(np.sum((strip - ca) * seg, axis=-1, keepdims=True) / L, 0.0, 1.0) proj = ca + t * seg return np.sum((strip - proj) ** 2, axis=-1) # (n,8,4) def _solve(dist, bg, plab=None, strip=None): """Pick per-cell colour-RAM colour c11 and per-line free colours c01,c10. If ``strip`` (the Lab pixels) is given, score by segment distance, crediting error-diffusion dithering (smoother gradients).""" n = dist.shape[0] dbg = dist[:, :, :, bg] # (n,8,4) aware = strip is not None cbg = plab[bg] if aware else None # c11: the single shared colour that best complements bg across the whole cell. cell_err = np.empty((16, n)) for c in range(16): m = _seg(strip, cbg, plab[c]) if aware else np.minimum(dbg, dist[:, :, :, c]) cell_err[c] = m.sum(axis=(1, 2)) cell_err[bg] = np.inf c11 = np.argmin(cell_err, axis=0) # (n,) # base error per strip using {bg, c11}. if aware: c11c = plab[c11][:, None, None, :] # (n,1,1,3) per-cell endpoint sbase = _seg(strip, cbg, c11c) # bg-c11 segment else: c11c = None dc11 = np.take_along_axis(dist, c11[:, None, None, None], axis=3)[..., 0] sbase = np.minimum(dbg, dc11) # (n,8,4) # per strip (cell,line) choose the best 2 free colours. best_err = np.full((n, 8), np.inf) c01 = np.zeros((n, 8), dtype=np.int64) c10 = np.zeros((n, 8), dtype=np.int64) for x, y in combinations(range(16), 2): if aware: e = np.minimum(sbase, _seg(strip, plab[x], plab[y])) # c01-c10 blend e = np.minimum(e, _seg(strip, c11c, plab[x])) # c11-c0x blends e = np.minimum(e, _seg(strip, c11c, plab[y])) e = e.sum(axis=2) else: e = np.minimum(np.minimum(sbase, dist[:, :, :, x]), dist[:, :, :, y]).sum(axis=2) better = e < best_err best_err = np.where(better, e, best_err) c01 = np.where(better, x, c01) c10 = np.where(better, y, c10) total = best_err.sum() return c11, c01, c10, float(total) def _allowed_map(bg, c11, c01, c10): """(H, W, 4) per-pixel allowed palette indices.""" yy, xx = np.indices((HEIGHT, WIDTH)) ci = (yy // CELL_H) * N_COLS + (xx // CELL_W) r = yy % CELL_H allowed = np.empty((HEIGHT, WIDTH, 4), dtype=np.int64) allowed[:, :, 0] = bg allowed[:, :, 1] = c11[ci] allowed[:, :, 2] = c01[ci, r] allowed[:, :, 3] = c10[ci, r] return allowed def _quantize(img_lab, plab, bg, c11, c01, c10, dither_mode): allowed = _allowed_map(bg, c11, c01, c10) return dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8) def _encode(index_image, bg, c11, c01, c10): bitmap = np.zeros(8000, dtype=np.uint8) screens = [np.zeros(1000, dtype=np.uint8) for _ in range(8)] colram = np.zeros(1000, dtype=np.uint8) for cr in range(N_ROWS): for cc in range(N_COLS): ci = cr * N_COLS + cc colram[ci] = c11[ci] & 0x0F base_addr = cr * 320 + cc * 8 for r in range(8): a01, a10 = int(c01[ci, r]), int(c10[ci, r]) screens[r][ci] = ((a01 & 0x0F) << 4) | (a10 & 0x0F) lut = {int(bg): 0b00, int(c11[ci]): 0b11, a01: 0b01, a10: 0b10} row = index_image[cr * 8 + r, cc * 4:cc * 4 + 4] byte = 0 for x in range(4): byte = (byte << 2) | lut.get(int(row[x]), 0b00) bitmap[base_addr + r] = byte block = bytearray() for r in range(8): block += bytes(screens[r]) + bytes(24) # pad each screen to 1K block += bytes(bitmap) # $6000 block += bytes(0x8000 - (0x6000 + 8000)) # pad to $8000 block += bytes(colram) # $8000 block += bytes([int(bg) & 0x0F]) # $83E8 return bytes(block)