Working Python version for Commodore.

This commit is contained in:
The Dust Council 2026-06-14 17:43:12 -07:00
commit 2a48f52979
51 changed files with 3095 additions and 0 deletions

View file

@ -0,0 +1,81 @@
"""Conversion dispatch + preview rendering."""
from __future__ import annotations
import numpy as np
from .. import imageprep, palette as pal
from . import base, hires, mono, multicolor
# mode name -> module
_MODULES = {
"hires": hires,
"multicolor": multicolor,
"mono": mono,
}
# Registered lazily so FLI/IFLI can be added without import cycles.
try:
from . import fli # noqa: E402
_MODULES["fli"] = fli
except Exception:
pass
try:
from . import ifli # noqa: E402
_MODULES["interlace"] = ifli
except Exception:
pass
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="multicolor", palette_name="colodore",
dither_mode="bayer", intensive=False,
prep_opt: imageprep.PrepOptions | None = None,
base_color=None) -> base.Conversion:
"""Prepare an image for ``mode`` and convert it. ``mode='auto'`` tries every
standard mode and returns the lowest-error result. ``base_color`` (palette
index, or None for grayscale) only applies to the ``mono`` mode."""
prep_opt = prep_opt or imageprep.PrepOptions()
if mode == "auto":
best = None
for m in ("multicolor", "hires"):
c = convert_image(path_or_img, m, palette_name, dither_mode, intensive, prep_opt)
if best is None or c.error < best.error:
best = c
return best
module = _MODULES[mode]
border_rgb = pal.get_palette(palette_name)[prep_opt.border_index]
img_rgb = imageprep.prepare(
path_or_img, module.WIDTH, module.HEIGHT, module.PIXEL_ASPECT,
prep_opt, border_rgb=border_rgb,
)
if mode == "mono":
return module.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)
return module.convert(img_rgb, palette_name, dither_mode, intensive)
def render_preview(conv: base.Conversion, palette_name="colodore",
scale: int = 2) -> np.ndarray:
"""Render the conversion's index image to a displayed-resolution RGB array.
Logical pixels are widened by the mode's pixel aspect (so multicolor pixels
are twice as wide), giving a uniform 320x200 base which is then integer-scaled.
"""
if conv.preview_rgb is not None:
rgb = conv.preview_rgb
if scale > 1:
rgb = np.repeat(np.repeat(rgb, scale, axis=0), scale, axis=1)
return rgb
prgb = pal.get_palette(palette_name).astype(np.uint8)
rgb = prgb[conv.index_image] # (H, W, 3)
xrep = int(round(conv.pixel_aspect))
if xrep > 1:
rgb = np.repeat(rgb, xrep, axis=1)
if scale > 1:
rgb = np.repeat(np.repeat(rgb, scale, axis=0), scale, axis=1)
return rgb

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

124
c64view/convert/base.py Normal file
View file

