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,30 @@
"""Atari 7800 conversion dispatch.
The 7800's MARIA chip has its own architecture (display lists + zones + objects),
nothing like ANTIC/GTIA -- but its 160A graphics mode is 2 bits/pixel, 4 px/byte,
exactly the packing of ANTIC mode E (Atari GR.15). So the per-mode encoders pack
160x192 2bpp bitmaps the same way GR.15 does and reuse the Atari 256-colour NTSC
palette + dither-aware selection; the MARIA-specific display lists are built by
the cartridge packer (viewer/assemble.py).
Modes: ``c160`` = 25-colour 160A (8 MARIA palettes, per-segment palette choice);
``mono`` = luminance two-tone / tinted.
"""
from __future__ import annotations
from ... import imageprep
from . import c160, mono
_MODULES = {"c160": c160, "mono": mono}
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="c160", palette_name="ntsc",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES.get(mode, c160)
img_rgb = imageprep.prepare(path_or_img, module.WIDTH, module.HEIGHT,
module.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0))
return module.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)

View file

@ -0,0 +1,127 @@
"""Atari 7800 MARIA 160A colour mode -- 160x192, 4 px/byte (2bpp).
MARIA gives 8 palettes of 3 colours each plus one shared background = up to 25
colours on screen. The line is split into 8 objects of 20 px (5 bytes) each, and
every object may use a different palette, so each 20 px segment can pick the
3-colour palette (+ shared background) that fits it best -- chosen globally by
clustering the segments into 8 palettes. The 2bpp bitmap packing is identical to
Atari GR.15, so the Atari encoder helpers are reused.
Conversion.data = bitmap(7680) + seg_palettes(192*8) + colours(25):
bitmap 192 lines x 40 bytes, 2bpp, value 0..3 per pixel
seg_palettes one palette index (0..7) per 20px segment (8 per line)
colours [BACKGRND, P0C1,P0C2,P0C3, P1C1..P7C3] (MARIA register order)
"""
from __future__ import annotations
import numpy as np
from ... import palette as c64pal
from ...convert.base import Conversion, perceptual_error, DIFFUSION_DITHERS
from ...atari import palette as apal
from ...atari.convert import _common
WIDTH, HEIGHT = 160, 192
PIXEL_ASPECT = 2.0
SEG_W = 40 # pixels per object (10 bytes, 2bpp)
N_SEG = WIDTH // SEG_W # 4 objects per line (MARIA DMA budget)
N_PAL = 8 # MARIA palettes
def _pack_bitmap(val_image):
return b"".join(bytes(b) for b in _common.pack_2bpp(val_image))
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None, candidates=None, _mode="c160"):
plab = apal.palette_lab("ntsc")
prgb = apal.get_palette("ntsc").astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
bg, palettes, seg_pal, idx = _solve(img_lab, plab, dither_mode, intensive,
candidates)
# value image (0..3) per pixel: 0 = background, 1..3 = the segment palette's
# three colours; build it from idx (palette indices) + the per-segment palette.
val = np.zeros((HEIGHT, WIDTH), np.uint8)
for row in range(HEIGHT):
for s in range(N_SEG):
x0 = s * SEG_W
pal = [bg] + palettes[seg_pal[row, s]]
lut = {c: v for v, c in enumerate(pal)}
block = idx[row, x0:x0 + SEG_W]
val[row, x0:x0 + SEG_W] = [lut[int(c)] for c in block]
bitmap = _pack_bitmap(val)
seg_bytes = bytes(seg_pal.reshape(-1).astype(np.uint8))
colours = bytearray([bg])
for p in range(N_PAL):
colours += bytes(palettes[p])
data = bitmap + seg_bytes + bytes(colours)
err = perceptual_error(idx, img_lab, plab)
return Conversion(
mode=_mode, width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=0,
viewer="a7800", preview_rgb=np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1),
error=err, meta={"palette": "ntsc", "dither": dither_mode, "bg": bg},
)
def _pick(pixels_lab, plab, k, diff, candidates):
"""Pick k palette colours best representing the given pixels."""
if diff:
return _common.choose_palette_dither(pixels_lab, plab, k=k,
candidates=candidates)
if candidates is not None:
sub = plab[candidates]
return [candidates[i] for i in _common.choose_palette(pixels_lab, sub, k=k)]
return _common.choose_palette(pixels_lab, plab, k=k)
def _solve(img_lab, plab, dither_mode, intensive, candidates=None):
"""Choose a shared background + 8 three-colour palettes by clustering the
image's 40px segments by colour (so each palette is tuned to a group of
similar segments), assign each segment its cluster's palette, and dither.
Returns (bg, palettes[8][3], seg_pal, idx)."""
from ... import dither as _dith
diff = dither_mode in DIFFUSION_DITHERS
H, W, _ = img_lab.shape
# shared background = darkest of a small global palette
gp = _pick(img_lab, plab, 8, diff, candidates)
bg = min(gp, key=lambda c: plab[c, 0])
# cluster the H*N_SEG segments by their mean colour into N_PAL groups
seg_means = img_lab.reshape(H, N_SEG, SEG_W, 3).mean(axis=2) # (H,N_SEG,3)
feats = seg_means.reshape(-1, 3)
rng = np.random.default_rng(0)
cent = feats[rng.choice(len(feats), N_PAL, replace=False)].copy()
labels = np.zeros(len(feats), np.int64)
for _ in range(12):
labels = ((feats[:, None] - cent[None]) ** 2).sum(-1).argmin(1)
for g in range(N_PAL):
m = labels == g
if m.any():
cent[g] = feats[m].mean(0)
seg_pal = labels.reshape(H, N_SEG)
# each palette = 3 colours tuned to the pixels of its cluster's segments
seg_px = img_lab.reshape(H, N_SEG, SEG_W, 3)
palettes = []
for g in range(N_PAL):
mask = seg_pal == g # (H,N_SEG)
if not mask.any():
palettes.append([bg, bg, bg])
continue
px = seg_px[mask].reshape(-1, 1, 3) # (Npix,1,3)
palettes.append(_pick(px, plab, 3, diff, candidates))
# dither the whole image with each segment restricted to {bg} + its palette
sets = [[bg] + palettes[g] for g in range(N_PAL)]
allowed = np.zeros((H, W, 4), np.int64)
for r in range(H):
for s in range(N_SEG):
allowed[r, s * SEG_W:s * SEG_W + SEG_W] = sets[seg_pal[r, s]]
idx = _dith.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
return bg, palettes, seg_pal, idx

