8bitlenser/lenser/convert/fli.py
2026-07-03 19:35:35 -07:00

170 lines
6.6 KiB
Python

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