8bitlenser/c64view/convert/ifli.py
2026-06-14 17:43:12 -07:00

151 lines
6.2 KiB
Python

"""Interlace mode: two multicolor frames shown on alternating fields (50Hz each).
The eye averages the two frames, so each pixel can show the blend of its colour
in frame A and frame B -- up to ~136 distinct apparent colours (16 base + 120
pairs). Frame A is an ordinary multicolor conversion; frame B targets the
*residual* (2*target - A) so that (A+B)/2 reconstructs the original. Both frames
share the global background and the colour-RAM colour per cell (the only VIC state
the viewer cannot cheaply swap per frame), and differ in bitmap + screen RAM.
Memory layout of the appended data (loads from $2000), matched to viewer/interlace.s:
$2000 bitmap A 8000 (bank 0, VIC reads here)
$3F40 screen A 1000 (copied to $0400)
$4400 screen B 1000 (bank 1 video matrix, in place)
$6000 bitmap B 8000 (bank 1, VIC reads here)
$8000 colour RAM 1000 (shared, copied 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 = 0x2000
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)
prgb = pal.get_palette(palette_name)
img_lab = pal.srgb_to_lab(img_rgb)
# ---- frame A: ordinary multicolor (bg + 3 free per cell) ----
cellsA, _, _ = base.cells_lab(img_lab, CELL_W, CELL_H)
distA = base.cell_distance(cellsA, plab)
if intensive:
bg, setsA, _ = base.optimize_background(distA, n_free=3)
else:
bg = base.best_global_color(img_lab, plab)
avail = [i for i in range(16) if i != bg]
setsA, _ = base.select_cell_sets(distA, avail, n_free=3, fixed=(bg,))
# colour-RAM colour (shared by both frames) = third free colour of A.
c11 = setsA[:, 3].astype(np.int64)
allowedA = base.per_pixel_allowed(setsA, N_ROWS, N_COLS, CELL_W, CELL_H, HEIGHT, WIDTH)
idxA = dither.quantize(img_lab, allowedA, plab, dither_mode).astype(np.uint8)
# ---- frame B: match residual 2*target - A in linear light ----
lin_target = pal.srgb_to_linear(img_rgb)
lin_A = pal.srgb_to_linear(prgb[idxA])
resid = np.clip(2.0 * lin_target - lin_A, 0.0, 1.0)
resid_srgb = pal.linear_to_srgb(resid)
resid_lab = pal.srgb_to_lab(resid_srgb)
setsB = _solve_frameB(resid_lab, plab, bg, c11)
allowedB = base.per_pixel_allowed(setsB, N_ROWS, N_COLS, CELL_W, CELL_H, HEIGHT, WIDTH)
idxB = dither.quantize(resid_lab, allowedB, plab, dither_mode).astype(np.uint8)
# ---- blended preview (linear average -> sRGB, widened to display aspect) ----
blend_lin = (pal.srgb_to_linear(prgb[idxA]) + pal.srgb_to_linear(prgb[idxB])) / 2.0
blend = pal.linear_to_srgb(blend_lin)
preview = np.repeat(blend, int(round(PIXEL_ASPECT)), axis=1)
blend_lab = pal.srgb_to_lab(blend)
error = float(np.sqrt(np.sum((blend_lab - img_lab) ** 2, axis=-1)).mean())
data = _encode(idxA, idxB, setsA, setsB, bg, c11)
return base.Conversion(
mode="interlace", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idxA, data=data, data_addr=DATA_ADDR, viewer="interlace",
preview_rgb=preview, error=error,
meta={"palette": palette_name, "dither": dither_mode, "background": int(bg)},
)
def _solve_frameB(resid_lab, plab, bg, c11):
"""Per cell, pick the 2 free colours for frame B given shared {bg, c11[cell]}."""
cells, _, _ = base.cells_lab(resid_lab, CELL_W, CELL_H)
dist = base.cell_distance(cells, plab) # (n, P, 16)
dbg = dist[:, :, bg] # (n, P)
dc11 = np.take_along_axis(dist, c11[:, None, None], axis=2)[:, :, 0]
sbase = np.minimum(dbg, dc11)
n = dist.shape[0]
best = np.full(n, np.inf)
b1 = np.zeros(n, dtype=np.int64)
b2 = np.zeros(n, dtype=np.int64)
for x, y in combinations(range(16), 2):
e = np.minimum(np.minimum(sbase, dist[:, :, x]), dist[:, :, y]).sum(axis=1)
better = e < best
best = np.where(better, e, best)
b1 = np.where(better, x, b1)
b2 = np.where(better, y, b2)
bg_arr = np.full(n, bg, dtype=np.int64)
return np.stack([bg_arr, b1, b2, c11], axis=1)
def _pack_frame(index_image, screen_assign, colram_assign, bg, get_lut):
"""Build (bitmap, screen) for one frame. ``get_lut`` maps cell index -> dict."""
bitmap = np.zeros(8000, dtype=np.uint8)
screen = np.zeros(1000, dtype=np.uint8)
for cr in range(N_ROWS):
for cc in range(N_COLS):
ci = cr * N_COLS + cc
hi, lo, lut = get_lut(ci)
screen[ci] = ((hi & 0x0F) << 4) | (lo & 0x0F)
base_addr = cr * 320 + cc * 8
block = index_image[cr * 8:cr * 8 + 8, cc * 4:cc * 4 + 4]
for r in range(8):
byte = 0
for x in range(4):
byte = (byte << 2) | lut.get(int(block[r, x]), 0b00)
bitmap[base_addr + r] = byte
return bitmap, screen
def _encode(idxA, idxB, setsA, setsB, bg, c11):
def lutA(ci):
cc11 = int(c11[ci])
a01, a10 = int(setsA[ci, 1]), int(setsA[ci, 2])
return a01, a10, {int(bg): 0b00, a01: 0b01, a10: 0b10, cc11: 0b11}
def lutB(ci):
cc11 = int(c11[ci])
b01, b10 = int(setsB[ci, 1]), int(setsB[ci, 2])
return b01, b10, {int(bg): 0b00, b01: 0b01, b10: 0b10, cc11: 0b11}
bitmapA, screenA = _pack_frame(idxA, None, None, bg, lutA)
bitmapB, screenB = _pack_frame(idxB, None, None, bg, lutB)
colram = (c11 & 0x0F).astype(np.uint8)
block = bytearray()
block += bytes(bitmapA) # $2000
block += bytes(screenA) # $3F40
block += bytes(0x4400 - (0x3F40 + 1000)) # pad to $4400
block += bytes(screenB) # $4400
block += bytes(0x6000 - (0x4400 + 1000)) # pad to $6000
block += bytes(bitmapB) # $6000
block += bytes(0x8000 - (0x6000 + 8000)) # pad to $8000
block += bytes(colram) # $8000
block += bytes([int(bg) & 0x0F]) # $83E8
return bytes(block)