First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
1
lenser/spectrum/__init__.py
Normal file
1
lenser/spectrum/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Sinclair ZX Spectrum target for lenser."""
|
||||
19
lenser/spectrum/convert/__init__.py
Normal file
19
lenser/spectrum/convert/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Sinclair ZX Spectrum conversion dispatch."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ... import imageprep
|
||||
from . import hires, mono
|
||||
|
||||
_MODULES = {"hires": hires, "mono": mono}
|
||||
MODES = list(_MODULES.keys())
|
||||
|
||||
|
||||
def convert_image(path_or_img, mode="hires", palette_name="spectrum",
|
||||
dither_mode="floyd", intensive=False, prep_opt=None,
|
||||
base_color=None):
|
||||
prep_opt = prep_opt or imageprep.PrepOptions()
|
||||
module = _MODULES.get(mode, hires)
|
||||
img_rgb = imageprep.prepare(path_or_img, hires.WIDTH, hires.HEIGHT,
|
||||
hires.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0))
|
||||
return module.convert(img_rgb, palette_name, dither_mode, intensive,
|
||||
base_color=base_color)
|
||||
90
lenser/spectrum/convert/hires.py
Normal file
90
lenser/spectrum/convert/hires.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""ZX Spectrum image encoder.
|
||||
|
||||
256x192, two colours per 8x8 cell (ink + paper), like C64 hires -- but the two
|
||||
colours of a cell must share the BRIGHT bit, so each cell's pair is chosen from
|
||||
one brightness group (normal 0-7 or bright 8-15). For error-diffusion dithers
|
||||
the pair is picked dither-aware (segment metric) so the two colours bracket the
|
||||
cell and dithering blends to the true shade; ordered/none use nearest-colour.
|
||||
|
||||
Produces the classic 6912-byte screen: 6144-byte interleaved bitmap + 768
|
||||
attribute bytes (FLASH BRIGHT PAPER INK).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither, palette as c64pal
|
||||
from ...convert import base
|
||||
from .. import palette as spal
|
||||
|
||||
WIDTH, HEIGHT = 256, 192
|
||||
CELL_W, CELL_H = 8, 8
|
||||
PIXEL_ASPECT = 1.0
|
||||
N_COLS, N_ROWS = 32, 24
|
||||
BITMAP_BYTES = 6144
|
||||
ATTR_BYTES = 768
|
||||
|
||||
|
||||
def _select(cells, plab, dither_mode):
|
||||
"""Per cell, pick the best two-colour pair from whichever brightness group
|
||||
(normal/bright) fits better -- both colours share the BRIGHT bit."""
|
||||
if dither_mode in base.DIFFUSION_DITHERS:
|
||||
seg = base.segment_distances(cells, plab)
|
||||
sets_n, err_n = base.select_cell_sets_dither(
|
||||
cells, plab, spal.NORMAL_GROUP, n_free=2, seg=seg)
|
||||
sets_b, err_b = base.select_cell_sets_dither(
|
||||
cells, plab, spal.BRIGHT_GROUP, n_free=2, seg=seg)
|
||||
else:
|
||||
dist = base.cell_distance(cells, plab)
|
||||
sets_n, err_n = base.select_cell_sets(dist, spal.NORMAL_GROUP, n_free=2)
|
||||
sets_b, err_b = base.select_cell_sets(dist, spal.BRIGHT_GROUP, n_free=2)
|
||||
use_b = err_b < err_n
|
||||
return np.where(use_b[:, None], sets_b, sets_n)
|
||||
|
||||
|
||||
def _bitmap_offset(y: int, cx: int) -> int:
|
||||
"""Offset within the 6144-byte bitmap for pixel row y, char column cx."""
|
||||
return ((y & 7) << 8) | ((y & 0x38) << 2) | ((y & 0xC0) << 5) | cx
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="spectrum", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
plab = spal.palette_lab()
|
||||
prgb = spal.get_palette().astype(np.uint8)
|
||||
img_lab = c64pal.srgb_to_lab(img_rgb)
|
||||
|
||||
cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H)
|
||||
sets = _select(cells, plab, dither_mode)
|
||||
|
||||
allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
|
||||
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
|
||||
|
||||
scr = _encode(idx, sets, rows, cols)
|
||||
|
||||
return base.Conversion(
|
||||
mode="hires", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx.astype(np.uint16), data=bytes(scr), data_addr=0x4000,
|
||||
viewer="spectrum", preview_rgb=prgb[idx],
|
||||
error=base.perceptual_error(idx, img_lab, plab),
|
||||
meta={"palette": "spectrum", "dither": dither_mode},
|
||||
)
|
||||
|
||||
|
||||
def _encode(idx, sets, rows, cols):
|
||||
"""Build the 6912-byte screen (interleaved bitmap + attributes)."""
|
||||
scr = bytearray(BITMAP_BYTES + ATTR_BYTES)
|
||||
for cy in range(rows):
|
||||
for cx in range(cols):
|
||||
ci = cy * cols + cx
|
||||
paper, ink = int(sets[ci, 0]), int(sets[ci, 1])
|
||||
bright = ink >> 3 # both colours share the BRIGHT bit
|
||||
scr[BITMAP_BYTES + ci] = (bright << 6) | ((paper & 7) << 3) | (ink & 7)
|
||||
for r in range(8):
|
||||
y = cy * 8 + r
|
||||
row = idx[y, cx * 8:cx * 8 + 8]
|
||||
byte = 0
|
||||
for px in range(8):
|
||||
byte = (byte << 1) | (1 if row[px] == ink else 0)
|
||||
scr[_bitmap_offset(y, cx)] = byte
|
||||
return scr
|
||||
47
lenser/spectrum/convert/mono.py
Normal file
47
lenser/spectrum/convert/mono.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""ZX Spectrum monochrome / tinted-mono mode.
|
||||
|
||||
256x192 matched by luminance. The Spectrum has no grey, so greyscale is a 2-level
|
||||
black/white halftone (bright black + bright white) -- which at 256x192 with
|
||||
dithering is a crisp, high-detail image free of attribute clash. A base colour
|
||||
gives a 3-level tinted ramp (black -> colour -> white), all within one brightness
|
||||
group so the per-cell BRIGHT constraint is satisfied. Reuses the hires packing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ...convert import base
|
||||
from .. import palette as spal
|
||||
from . import hires
|
||||
|
||||
|
||||
def _ramp(base_color):
|
||||
"""Luminance ramp kept inside ONE brightness group (shared BRIGHT bit)."""
|
||||
if base_color is None:
|
||||
return [8, 15] # bright black + bright white
|
||||
c = base_color & 7 # base hue 0-7
|
||||
if c in (0, 7):
|
||||
return [8, 15]
|
||||
return [8, 8 | c, 15] # bright black, bright hue, bright white
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="spectrum", dither_mode="atkinson",
|
||||
intensive=False, base_color=None):
|
||||
plab = spal.palette_lab()
|
||||
prgb = spal.get_palette().astype(np.uint8)
|
||||
ramp = _ramp(base_color)
|
||||
|
||||
idx, sets, rows, cols, err = base.mono_render(
|
||||
img_rgb, plab, ramp, hires.WIDTH, hires.HEIGHT,
|
||||
hires.CELL_W, hires.CELL_H, dither_mode, n_free=2)
|
||||
|
||||
scr = hires._encode(idx, sets, rows, cols)
|
||||
|
||||
return base.Conversion(
|
||||
mode="mono", width=hires.WIDTH, height=hires.HEIGHT,
|
||||
pixel_aspect=hires.PIXEL_ASPECT, index_image=idx.astype(np.uint16),
|
||||
data=bytes(scr), data_addr=0x4000, viewer="spectrum", preview_rgb=prgb[idx],
|
||||
error=err,
|
||||
meta={"palette": "spectrum", "dither": dither_mode, "base_color": base_color},
|
||||
)
|
||||
32
lenser/spectrum/exporter.py
Normal file
32
lenser/spectrum/exporter.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Export a ZX Spectrum image as a .SNA snapshot (+ a standard .SCR alongside)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from . import snapshot
|
||||
|
||||
_EXTS = (".sna", ".scr", ".z80")
|
||||
|
||||
|
||||
def export_sna(conv, output_path, source_path=None, display="forever",
|
||||
seconds=0, video="pal"):
|
||||
if not output_path.lower().endswith(_EXTS):
|
||||
output_path += ".sna"
|
||||
screen = bytes(conv.data)
|
||||
border = _dominant_paper(screen)
|
||||
sna = snapshot.build_sna(screen, border=border)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(sna)
|
||||
# also drop the standard raw-screen .scr next to it (interchange format)
|
||||
scr_path = os.path.splitext(output_path)[0] + ".scr"
|
||||
with open(scr_path, "wb") as f:
|
||||
f.write(snapshot.build_scr(screen))
|
||||
return output_path
|
||||
|
||||
|
||||
def _dominant_paper(screen: bytes) -> int:
|
||||
"""Most common paper colour across the 768 attribute cells -> border colour."""
|
||||
counts = [0] * 8
|
||||
for a in screen[6144:6912]:
|
||||
counts[(a >> 3) & 7] += 1
|
||||
return max(range(8), key=lambda c: counts[c])
|
||||
47
lenser/spectrum/palette.py
Normal file
47
lenser/spectrum/palette.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Sinclair ZX Spectrum colour palette.
|
||||
|
||||
The ULA has 8 base colours, each in a normal and a BRIGHT version (component
|
||||
value 0xD7 normal, 0xFF bright; black is the same in both). Crucially the BRIGHT
|
||||
bit is per 8x8 *cell*, shared by that cell's ink and paper -- so a cell's two
|
||||
colours must both be normal or both be bright (they can't mix). We model this as
|
||||
a 16-entry palette: indices 0-7 = normal, 8-15 = bright, where index & 7 is the
|
||||
ink/paper colour number and index >> 3 is the BRIGHT bit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..palette import srgb_to_lab
|
||||
|
||||
_N = 0xD7 # normal-brightness component
|
||||
_B = 0xFF # bright component
|
||||
|
||||
|
||||
def _ramp(v):
|
||||
# colour order matches the Spectrum's INK/PAPER numbering 0..7
|
||||
return [
|
||||
(0, 0, 0), # 0 black
|
||||
(0, 0, v), # 1 blue
|
||||
(v, 0, 0), # 2 red
|
||||
(v, 0, v), # 3 magenta
|
||||
(0, v, 0), # 4 green
|
||||
(0, v, v), # 5 cyan
|
||||
(v, v, 0), # 6 yellow
|
||||
(v, v, v), # 7 white
|
||||
]
|
||||
|
||||
|
||||
SPECTRUM = np.array(_ramp(_N) + _ramp(_B), dtype=np.float64)
|
||||
|
||||
# Cell colour pairs must come from one brightness group (shared BRIGHT bit).
|
||||
NORMAL_GROUP = list(range(0, 8))
|
||||
BRIGHT_GROUP = list(range(8, 16))
|
||||
|
||||
|
||||
def get_palette() -> np.ndarray:
|
||||
return SPECTRUM
|
||||
|
||||
|
||||
def palette_lab() -> np.ndarray:
|
||||
return srgb_to_lab(SPECTRUM)
|
||||
50
lenser/spectrum/snapshot.py
Normal file
50
lenser/spectrum/snapshot.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Build a 48K ZX Spectrum .SNA snapshot (and raw .SCR) from a screen image.
|
||||
|
||||
A .SNA bakes the whole 48K RAM plus the CPU state. We put the 6912-byte screen
|
||||
at $4000, a 3-byte idle stub (DI; JR $) at $8000, and set SP so the loader's
|
||||
RETN jumps to the stub -- the ULA then displays the screen forever while the CPU
|
||||
idles, so the picture appears the instant MAME loads the file.
|
||||
|
||||
.SNA 27-byte header: I, HL', DE', BC', AF', HL, DE, BC, IY, IX, IFF2, R, AF, SP,
|
||||
IM, border; followed by 49152 bytes of RAM ($4000-$FFFF).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
RAM_BASE = 0x4000
|
||||
RAM_SIZE = 0xC000 # 48K ($4000-$FFFF)
|
||||
SCREEN_LEN = 6912 # 6144 bitmap + 768 attributes
|
||||
STUB_ADDR = 0x8000 # idle loop DI; JR $
|
||||
STUB = bytes([0xF3, 0x18, 0xFE])
|
||||
SP_ADDR = 0xFF00 # stack holds the stub address for the loader's RETN
|
||||
|
||||
|
||||
def build_sna(screen: bytes, border: int = 0) -> bytes:
|
||||
if len(screen) != SCREEN_LEN:
|
||||
raise ValueError(f"screen must be {SCREEN_LEN} bytes, got {len(screen)}")
|
||||
ram = bytearray(RAM_SIZE)
|
||||
ram[0x0000:SCREEN_LEN] = screen # $4000 screen
|
||||
off = STUB_ADDR - RAM_BASE
|
||||
ram[off:off + len(STUB)] = STUB # $8000 idle stub
|
||||
sp = SP_ADDR - RAM_BASE # return address for RETN
|
||||
ram[sp] = STUB_ADDR & 0xFF
|
||||
ram[sp + 1] = (STUB_ADDR >> 8) & 0xFF
|
||||
|
||||
header = bytearray(27)
|
||||
header[0x00] = 0x3F # I
|
||||
# HL',DE',BC',AF',HL,DE,BC,IY,IX all zero
|
||||
header[0x13] = 0x00 # IFF2 = 0 (interrupts off)
|
||||
header[0x14] = 0x00 # R
|
||||
struct.pack_into("<H", header, 0x17, SP_ADDR) # SP
|
||||
header[0x19] = 0x01 # IM 1
|
||||
header[0x1A] = border & 0x07 # border colour
|
||||
return bytes(header) + bytes(ram)
|
||||
|
||||
|
||||
def build_scr(screen: bytes) -> bytes:
|
||||
"""The standard 6912-byte ZX Spectrum screen file (raw $4000 dump)."""
|
||||
if len(screen) != SCREEN_LEN:
|
||||
raise ValueError(f"screen must be {SCREEN_LEN} bytes, got {len(screen)}")
|
||||
return bytes(screen)
|
||||
Loading…
Add table
Add a link
Reference in a new issue