@ -0,0 +1,124 @@
"""Shared machinery for every C64 display mode.
The hard part of "make this photo look good on a C64" is that each screen cell
may only show a handful of the 16 fixed colours. We solve that per cell with an
exhaustive, vectorised search over every legal colour combination, scored by
perceptual (CIELAB) reproduction error. The winning per-cell colour sets then
drive a constrained dither (see ``dither.py``) to produce the final index image.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from itertools import combinations
import numpy as np
@dataclass
class Conversion:
"""Result of converting an image to one C64 display mode."""
mode: str
width: int # logical pixel width (160 or 320)
height: int # logical pixel height (200)
pixel_aspect: float # on-screen width of one logical pixel
index_image: np.ndarray # (H, W) uint8 palette indices, for preview
data: bytes = b"" # picture block that must reside from data_addr up
data_addr: int = 0x2000 # memory address where ``data`` must load
preview_rgb: np.ndarray = None # optional explicit preview (e.g. interlace blend)
extra_files: list = field(default_factory=list) # (cbm_name, full_prg_bytes)
viewer: str = "" # viewer key (see viewer/assemble.py)
error: float = 0.0 # mean per-pixel CIELAB error
meta: dict = field(default_factory=dict)
def cells_lab(img_lab: np.ndarray, cell_w: int, cell_h: int):
"""Reshape (H,W,3) -> (n_cells, cell_w*cell_h, 3) plus (rows, cols)."""
H, W, _ = img_lab.shape
rows, cols = H // cell_h, W // cell_w
a = img_lab.reshape(rows, cell_h, cols, cell_w, 3)
a = a.transpose(0, 2, 1, 3, 4).reshape(rows * cols, cell_h * cell_w, 3)
return a, rows, cols
def cell_distance(cells: np.ndarray, palette_lab: np.ndarray) -> np.ndarray:
"""Squared CIELAB distance from every cell pixel to every palette colour.
cells: (n_cells, P, 3) -> (n_cells, P, 16)
"""
return np.sum((cells[:, :, None, :] - palette_lab[None, None, :, :]) ** 2, axis=-1)
def best_global_color(img_lab: np.ndarray, palette_lab: np.ndarray) -> int:
"""Palette index closest, on average, to the whole image (good bg seed)."""
flat = img_lab.reshape(-1, 3)
d = np.sum((flat[:, None, :] - palette_lab[None, :, :]) ** 2, axis=-1)
return int(np.argmin(d.mean(axis=0)))
def select_cell_sets(dist: np.ndarray, available, n_free: int, fixed=()):
"""Pick, per cell, the ``n_free`` palette colours (plus any ``fixed`` ones)
that minimise nearest-colour reproduction error.
dist: (n_cells, P, 16) squared distances (from ``cell_distance``).
Returns (sets, errors): sets is (n_cells, len(fixed)+n_free) palette indices,
errors is (n_cells,) summed squared error of the winning set.
"""
n_cells = dist.shape[0]
fixed = list(fixed)
combos = list(combinations(sorted(available), n_free))
best_err = np.full(n_cells, np.inf)
best_combo = np.zeros((n_cells, n_free), dtype=np.int64)
if fixed:
fixed_min = dist[:, :, fixed].min(axis=2) # (n_cells, P)
for combo in combos:
idx = list(combo)
m = dist[:, :, idx].min(axis=2) # (n_cells, P)
if fixed:
m = np.minimum(m, fixed_min)
err = m.sum(axis=1)
better = err < best_err
best_err = np.where(better, err, best_err)
best_combo[better] = idx
if fixed:
fixed_arr = np.tile(np.array(fixed, dtype=np.int64), (n_cells, 1))
sets = np.concatenate([fixed_arr, best_combo], axis=1)
else:
sets = best_combo
return sets, best_err
def optimize_background(dist: np.ndarray, n_free: int, candidates=range(16)):
"""Choose the single shared background colour (multicolor/FLI) that minimises
total image error, returning (bg_index, sets, errors)."""
best_total = np.inf
best = None
for bg in candidates:
avail = [i for i in range(16) if i != bg]
sets, errors = select_cell_sets(dist, avail, n_free, fixed=(bg,))
total = errors.sum()
if total < best_total:
best_total = total
best = (bg, sets, errors)
return best
def per_pixel_allowed(sets: np.ndarray, rows: int, cols: int,
cell_w: int, cell_h: int, H: int, W: int) -> np.ndarray:
"""Expand per-cell colour sets to an (H, W, K) per-pixel allowed-index table."""
yy, xx = np.indices((H, W))
cell_idx = (yy // cell_h) * cols + (xx // cell_w)
return sets[cell_idx]
def prg(load_addr: int, data: bytes) -> bytes:
"""Wrap raw bytes as a CBM PRG (little-endian load address prefix)."""
return bytes([load_addr & 0xFF, (load_addr >> 8) & 0xFF]) + bytes(data)
def mean_error(index_image: np.ndarray, img_lab: np.ndarray, palette_lab: np.ndarray) -> float:
"""Mean CIELAB delta-E between the chosen indices and the source image."""
chosen = palette_lab[index_image]
return float(np.sqrt(np.sum((chosen - img_lab) ** 2, axis=-1)).mean())

142
c64view/convert/fli.py Normal file
View file

@ -0,0 +1,142 @@
"""FLI (Flexible Line Interpretation) multicolor mode.
A stable raster routine re-points the VIC video matrix every scanline, so the two
screen-RAM-derived colours gain per-line (4x1) resolution while the colour-RAM
colour stays per-cell (4x8) and the background is global. Per 4x1 strip the
displayable colours are therefore {background, colourRAM(cell), screen01(line),
screen10(line)} -- four colours that refresh every line, far more than plain
multicolor.
Memory layout of the appended data block (loads from $4000), matched to
viewer/fli.s:
$4000+L*$400 screen RAM for line L of each char row (L=0..7), 1000 bytes each
$6000 bitmap 8000 (VIC reads here, offset $2000 in bank 1)
$8000 colour RAM 1000 (viewer copies to $D800)
$83E8 background 1
"""
from __future__ import annotations
from itertools import combinations
import numpy as np
from .. import dither, palette as pal
from . import base
WIDTH, HEIGHT = 160, 200
CELL_W, CELL_H = 4, 8
PIXEL_ASPECT = 2.0
DATA_ADDR = 0x4000
N_COLS, N_ROWS = 40, 25
N_CELLS = N_COLS * N_ROWS
def convert(img_rgb, palette_name="colodore", dither_mode="bayer", intensive=False):
plab = pal.palette_lab(palette_name)
img_lab = pal.srgb_to_lab(img_rgb)
# (n_cells, 8 rows, 4 px, 3): one 4x1 strip per (cell, line).
a = img_lab.reshape(N_ROWS, CELL_H, N_COLS, CELL_W, 3)
a = a.transpose(0, 2, 1, 3, 4).reshape(N_CELLS, CELL_H, CELL_W, 3)
dist = np.sum((a[:, :, :, None, :] - plab[None, None, None, :, :]) ** 2, axis=-1)
# dist: (n_cells, 8, 4, 16)
bg_candidates = range(16) if intensive else [base.best_global_color(img_lab, plab)]
best = None
for bg in bg_candidates:
c11, c01, c10, err = _solve(dist, bg)
if best is None or err < best[-1]:
best = (bg, c11, c01, c10, err)
bg, c11, c01, c10, _ = best
index_image = _quantize(img_lab, plab, bg, c11, c01, c10, dither_mode)
data = _encode(index_image, bg, c11, c01, c10)
conv = base.Conversion(
mode="fli", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=index_image, data=data, data_addr=DATA_ADDR, viewer="fli",
error=base.mean_error(index_image, img_lab, plab),
meta={"palette": palette_name, "dither": dither_mode, "background": int(bg)},
)
return conv
def _solve(dist, bg):
"""Pick per-cell colour-RAM colour c11 and per-line free colours c01,c10."""
n = dist.shape[0]
dbg = dist[:, :, :, bg] # (n,8,4)
# c11: the single shared colour that best complements bg across the whole cell.
cell_err = np.empty((16, n))
for c in range(16):
m = np.minimum(dbg, dist[:, :, :, c])
cell_err[c] = m.sum(axis=(1, 2))
cell_err[bg] = np.inf
c11 = np.argmin(cell_err, axis=0) # (n,)
# base error per strip using {bg, c11}.
dc11 = np.take_along_axis(dist, c11[:, None, None, None], axis=3)[..., 0] # (n,8,4)
sbase = np.minimum(dbg, dc11) # (n,8,4)
# per strip (cell,line) choose the best 2 free colours.
best_err = np.full((n, 8), np.inf)
c01 = np.zeros((n, 8), dtype=np.int64)
c10 = np.zeros((n, 8), dtype=np.int64)
for x, y in combinations(range(16), 2):
e = np.minimum(np.minimum(sbase, dist[:, :, :, x]), dist[:, :, :, y]).sum(axis=2)
better = e < best_err
best_err = np.where(better, e, best_err)
c01 = np.where(better, x, c01)
c10 = np.where(better, y, c10)
total = best_err.sum()
return c11, c01, c10, float(total)
def _allowed_map(bg, c11, c01, c10):
"""(H, W, 4) per-pixel allowed palette indices."""
yy, xx = np.indices((HEIGHT, WIDTH))
ci = (yy // CELL_H) * N_COLS + (xx // CELL_W)
r = yy % CELL_H
allowed = np.empty((HEIGHT, WIDTH, 4), dtype=np.int64)
allowed[:, :, 0] = bg
allowed[:, :, 1] = c11[ci]
allowed[:, :, 2] = c01[ci, r]
allowed[:, :, 3] = c10[ci, r]
return allowed
def _quantize(img_lab, plab, bg, c11, c01, c10, dither_mode):
allowed = _allowed_map(bg, c11, c01, c10)
return dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
def _encode(index_image, bg, c11, c01, c10):
bitmap = np.zeros(8000, dtype=np.uint8)
screens = [np.zeros(1000, dtype=np.uint8) for _ in range(8)]
colram = np.zeros(1000, dtype=np.uint8)
for cr in range(N_ROWS):
for cc in range(N_COLS):
ci = cr * N_COLS + cc
colram[ci] = c11[ci] & 0x0F
base_addr = cr * 320 + cc * 8
for r in range(8):
a01, a10 = int(c01[ci, r]), int(c10[ci, r])
screens[r][ci] = ((a01 & 0x0F) << 4) | (a10 & 0x0F)
lut = {int(bg): 0b00, int(c11[ci]): 0b11, a01: 0b01, a10: 0b10}
row = index_image[cr * 8 + r, cc * 4:cc * 4 + 4]
byte = 0
for x in range(4):
byte = (byte << 2) | lut.get(int(row[x]), 0b00)
bitmap[base_addr + r] = byte
block = bytearray()
for r in range(8):
block += bytes(screens[r]) + bytes(24) # pad each screen to 1K
block += bytes(bitmap) # $6000
block += bytes(0x8000 - (0x6000 + 8000)) # pad to $8000
block += bytes(colram) # $8000
block += bytes([int(bg) & 0x0F]) # $83E8
return bytes(block)

64
c64view/convert/hires.py Normal file
View file

@ -0,0 +1,64 @@
"""Hires bitmap mode: 320x200, two colours per 8x8 cell.
Data file layout (PRG, load $2000), matched to viewer/hires.s:
$2000 bitmap 8000 bytes (VIC reads here directly)
$3F40 screen RAM 1000 bytes (viewer copies to $0400)
"""
from __future__ import annotations
import numpy as np
from .. import dither, palette as pal
from . import base
WIDTH, HEIGHT = 320, 200
CELL_W, CELL_H = 8, 8
PIXEL_ASPECT = 1.0
DATA_LOAD = 0x2000
def convert(img_rgb: np.ndarray, palette_name="colodore",
dither_mode="bayer", intensive=False) -> base.Conversion:
plab = pal.palette_lab(palette_name)
img_lab = pal.srgb_to_lab(img_rgb)
cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H)
dist = base.cell_distance(cells, plab)
sets, _ = base.select_cell_sets(dist, range(16), n_free=2)
allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
index_image = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
bitmap, screen = _encode(index_image, sets, rows, cols)
payload = bytes(bitmap) + bytes(screen)
conv = base.Conversion(
mode="hires", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=index_image, data=payload, viewer="hires",
error=base.mean_error(index_image, img_lab, plab),
meta={"palette": palette_name, "dither": dither_mode},
)
# Standard OCP Art Studio hires file (load $2000): bitmap, screen, border.
conv.extra_files = [("picture.art", base.prg(0x2000, payload + b"\x00"))]
return conv
def _encode(index_image, sets, rows, cols):
"""Build the 8000-byte bitmap and 1000-byte screen RAM."""
bitmap = np.zeros(8000, dtype=np.uint8)
screen = np.zeros(1000, dtype=np.uint8)
for cr in range(rows):
for cc in range(cols):
ci = cr * cols + cc
bg_col, fg_col = int(sets[ci, 0]), int(sets[ci, 1])
screen[ci] = ((fg_col & 0x0F) << 4) | (bg_col & 0x0F)
base_addr = cr * 320 + cc * 8
block = index_image[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8]
for r in range(8):
row = block[r]
byte = 0
for x in range(8):
byte = (byte << 1) | (1 if row[x] == fg_col else 0)
bitmap[base_addr + r] = byte
return bitmap, screen

151
c64view/convert/ifli.py Normal file
View file

@ -0,0 +1,151 @@
"""Interlace mode: two multicolor frames shown on alternating fields (50Hz each).
The eye averages the two frames, so each pixel can show the blend of its colour
in frame A and frame B -- up to ~136 distinct apparent colours (16 base + 120
pairs). Frame A is an ordinary multicolor conversion; frame B targets the
*residual* (2*target - A) so that (A+B)/2 reconstructs the original. Both frames
share the global background and the colour-RAM colour per cell (the only VIC state
the viewer cannot cheaply swap per frame), and differ in bitmap + screen RAM.
Memory layout of the appended data (loads from $2000), matched to viewer/interlace.s:
$2000 bitmap A 8000 (bank 0, VIC reads here)
$3F40 screen A 1000 (copied to $0400)
$4400 screen B 1000 (bank 1 video matrix, in place)
$6000 bitmap B 8000 (bank 1, VIC reads here)
$8000 colour RAM 1000 (shared, copied to $D800)
$83E8 background 1
"""
from __future__ import annotations
from itertools import combinations
import numpy as np
from .. import dither, palette as pal
from . import base
WIDTH, HEIGHT = 160, 200
CELL_W, CELL_H = 4, 8
PIXEL_ASPECT = 2.0
DATA_ADDR = 0x2000
N_COLS, N_ROWS = 40, 25
N_CELLS = N_COLS * N_ROWS
def convert(img_rgb, palette_name="colodore", dither_mode="bayer", intensive=False):
plab = pal.palette_lab(palette_name)
prgb = pal.get_palette(palette_name)
img_lab = pal.srgb_to_lab(img_rgb)
# ---- frame A: ordinary multicolor (bg + 3 free per cell) ----
cellsA, _, _ = base.cells_lab(img_lab, CELL_W, CELL_H)
distA = base.cell_distance(cellsA, plab)
if intensive:
bg, setsA, _ = base.optimize_background(distA, n_free=3)
else:
bg = base.best_global_color(img_lab, plab)
avail = [i for i in range(16) if i != bg]
setsA, _ = base.select_cell_sets(distA, avail, n_free=3, fixed=(bg,))
# colour-RAM colour (shared by both frames) = third free colour of A.
c11 = setsA[:, 3].astype(np.int64)
allowedA = base.per_pixel_allowed(setsA, N_ROWS, N_COLS, CELL_W, CELL_H, HEIGHT, WIDTH)
idxA = dither.quantize(img_lab, allowedA, plab, dither_mode).astype(np.uint8)
# ---- frame B: match residual 2*target - A in linear light ----
lin_target = pal.srgb_to_linear(img_rgb)
lin_A = pal.srgb_to_linear(prgb[idxA])
resid = np.clip(2.0 * lin_target - lin_A, 0.0, 1.0)
resid_srgb = pal.linear_to_srgb(resid)
resid_lab = pal.srgb_to_lab(resid_srgb)
setsB = _solve_frameB(resid_lab, plab, bg, c11)
allowedB = base.per_pixel_allowed(setsB, N_ROWS, N_COLS, CELL_W, CELL_H, HEIGHT, WIDTH)
idxB = dither.quantize(resid_lab, allowedB, plab, dither_mode).astype(np.uint8)
# ---- blended preview (linear average -> sRGB, widened to display aspect) ----
blend_lin = (pal.srgb_to_linear(prgb[idxA]) + pal.srgb_to_linear(prgb[idxB])) / 2.0
blend = pal.linear_to_srgb(blend_lin)
preview = np.repeat(blend, int(round(PIXEL_ASPECT)), axis=1)
blend_lab = pal.srgb_to_lab(blend)
error = float(np.sqrt(np.sum((blend_lab - img_lab) ** 2, axis=-1)).mean())
data = _encode(idxA, idxB, setsA, setsB, bg, c11)
return base.Conversion(
mode="interlace", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idxA, data=data, data_addr=DATA_ADDR, viewer="interlace",
preview_rgb=preview, error=error,
meta={"palette": palette_name, "dither": dither_mode, "background": int(bg)},
)
def _solve_frameB(resid_lab, plab, bg, c11):
"""Per cell, pick the 2 free colours for frame B given shared {bg, c11[cell]}."""
cells, _, _ = base.cells_lab(resid_lab, CELL_W, CELL_H)
dist = base.cell_distance(cells, plab) # (n, P, 16)
dbg = dist[:, :, bg] # (n, P)
dc11 = np.take_along_axis(dist, c11[:, None, None], axis=2)[:, :, 0]
sbase = np.minimum(dbg, dc11)
n = dist.shape[0]
best = np.full(n, np.inf)
b1 = np.zeros(n, dtype=np.int64)
b2 = np.zeros(n, dtype=np.int64)
for x, y in combinations(range(16), 2):
e = np.minimum(np.minimum(sbase, dist[:, :, x]), dist[:, :, y]).sum(axis=1)
better = e < best
best = np.where(better, e, best)
b1 = np.where(better, x, b1)
b2 = np.where(better, y, b2)
bg_arr = np.full(n, bg, dtype=np.int64)
return np.stack([bg_arr, b1, b2, c11], axis=1)
def _pack_frame(index_image, screen_assign, colram_assign, bg, get_lut):
"""Build (bitmap, screen) for one frame. ``get_lut`` maps cell index -> dict."""
bitmap = np.zeros(8000, dtype=np.uint8)
screen = np.zeros(1000, dtype=np.uint8)
for cr in range(N_ROWS):
for cc in range(N_COLS):
ci = cr * N_COLS + cc
hi, lo, lut = get_lut(ci)
screen[ci] = ((hi & 0x0F) << 4) | (lo & 0x0F)
base_addr = cr * 320 + cc * 8
block = index_image[cr * 8:cr * 8 + 8, cc * 4:cc * 4 + 4]
for r in range(8):
byte = 0
for x in range(4):
byte = (byte << 2) | lut.get(int(block[r, x]), 0b00)
bitmap[base_addr + r] = byte
return bitmap, screen
def _encode(idxA, idxB, setsA, setsB, bg, c11):
def lutA(ci):
cc11 = int(c11[ci])
a01, a10 = int(setsA[ci, 1]), int(setsA[ci, 2])
return a01, a10, {int(bg): 0b00, a01: 0b01, a10: 0b10, cc11: 0b11}
def lutB(ci):
cc11 = int(c11[ci])
b01, b10 = int(setsB[ci, 1]), int(setsB[ci, 2])
return b01, b10, {int(bg): 0b00, b01: 0b01, b10: 0b10, cc11: 0b11}
bitmapA, screenA = _pack_frame(idxA, None, None, bg, lutA)
bitmapB, screenB = _pack_frame(idxB, None, None, bg, lutB)
colram = (c11 & 0x0F).astype(np.uint8)
block = bytearray()
block += bytes(bitmapA) # $2000
block += bytes(screenA) # $3F40
block += bytes(0x4400 - (0x3F40 + 1000)) # pad to $4400
block += bytes(screenB) # $4400
block += bytes(0x6000 - (0x4400 + 1000)) # pad to $6000
block += bytes(bitmapB) # $6000
block += bytes(0x8000 - (0x6000 + 8000)) # pad to $8000
block += bytes(colram) # $8000
block += bytes([int(bg) & 0x0F]) # $83E8
return bytes(block)

77
c64view/convert/mono.py Normal file
View file

@ -0,0 +1,77 @@
"""Monochrome / grayscale mode -- the highest-resolution path.
Renders at hires (320x200) but matches the image by *luminance* to a small ramp
of palette colours, so detail is carried entirely by spatial dithering. With the
grayscale ramp (black -> dark grey -> grey -> light grey -> white) this gives a
proper greyscale photo; pick any base colour and the ramp becomes that hue's
shades (e.g. black -> blue -> light blue -> white) for a tinted monochrome.
Output is ordinary hires-format data, so it reuses the hires viewer.
"""
from __future__ import annotations
import numpy as np
from .. import dither, palette as pal
from . import base, hires
WIDTH, HEIGHT = 320, 200
CELL_W, CELL_H = 8, 8
PIXEL_ASPECT = 1.0
DATA_LOAD = 0x2000
# Luminance-ordered grey ramp: black, dark grey, grey, light grey, white.
GRAY_RAMP = [0, 11, 12, 15, 1]
# A few palette colours have a lighter sibling, giving a richer tinted ramp.
SIBLINGS = {2: 10, 10: 2, 5: 13, 13: 5, 6: 14, 14: 6, 8: 9, 9: 8}
def build_ramp(base_color, plab):
"""Return palette indices (luminance-sorted) used to render the image."""
if base_color is None or base_color in (0, 1, 11, 12, 15):
ramp = list(GRAY_RAMP)
else:
ramp = {0, 1, base_color}
if base_color in SIBLINGS:
ramp.add(SIBLINGS[base_color])
ramp = list(ramp)
ramp.sort(key=lambda i: plab[i, 0]) # by Lab lightness
return ramp
def convert(img_rgb, palette_name="colodore", dither_mode="floyd",
intensive=False, base_color=None):
plab = pal.palette_lab(palette_name)
# Work purely in luminance: collapse image and palette to (L, 0, 0).
L_pix = pal.srgb_to_lab(img_rgb)[..., 0]
img_mono = np.zeros((HEIGHT, WIDTH, 3))
img_mono[..., 0] = L_pix
plab_mono = np.zeros((16, 3))
plab_mono[:, 0] = plab[:, 0]
ramp = build_ramp(base_color, plab)
n_free = min(2, len(ramp))
cells, rows, cols = base.cells_lab(img_mono, CELL_W, CELL_H)
dist = base.cell_distance(cells, plab_mono)
sets, _ = base.select_cell_sets(dist, ramp, n_free=n_free)
if n_free == 1: # pad to 2 colours per cell for hires
sets = np.concatenate([sets, sets], axis=1)
allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
index_image = dither.quantize(img_mono, allowed, plab_mono, dither_mode).astype(np.uint8)
bitmap, screen = hires._encode(index_image, sets, rows, cols)
payload = bytes(bitmap) + bytes(screen)
conv = base.Conversion(
mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=index_image, data=payload, data_addr=DATA_LOAD, viewer="hires",
error=base.mean_error(index_image, img_mono, plab_mono),
meta={"palette": palette_name, "dither": dither_mode,
"base_color": base_color, "ramp": ramp},
)
conv.extra_files = [("picture.art", base.prg(0x2000, payload + b"\x00"))]
return conv

View file

@ -0,0 +1,80 @@
"""Multicolor bitmap mode ("Koala"): 160x200, one shared background plus three
freely chosen colours per 4x8 cell.
Data file layout (PRG, load $2000), matched to viewer/multicolor.s:
$2000 bitmap 8000 bytes (VIC reads here directly)
$3F40 screen RAM 1000 bytes (viewer copies to $0400)
$4328 colour RAM 1000 bytes (viewer copies to $D800)
$4710 background 1 byte (viewer writes to $D021)
"""
from __future__ import annotations
import numpy as np
from .. import dither, palette as pal
from . import base
WIDTH, HEIGHT = 160, 200
CELL_W, CELL_H = 4, 8
PIXEL_ASPECT = 2.0
DATA_LOAD = 0x2000
# bit-pair -> colour source: 01 screen hi nibble, 10 screen lo nibble, 11 colour RAM
_SLOT_BITS = {1: 0b01, 2: 0b10, 3: 0b11}
def convert(img_rgb: np.ndarray, palette_name="colodore",
dither_mode="bayer", intensive=False) -> base.Conversion:
plab = pal.palette_lab(palette_name)
img_lab = pal.srgb_to_lab(img_rgb)
cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H)
dist = base.cell_distance(cells, plab)
if intensive:
bg, sets, _ = base.optimize_background(dist, n_free=3)
else:
bg = base.best_global_color(img_lab, plab)
avail = [i for i in range(16) if i != bg]
sets, _ = base.select_cell_sets(dist, avail, n_free=3, fixed=(bg,))
allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
index_image = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
bitmap, screen, colram = _encode(index_image, sets, bg, rows, cols)
# This block also *is* a Koala body: bitmap, screen, colram, background.
payload = bytes(bitmap) + bytes(screen) + bytes(colram) + bytes([bg])
conv = base.Conversion(
mode="multicolor", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=index_image, data=payload, viewer="multicolor",
error=base.mean_error(index_image, img_lab, plab),
meta={"palette": palette_name, "dither": dither_mode, "background": bg},
)
# Standard "Koala Painter" file (load $6000) for use in other C64 art tools.
conv.extra_files = [("picture.koa", base.prg(0x6000, payload))]
return conv
def _encode(index_image, sets, bg, rows, cols):
bitmap = np.zeros(8000, dtype=np.uint8)
screen = np.zeros(1000, dtype=np.uint8)
colram = np.zeros(1000, dtype=np.uint8)
for cr in range(rows):
for cc in range(cols):
ci = cr * cols + cc
# sets[ci] = [bg, c1, c2, c3]; assign the three non-bg colours to slots.
c1, c2, c3 = int(sets[ci, 1]), int(sets[ci, 2]), int(sets[ci, 3])
screen[ci] = ((c1 & 0x0F) << 4) | (c2 & 0x0F)
colram[ci] = c3 & 0x0F
color_to_bits = {bg: 0b00, c1: 0b01, c2: 0b10, c3: 0b11}
base_addr = cr * 320 + cc * 8
block = index_image[cr * 8:cr * 8 + 8, cc * 4:cc * 4 + 4]
for r in range(8):
row = block[r]
byte = 0
for x in range(4):
byte = (byte << 2) | color_to_bits.get(int(row[x]), 0b00)
bitmap[base_addr + r] = byte
return bitmap, screen, colram