First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
0
lenser/cpc/__init__.py
Normal file
0
lenser/cpc/__init__.py
Normal file
19
lenser/cpc/convert/__init__.py
Normal file
19
lenser/cpc/convert/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Amstrad CPC conversion dispatch."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ... import imageprep
|
||||
from . import mode0, mode1, mono
|
||||
|
||||
_MODULES = {"mode0": mode0, "mode1": mode1, "mono": mono}
|
||||
MODES = list(_MODULES.keys())
|
||||
|
||||
|
||||
def convert_image(path_or_img, mode="mode0", palette_name="cpc",
|
||||
dither_mode="floyd", intensive=False, prep_opt=None,
|
||||
base_color=None):
|
||||
prep_opt = prep_opt or imageprep.PrepOptions()
|
||||
module = _MODULES.get(mode, mode0)
|
||||
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)
|
||||
97
lenser/cpc/convert/_common.py
Normal file
97
lenser/cpc/convert/_common.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Shared CPC encoder helpers: global palette selection + per-mode bit packing.
|
||||
|
||||
The CPC has no per-cell colour limit -- a mode draws a flat palette of pens
|
||||
(16 in mode 0, 4 in mode 1, 2 in mode 2), each pen any of the 27 colours. So the
|
||||
job is: choose the best N-colour sub-palette for the whole image, dither to it,
|
||||
then pack pens into the CPC's (scrambled) screen bytes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither, palette as c64pal
|
||||
from .. import palette as cpcpal
|
||||
from ..snapshot import screen_offset
|
||||
|
||||
|
||||
def choose_inks(img_lab, plab, n):
|
||||
"""Greedy forward selection of the ``n`` palette colours (indices into the
|
||||
27-colour PALETTE) that minimise nearest-colour error over the image."""
|
||||
flat = img_lab.reshape(-1, 3)
|
||||
d = np.sum((flat[:, None, :] - plab[None, :, :]) ** 2, axis=-1) # (px, 27)
|
||||
chosen = []
|
||||
best = np.full(flat.shape[0], np.inf)
|
||||
for _ in range(n):
|
||||
# pick the colour that most reduces the running per-pixel min distance
|
||||
cand_err = np.minimum(best[:, None], d).sum(0) # (27,)
|
||||
for c in chosen:
|
||||
cand_err[c] = np.inf
|
||||
c = int(cand_err.argmin())
|
||||
chosen.append(c)
|
||||
best = np.minimum(best, d[:, c])
|
||||
return sorted(chosen)
|
||||
|
||||
|
||||
def render(img_rgb, n, dither_mode, ramp=None):
|
||||
"""Pick (or use ``ramp``) an n-colour palette, dither the image to it, and
|
||||
return (pen_image (H,W) of 0..n-1, inks (n hardware-ink numbers), idx_image
|
||||
(H,W) palette indices, plab, prgb)."""
|
||||
plab = cpcpal.palette_lab()
|
||||
prgb = cpcpal.get_palette().astype(np.uint8)
|
||||
img_lab = c64pal.srgb_to_lab(img_rgb)
|
||||
|
||||
pal_idx = ramp if ramp is not None else choose_inks(img_lab, plab, n)
|
||||
pal_idx = list(pal_idx)
|
||||
allowed = np.tile(np.array(pal_idx), (*img_lab.shape[:2], 1))
|
||||
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
|
||||
|
||||
# map palette index -> pen (0..n-1)
|
||||
lut = {p: k for k, p in enumerate(pal_idx)}
|
||||
pen = np.vectorize(lut.get)(idx).astype(np.uint8)
|
||||
inks = [cpcpal.ink_byte(p) for p in pal_idx]
|
||||
return pen, inks, idx.astype(np.uint16), img_lab, plab, prgb
|
||||
|
||||
|
||||
def _pack_mode0(pen):
|
||||
"""160x200 pens (0-15) -> 16K screen. 2 pixels/byte, scrambled bits."""
|
||||
scr = bytearray(0x4000)
|
||||
for y in range(200):
|
||||
row = pen[y]
|
||||
for bx in range(80):
|
||||
L = int(row[bx * 2]); R = int(row[bx * 2 + 1])
|
||||
byte = (((L & 1) << 7) | ((R & 1) << 6) | (((L >> 2) & 1) << 5) |
|
||||
(((R >> 2) & 1) << 4) | (((L >> 1) & 1) << 3) |
|
||||
(((R >> 1) & 1) << 2) | (((L >> 3) & 1) << 1) | ((R >> 3) & 1))
|
||||
scr[screen_offset(y, bx)] = byte
|
||||
return bytes(scr)
|
||||
|
||||
|
||||
def _pack_mode1(pen):
|
||||
"""320x200 pens (0-3) -> 16K screen. 4 pixels/byte."""
|
||||
scr = bytearray(0x4000)
|
||||
for y in range(200):
|
||||
row = pen[y]
|
||||
for bx in range(80):
|
||||
p = [int(row[bx * 4 + k]) for k in range(4)]
|
||||
byte = 0
|
||||
for k in range(4):
|
||||
byte |= (p[k] & 1) << (7 - k) # bit0 of each pen
|
||||
byte |= ((p[k] >> 1) & 1) << (3 - k) # bit1 of each pen
|
||||
scr[screen_offset(y, bx)] = byte
|
||||
return bytes(scr)
|
||||
|
||||
|
||||
def _pack_mode2(pen):
|
||||
"""640x200 pens (0-1) -> 16K screen. 8 pixels/byte, MSB leftmost."""
|
||||
scr = bytearray(0x4000)
|
||||
for y in range(200):
|
||||
row = pen[y]
|
||||
for bx in range(80):
|
||||
byte = 0
|
||||
for k in range(8):
|
||||
byte |= (int(row[bx * 8 + k]) & 1) << (7 - k)
|
||||
scr[screen_offset(y, bx)] = byte
|
||||
return bytes(scr)
|
||||
|
||||
|
||||
PACK = {0: _pack_mode0, 1: _pack_mode1, 2: _pack_mode2}
|
||||
22
lenser/cpc/convert/mode0.py
Normal file
22
lenser/cpc/convert/mode0.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""CPC Mode 0: 160x200, 16 colours from the 27-colour palette (the flagship
|
||||
photo mode -- wide pixels but the most colour)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...convert.base import Conversion, perceptual_error
|
||||
from . import _common
|
||||
|
||||
WIDTH, HEIGHT = 160, 200
|
||||
PIXEL_ASPECT = 2.0 # 160x200 on a 4:3 screen -> pixels twice as wide
|
||||
NCOL = 16
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="cpc", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
pen, inks, idx, img_lab, plab, prgb = _common.render(img_rgb, NCOL, dither_mode)
|
||||
screen = _common.PACK[0](pen)
|
||||
return Conversion(
|
||||
mode="mode0", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx, data=screen, data_addr=0xC000, viewer="cpc",
|
||||
preview_rgb=prgb[idx], error=perceptual_error(idx, img_lab, plab),
|
||||
meta={"palette": "cpc", "dither": dither_mode, "cpc_mode": 0, "inks": inks},
|
||||
)
|
||||
22
lenser/cpc/convert/mode1.py
Normal file
22
lenser/cpc/convert/mode1.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""CPC Mode 1: 320x200, 4 colours from the 27-colour palette (more resolution,
|
||||
fewer colours than mode 0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...convert.base import Conversion, perceptual_error
|
||||
from . import _common
|
||||
|
||||
WIDTH, HEIGHT = 320, 200
|
||||
PIXEL_ASPECT = 1.0
|
||||
NCOL = 4
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="cpc", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
pen, inks, idx, img_lab, plab, prgb = _common.render(img_rgb, NCOL, dither_mode)
|
||||
screen = _common.PACK[1](pen)
|
||||
return Conversion(
|
||||
mode="mode1", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx, data=screen, data_addr=0xC000, viewer="cpc",
|
||||
preview_rgb=prgb[idx], error=perceptual_error(idx, img_lab, plab),
|
||||
meta={"palette": "cpc", "dither": dither_mode, "cpc_mode": 1, "inks": inks},
|
||||
)
|
||||
47
lenser/cpc/convert/mono.py
Normal file
47
lenser/cpc/convert/mono.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""CPC monochrome: Mode 2, 640x200, 2 colours (black + bright white) -- the
|
||||
highest-resolution CPC mode, tone carried by dithering. ``--mono-base`` tints
|
||||
the second tone to a chosen hue."""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither, palette as c64pal
|
||||
from ...convert.base import Conversion, perceptual_error
|
||||
from .. import palette as cpcpal
|
||||
from . import _common
|
||||
|
||||
WIDTH, HEIGHT = 640, 200
|
||||
PIXEL_ASPECT = 0.5 # 640x200 on a 4:3 screen -> pixels half as wide
|
||||
NCOL = 2
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="cpc", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
plab = cpcpal.palette_lab()
|
||||
# black + the lightest tone (bright white), or black + a tint
|
||||
black = min(cpcpal.GREYS, key=lambda i: plab[i, 0])
|
||||
if base_color in range(len(cpcpal.PALETTE)) and base_color != black:
|
||||
ramp = sorted({black, int(base_color)}, key=lambda i: plab[i, 0])
|
||||
else:
|
||||
ramp = sorted(cpcpal.GREYS, key=lambda i: plab[i, 0])
|
||||
ramp = [ramp[0], ramp[-1]] # black + brightest grey
|
||||
# collapse to luminance so the two tones bracket the image's lightness
|
||||
img_lab = c64pal.srgb_to_lab(img_rgb)
|
||||
mono_lab = np.zeros_like(img_lab); mono_lab[..., 0] = img_lab[..., 0]
|
||||
plab_mono = np.zeros_like(plab); plab_mono[:, 0] = plab[:, 0]
|
||||
|
||||
allowed = np.tile(np.array(ramp), (*mono_lab.shape[:2], 1))
|
||||
idx = dither.quantize(mono_lab, allowed, plab_mono, dither_mode).astype(np.int64)
|
||||
lut = {p: k for k, p in enumerate(ramp)}
|
||||
pen = np.vectorize(lut.get)(idx).astype(np.uint8)
|
||||
inks = [cpcpal.ink_byte(p) for p in ramp]
|
||||
|
||||
screen = _common.PACK[2](pen)
|
||||
prgb = cpcpal.get_palette().astype(np.uint8)
|
||||
return Conversion(
|
||||
mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx.astype(np.uint16), data=screen, data_addr=0xC000,
|
||||
viewer="cpc", preview_rgb=prgb[idx.astype(np.uint16)],
|
||||
error=perceptual_error(idx, mono_lab, plab_mono),
|
||||
meta={"palette": "cpc", "dither": dither_mode, "cpc_mode": 2, "inks": inks},
|
||||
)
|
||||
18
lenser/cpc/exporter.py
Normal file
18
lenser/cpc/exporter.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""Build an Amstrad CPC .sna snapshot from a conversion."""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import snapshot
|
||||
|
||||
_EXTS = (".sna",)
|
||||
|
||||
|
||||
def export_sna(conv, output_path, source_path=None, display="forever",
|
||||
seconds=0, video="ntsc"):
|
||||
if not output_path.lower().endswith(_EXTS):
|
||||
output_path += ".sna"
|
||||
inks = conv.meta["inks"]
|
||||
mode = conv.meta["cpc_mode"]
|
||||
sna = snapshot.build_sna(bytes(conv.data), inks, mode, border=inks[0])
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(sna)
|
||||
return output_path
|
||||
60
lenser/cpc/palette.py
Normal file
60
lenser/cpc/palette.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Amstrad CPC (Gate Array) colour palette.
|
||||
|
||||
The CPC has a fixed palette of 27 colours (3 levels -- 0x00, 0x60, 0xFF -- of
|
||||
R, G and B). The Gate Array selects them by a 5-bit "hardware ink" number 0-31;
|
||||
those 32 numbers map onto the 27 colours (a few duplicates). RGB values and the
|
||||
hardware-ink ordering are taken verbatim from MAME's ``amstrad_palette[32]`` so
|
||||
the encoder matches exactly what the emulator renders.
|
||||
|
||||
A pen (the value stored per pixel: 0-15 in mode 0, 0-3 in mode 1, 0-1 in mode 2)
|
||||
is assigned an ink via the Gate Array palette; the .sna stores those 17 ink
|
||||
numbers (16 pens + border). We therefore index colours by HARDWARE INK NUMBER,
|
||||
so ``HW_INK[k]`` is both this palette's RGB and the byte written to the snapshot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..palette import srgb_to_lab
|
||||
|
||||
# amstrad_palette[32] from MAME (src/mame/amstrad/amstrad_m.cpp), indexed by the
|
||||
# Gate Array hardware ink number.
|
||||
HW_INK = np.array([
|
||||
(96, 96, 96), (96, 96, 96), (0, 255, 96), (255, 255, 96),
|
||||
(0, 0, 96), (255, 0, 96), (0, 96, 96), (255, 96, 96),
|
||||
(255, 0, 96), (255, 255, 96), (255, 255, 0), (255, 255, 255),
|
||||
(255, 0, 0), (255, 0, 255), (255, 96, 0), (255, 96, 255),
|
||||
(0, 0, 96), (0, 255, 96), (0, 255, 0), (0, 255, 255),
|
||||
(0, 0, 0), (0, 0, 255), (0, 96, 0), (0, 96, 255),
|
||||
(96, 0, 96), (96, 255, 96), (96, 255, 0), (96, 255, 255),
|
||||
(96, 0, 0), (96, 0, 255), (96, 96, 0), (96, 96, 255),
|
||||
], dtype=np.float64)
|
||||
|
||||
# The 27 unique colours, each as the FIRST hardware ink number that produces it
|
||||
# (so encoders work with a clean 27-entry palette, and INK[i] gives the snapshot
|
||||
# byte for unique colour i).
|
||||
INK = []
|
||||
_seen = {}
|
||||
for _k, _rgb in enumerate(map(tuple, HW_INK.astype(int))):
|
||||
if _rgb not in _seen:
|
||||
_seen[_rgb] = _k
|
||||
INK.append(_k)
|
||||
INK = np.array(INK, dtype=np.int64) # 27 hardware ink numbers
|
||||
PALETTE = HW_INK[INK] # 27 unique RGB colours
|
||||
|
||||
# Neutral grey ramp (for the monochrome mode): black, grey, bright white.
|
||||
GREYS = [i for i, (r, g, b) in enumerate(map(tuple, PALETTE.astype(int)))
|
||||
if r == g == b] # indices into PALETTE
|
||||
|
||||
|
||||
def get_palette() -> np.ndarray:
|
||||
return PALETTE
|
||||
|
||||
|
||||
def palette_lab() -> np.ndarray:
|
||||
return srgb_to_lab(PALETTE)
|
||||
|
||||
|
||||
def ink_byte(unique_index: int) -> int:
|
||||
"""Hardware ink number (snapshot byte) for a unique-palette colour index."""
|
||||
return int(INK[unique_index])
|
||||
77
lenser/cpc/snapshot.py
Normal file
77
lenser/cpc/snapshot.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Build an Amstrad CPC .SNA snapshot (CPCEMU v1) from a screen image.
|
||||
|
||||
MAME loads a .sna by restoring the Z80, Gate Array (mode + 16-pen palette),
|
||||
CRTC and 64K RAM, so the picture appears instantly with no loader. We bake the
|
||||
16K screen at &C000, set the Gate Array palette + mode, program a standard 80x200
|
||||
CRTC screen, and point the CPU at a tiny ``DI: JR $`` idle stub -- the CRTC then
|
||||
DMAs the screen forever while the Z80 idles.
|
||||
|
||||
Header layout (offsets) per MAME's amstrad_handle_snapshot:
|
||||
0x00 "MV - SNA" signature 0x10 version
|
||||
0x11 AF 0x13 BC 0x15 DE 0x17 HL 0x19 R 0x1a I 0x1b IFF1 0x1c IFF2
|
||||
0x1d IX 0x1f IY 0x21 SP 0x23 PC 0x25 IM 0x26 AF' .. 0x2c HL'
|
||||
0x2e GA selected pen 0x2f..0x3f 17 ink numbers (16 pens + border)
|
||||
0x40 GA multi-config (mode/rom) 0x41 RAM config
|
||||
0x42 CRTC selected reg 0x43..0x54 18 CRTC regs 0x55 upper ROM
|
||||
0x56..0x58 PPI A/B/C 0x59 PPI control 0x5a PSG reg 0x5b..0x6a PSG regs
|
||||
0x6b memory size (KB) 0x100 RAM dump
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
SCREEN_BASE = 0xC000
|
||||
SCREEN_LEN = 0x4000 # 16K
|
||||
STUB_ADDR = 0x8000 # idle loop DI; JR $ (central RAM, always present)
|
||||
STUB = bytes([0xF3, 0x18, 0xFE])
|
||||
RAM_SIZE = 0x10000 # 64K
|
||||
|
||||
# Standard CPC 50Hz CRTC register set for a 40x25-char (80x200 byte) screen at
|
||||
# &C000 (R12/R13 = 0x30/0x00).
|
||||
CRTC = [0x3F, 0x28, 0x2E, 0x8E, 0x26, 0x00, 0x19, 0x1E,
|
||||
0x00, 0x07, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00]
|
||||
|
||||
|
||||
def screen_offset(y: int, bx: int) -> int:
|
||||
"""Offset within the 16K screen for scan line ``y`` (0-199), byte column
|
||||
``bx`` (0-79) -- the CPC's CRTC interleave (8 lines per char row)."""
|
||||
return (y % 8) * 0x800 + (y // 8) * 80 + bx
|
||||
|
||||
|
||||
def build_sna(screen: bytes, inks, mode: int, border: int = 20) -> bytes:
|
||||
"""screen: 16384 bytes (&C000 RAM); inks: 16 hardware ink numbers (pens);
|
||||
mode: 0/1/2; border: hardware ink number for the border pen."""
|
||||
if len(screen) != SCREEN_LEN:
|
||||
raise ValueError(f"screen must be {SCREEN_LEN} bytes, got {len(screen)}")
|
||||
if not 1 <= len(inks) <= 16:
|
||||
raise ValueError(f"need 1-16 ink numbers, got {len(inks)}")
|
||||
# the Gate Array always has 16 pen slots; modes 1/2 use only the first 4/2.
|
||||
inks = list(inks) + [border] * (16 - len(inks))
|
||||
|
||||
ram = bytearray(RAM_SIZE)
|
||||
ram[STUB_ADDR:STUB_ADDR + len(STUB)] = STUB
|
||||
ram[SCREEN_BASE:SCREEN_BASE + SCREEN_LEN] = screen
|
||||
|
||||
h = bytearray(0x100)
|
||||
h[0x00:0x10] = b"MV - SNAPSHOT V1" # signature (MAME checks "MV - SNA")
|
||||
h[0x10] = 1 # version
|
||||
# Z80: everything 0 except SP/PC/IM; DI (IFF=0) and HALT-free idle stub.
|
||||
struct.pack_into("<H", h, 0x21, STUB_ADDR) # SP
|
||||
struct.pack_into("<H", h, 0x23, STUB_ADDR) # PC
|
||||
h[0x25] = 1 # IM 1
|
||||
# Gate Array palette: 16 pens + border, each a 5-bit hardware ink number.
|
||||
h[0x2e] = 0 # selected pen
|
||||
for pen in range(16):
|
||||
h[0x2f + pen] = inks[pen] & 0x1F
|
||||
h[0x2f + 16] = border & 0x1F # border ink
|
||||
h[0x40] = (mode & 0x03) | 0x0C # mode + both ROMs disabled (RAM)
|
||||
h[0x41] = 0x00 # RAM config 0 (standard 64K)
|
||||
h[0x42] = 0 # CRTC selected reg
|
||||
for i, v in enumerate(CRTC):
|
||||
h[0x43 + i] = v & 0xFF
|
||||
h[0x55] = 0 # upper ROM
|
||||
h[0x59] = 0x82 # PPI control (conventional)
|
||||
h[0x5b + 7] = 0x3F # PSG mixer: all channels off (silent)
|
||||
struct.pack_into("<H", h, 0x6b, 64) # 64K dump
|
||||
|
||||
return bytes(h) + bytes(ram)
|
||||
Loading…
Add table
Add a link
Reference in a new issue