First public commit.

This commit is contained in:
The Dust Council 2026-07-03 19:35:35 -07:00
parent 2a48f52979
commit 4bac9d83ed
288 changed files with 18417 additions and 1076 deletions

View file

@ -0,0 +1,31 @@
"""Atari conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from .. import palette as apal
from . import gr15
_MODULES = {"gr15": gr15}
for _name in ("gr9", "gr8", "gr15dli", "mono"):
try:
_mod = __import__(f"lenser.atari.convert.{_name}", fromlist=[_name])
_MODULES[_name] = _mod
except Exception:
pass
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="gr15", palette_name="ntsc",
dither_mode="floyd", intensive=False,
prep_opt: imageprep.PrepOptions | None = None, base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES[mode]
border_rgb = apal.get_palette(palette_name)[0]
img_rgb = imageprep.prepare(
path_or_img, module.WIDTH, module.HEIGHT, module.PIXEL_ASPECT,
prep_opt, border_rgb=border_rgb,
)
return module.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)

View file

@ -0,0 +1,176 @@
"""Shared helpers for the Atari encoders."""
from __future__ import annotations
import numpy as np
DATA_ADDR = 0x4000 # bitmap base
COLOR_ADDR = 0x6000 # colour data base (fixed, after the bitmap)
SPLIT_LINE = 102 # lines that fit in the first 4K ($4000-$4FEF)
BYTES_PER_LINE = 40
LINES = 192
def split_screen(line_bytes: list[bytes]) -> bytes:
"""Lay out 192 screen lines with the 16-byte gap that pushes line 102 onto
the $5000 boundary (so no ANTIC line crosses a 4K boundary), then pad up to
COLOR_ADDR so colour data can follow at a fixed address."""
first = b"".join(line_bytes[:SPLIT_LINE]) # 4080 bytes -> $4000
second = b"".join(line_bytes[SPLIT_LINE:]) # 3600 bytes -> $5000
body = first + bytes(0x1000 - len(first)) + second # gap fills to $5000
pad = (COLOR_ADDR - DATA_ADDR) - len(body)
return body + bytes(pad)
def luminance_lab(img_rgb, plab):
"""Return (image, palette) recast into luminance-only CIELAB (L, 0, 0), so
matching is by brightness alone -- used by the single-hue modes."""
from ...palette import srgb_to_lab
L = srgb_to_lab(img_rgb)[..., 0]
img_mono = np.zeros(img_rgb.shape[:2] + (3,))
img_mono[..., 0] = L
plab_mono = np.zeros_like(plab)
plab_mono[:, 0] = plab[:, 0]
return img_mono, plab_mono
def choose_palette(img_lab: np.ndarray, plab: np.ndarray, k: int,
iters: int = 12) -> list[int]:
"""Pick the ``k`` palette register values (0..255) that best represent the
image, by palette-constrained k-means in CIELAB."""
flat = img_lab.reshape(-1, 3).astype(np.float32)
D = np.sum((flat[:, None, :] - plab[None, :, :].astype(np.float32)) ** 2, axis=-1) # (N,256)
# k-means++-ish greedy init.
chosen = [int(np.argmin(np.sum((plab - flat.mean(0)) ** 2, axis=-1)))]
for _ in range(k - 1):
md = D[:, chosen].min(axis=1)
improv = np.maximum(0.0, md[:, None] - D).sum(axis=0)
improv[chosen] = -1.0
chosen.append(int(np.argmax(improv)))
# Lloyd refinement, each centroid snapped to its best palette colour.
for _ in range(iters):
assign = np.argmin(D[:, chosen], axis=1)
new = []
for j in range(k):
mask = assign == j
if not mask.any():
new.append(chosen[j])
else:
new.append(int(np.argmin(D[mask].sum(axis=0))))
# keep distinct where possible
if new == chosen:
break
chosen = new
return chosen
def _seg_all(sub, c1all, c2):
"""Distance from each ``sub`` pixel to the segment between every palette colour
(c1all, shape (256,3)) and a fixed endpoint c2. Returns (256, Nsub)."""
seg = c2 - c1all # (256,3)
L = np.sum(seg * seg, axis=1) + 1e-9 # (256,)
rel = sub[None, :, :] - c1all[:, None, :] # (256,Nsub,3)
t = np.clip(np.sum(rel * seg[:, None, :], axis=2) / L[:, None], 0.0, 1.0)
proj = c1all[:, None, :] + t[:, :, None] * seg[:, None, :]
return np.sum((sub[None, :, :] - proj) ** 2, axis=2)
def relevant_candidates(img_lab, plab):
"""Palette colours that are the nearest match to some image pixel -- a small
set (the image's own gamut) to restrict the dither-aware search to."""
flat = img_lab.reshape(-1, 3).astype(np.float32)
if len(flat) > 4000:
flat = flat[::len(flat) // 4000]
d = np.sum((flat[:, None, :] - plab[None, :, :].astype(np.float32)) ** 2, axis=-1)
return np.unique(np.argmin(d, axis=1)).astype(np.int64)
def choose_palette_dither(img_lab, plab, k, init=None, n_sample=900, iters=5,
candidates=None):
"""Dither-aware palette: pick the ``k`` colours whose pairwise *segment* blends
(what error diffusion can reproduce) best cover the image -- so the colours
span the gamut instead of sitting at k-means centroids. Vectorised local
search (all candidates per slot at once) from a k-means start."""
from itertools import combinations
flat = img_lab.reshape(-1, 3)
sub = flat[::max(1, len(flat) // n_sample)] if len(flat) > n_sample else flat
colors = list(init) if init is not None else choose_palette(img_lab, plab, k)
cand = np.asarray(candidates if candidates is not None else range(256), np.int64)
cand_lab = plab[cand].astype(np.float64) # (C,3)
for _ in range(iters):
changed = False
for i in range(k):
others = [colors[j] for j in range(k) if j != i]
fixed = None
for x, y in combinations(others, 2):
s = _seg_all(sub, plab[x][None], plab[y])[0]
fixed = s if fixed is None else np.minimum(fixed, s)
m = None
for o in others:
d = _seg_all(sub, cand_lab, plab[o]) # (C, Nsub)
m = d if m is None else np.minimum(m, d)
if fixed is not None:
m = np.minimum(m, fixed[None, :])
err = m.sum(axis=1) # (C,)
for ci, c in enumerate(cand):
if c in others:
err[ci] = np.inf # avoid duplicate colours
best = int(cand[np.argmin(err)])
if best != colors[i]:
colors[i] = best
changed = True
if not changed:
break
return colors
def quantize_global(img_lab, plab, colors, dither_mode):
"""Dither the whole image to a fixed global set of palette indices."""
from ... import dither
H, W, _ = img_lab.shape
allowed = np.tile(np.array(colors, dtype=np.int64), (H, W, 1))
return dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
def pack_2bpp(val_image: np.ndarray) -> list[bytes]:
"""160-wide 2-bits-per-pixel -> list of 192 x 40-byte lines."""
H, W = val_image.shape
lines = []
for y in range(H):
row = val_image[y]
out = bytearray()
for x in range(0, W, 4):
out.append((row[x] << 6) | (row[x + 1] << 4) | (row[x + 2] << 2) | row[x + 3])
lines.append(bytes(out))
return lines
def pack_4bpp(val_image: np.ndarray) -> list[bytes]:
"""80-wide 4-bits-per-pixel -> list of 192 x 40-byte lines."""
H, W = val_image.shape
lines = []
for y in range(H):
row = val_image[y]
out = bytearray()
for x in range(0, W, 2):
out.append((row[x] << 4) | row[x + 1])
lines.append(bytes(out))
return lines
def pack_1bpp(val_image: np.ndarray) -> list[bytes]:
"""320-wide 1-bit-per-pixel -> list of 192 x 40-byte lines."""
H, W = val_image.shape
lines = []
for y in range(H):
row = val_image[y]
out = bytearray()
for x in range(0, W, 8):
b = 0
for i in range(8):
b = (b << 1) | int(row[x + i])
out.append(b)
lines.append(bytes(out))
return lines

View file

@ -0,0 +1,51 @@
"""Atari GR.15 (ANTIC mode E): 160x192, 4 colours chosen globally from 256.
No per-cell colour limit, so this is a clean 4-colour dithered image.
"""
from __future__ import annotations
import numpy as np
from ... import palette as c64pal # for srgb_to_lab (shared)
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
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)
# Dither-aware palette for error-diffusion modes: pick 4 colours whose blends
# span the image gamut (so dithering reproduces saturated/intermediate shades)
# instead of k-means centroids the dither can't reach.
if dither_mode in DIFFUSION_DITHERS:
colors = _common.choose_palette_dither(img_lab, plab, k=4)
else:
colors = _common.choose_palette(img_lab, plab, k=4)
colors.sort(key=lambda c: plab[c, 0]) # value 0 = darkest (background)
idx = _common.quantize_global(img_lab, plab, colors, dither_mode)
value_of = {c: v for v, c in enumerate(colors)}
val_image = np.vectorize(value_of.get)(idx).astype(np.uint8)
lines = _common.pack_2bpp(val_image)
data = _common.split_screen(lines) + bytes(colors) # 4 colour regs at $6000
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
err = (perceptual_error if dither_mode in DIFFUSION_DITHERS else mean_error)
return Conversion(
mode="gr15", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr15", preview_rgb=preview,
error=err(idx, img_lab, plab),
meta={"palette": palette_name, "dither": dither_mode, "colors": colors},
)

View file

@ -0,0 +1,84 @@
"""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},
)

View file

@ -0,0 +1,40 @@
"""Atari GR.8 (ANTIC mode F): 320x192 hi-res, two tones of one hue.
Highest spatial resolution; carries tone by dithering between background and a
foreground luminance. ``base_color`` picks the hue (None = greyscale).
"""
from __future__ import annotations
import numpy as np
from ...convert.base import Conversion, perceptual_error
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 320, 192
PIXEL_ASPECT = 1.0
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
hue = 0 if base_color is None else (int(base_color) & 0x0F)
bg_reg = (hue << 4) | 0x00 # darkest of the hue
fg_reg = (hue << 4) | 0x0E # brightest of the hue
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_mono, plab_mono = _common.luminance_lab(img_rgb, plab)
idx = _common.quantize_global(img_mono, plab_mono, [bg_reg, fg_reg], dither_mode)
val = (idx == fg_reg).astype(np.uint8)
data = _common.split_screen(_common.pack_1bpp(val)) + bytes([bg_reg, fg_reg])
preview = prgb[idx] # already 320 wide
return Conversion(
mode="gr8", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr8", preview_rgb=preview,
error=perceptual_error(idx, img_mono, plab_mono),
meta={"palette": palette_name, "dither": dither_mode, "hue": hue},
)

View file

@ -0,0 +1,48 @@
"""Atari GR.9 (GTIA): 80x192, 16 luminance shades of one hue.
Excellent greyscale (hue 0) or tinted monochrome (any of 16 hues) -- 16 real
shades, not just dithered. ``base_color`` selects the hue (0..15); None = grey.
"""
from __future__ import annotations
import numpy as np
from ...convert.base import Conversion, perceptual_error
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 80, 192
PIXEL_ASPECT = 4.0
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
hue = 0 if base_color is None else (int(base_color) & 0x0F)
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_mono, plab_mono = _common.luminance_lab(img_rgb, plab)
ramp = apal.hue_ramp(hue) # 16 register values of this hue
idx = _common.quantize_global(img_mono, plab_mono, ramp, dither_mode)
val = (idx & 0x0F).astype(np.uint8) # GR.9 pixel = 4-bit luminance
# GTIA mode 9 takes the hue from COLBK and the luminance from each pixel. A
# COLBK of exactly $00, though, blanks the whole playfield to black -- the
# register must be non-zero to enable the display. For a tinted hue that is
# automatic ((hue<<4) != 0); for greyscale (hue 0) force a non-zero luminance
# nibble, which the mode ignores for output (luminance still comes from the
# pixels) but which switches the 16-shade display on.
colbk = (hue & 0x0F) << 4
if colbk == 0:
colbk = 0x0E
data = _common.split_screen(_common.pack_4bpp(val)) + bytes([colbk])
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
return Conversion(
mode="gr9", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr9", preview_rgb=preview,
error=perceptual_error(idx, img_mono, plab_mono),
meta={"palette": palette_name, "dither": dither_mode, "hue": hue},
)

View file

@ -0,0 +1,16 @@
"""Atari monochrome -- GR.9's 16 luminance shades, exposed as the standard
``mono`` mode for cross-platform parity. Greyscale by default; ``--mono-base``
tints it to one of the GTIA hues (16 real shades of that hue)."""
from __future__ import annotations
from . import gr9
WIDTH, HEIGHT, PIXEL_ASPECT = gr9.WIDTH, gr9.HEIGHT, gr9.PIXEL_ASPECT
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
conv = gr9.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)
conv.mode = "mono"
return conv