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

84 lines
3.8 KiB
Python

"""Atari GR.15 + DLI: 160x192, a fresh set of 4 colours every 2 scanlines.
A display-list interrupt rewrites the four colour registers for each 2-line band
(96 bands). Every *single* scanline is impossible -- four register writes don't
fit the inter-DLI window -- but every 2 lines leaves a comfortable budget, and
96x4 colours is still far beyond flat GR.15. The display list (with DLI bits on
the right lines) is generated here and shipped in the data block.
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert.base import (Conversion, mean_error, perceptual_error,
DIFFUSION_DITHERS)
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 160, 192
PIXEL_ASPECT = 2.0
BAND_H = 2
N_BANDS = HEIGHT // BAND_H # 96
COLOR_ADDR = 0x6000
DL_ADDR = 0x6400 # display list, after the colour table
def make_dlist() -> bytes:
"""ANTIC mode-E display list, 4K-split, DLI bit on the last line of each
2-line band (odd lines 1..189) so the handler sets up the next band."""
dl = bytearray([0x70, 0x70, 0x70]) # 24 blank lines
dl += bytes([0x4e, 0x00, 0x40]) # line 0: LMS $4000 (no DLI)
for ln in range(1, 102): # lines 1..101
dl.append(0x8e if ln % 2 == 1 else 0x0e)
dl += bytes([0x4e, 0x00, 0x50]) # line 102: LMS $5000 (no DLI)
for ln in range(103, 192): # lines 103..191
dl.append(0x8e if (ln % 2 == 1 and ln != 191) else 0x0e)
dl += bytes([0x41, DL_ADDR & 0xFF, DL_ADDR >> 8]) # JVB -> start
return bytes(dl)
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
band_sets = np.zeros((N_BANDS, 4), dtype=np.int64)
aware = dither_mode in DIFFUSION_DITHERS
iters = 10 if intensive else 5
# restrict the per-band dither-aware search to the image's own gamut (fast).
cand = _common.relevant_candidates(img_lab, plab) if aware else None
for b in range(N_BANDS):
block = img_lab[b * BAND_H:(b + 1) * BAND_H].reshape(-1, 3)
cols = _common.choose_palette(block, plab, k=4, iters=iters)
if aware: # span each band's gamut so dithering blends to the true shade
cols = _common.choose_palette_dither(block, plab, k=4, init=cols,
iters=4 if intensive else 3,
candidates=cand)
cols.sort(key=lambda c: plab[c, 0])
band_sets[b] = cols
allowed = np.repeat((band_sets[np.arange(HEIGHT) // BAND_H])[:, None, :],
WIDTH, axis=1)
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
val = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
for y in range(HEIGHT):
lut = {int(c): v for v, c in enumerate(band_sets[y // BAND_H])}
val[y] = [lut.get(int(p), 0) for p in idx[y]]
bitmap = _common.split_screen(_common.pack_2bpp(val)) # 8192 -> $4000..$5FFF
coltab = band_sets.astype(np.uint8).tobytes() # 384 -> $6000
region = coltab + bytes((DL_ADDR - COLOR_ADDR) - len(coltab)) + make_dlist()
data = bitmap + region
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
return Conversion(
mode="gr15dli", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr15dli", preview_rgb=preview,
error=(perceptual_error if aware else mean_error)(idx, img_lab, plab),
meta={"palette": palette_name, "dither": dither_mode},
)