View file

@ -0,0 +1,36 @@
"""Atari 7800 monochrome / tinted-mono (MARIA 160A restricted to one hue).
Reuses the c160 machinery but restricts the colour pool to the 16 luminances of a
single hue (hue 0 = greyscale), so the 8 palettes become up to ~16 grey levels and
the per-segment palette choice yields a smooth, detailed luminance image.
"""
from __future__ import annotations
import numpy as np
from ... import palette as c64pal
from ...convert.base import DIFFUSION_DITHERS, perceptual_error
from ...atari import palette as apal
from . import c160
WIDTH, HEIGHT, PIXEL_ASPECT = c160.WIDTH, c160.HEIGHT, c160.PIXEL_ASPECT
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
# mono is carried by dithering -> needs error diffusion
if dither_mode not in DIFFUSION_DITHERS:
dither_mode = "floyd"
hue = 0 if base_color is None else (int(base_color) & 0x0F)
candidates = list(range(hue * 16, hue * 16 + 16)) # 16 lums of this hue
conv = c160.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color, candidates=candidates, _mode="mono")
# report error in LUMINANCE space (a greyscale image must not be scored
# against the colour original, as the colour modes are)
plab = apal.palette_lab("ntsc").copy()
plab[:, 1:] = 0.0
img_l = c64pal.srgb_to_lab(img_rgb)
img_l[..., 1:] = 0.0
conv.error = perceptual_error(np.asarray(conv.index_image), img_l, plab)
conv.meta["base_color"] = base_color
return conv