First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
21
lenser/c128/convert/__init__.py
Normal file
21
lenser/c128/convert/__init__.py
Normal 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)
|
||||
44
lenser/c128/convert/color.py
Normal file
44
lenser/c128/convert/color.py
Normal 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"},
|
||||
)
|
||||
142
lenser/c128/convert/hicolor.py
Normal file
142
lenser/c128/convert/hicolor.py
Normal 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},
|
||||
)
|
||||
29
lenser/c128/convert/mono.py
Normal file
29
lenser/c128/convert/mono.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue