First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
235
lenser/intv/convert/stic.py
Normal file
235
lenser/intv/convert/stic.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"""Intellivision STIC Foreground/Background image encoder.
|
||||
|
||||
160x96 = 20x12 cells of 8x8. FGBG mode gives each cell TWO independent colours
|
||||
(like C64 hires): a foreground from the 8 primary colours (0-7) and a background
|
||||
from all 16. Each cell's 8x8 bitmap is drawn from a dictionary of 64 user tiles
|
||||
(GRAM), so cell bitmaps are clustered to 64 patterns.
|
||||
|
||||
Card word (FGBG): FG(bits 0-2) | GRAMbit(0x800) | (tile<<3) | BG(bits 9,10,12,13).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither, palette as c64pal
|
||||
from ...convert.base import (Conversion, cell_distance, cells_lab,
|
||||
per_pixel_allowed, perceptual_error)
|
||||
from .. import palette as ipal
|
||||
|
||||
WIDTH, HEIGHT = 160, 96
|
||||
PIXEL_ASPECT = 1.2 # 160x96 shown on a ~4:3 screen -> pixels a touch tall
|
||||
CELL_W, CELL_H = 8, 8
|
||||
N_COLS, N_ROWS = 20, 12
|
||||
N_TILES = 64
|
||||
GRAM_BIT = 0x0800
|
||||
N_FG = 8 # foreground limited to primary colours 0-7
|
||||
|
||||
|
||||
def _bg_bits(c: int) -> int:
|
||||
"""Pack a 0-15 background colour into FGBG card bits 9,10,12,13."""
|
||||
return (((c >> 0) & 1) << 9) | (((c >> 1) & 1) << 10) | \
|
||||
(((c >> 2) & 1) << 12) | (((c >> 3) & 1) << 13)
|
||||
|
||||
|
||||
def _best_pairs(dist):
|
||||
"""Per cell choose (fg in 0-7, bg in 0-15) minimising nearest-colour error.
|
||||
dist: (n_cells, P, 16) squared distances. Returns (fg, bg) int arrays."""
|
||||
n = dist.shape[0]
|
||||
best_err = np.full(n, np.inf)
|
||||
best_fg = np.zeros(n, np.int64)
|
||||
best_bg = np.zeros(n, np.int64)
|
||||
for fg in range(N_FG):
|
||||
dfg = dist[:, :, fg]
|
||||
for bg in range(16):
|
||||
m = np.minimum(dfg, dist[:, :, bg])
|
||||
err = m.sum(axis=1)
|
||||
better = err < best_err
|
||||
best_err[better] = err[better]
|
||||
best_fg[better] = fg
|
||||
best_bg[better] = bg
|
||||
return best_fg, best_bg
|
||||
|
||||
|
||||
def _cluster_tiles(bitmaps, k=N_TILES, iters=16, seed=0):
|
||||
"""k-means on 64-dim {0,1} cell bitmaps -> k binary representative tiles."""
|
||||
pats = bitmaps.astype(np.float64)
|
||||
uniq, counts = np.unique(bitmaps, axis=0, return_counts=True)
|
||||
if len(uniq) <= k:
|
||||
tiles = np.zeros((k, 64), np.uint8)
|
||||
tiles[:len(uniq)] = uniq
|
||||
lut = {tuple(p): i for i, p in enumerate(uniq)}
|
||||
labels = np.array([lut[tuple(b)] for b in bitmaps])
|
||||
return tiles, labels
|
||||
order = np.argsort(-counts)[:k]
|
||||
cent = uniq[order].astype(np.float64)
|
||||
rng = np.random.default_rng(seed)
|
||||
for _ in range(iters):
|
||||
d = ((pats[:, None, :] - cent[None, :, :]) ** 2).sum(-1)
|
||||
labels = d.argmin(1)
|
||||
for j in range(k):
|
||||
msk = labels == j
|
||||
if msk.any():
|
||||
cent[j] = pats[msk].mean(0)
|
||||
else:
|
||||
cent[j] = pats[rng.integers(len(pats))]
|
||||
tiles = (cent >= 0.5).astype(np.uint8)
|
||||
d = ((pats[:, None, :] - tiles[None, :, :].astype(np.float64)) ** 2).sum(-1)
|
||||
labels = d.argmin(1)
|
||||
return tiles, labels
|
||||
|
||||
|
||||
def _recolor(dist, tiles, labels):
|
||||
"""Given each cell's tile (shape), pick the (fg in 0-7, bg in 0-15) that
|
||||
minimise reconstruction error. Returns (fg, bg, total_error)."""
|
||||
n = dist.shape[0]
|
||||
M = tiles[labels].astype(np.float64) # (n, P) 1=fg
|
||||
fg_cost = np.einsum('np,npk->nk', M, dist) # (n,16) error on fg pixels
|
||||
bg_cost = np.einsum('np,npk->nk', 1.0 - M, dist) # (n,16) error on bg pixels
|
||||
fg = fg_cost[:, :N_FG].argmin(1)
|
||||
bg = bg_cost.argmin(1)
|
||||
rows = np.arange(n)
|
||||
total = fg_cost[rows, fg].sum() + bg_cost[rows, bg].sum()
|
||||
return fg.astype(np.int64), bg.astype(np.int64), float(total)
|
||||
|
||||
|
||||
def _reassign(dist, tiles):
|
||||
"""Assign each cell to the tile that (after optimal recolouring) minimises its
|
||||
error. Returns labels (n,)."""
|
||||
M = tiles.astype(np.float64) # (T, P)
|
||||
fg_sum = np.einsum('tp,npk->ntk', M, dist) # (n,T,16)
|
||||
bg_sum = np.einsum('tp,npk->ntk', 1.0 - M, dist)
|
||||
cost = fg_sum[:, :, :N_FG].min(2) + bg_sum.min(2) # (n,T)
|
||||
return cost.argmin(1)
|
||||
|
||||
|
||||
def _reshape(dist, labels, fg, bg, n_tiles=N_TILES):
|
||||
"""Recompute each tile's 8x8 mask from all cells using it: a pixel is fg where
|
||||
that lowers total error across those cells (block-truncation coding)."""
|
||||
n, P, _ = dist.shape
|
||||
rows = np.arange(n)[:, None]
|
||||
cols = np.arange(P)[None, :]
|
||||
dfg = dist[rows, cols, fg[:, None]] # (n,P) error if pixel = fg
|
||||
dbg = dist[rows, cols, bg[:, None]] # (n,P) error if pixel = bg
|
||||
tiles = np.zeros((n_tiles, P), np.uint8)
|
||||
# cells with the largest fg-vs-bg disagreement seed any empty tiles
|
||||
worst = np.argsort(-(np.abs(dfg - dbg).sum(1)))
|
||||
wi = 0
|
||||
for t in range(n_tiles):
|
||||
msk = labels == t
|
||||
if msk.any():
|
||||
tiles[t] = (dfg[msk].sum(0) < dbg[msk].sum(0)).astype(np.uint8)
|
||||
else:
|
||||
# reseed from an unused high-contrast cell so all 64 tiles get used
|
||||
c = int(worst[wi % n]); wi += 1
|
||||
tiles[t] = (dfg[c] < dbg[c]).astype(np.uint8)
|
||||
return tiles
|
||||
|
||||
|
||||
def _refine_once(dist, tiles, max_iters=40):
|
||||
"""One Lloyd run from a given tile set to convergence. Returns
|
||||
(tiles, labels, fg, bg, total_error)."""
|
||||
best = None
|
||||
best_err = np.inf
|
||||
for _ in range(max_iters):
|
||||
labels = _reassign(dist, tiles)
|
||||
fg, bg, _ = _recolor(dist, tiles, labels)
|
||||
tiles = _reshape(dist, labels, fg, bg)
|
||||
fg, bg, err = _recolor(dist, tiles, labels)
|
||||
if err < best_err - 1e-6:
|
||||
best_err = err
|
||||
best = (tiles.copy(), labels.copy(), fg.copy(), bg.copy())
|
||||
else:
|
||||
break # converged
|
||||
return (*best, best_err)
|
||||
|
||||
|
||||
def _refine(dist, tiles0, n_restarts):
|
||||
"""Joint optimisation of the 64 tile shapes and the per-cell (tile, fg, bg).
|
||||
|
||||
Lloyd iteration has local minima, so run it from the clustered initialisation
|
||||
plus several random tile sets and keep the globally lowest-error result -- the
|
||||
one that reproduces the picture most faithfully within the 64-tile budget.
|
||||
"""
|
||||
P = dist.shape[1]
|
||||
best = None
|
||||
best_err = np.inf
|
||||
rng = np.random.default_rng(0)
|
||||
inits = [tiles0] + [rng.integers(0, 2, (N_TILES, P)).astype(np.uint8)
|
||||
for _ in range(n_restarts)]
|
||||
for t0 in inits:
|
||||
tiles, labels, fg, bg, err = _refine_once(dist, t0)
|
||||
if err < best_err:
|
||||
best_err = err
|
||||
best = (tiles, labels, fg, bg)
|
||||
return best
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="stic", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
plab = ipal.palette_lab()
|
||||
prgb = ipal.get_palette().astype(np.uint8)
|
||||
img_lab = c64pal.srgb_to_lab(img_rgb)
|
||||
|
||||
cells, rows, cols = cells_lab(img_lab, CELL_W, CELL_H)
|
||||
dist = cell_distance(cells, plab)
|
||||
fg, bg = _best_pairs(dist) # (240,) each
|
||||
|
||||
# dither each cell between its two colours (order [bg, fg] for per_pixel_allowed)
|
||||
sets = np.stack([bg, fg], axis=1)
|
||||
allowed = per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
|
||||
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
|
||||
|
||||
bitmaps = np.zeros((N_ROWS * N_COLS, 64), np.uint8)
|
||||
for cr in range(rows):
|
||||
for cc in range(cols):
|
||||
ci = cr * cols + cc
|
||||
block = idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8]
|
||||
bitmaps[ci] = (block == fg[ci]).astype(np.uint8).reshape(-1)
|
||||
|
||||
tiles, labels = _cluster_tiles(bitmaps)
|
||||
|
||||
# Joint vector-quantisation refinement: the one-shot select->cluster->recolour
|
||||
# leaves the 64 shared tile shapes and the per-cell (tile, fg, bg) far from
|
||||
# optimal. Alternately re-assign each cell to its best tile, re-pick the two
|
||||
# colours, and re-cut every tile's shape (block-truncation coding) -- each step
|
||||
# provably lowers total CIELAB error, so the picture converges much closer to
|
||||
# the original within the 64-tile budget.
|
||||
n_restarts = 16 if intensive else 4
|
||||
refined = _refine(dist, tiles, n_restarts)
|
||||
if refined is not None:
|
||||
tiles, labels, fg, bg = refined
|
||||
|
||||
prev_idx = np.empty((HEIGHT, WIDTH), np.uint8)
|
||||
cards = np.zeros(N_ROWS * N_COLS, np.uint16)
|
||||
for ci in range(N_ROWS * N_COLS):
|
||||
cr, cc = divmod(ci, cols)
|
||||
tile = tiles[labels[ci]].reshape(8, 8)
|
||||
f, b = int(fg[ci]), int(bg[ci])
|
||||
prev_idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] = np.where(tile == 1, f, b)
|
||||
cards[ci] = (f & 0x07) | GRAM_BIT | ((labels[ci] & 0x3F) << 3) | _bg_bits(b)
|
||||
|
||||
gram = np.zeros(N_TILES * 8, np.uint16)
|
||||
for t in range(N_TILES):
|
||||
rb = tiles[t].reshape(8, 8)
|
||||
for r in range(8):
|
||||
byte = 0
|
||||
for x in range(8):
|
||||
byte = (byte << 1) | int(rb[r, x])
|
||||
gram[t * 8 + r] = byte
|
||||
|
||||
data = {"gram": gram, "cards": cards}
|
||||
|
||||
# pre-widen preview to display resolution (matches atari/apple/a2600 convention)
|
||||
preview = prgb[prev_idx]
|
||||
disp_w = int(round(WIDTH * PIXEL_ASPECT))
|
||||
xs = (np.arange(disp_w) * WIDTH) // disp_w
|
||||
preview = preview[:, xs]
|
||||
|
||||
return Conversion(
|
||||
mode="stic", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=prev_idx.astype(np.uint16), data=data, data_addr=0,
|
||||
viewer="stic", preview_rgb=preview,
|
||||
error=perceptual_error(prev_idx, img_lab, plab),
|
||||
meta={"palette": "stic", "dither": dither_mode, "mode": "fgbg"},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue