183 lines
7.7 KiB
Python
183 lines
7.7 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)
|
|
|
|
aware = dither_mode in base.DIFFUSION_DITHERS # dither-aware (segment) scoring
|
|
|
|
# ---- frame A: ordinary multicolor (bg + 3 free per cell) ----
|
|
cellsA, _, _ = base.cells_lab(img_lab, CELL_W, CELL_H)
|
|
if intensive:
|
|
if aware:
|
|
bg, setsA, _ = base.optimize_background_dither(cellsA, plab, n_free=3)
|
|
else:
|
|
bg, setsA, _ = base.optimize_background(base.cell_distance(cellsA, plab),
|
|
n_free=3)
|
|
else:
|
|
bg = base.best_global_color(img_lab, plab)
|
|
avail = [i for i in range(16) if i != bg]
|
|
if aware:
|
|
setsA, _ = base.select_cell_sets_dither(cellsA, plab, avail, n_free=3,
|
|
fixed=(bg,))
|
|
else:
|
|
setsA, _ = base.select_cell_sets(base.cell_distance(cellsA, plab),
|
|
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, aware)
|
|
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)
|
|
# perceptual error of the blend (blur credits the dither the eye averages).
|
|
bl = base._box_blur(pal.srgb_to_lab(blend)); tg = base._box_blur(img_lab)
|
|
error = float(np.sqrt(np.sum((bl - tg) ** 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 _segc(cells, ca, cb):
|
|
"""Per-pixel squared distance to segment ca-cb; ca may be (n,1,3), cb (3,)."""
|
|
seg = cb - ca
|
|
L = np.sum(seg * seg, axis=-1, keepdims=True) + 1e-9
|
|
t = np.clip(np.sum((cells - ca) * seg, axis=-1, keepdims=True) / L, 0.0, 1.0)
|
|
return np.sum((cells - (ca + t * seg)) ** 2, axis=-1) # (n, P)
|
|
|
|
|
|
def _solve_frameB(resid_lab, plab, bg, c11, aware=False):
|
|
"""Per cell, pick the 2 free colours for frame B given shared {bg, c11[cell]}.
|
|
With ``aware`` the colours are scored by segment distance (dither-aware)."""
|
|
cells, _, _ = base.cells_lab(resid_lab, CELL_W, CELL_H)
|
|
n = cells.shape[0]
|
|
cbg = plab[bg]
|
|
c11c = plab[c11][:, None, :] # (n,1,3) per-cell c11
|
|
if aware:
|
|
sbase = _segc(cells, cbg[None, None, :], c11c) # bg-c11 segment
|
|
else:
|
|
dist = base.cell_distance(cells, plab)
|
|
dbg = dist[:, :, bg]
|
|
dc11 = np.take_along_axis(dist, c11[:, None, None], axis=2)[:, :, 0]
|
|
sbase = np.minimum(dbg, dc11)
|
|
|
|
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):
|
|
if aware:
|
|
e = np.minimum(sbase, _segc(cells, plab[x], plab[y])) # b1-b2 blend
|
|
e = np.minimum(e, _segc(cells, c11c, plab[x])) # c11-bx blends
|
|
e = np.minimum(e, _segc(cells, c11c, plab[y]))
|
|
e = e.sum(axis=1)
|
|
else:
|
|
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)
|