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,21 @@
"""Commodore 128 conversion dispatch (VDC 80-column)."""
from __future__ import annotations
from ... import imageprep
from . import mono
from . import color
from . import hicolor
_MODULES = {"mono": mono, "color": color, "hicolor": hicolor}
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="mono", palette_name="vdc",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES.get(mode, mono)
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,44 @@
"""C128 VDC chunky 80x100 16-colour mode.
MAME's 8563 draws the "bitmap" through the character/font path, so true
per-pixel bitmap colour isn't possible -- but each 8x2 cell can carry its own
colour via the attribute byte (fg = high nibble). Filling the character matrix
with a solid glyph turns that into a per-cell SOLID colour image: a chunky
80x100 picture using all 16 VDC colours. Lower resolution than `mono`'s
640x200, but full colour (or smooth multi-level greyscale on the four greys).
"""
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 vdcpal
# 8x2 device-pixel cells over the 640x200 display = 80x100 colour cells. Each
# cell is ~8*0.42 wide x 2 tall, so the 80x100 grid is ~4:3 -> aspect ~1.68.
WIDTH, HEIGHT = 80, 100
PIXEL_ASPECT = 1.68
def convert(img_rgb, palette_name="vdc", dither_mode="floyd",
intensive=False, base_color=None):
prgb = vdcpal.get_palette().astype(np.uint8)
plab = vdcpal.palette_lab()
lab = c64pal.srgb_to_lab(img_rgb)
allowed = np.tile(np.arange(16), (HEIGHT, WIDTH, 1))
idx = dither.quantize(lab, allowed, plab, dither_mode).astype(np.uint8)
# one attribute byte per cell; colour in the HIGH nibble (VDC fg = attr>>4),
# low nibble (bg) left 0 -- the solid glyph means only fg shows.
attr = ((idx.reshape(-1).astype(np.uint8) & 0x0F) << 4)
data = bytes(attr.tolist()) # 80*100 = 8000
return Conversion(
mode="color", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=0, viewer="c128",
preview_rgb=prgb[idx], error=perceptual_error(idx, lab, plab),
meta={"palette": "vdc", "dither": dither_mode, "fgbg": 0x0F,
"vdc_mode": "color"},
)

View file

