Session 2: hybrid VQ codec, two quality profiles, three corrections
Answers session 1's critical-path question. Flat 4x4 VQ at k=256 was prototyped and REJECTED by eye: Dirk's face disintegrates and ink outlines break into 4-pixel stair-steps. The 256-colour palettised frame is excellent, so the palette was never the problem -- block VQ was. Replaced it with a Cinepak-style hybrid: each 4x4 block is SKIP, one 4x4 codeword, four 2x2 codewords, or RAW literal pixels, chosen per block by rate-distortion. The RAW escape makes lam=0 pixel-exact (measured 0.00 dB loss), so the quality knob spans lossless to heavily-compressed in one bitstream. Per the user's decision, ships TWO quality profiles from that one codec, one decoder and one bitstream -- only the rate knob differs: sasi 45 KB/s lam=300 34.8 dB stock 10MHz ACE/EXPERT scsi 75 KB/s lam=100 35.9 dB Super/XVI or CZ-6BS1 Three corrections to earlier numbers: 1. Session 1's "183 KB/s at 12fps" was a bad extrapolation. Halving the framerate does not halve the bitrate -- decimation roughly doubles the per-frame delta. Re-measured directly: 340 KB/s for session 1's own RLE, 247 KB/s for changed-spans+deflate. The lossless floor is 319 MB. 2. A FOURTH false-good result, same family as the three in FINDINGS 4: k=1024 codebooks appeared to buy +2.4 dB free, because the rate model charged 1 byte for a 10-bit index. Charging the true cost reverses the verdict -- k=256 wins at every matched bitrate, and by 5 dB at the low end where the SASI profile lives. k=256 ships. 3. Stream inventory: the ~3-5MB clips are 1.2-1.7s, not ~60s, and some 60s streams are menus, not content. Any survey must classify before averaging. Also cleared both candidate sources for the game-logic layer: the SNES project is MIT and DirkSimple is zlib, so the arcade scene graph can be imported and the two transcriptions diffed against each other. Encoder is working end-to-end: extract.py -> vq/vq_hybrid/ratectl -> encode.py, emitting a big-endian DLX1 container the 68000 can parse with plain moves. Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rate control: hit a target bitrate exactly, so one encoder serves both targets.
|
||||
|
||||
USER DECISION (session 2): ship TWO quality modes, SASI and SCSI. The codec's
|
||||
bitrate ceiling is a build parameter; the encoder is otherwise identical.
|
||||
|
||||
Mechanism: the hybrid encoder's lagrangian `lam` trades distortion for bytes
|
||||
monotonically, so per frame we binary-search lam to land inside a byte budget.
|
||||
A leaky bucket lets a quiet frame bank bytes that an action frame can spend --
|
||||
without that, quiet frames waste budget and action frames stay ugly.
|
||||
|
||||
The ceiling is HARD: the 68000 streams at a fixed rate off the disk, and a frame
|
||||
that overruns is a dropped frame, not a slow frame.
|
||||
"""
|
||||
import numpy as np
|
||||
import vq_hybrid as H
|
||||
|
||||
# Profiles. Bandwidths are the sustained-read figures the player can rely on;
|
||||
# see docs/FINDINGS.md 5 -- these are FOLKLORE-grade until the disk benchmark
|
||||
# is unblocked, so they are deliberately conservative fractions of the quoted
|
||||
# ceiling (audio, seeks and container overhead come out of the same pipe).
|
||||
# Calibrated against the CORRECTED rate-distortion measurement (FINDINGS 14).
|
||||
#
|
||||
# k=256 with 1-byte indices beats k=1024 with 2-byte indices at every matched
|
||||
# bitrate. The earlier "+2.4 dB for k=1024" was an artifact of a rate model that
|
||||
# charged 1 byte for a 10-bit index. 1-byte indices also mean the 68000 decoder
|
||||
# reads a plain move.b with no alignment case, and the codebook is 8 KB not 32 KB.
|
||||
#
|
||||
# The two profiles are the SAME codec, decoder and bitstream -- only `lam` differs.
|
||||
PROFILES = {
|
||||
"sasi": dict(kbps=45, lam=300.0, k1=256, k4=256,
|
||||
desc="stock 10MHz ACE/EXPERT, SASI",
|
||||
quality="34.8 dB on 00020 / 28.3 dB on 00146"),
|
||||
"scsi": dict(kbps=75, lam=100.0, k1=256, k4=256,
|
||||
desc="Super/XVI, or CZ-6BS1 board in a 10MHz machine",
|
||||
quality="35.9 dB on 00020 / 29.0 dB on 00146"),
|
||||
}
|
||||
# Not a shipping profile, but the curve continues: lam=25 is ~185 KB/s at ~38.7 dB
|
||||
# with 26% RAW blocks, and lam->0 is pixel-exact (0.00 dB loss). Entropy-coding
|
||||
# the payload (NOT YET IMPLEMENTED) should shift the whole curve ~1.4x left.
|
||||
|
||||
AUDIO_KBPS = 7.8 # MSM6258 ADPCM 15.6kHz mono -- comes out of the same budget
|
||||
|
||||
|
||||
def frame_budget(kbps, fps=12, audio=AUDIO_KBPS):
|
||||
"""bytes per video frame after audio takes its cut"""
|
||||
return (kbps - audio) * 1024.0 / fps
|
||||
|
||||
|
||||
def encode_rate_controlled(m, target_kbps, fps=12, bucket_frames=8,
|
||||
lam_lo=1.0, lam_hi=2e5, steps=9, verbose=False):
|
||||
budget = frame_budget(target_kbps, fps)
|
||||
bucket = 0.0 # banked bytes, capped at bucket_frames*budget
|
||||
cap = bucket_frames * budget
|
||||
out_recon, out_modes, out_sizes, out_lam = [], [], [], []
|
||||
|
||||
# encode() is whole-sequence; drive it per-lam and pick per frame.
|
||||
# Cheaper than re-running the whole encoder per frame: precompute the ladder.
|
||||
ladder = []
|
||||
lams = np.geomspace(lam_lo, lam_hi, steps)
|
||||
for lam in lams:
|
||||
e = H.encode(m, lam=float(lam))
|
||||
ladder.append(e)
|
||||
if verbose:
|
||||
print(f" lam={lam:9.0f} mean {e['sizes'].mean():6.0f} B/frame")
|
||||
|
||||
nf = len(m["idx"])
|
||||
for f in range(nf):
|
||||
allow = budget + bucket
|
||||
# cheapest lam (highest quality) whose size fits the allowance
|
||||
pick = len(lams) - 1
|
||||
for i in range(len(lams)):
|
||||
if ladder[i]["sizes"][f] <= allow:
|
||||
pick = i; break
|
||||
sz = ladder[pick]["sizes"][f]
|
||||
bucket = min(cap, bucket + budget - sz)
|
||||
out_recon.append(ladder[pick]["recon"][f])
|
||||
out_modes.append(ladder[pick]["modes"][f])
|
||||
out_sizes.append(sz); out_lam.append(lams[pick])
|
||||
|
||||
return dict(recon=out_recon, modes=out_modes, sizes=np.array(out_sizes),
|
||||
lam=np.array(out_lam), nb=ladder[0]["nb"], budget=budget)
|
||||
|
||||
|
||||
def summarise(m, enc, target_kbps, fps=12):
|
||||
import vq as VQ
|
||||
pal = m["pal"]
|
||||
rec = [pal[i] for i in enc["recon"]]
|
||||
src = [pal[i] for i in m["idx"]]
|
||||
p = np.mean([VQ.psnr(o, v) for o, v in zip(m["rgb"], rec)])
|
||||
pp = np.mean([VQ.psnr(o, v) for o, v in zip(m["rgb"], src)])
|
||||
sz = enc["sizes"]
|
||||
mo = np.concatenate(enc["modes"])
|
||||
return dict(target=target_kbps, psnr=p, pal=pp, loss=pp - p,
|
||||
mean_B=sz.mean(), max_B=sz.max(), budget=enc["budget"],
|
||||
kbps=sz.mean() * fps / 1024 + AUDIO_KBPS,
|
||||
over=100.0 * np.mean(sz > enc["budget"]),
|
||||
skip=100 * (mo == 0).mean(), v1=100 * (mo == 1).mean(),
|
||||
v4=100 * (mo == 2).mean())
|
||||
Reference in New Issue
Block a user