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

0
lenser/nes/__init__.py Normal file
View file

65
lenser/nes/cartridge.py Normal file
View file

@ -0,0 +1,65 @@
"""Build an iNES (.nes) NROM cartridge: 6502 PPU-init viewer + image data + CHR.
Layout: 16-byte iNES header, 16K PRG-ROM (viewer at $C000, 32-byte palette at
$F000, 1024-byte nametable+attributes at $F020, vectors at $FFFA), then 8K
CHR-ROM (256 background tiles in pattern table 0).
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
PRG_BASE = 0xC000
PRG_SIZE = 0x4000 # 16K
PAL_ADDR = 0xF000 # 32-byte palette
NT_ADDR = 0xF020 # 1024-byte nametable + attribute
def have_xa() -> bool:
return shutil.which("xa") is not None
def _assemble() -> bytes:
if not have_xa():
raise RuntimeError("The 'xa' (xa65) assembler was not found (apt install xa65).")
with tempfile.TemporaryDirectory() as td:
out = os.path.join(td, "v.bin")
proc = subprocess.run(["xa", "-o", out, "viewer.s"],
capture_output=True, text=True, cwd=VIEWER_DIR)
if proc.returncode != 0:
raise RuntimeError(f"xa failed:\n{proc.stdout}{proc.stderr}")
with open(out, "rb") as f:
return f.read()
def build_rom(palette: bytes, nametable: bytes, chr_rom: bytes) -> bytes:
"""palette: 32 bytes; nametable: 1024 (960 names + 64 attr); chr_rom: 8192."""
if len(palette) != 32 or len(nametable) != 1024 or len(chr_rom) != 8192:
raise ValueError("bad NES data block sizes")
code = _assemble() # 6502 viewer, org $C000
if len(code) > PAL_ADDR - PRG_BASE:
raise RuntimeError("viewer code overruns the $F000 data area")
prg = bytearray(PRG_SIZE)
prg[0:len(code)] = code # start == $C000
prg[PAL_ADDR - PRG_BASE:PAL_ADDR - PRG_BASE + 32] = palette
prg[NT_ADDR - PRG_BASE:NT_ADDR - PRG_BASE + 1024] = nametable
# CPU vectors $FFFA/$FFFC/$FFFE all point at start ($C000)
for off in (0x3FFA, 0x3FFC, 0x3FFE):
prg[off] = PRG_BASE & 0xFF
prg[off + 1] = PRG_BASE >> 8
header = bytes([0x4E, 0x45, 0x53, 0x1A, # "NES\x1A"
1, # 16K PRG units
1, # 8K CHR units
0x00, 0x00] + [0] * 8) # NROM (mapper 0)
return header + bytes(prg) + bytes(chr_rom)
def write_nes(rom: bytes, path: str) -> str:
with open(path, "wb") as f:
f.write(rom)
return path

View file

@ -0,0 +1,19 @@
"""Nintendo Entertainment System conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from . import bg, mono
_MODULES = {"bg": bg, "mono": mono}
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="bg", palette_name="nes",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES.get(mode, bg)
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,206 @@
"""NES background encoder: 4 sub-palettes + per-16x16 attribute + 256-tile CHR.
The NES PPU draws a 32x30 grid of 8x8 tiles (pattern table, <=256 unique), each
tile 2bpp. Colour comes from 4 background sub-palettes (each = a shared universal
background + 3 colours), one chosen per 16x16 region via the attribute table. So
the pipeline is: choose the universal bg, cluster the image into 4 sub-palettes,
assign each region its best one, dither, then vector-quantise the 8x8 tile
patterns down to 256 CHR tiles.
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert import base
from .. import palette as npal
W, H = 256, 240
RCOLS, RROWS = 16, 15 # 16x16-pixel regions (2x2 tiles)
TCOLS, TROWS = 32, 30 # 8x8 tiles
NTILES = 256
def _best_colors(pix_lab, plab, bg0, n, k_cand=20):
"""Best ``n`` NES colours (besides fixed bg0) for a pool of pixels."""
mean_d = np.sum((pix_lab.mean(0)[None, :] - plab) ** 2, 1)
cand = [c for c in np.argsort(mean_d)[:k_cand] if c != bg0]
dist = np.sum((pix_lab[:, None, :] - plab[None, cand, :]) ** 2, 2) # (px, k)
d_bg = np.sum((pix_lab - plab[bg0]) ** 2, 1) # (px,)
from itertools import combinations
best, best_err = None, np.inf
for combo in combinations(range(len(cand)), n):
m = np.minimum(d_bg, dist[:, combo].min(1)).sum()
if m < best_err:
best_err, best = m, [cand[i] for i in combo]
return best
def _tile_codebook(patterns, k, iters=8):
"""k-medoids over 8x8 pen patterns (values 0-3); distance = differing pixels."""
uniq, counts = np.unique(patterns, axis=0, return_counts=True)
if len(uniq) <= k:
code = np.zeros((k, patterns.shape[1]), patterns.dtype)
code[:len(uniq)] = uniq
lut = {tuple(p): i for i, p in enumerate(uniq)}
return code, np.array([lut[tuple(p)] for p in patterns])
code = uniq[np.argsort(-counts)[:k]].copy()
labels = np.zeros(len(patterns), np.int64)
for _ in range(iters):
for s in range(0, len(patterns), 2048):
blk = patterns[s:s + 2048]
labels[s:s + 2048] = (blk[:, None, :] != code[None]).sum(2).argmin(1)
moved = False
for j in range(k):
mem = patterns[labels == j]
if len(mem):
med = np.array([np.bincount(mem[:, p], minlength=4).argmax()
for p in range(mem.shape[1])], patterns.dtype)
if not np.array_equal(med, code[j]):
code[j] = med; moved = True
if not moved:
break
for s in range(0, len(patterns), 2048):
blk = patterns[s:s + 2048]
labels[s:s + 2048] = (blk[:, None, :] != code[None]).sum(2).argmin(1)
return code, labels
def encode(img_rgb, dither_mode, subpalettes):
"""subpalettes: list of 4 lists [bg0,c1,c2,c3] of NES colour indices (the
universal bg = subpalettes[*][0], identical across all four)."""
plab = npal.palette_lab()
prgb = npal.get_palette().astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
bg0 = subpalettes[0][0]
sp = np.array(subpalettes) # (4,4) NES indices
# assign each 16x16 region to the sub-palette giving least nearest-colour error
region_pal = np.zeros((RROWS, RCOLS), np.int64)
for ry in range(RROWS):
for rx in range(RCOLS):
blk = img_lab[ry * 16:ry * 16 + 16, rx * 16:rx * 16 + 16].reshape(-1, 3)
errs = []
for s in range(4):
d = np.sum((blk[:, None, :] - plab[sp[s]][None, :, :]) ** 2, 2)
errs.append(d.min(1).sum())
region_pal[ry, rx] = int(np.argmin(errs))
# per-pixel allowed colours = the pixel's region sub-palette; dither
allowed = np.zeros((H, W, 4), np.int64)
for ry in range(RROWS):
for rx in range(RCOLS):
allowed[ry * 16:ry * 16 + 16, rx * 16:rx * 16 + 16] = sp[region_pal[ry, rx]]
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
# pen per pixel = position of its colour within its region's sub-palette
pen = np.zeros((H, W), np.uint8)
for ry in range(RROWS):
for rx in range(RCOLS):
ys, xs = slice(ry * 16, ry * 16 + 16), slice(rx * 16, rx * 16 + 16)
pal = sp[region_pal[ry, rx]]
block = idx[ys, xs]
pmap = np.zeros(block.shape, np.uint8)
for k, col in enumerate(pal):
pmap[block == col] = k
pen[ys, xs] = pmap
# 8x8 tiles -> patterns; vector-quantise to <=256 CHR tiles
tiles = pen.reshape(TROWS, 8, TCOLS, 8).transpose(0, 2, 1, 3).reshape(TROWS * TCOLS, 64)
code, labels = _tile_codebook(tiles, NTILES)
nametable = labels.astype(np.uint8).reshape(TROWS, TCOLS)
# ---- emit NES data ----
chr_rom = bytearray(8192)
for t in range(NTILES):
pat = code[t].reshape(8, 8)
for r in range(8):
p0 = p1 = 0
for x in range(8):
v = int(pat[r, x])
p0 |= (v & 1) << (7 - x)
p1 |= ((v >> 1) & 1) << (7 - x)
chr_rom[t * 16 + r] = p0
chr_rom[t * 16 + 8 + r] = p1
attr = bytearray(64)
for ar in range(8):
for ac in range(8):
b = 0
for q, (dy, dx) in enumerate(((0, 0), (0, 1), (1, 0), (1, 1))):
ry, rx = ar * 2 + dy, ac * 2 + dx
s = int(region_pal[ry, rx]) if ry < RROWS and rx < RCOLS else 0
b |= (s & 3) << (q * 2)
attr[ar * 8 + ac] = b
pal32 = bytearray(32)
for s in range(4):
pal32[s * 4] = bg0
pal32[s * 4 + 1] = int(sp[s][1])
pal32[s * 4 + 2] = int(sp[s][2])
pal32[s * 4 + 3] = int(sp[s][3])
pal32[16:32] = pal32[0:16] # sprite palette = mirror
nametable_full = bytes(nametable.reshape(-1)) + bytes(attr) # 960 + 64
# rebuild the displayed image (clustered tiles + region palettes) for preview
disp = code[labels].reshape(TROWS, TCOLS, 8, 8).transpose(0, 2, 1, 3).reshape(H, W)
final_idx = np.zeros((H, W), np.uint16)
for ry in range(RROWS):
for rx in range(RCOLS):
ys, xs = slice(ry * 16, ry * 16 + 16), slice(rx * 16, rx * 16 + 16)
pal = sp[region_pal[ry, rx]]
final_idx[ys, xs] = pal[disp[ys, xs]]
err = base.perceptual_error(final_idx, img_lab, plab)
return bytes(pal32), nametable_full, bytes(chr_rom), final_idx, err, plab, prgb
def pick_subpalettes(img_rgb, n_groups=4, mono=False, base_color=None):
"""Choose the universal bg + ``n_groups`` sub-palettes (each bg + 3 colours)."""
plab = npal.palette_lab()
img_lab = c64pal.srgb_to_lab(img_rgb)
if mono:
greys = sorted(npal.GREYS, key=lambda i: plab[i, 0])
if base_color in range(64):
ramp = sorted({greys[0], int(base_color), greys[-1]}, key=lambda i: plab[i, 0])
else:
# 4 greys spanning black->white (include the lightest so highlights
# actually reach white -- otherwise the image comes out muddy/dark)
lums = np.array([plab[i, 0] for i in greys])
ramp = [greys[int(np.argmin(np.abs(lums - t)))]
for t in np.linspace(lums.min(), lums.max(), 4)]
bg0 = ramp[0]
others = [c for c in ramp if c != bg0][:3]
while len(others) < 3:
others.append(others[-1])
return [[bg0] + others] * 4
bg0 = base.best_global_color(img_lab, plab)
# cluster regions by mean colour into n_groups, then pick 3 colours per group
feats = []
for ry in range(RROWS):
for rx in range(RCOLS):
feats.append(img_lab[ry * 16:ry * 16 + 16, rx * 16:rx * 16 + 16]
.reshape(-1, 3).mean(0))
feats = np.array(feats)
rng = np.random.default_rng(0)
cen = feats[rng.choice(len(feats), n_groups, replace=False)]
for _ in range(12):
lab = np.argmin(np.sum((feats[:, None, :] - cen[None]) ** 2, 2), 1)
for g in range(n_groups):
if (lab == g).any():
cen[g] = feats[lab == g].mean(0)
subs = []
for g in range(n_groups):
members = np.where(lab == g)[0]
if len(members) == 0:
subs.append([bg0, bg0, bg0, bg0]); continue
pool = np.concatenate([
img_lab[(m // RCOLS) * 16:(m // RCOLS) * 16 + 16,
(m % RCOLS) * 16:(m % RCOLS) * 16 + 16].reshape(-1, 3)
for m in members])
if len(pool) > 4000:
pool = pool[rng.choice(len(pool), 4000, replace=False)]
subs.append([bg0] + _best_colors(pool, plab, bg0, 3))
return subs

21
lenser/nes/convert/bg.py Normal file
View file

@ -0,0 +1,21 @@
"""NES background image: 256x240, 4 sub-palettes (universal bg + 3 each), tiles."""
from __future__ import annotations
from ...convert.base import Conversion
from . import _common
WIDTH, HEIGHT = 256, 240
PIXEL_ASPECT = 1.0 # NES pixels are ~square (8:7 really; close enough)
def convert(img_rgb, palette_name="nes", dither_mode="floyd",
intensive=False, base_color=None):
subs = _common.pick_subpalettes(img_rgb, mono=False)
pal32, nt, chr_rom, idx, err, plab, prgb = _common.encode(
img_rgb, dither_mode, subs)
return Conversion(
mode="bg", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx, data={"palette": pal32, "nametable": nt, "chr": chr_rom},
data_addr=0, viewer="nes", preview_rgb=prgb[idx], error=err,
meta={"palette": "nes", "dither": dither_mode},
)

View file

@ -0,0 +1,22 @@
"""NES monochrome: 256x240 greyscale using the PPU's grey ramp (tone by
dithering). ``--mono-base`` tints the ramp toward a hue."""
from __future__ import annotations
from ...convert.base import Conversion
from . import _common
WIDTH, HEIGHT = 256, 240
PIXEL_ASPECT = 1.0
def convert(img_rgb, palette_name="nes", dither_mode="floyd",
intensive=False, base_color=None):
subs = _common.pick_subpalettes(img_rgb, mono=True, base_color=base_color)
pal32, nt, chr_rom, idx, err, plab, prgb = _common.encode(
img_rgb, dither_mode, subs)
return Conversion(
mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx, data={"palette": pal32, "nametable": nt, "chr": chr_rom},
data_addr=0, viewer="nes", preview_rgb=prgb[idx], error=err,
meta={"palette": "nes", "dither": dither_mode},
)

13
lenser/nes/exporter.py Normal file
View file

@ -0,0 +1,13 @@
"""Build an NES (.nes) cartridge from a conversion."""
from __future__ import annotations
from . import cartridge
def export_nes(conv, output_path, source_path=None, display="forever",
seconds=0, video="ntsc"):
if not output_path.lower().endswith((".nes", ".unf")):
output_path += ".nes"
d = conv.data
rom = cartridge.build_rom(d["palette"], d["nametable"], d["chr"])
return cartridge.write_nes(rom, output_path)

58
lenser/nes/palette.py Normal file
View file

@ -0,0 +1,58 @@
"""Nintendo Entertainment System (2C02 PPU) master palette.
The NES has no fixed RGB palette -- the PPU generates an NTSC signal. We
reproduce MAME's exact ``nespal_to_RGB`` (YUV->RGB) formula so the encoder
matches what the emulator renders. A palette value is a 6-bit index 0-63:
high nibble = luminance (0-3), low nibble = hue (0-15, with 0/13/14/15 greyscale).
The byte written to PPU palette RAM IS this index, so ``PALETTE``/``GREYS`` are
indexed by the hardware value.
"""
from __future__ import annotations
import math
import numpy as np
from ..palette import srgb_to_lab
_TINT, _HUE = 0.22, 287.0
_Kr, _Kb, _Ku, _Kv = 0.2989, 0.1145, 2.029, 1.140
_BRIGHT = [[0.50, 0.75, 1.0, 1.0],
[0.29, 0.45, 0.73, 0.9],
[0.0, 0.24, 0.47, 0.77]]
def _rgb(intensity: int, num: int):
if num == 0:
sat = rad = 0.0; y = _BRIGHT[0][intensity]
elif num == 13:
sat = rad = 0.0; y = _BRIGHT[2][intensity]
elif num in (14, 15):
sat = rad = y = 0.0
else:
sat = _TINT; rad = math.radians(num * 30 + _HUE); y = _BRIGHT[1][intensity]
u, v = sat * math.cos(rad), sat * math.sin(rad)
R = (y + _Kv * v) * 255.0
G = (y - (_Kb * _Ku * u + _Kr * _Kv * v) / (1 - _Kb - _Kr)) * 255.0
B = (y + _Ku * u) * 255.0
cl = lambda x: max(0, min(255, int(math.floor(x + 0.5))))
return (cl(R), cl(G), cl(B))
PALETTE = np.array([_rgb(i >> 4, i & 0x0F) for i in range(64)], dtype=np.float64)
# Distinct grey ramp (R==G==B), sorted dark->light, deduped by luminance.
_grey = {}
for _i in range(64):
r, g, b = PALETTE[_i]
if r == g == b:
_grey.setdefault(int(r), _i)
GREYS = [_grey[k] for k in sorted(_grey)] # e.g. $0F,$1D,$2D,$10,$20
def get_palette() -> np.ndarray:
return PALETTE
def palette_lab() -> np.ndarray:
return srgb_to_lab(PALETTE)

77
lenser/nes/viewer.s Normal file
View file

@ -0,0 +1,77 @@
; NES background image viewer (6502 / 2C02 PPU). NROM, 16K PRG at $C000.
; Waits for PPU warm-up, loads the palette ($3F00, 32 bytes), nametable +
; attribute table ($2000, 1024 bytes), enables background, then idles. The
; image-specific data lives at fixed PRG addresses written by the builder-
; $F000 32-byte palette $F020 1024-byte nametable + attributes
; Tiles come from CHR-ROM pattern table 0.
* = $C000
PPUCTRL = $2000
PPUMASK = $2001
PPUSTATUS = $2002
PPUSCROLL = $2005
PPUADDR = $2006
PPUDATA = $2007
start:
sei
cld
ldx #$ff
txs
lda #$00
sta PPUCTRL ; NMI off
sta PPUMASK ; rendering off during setup
bit PPUSTATUS ; clear latch
w1: bit PPUSTATUS
bpl w1 ; wait for first vblank
w2: bit PPUSTATUS
bpl w2 ; wait for second vblank (PPU warmed up)
; ---- palette- $3F00..$3F1F from $F000 ----
lda #$3f
sta PPUADDR
lda #$00
sta PPUADDR
ldx #$00
pal: lda $f000,x
sta PPUDATA
inx
cpx #$20
bne pal
; ---- nametable + attributes- $2000..$23FF (1024) from $F020 ----
lda #$20
sta PPUADDR
lda #$00
sta PPUADDR
ldx #$00
nt0: lda $f020,x
sta PPUDATA
inx
bne nt0
nt1: lda $f120,x
sta PPUDATA
inx
bne nt1
nt2: lda $f220,x
sta PPUDATA
inx
bne nt2
nt3: lda $f320,x
sta PPUDATA
inx
bne nt3
; ---- enable background ----
lda #$00
sta PPUSCROLL
sta PPUSCROLL
lda #$00
sta PPUCTRL ; nametable $2000, bg pattern table $0000, NMI off
lda #$0a
sta PPUMASK ; show background, no left-column clip
loop:
jmp loop
; the builder writes the $FFFA-$FFFF vectors (all -> start = $C000)