@ -0,0 +1,142 @@
"""C128 VDC high-resolution colour mode (640x200 via a custom character set).
MAME's 8563 (and real hardware) renders 80-column *character* mode with a custom
font + per-cell attributes. That gives genuine per-pixel detail (8x8 glyph per
cell) at 640x200, with one freely chosen INK colour per cell (attribute low
nibble) over a single GLOBAL background (VDC register 26) -- a ZX-Spectrum-like
colour model, but at double the Spectrum's horizontal resolution.
The picture is built by, for each 8x8 cell, choosing the ink colour that best
represents it against the global background, dithering the cell to ink/background
(a 1bpp glyph), then vector-quantising the ~2000 cell glyphs down to a 512-entry
character set. The VDC addresses 256 glyphs per bank; a second bank is selected
per cell by the attribute's ALTERNATE_CHARSET bit (bit 7), giving 512 glyphs.
"""
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 vdcpal
WIDTH, HEIGHT = 640, 200
PIXEL_ASPECT = 0.42 # same fine pixels as the mono bitmap
CW, CH = 8, 8 # 8x8 character cells
COLS, ROWS = WIDTH // CW, HEIGHT // CH # 80 x 25 = 2000 cells
BANK = 256 # glyphs per VDC charset bank
NGLYPH = 512 # two banks (selected per cell by attr bit 7)
# VDC RAM layout (matches the C128 default so registers stay default).
CODES_ADDR = 0x0000
ATTR_ADDR = 0x0800
CHAR_ADDR = 0x2000 # bank 0 at $2000, bank 1 at $3000 (+$1000)
VDC_LEN = 0x4000 # full 16K, copied to VDC RAM by the viewer
def _cell_view(a):
"""(ROWS*CH, COLS*CW, ...) -> (ROWS, COLS, CH, CW, ...) cell blocks."""
r = a.reshape(ROWS, CH, COLS, CW, *a.shape[2:])
return r.transpose(0, 2, 1, 3, *range(4, a.ndim + 2))
def _kmeans_binary(patterns, k, iters=10, seed=0):
"""Cluster 0/1 patterns (N, D) into k binary centroids (Hamming / majority)."""
n = len(patterns)
rng = np.random.default_rng(seed)
uniq = np.unique(patterns, axis=0)
if len(uniq) <= k:
cb = np.zeros((k, patterns.shape[1]), np.uint8)
cb[:len(uniq)] = uniq
d = (patterns[:, None, :] != cb[None, :, :]).sum(2)
return cb, d.argmin(1)
cb = uniq[rng.choice(len(uniq), k, replace=False)].astype(np.uint8)
assign = np.zeros(n, np.int32)
for _ in range(iters):
# nearest centroid by Hamming distance, in chunks to bound memory
for s in range(0, n, 4096):
blk = patterns[s:s + 4096]
d = (blk[:, None, :] != cb[None, :, :]).sum(2)
assign[s:s + 4096] = d.argmin(1)
for j in range(k):
members = patterns[assign == j]
if len(members):
cb[j] = (members.mean(0) >= 0.5).astype(np.uint8)
return cb, assign
def convert(img_rgb, palette_name="vdc", dither_mode="floyd",
intensive=False, base_color=None):
"""Full 16-colour high-resolution mode."""
return build(img_rgb, dither_mode, inks=list(range(16)),
bg_list=list(range(16)), mode_name="hicolor")
def build(img_rgb, dither_mode, inks, bg_list, mode_name):
"""Shared 640x200 custom-charset encoder.
inks palette indices a cell's foreground may use (per-cell ink choice)
bg_list palette indices to try as the single global background
mode_name value for Conversion.mode (the VDC viewer is the same either way)
"""
prgb = vdcpal.get_palette().astype(np.uint8)
plab = vdcpal.palette_lab()
lab = c64pal.srgb_to_lab(img_rgb) # (H, W, 3)
inks = np.asarray(inks)
# distance from every pixel to every palette colour
dist = np.linalg.norm(lab[:, :, None, :] - plab[None, None, :, :], axis=3)
dist_cells = _cell_view(dist) # (R,C,CH,CW,16)
dist_inks = dist_cells[..., inks] # (R,C,CH,CW,len)
# choose the global background: the colour that, as the shared second tone,
# lets each cell's best ink reproduce the image with least total error.
best_bg, best_err, best_ink = bg_list[0], None, None
for bg in bg_list:
d_bg = dist_cells[..., bg:bg + 1] # (R,C,CH,CW,1)
per_ink = np.minimum(d_bg, dist_inks).sum((2, 3)) # (R,C,len)
sel = per_ink.argmin(2) # (R,C) index into inks
err = np.take_along_axis(per_ink, sel[..., None], 2).sum()
if best_err is None or err < best_err:
best_bg, best_err, best_ink = bg, err, inks[sel]
bg, ink_cell = best_bg, best_ink # ink_cell (R,C) palette
# dither each pixel between the global bg and its cell's ink
ink_px = np.repeat(np.repeat(ink_cell, CH, 0), CW, 1) # (H,W)
allowed = np.stack([np.full((HEIGHT, WIDTH), bg), ink_px], axis=2)
idx = dither.quantize(lab, allowed, plab, dither_mode).astype(np.uint8)
# 1bpp glyph per cell (1 = ink), then vector-quantise to a 256-glyph charset
bits = (idx == ink_px).astype(np.uint8) # (H,W)
glyph_cells = _cell_view(bits).reshape(ROWS * COLS, CH * CW)
codebook, assign = _kmeans_binary(glyph_cells, NGLYPH) # assign 0..NGLYPH-1
code_map = assign.reshape(ROWS, COLS)
# rebuild the actually-displayed image from the quantised glyphs + colours
glyphs_img = codebook[assign].reshape(ROWS, COLS, CH, CW)
shown_ink = (glyphs_img == 1)
final_idx = np.where(
shown_ink, ink_cell[:, :, None, None], bg).astype(np.uint16)
final_idx = final_idx.transpose(0, 2, 1, 3).reshape(HEIGHT, WIDTH)
# ---- assemble the VDC RAM image ----
vdc = bytearray(VDC_LEN)
glyph = code_map.reshape(-1)
codes = (glyph & 0xFF).astype(np.uint8) # char code within bank
# ink in the low nibble; bit 7 (ALTERNATE_CHARSET) selects bank 1
attr = ((ink_cell.reshape(-1) & 0x0F) | ((glyph >> 8) << 7)).astype(np.uint8)
vdc[CODES_ADDR:CODES_ADDR + codes.size] = codes.tobytes()
vdc[ATTR_ADDR:ATTR_ADDR + attr.size] = attr.tobytes()
for g in range(NGLYPH):
rows = np.packbits(codebook[g].reshape(CH, CW), axis=1).reshape(-1)
off = CHAR_ADDR + (g >> 8) * 0x1000 + ((g & 0xFF) << 4)
vdc[off:off + CH] = rows.tobytes()
return Conversion(
mode=mode_name, width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=final_idx, data=bytes(vdc), data_addr=0, viewer="c128",
preview_rgb=prgb[final_idx],
error=perceptual_error(final_idx, lab, plab),
meta={"palette": "vdc", "dither": dither_mode, "fgbg": bg & 0x0F,
"vdc_mode": "hicolor", "bg": bg},
)

View file

@ -0,0 +1,29 @@
"""C128 VDC high-resolution greyscale / black-and-white (640x200).
MAME's 8563 has no true linear bitmap renderer (its R25-bit7 "bitmap" path only
emits one bit per 8-pixel cell), so genuine 640x200 detail has to come through
the character/font path -- exactly as the `hicolor` mode does. This mode reuses
that machinery restricted to the VDC's four greys (black, dark grey, light grey,
white): each 8x8 cell picks the grey that best matches it over a global grey
background and dithers to a 1bpp glyph, giving smooth multi-level greyscale at
full resolution. `--mono-base` swaps the grey ramp for a black->colour->white
ramp to tint the result.
"""
from __future__ import annotations
from . import hicolor
WIDTH, HEIGHT = hicolor.WIDTH, hicolor.HEIGHT
PIXEL_ASPECT = hicolor.PIXEL_ASPECT
GREYS = [0, 1, 14, 15] # VDC black, dark grey, light grey, white
def convert(img_rgb, palette_name="vdc", dither_mode="floyd",
intensive=False, base_color=None):
if base_color in range(1, 16):
inks = sorted({0, int(base_color), 15}) # black -> tint -> white
else:
inks = GREYS
return hicolor.build(img_rgb, dither_mode, inks=inks, bg_list=inks,
mode_name="mono")