First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
86
lenser/convert/__init__.py
Normal file
86
lenser/convert/__init__.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""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.
|
||||
|
||||
NOTE: ``pixel_aspect`` is applied here only for the index-image fallback path.
|
||||
A converter that supplies ``preview_rgb`` MUST pre-widen it to display
|
||||
resolution itself (e.g. repeat columns by the pixel aspect); otherwise modes
|
||||
with wide pixels render as a narrow sliver. atari/apple/a2600/intv do this.
|
||||
"""
|
||||
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
|
||||
361
lenser/convert/base.py
Normal file
361
lenser/convert/base.py
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
"""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 segment_distances(cells: np.ndarray, palette_lab: np.ndarray) -> np.ndarray:
|
||||
"""Squared CIELAB distance from each cell pixel to every colour-pair *segment*.
|
||||
|
||||
Unlike :func:`cell_distance` (distance to the nearest palette vertex), this
|
||||
credits what error-diffusion dithering actually achieves: blending two
|
||||
colours reproduces any shade on the line between them. Returns a
|
||||
(Q, Q, n_cells, P) array (Q = palette size) where ``[a, b]`` is the distance
|
||||
to segment a-b.
|
||||
"""
|
||||
n_cells, P, _ = cells.shape
|
||||
Q = palette_lab.shape[0]
|
||||
D = np.empty((Q, Q, n_cells, P), dtype=np.float64)
|
||||
for a in range(Q):
|
||||
ca = palette_lab[a]
|
||||
for b in range(a, Q):
|
||||
seg = palette_lab[b] - ca
|
||||
L = float(seg @ seg) + 1e-9
|
||||
t = np.clip(((cells - ca) @ seg) / L, 0.0, 1.0) # (n,P)
|
||||
proj = ca + t[..., None] * seg
|
||||
d = np.sum((cells - proj) ** 2, axis=-1) # (n,P)
|
||||
D[a, b] = d
|
||||
D[b, a] = d
|
||||
return D
|
||||
|
||||
|
||||
def select_cell_sets_dither(cells, palette_lab, available, n_free, fixed=(),
|
||||
seg=None):
|
||||
"""Like :func:`select_cell_sets` but scores each colour combination by the
|
||||
distance to the nearest pairwise *segment* among its colours, so the chosen
|
||||
colours best support error-diffusion dithering (smoother gradients).
|
||||
"""
|
||||
n_cells = cells.shape[0]
|
||||
fixed = list(fixed)
|
||||
if seg is None:
|
||||
seg = segment_distances(cells, palette_lab)
|
||||
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)
|
||||
for combo in combos:
|
||||
colors = fixed + list(combo)
|
||||
pairs = list(combinations(colors, 2)) or [(colors[0], colors[0])]
|
||||
m = seg[pairs[0][0], pairs[0][1]]
|
||||
for a, b in pairs[1:]:
|
||||
m = np.minimum(m, seg[a, b])
|
||||
err = m.sum(axis=1)
|
||||
better = err < best_err
|
||||
best_err = np.where(better, err, best_err)
|
||||
best_combo[better] = combo
|
||||
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_dither(cells, palette_lab, n_free, candidates=range(16)):
|
||||
"""Dither-aware background search (see :func:`optimize_background`)."""
|
||||
seg = segment_distances(cells, palette_lab)
|
||||
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_dither(cells, palette_lab, avail, n_free,
|
||||
fixed=(bg,), seg=seg)
|
||||
total = errors.sum()
|
||||
if total < best_total:
|
||||
best_total = total
|
||||
best = (bg, sets, errors)
|
||||
return best
|
||||
|
||||
|
||||
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())
|
||||
|
||||
|
||||
# error-diffusion dithers benefit from dither-aware (segment) colour selection;
|
||||
# ordered ("bayer") and "none" must use plain nearest-colour selection.
|
||||
# "yliluoma" is ordered, not diffusion, but it likewise reproduces a cell's
|
||||
# *average* colour by mixing >2 entries, so it wants the same dither-aware
|
||||
# (segment) colour selection and perceptual (blurred) scoring as the diffusers.
|
||||
DIFFUSION_DITHERS = {"floyd", "atkinson", "stucki", "jarvis", "sierra",
|
||||
"sierra_lite", "burkes", "riemersma", "ostromoukhov",
|
||||
"yliluoma"}
|
||||
|
||||
|
||||
def _box_blur(a: np.ndarray, passes: int = 2) -> np.ndarray:
|
||||
"""Cheap separable 3x3 box blur (approximates the eye averaging a dither)."""
|
||||
for _ in range(passes):
|
||||
p = np.pad(a, ((1, 1), (1, 1), (0, 0)), mode="edge")
|
||||
a = (p[:-2, :-2] + p[:-2, 1:-1] + p[:-2, 2:] +
|
||||
p[1:-1, :-2] + p[1:-1, 1:-1] + p[1:-1, 2:] +
|
||||
p[2:, :-2] + p[2:, 1:-1] + p[2:, 2:]) / 9.0
|
||||
return a
|
||||
|
||||
|
||||
def luminance_ramp(plab: np.ndarray, neutral, base_color, siblings=None):
|
||||
"""Build a luminance-sorted ramp of palette indices for monochrome rendering.
|
||||
|
||||
With no/neutral base colour -> the platform's neutral (grey) ramp; otherwise a
|
||||
tinted ramp of black + the base colour (+ its lighter sibling, if any) + white,
|
||||
so the image becomes that hue's shades. ``neutral`` is the platform's grey
|
||||
ramp (e.g. [black, grey, white]); ``siblings`` maps a colour to a lighter
|
||||
variant. All indices are returned sorted by CIELAB lightness.
|
||||
"""
|
||||
neutral = list(neutral)
|
||||
if base_color is None or base_color in neutral:
|
||||
ramp = list(neutral)
|
||||
else:
|
||||
black, white = neutral[0], neutral[-1]
|
||||
ramp = {black, white, base_color}
|
||||
if siblings and base_color in siblings:
|
||||
ramp.add(siblings[base_color])
|
||||
ramp = list(ramp)
|
||||
ramp.sort(key=lambda i: plab[i, 0])
|
||||
return ramp
|
||||
|
||||
|
||||
def mono_render(img_rgb, plab, ramp, W, H, cell_w, cell_h, dither_mode, n_free=2):
|
||||
"""Luminance-matched monochrome render shared by every 2-colour-per-cell
|
||||
platform: collapse image and palette to pure lightness, pick (per cell) the
|
||||
ramp levels that bracket the cell's luminance (dither-aware for error
|
||||
diffusion), then dither. Returns (index_image, sets, rows, cols, error)."""
|
||||
from .. import dither, palette as pal
|
||||
L = pal.srgb_to_lab(img_rgb)[..., 0]
|
||||
img_mono = np.zeros((H, W, 3))
|
||||
img_mono[..., 0] = L
|
||||
plab_mono = np.zeros_like(plab)
|
||||
plab_mono[:, 0] = plab[:, 0]
|
||||
|
||||
# The per-cell search runs on a COMPACT luminance-only sub-palette of just the
|
||||
# ramp colours (indices 0..len(ramp)-1), then maps back to real palette
|
||||
# indices. This stays small and correct even when the real palette has far
|
||||
# more than 16 colours (e.g. the TED's 128) -- segment_distances assumes a
|
||||
# small palette -- and is faster since only the ramp is considered.
|
||||
ramp = list(ramp)
|
||||
real = np.array(ramp, dtype=np.int64)
|
||||
cpal = np.zeros((len(ramp), 3))
|
||||
cpal[:, 0] = plab[real, 0]
|
||||
|
||||
cells, rows, cols = cells_lab(img_mono, cell_w, cell_h)
|
||||
avail = range(len(ramp))
|
||||
n_free = min(n_free, len(ramp))
|
||||
if n_free >= 2 and dither_mode in DIFFUSION_DITHERS:
|
||||
sets, _ = select_cell_sets_dither(cells, cpal, avail, n_free=n_free)
|
||||
else:
|
||||
dist = cell_distance(cells, cpal)
|
||||
sets, _ = select_cell_sets(dist, avail, n_free=max(n_free, 1))
|
||||
sets = real[sets] # compact -> real palette indices
|
||||
if sets.shape[1] == 1:
|
||||
sets = np.concatenate([sets, sets], axis=1)
|
||||
|
||||
allowed = per_pixel_allowed(sets, rows, cols, cell_w, cell_h, H, W)
|
||||
idx = dither.quantize(img_mono, allowed, plab_mono, dither_mode).astype(np.uint8)
|
||||
err = perceptual_error(idx, img_mono, plab_mono)
|
||||
return idx, sets, rows, cols, err
|
||||
|
||||
|
||||
def mono_codebook(bitmaps, k, iters=8):
|
||||
"""Reduce per-cell binary patterns to a k-entry dictionary of REAL patterns
|
||||
(k-medoids under Hamming distance). Every dictionary entry is a genuine
|
||||
dithered pattern -- unlike k-means centroids, which threshold a cluster mean
|
||||
and can invent a near-solid 'average' pattern (block artefacts). Initialised
|
||||
from the k most frequent patterns, then refined by alternating nearest-Hamming
|
||||
assignment and medoid update so the entries spread to cover the pattern space.
|
||||
Returns (codebook (k, P) uint8, labels)."""
|
||||
P = bitmaps.shape[1]
|
||||
uniq, counts = np.unique(bitmaps, axis=0, return_counts=True)
|
||||
if len(uniq) <= k:
|
||||
code = np.zeros((k, P), np.uint8)
|
||||
code[:len(uniq)] = uniq
|
||||
lut = {tuple(p): i for i, p in enumerate(uniq)}
|
||||
labels = np.array([lut[tuple(b)] for b in bitmaps])
|
||||
return code, labels
|
||||
order = np.argsort(-counts)[:k]
|
||||
code = uniq[order].copy()
|
||||
bm = bitmaps.astype(np.int16)
|
||||
labels = np.zeros(len(bitmaps), np.int64)
|
||||
for _ in range(iters):
|
||||
labels = (bm[:, None, :] ^ code[None].astype(np.int16)).sum(-1).argmin(1)
|
||||
moved = False
|
||||
for j in range(k):
|
||||
members = bm[labels == j]
|
||||
if len(members) > 1:
|
||||
intra = (members[:, None, :] ^ members[None, :, :]).sum(-1).sum(1)
|
||||
med = members[intra.argmin()].astype(np.uint8)
|
||||
if not np.array_equal(med, code[j]):
|
||||
code[j] = med
|
||||
moved = True
|
||||
if not moved:
|
||||
break
|
||||
labels = (bm[:, None, :] ^ code[None].astype(np.int16)).sum(-1).argmin(1)
|
||||
return code, labels
|
||||
|
||||
|
||||
def refine_mono_tiles(distL, tiles, labels, fg, bg, n_tiles, iters=25):
|
||||
"""Fixed-colour vector quantisation of a two-tone tile dictionary (shared by
|
||||
the VIC-20 and Intellivision mono modes). With ink/paper fixed, alternately
|
||||
re-assign each cell to its best tile and re-cut every tile's shape (a pixel is
|
||||
ink where that lowers summed luminance error across the cells using the tile);
|
||||
empty tiles are reseeded from the highest-contrast cells so the whole budget is
|
||||
used. Returns (tiles, labels)."""
|
||||
n, P, _ = distL.shape
|
||||
dfg = distL[:, :, fg] # (n,P) error if pixel = ink
|
||||
dbg = distL[:, :, bg] # (n,P) error if pixel = paper
|
||||
worst = np.argsort(-(np.abs(dfg - dbg).sum(1)))
|
||||
best = (tiles, labels)
|
||||
best_err = np.inf
|
||||
for _ in range(iters):
|
||||
M = tiles.astype(np.float64)
|
||||
cost = np.einsum('tp,np->nt', M, dfg) + np.einsum('tp,np->nt', 1.0 - M, dbg)
|
||||
labels = cost.argmin(1)
|
||||
newt = np.zeros((n_tiles, P), np.uint8)
|
||||
wi = 0
|
||||
for t in range(n_tiles):
|
||||
msk = labels == t
|
||||
if msk.any():
|
||||
newt[t] = (dfg[msk].sum(0) < dbg[msk].sum(0)).astype(np.uint8)
|
||||
else:
|
||||
c = int(worst[wi % n]); wi += 1
|
||||
newt[t] = (dfg[c] < dbg[c]).astype(np.uint8)
|
||||
tiles = newt
|
||||
err = float(cost[np.arange(n), labels].sum())
|
||||
if err < best_err - 1e-6:
|
||||
best_err = err
|
||||
best = (tiles.copy(), labels.copy())
|
||||
else:
|
||||
break
|
||||
return best
|
||||
|
||||
|
||||
def perceptual_error(index_image: np.ndarray, img_lab: np.ndarray,
|
||||
palette_lab: np.ndarray) -> float:
|
||||
"""Delta-E after a light blur of both images -- credits dithering (the eye/CRT
|
||||
averages the dither) so dithered results are ranked by how they actually look,
|
||||
not penalised for per-pixel dither noise."""
|
||||
chosen = _box_blur(palette_lab[index_image])
|
||||
target = _box_blur(img_lab)
|
||||
return float(np.sqrt(np.sum((chosen - target) ** 2, axis=-1)).mean())
|
||||
170
lenser/convert/fli.py
Normal file
170
lenser/convert/fli.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""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)
|
||||
|
||||
aware = dither_mode in base.DIFFUSION_DITHERS # dither-aware (segment) scoring
|
||||
strip = a if aware else None
|
||||
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, plab, strip)
|
||||
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.perceptual_error(index_image, img_lab, plab),
|
||||
meta={"palette": palette_name, "dither": dither_mode, "background": int(bg)},
|
||||
)
|
||||
return conv
|
||||
|
||||
|
||||
def _seg(strip, ca, cb):
|
||||
"""Squared distance from each strip pixel to segment ca-cb (Lab points);
|
||||
ca may be (3,) or (n,1,1,3) for a per-cell endpoint, cb is (3,)."""
|
||||
seg = cb - ca
|
||||
L = np.sum(seg * seg, axis=-1, keepdims=True) + 1e-9
|
||||
t = np.clip(np.sum((strip - ca) * seg, axis=-1, keepdims=True) / L, 0.0, 1.0)
|
||||
proj = ca + t * seg
|
||||
return np.sum((strip - proj) ** 2, axis=-1) # (n,8,4)
|
||||
|
||||
|
||||
def _solve(dist, bg, plab=None, strip=None):
|
||||
"""Pick per-cell colour-RAM colour c11 and per-line free colours c01,c10.
|
||||
If ``strip`` (the Lab pixels) is given, score by segment distance, crediting
|
||||
error-diffusion dithering (smoother gradients)."""
|
||||
n = dist.shape[0]
|
||||
dbg = dist[:, :, :, bg] # (n,8,4)
|
||||
aware = strip is not None
|
||||
cbg = plab[bg] if aware else None
|
||||
|
||||
# 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 = _seg(strip, cbg, plab[c]) if aware else 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}.
|
||||
if aware:
|
||||
c11c = plab[c11][:, None, None, :] # (n,1,1,3) per-cell endpoint
|
||||
sbase = _seg(strip, cbg, c11c) # bg-c11 segment
|
||||
else:
|
||||
c11c = None
|
||||
dc11 = np.take_along_axis(dist, c11[:, None, None, None], axis=3)[..., 0]
|
||||
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):
|
||||
if aware:
|
||||
e = np.minimum(sbase, _seg(strip, plab[x], plab[y])) # c01-c10 blend
|
||||
e = np.minimum(e, _seg(strip, c11c, plab[x])) # c11-c0x blends
|
||||
e = np.minimum(e, _seg(strip, c11c, plab[y]))
|
||||
e = e.sum(axis=2)
|
||||
else:
|
||||
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)
|
||||
70
lenser/convert/hires.py
Normal file
70
lenser/convert/hires.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""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)
|
||||
# Dither-aware colour selection for error-diffusion modes (the chosen pair
|
||||
# brackets the cell so dithering blends to the true shade); plain
|
||||
# nearest-colour for ordered/none.
|
||||
if dither_mode in base.DIFFUSION_DITHERS:
|
||||
sets, _ = base.select_cell_sets_dither(cells, plab, range(16), n_free=2)
|
||||
else:
|
||||
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.perceptual_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
|
||||
183
lenser/convert/ifli.py
Normal file
183
lenser/convert/ifli.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"""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)
|
||||
|
||||
aware = dither_mode in base.DIFFUSION_DITHERS # dither-aware (segment) scoring
|
||||
|
||||
# ---- frame A: ordinary multicolor (bg + 3 free per cell) ----
|
||||
cellsA, _, _ = base.cells_lab(img_lab, CELL_W, CELL_H)
|
||||
if intensive:
|
||||
if aware:
|
||||
bg, setsA, _ = base.optimize_background_dither(cellsA, plab, n_free=3)
|
||||
else:
|
||||
bg, setsA, _ = base.optimize_background(base.cell_distance(cellsA, plab),
|
||||
n_free=3)
|
||||
else:
|
||||
bg = base.best_global_color(img_lab, plab)
|
||||
avail = [i for i in range(16) if i != bg]
|
||||
if aware:
|
||||
setsA, _ = base.select_cell_sets_dither(cellsA, plab, avail, n_free=3,
|
||||
fixed=(bg,))
|
||||
else:
|
||||
setsA, _ = base.select_cell_sets(base.cell_distance(cellsA, plab),
|
||||
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, aware)
|
||||
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)
|
||||
# perceptual error of the blend (blur credits the dither the eye averages).
|
||||
bl = base._box_blur(pal.srgb_to_lab(blend)); tg = base._box_blur(img_lab)
|
||||
error = float(np.sqrt(np.sum((bl - tg) ** 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 _segc(cells, ca, cb):
|
||||
"""Per-pixel squared distance to segment ca-cb; ca may be (n,1,3), cb (3,)."""
|
||||
seg = cb - ca
|
||||
L = np.sum(seg * seg, axis=-1, keepdims=True) + 1e-9
|
||||
t = np.clip(np.sum((cells - ca) * seg, axis=-1, keepdims=True) / L, 0.0, 1.0)
|
||||
return np.sum((cells - (ca + t * seg)) ** 2, axis=-1) # (n, P)
|
||||
|
||||
|
||||
def _solve_frameB(resid_lab, plab, bg, c11, aware=False):
|
||||
"""Per cell, pick the 2 free colours for frame B given shared {bg, c11[cell]}.
|
||||
With ``aware`` the colours are scored by segment distance (dither-aware)."""
|
||||
cells, _, _ = base.cells_lab(resid_lab, CELL_W, CELL_H)
|
||||
n = cells.shape[0]
|
||||
cbg = plab[bg]
|
||||
c11c = plab[c11][:, None, :] # (n,1,3) per-cell c11
|
||||
if aware:
|
||||
sbase = _segc(cells, cbg[None, None, :], c11c) # bg-c11 segment
|
||||
else:
|
||||
dist = base.cell_distance(cells, plab)
|
||||
dbg = dist[:, :, bg]
|
||||
dc11 = np.take_along_axis(dist, c11[:, None, None], axis=2)[:, :, 0]
|
||||
sbase = np.minimum(dbg, dc11)
|
||||
|
||||
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):
|
||||
if aware:
|
||||
e = np.minimum(sbase, _segc(cells, plab[x], plab[y])) # b1-b2 blend
|
||||
e = np.minimum(e, _segc(cells, c11c, plab[x])) # c11-bx blends
|
||||
e = np.minimum(e, _segc(cells, c11c, plab[y]))
|
||||
e = e.sum(axis=1)
|
||||
else:
|
||||
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)
|
||||
82
lenser/convert/mono.py
Normal file
82
lenser/convert/mono.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""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)
|
||||
# Dither-aware selection picks the two ramp levels that bracket each cell's
|
||||
# luminance so dithering blends to the true shade (smoother greys).
|
||||
if n_free >= 2 and dither_mode in base.DIFFUSION_DITHERS:
|
||||
sets, _ = base.select_cell_sets_dither(cells, plab_mono, ramp, n_free=n_free)
|
||||
else:
|
||||
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.perceptual_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
|
||||
89
lenser/convert/multicolor.py
Normal file
89
lenser/convert/multicolor.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""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)
|
||||
aware = dither_mode in base.DIFFUSION_DITHERS # dither-aware colour selection
|
||||
|
||||
if intensive:
|
||||
if aware:
|
||||
bg, sets, _ = base.optimize_background_dither(cells, plab, n_free=3)
|
||||
else:
|
||||
bg, sets, _ = base.optimize_background(base.cell_distance(cells, plab),
|
||||
n_free=3)
|
||||
else:
|
||||
bg = base.best_global_color(img_lab, plab)
|
||||
avail = [i for i in range(16) if i != bg]
|
||||
if aware:
|
||||
sets, _ = base.select_cell_sets_dither(cells, plab, avail, n_free=3,
|
||||
fixed=(bg,))
|
||||
else:
|
||||
dist = base.cell_distance(cells, plab)
|
||||
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.perceptual_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
|
||||
Loading…
Add table
Add a link
Reference in a new issue