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,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)

View 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

View 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},
)