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 @@
"""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)

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

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

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

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