forked from arti/vt1200
646 lines
28 KiB
Python
646 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""make_tiles.py -- a clip to a tile dictionary and a cached index/delta stream.
|
|
|
|
Scheme C's host half, end to end: read a clip, build the 32x32 1-bpp dictionary
|
|
(Task 1), quantise every frame to 1,280 indices, plan a ``TILES_FULL`` keyframe
|
|
or a changed-tile delta for each frame (Task 5's cadence), and write the whole
|
|
thing to a directory the sender and the MAME replay both consume:
|
|
|
|
tiles/
|
|
dictionary.bin 32,768 bytes: 256 entries of 128 bytes
|
|
indices.bin one 1,280-byte index frame per clip frame
|
|
stream.bin the plan, in wire order (see below)
|
|
manifest.json what the numbers mean: bytes, changes, PSNR
|
|
|
|
``stream.bin`` is the *plan*, not a capture of a wire run -- the 16-byte frame
|
|
header is ``frame.py``'s job and is rebuilt identically by the sender and by the
|
|
replay Lua, so there is one implementation of the wire format and one of the
|
|
schedule:
|
|
|
|
offset size field
|
|
0 4 magic 'TS01'
|
|
4 2 frame count, u16 little-endian
|
|
6 - records, one per clip frame, in order:
|
|
1 kind: 0 = SAME (nothing to send), 2 = TILES_FULL,
|
|
3 = TILES_DELTA
|
|
2 frame_no, u16 little-endian (the clip frame number)
|
|
2 payload length, u16 little-endian
|
|
- payload (empty for SAME)
|
|
|
|
A SAME frame is a record with no payload rather than an omitted record: the
|
|
replay then knows the frame exists, paints nothing for it, and can still compare
|
|
the screen it did not touch. Neither the sender nor the replay sends kind 0.
|
|
|
|
The clip
|
|
--------
|
|
|
|
``--clip DIR`` reads every ``*.bin`` in ``DIR`` in sorted order; each file is
|
|
one raw packed 1-bpp frame -- whole 160-byte rows, whole 32-row tile bands, at
|
|
most the 1024-row screen, top-aligned like ``tilecost.quantise``. With no
|
|
``--clip`` the deterministic :func:`synthetic_clip` is used, because there is no
|
|
Bad Apple clip on this machine; see NETWORK_REPORT.md.
|
|
|
|
The error bound
|
|
---------------
|
|
|
|
Quantising a tile stores the nearest dictionary entry under Hamming distance,
|
|
and that distance is exactly the number of pixels the rendered frame will
|
|
differ by. So the reconstruction error is not estimated: :func:`tile_errors`
|
|
returns the per-tile distance, the frame's total is their sum, and no encoder
|
|
using the same dictionary can beat it. :func:`psnr_db` reports it as
|
|
``-10 log10(BER)`` (the peak-to-peak of a 1-bpp image is 1, and MSE equals BER),
|
|
which is infinite -- ``None`` -- only when the dictionary happens to hold every
|
|
tile of the clip.
|
|
|
|
Pure standard library.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import functools
|
|
import json
|
|
import math
|
|
import os
|
|
import struct
|
|
import sys
|
|
from collections import namedtuple
|
|
from pathlib import Path
|
|
|
|
if __package__ in (None, ""): # run as a script, not a module
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
|
|
os.path.abspath(__file__)))))
|
|
|
|
from harness.udpstream.frame import KIND_TILES_DELTA, KIND_TILES_FULL # noqa
|
|
from harness.udpstream.tilecost import ( # noqa
|
|
MAX_TILES, SCREEN_TILE_ROWS, TILE_BYTES, TILE_COLS, TILES_PER_SCREEN,
|
|
_frame_tiles, build_dictionary, quantise)
|
|
from harness.udpstream.tiledict import ( # noqa
|
|
KEYFRAME_EVERY, delta_frame, keyframe_due, unpack_delta)
|
|
from harness.udpstream.vidsrc import ROW_BYTES, ROW_PIXELS, SCREEN_ROWS # noqa
|
|
|
|
__all__ = [
|
|
"ACTIVE_ROW_BYTES", "CACHE_VERSION", "Cache", "DEFAULT_CACHE",
|
|
"KIND_DELTA", "KIND_FULL", "KIND_SAME", "Record", "STREAM_MAGIC", "Tiles",
|
|
"ber", "build", "load_clip", "plan", "psnr_db", "read_cache", "render",
|
|
"synthetic_clip", "tile_errors", "write_cache",
|
|
]
|
|
|
|
#: A stream record's kind is a wire kind, except SAME: that frame is not sent.
|
|
KIND_SAME = 0
|
|
KIND_FULL = KIND_TILES_FULL # 2
|
|
KIND_DELTA = KIND_TILES_DELTA # 3
|
|
|
|
#: The stream file's magic, and the version of the cache layout.
|
|
STREAM_MAGIC = b"TS01"
|
|
CACHE_VERSION = 1
|
|
|
|
#: ``magic, frame count`` then one ``kind u8, frame_no u16, len u16`` per frame.
|
|
STREAM_HEADER = struct.Struct("<4sH")
|
|
RECORD_HEADER = struct.Struct("<BHH")
|
|
|
|
#: Width of the pixels the tile grid covers: 40 tiles x 32 px = 160 bytes.
|
|
ACTIVE_ROW_BYTES = TILE_COLS * 4 # 160
|
|
|
|
#: The four files a cache directory holds.
|
|
DICTIONARY_FILE = "dictionary.bin"
|
|
INDICES_FILE = "indices.bin"
|
|
STREAM_FILE = "stream.bin"
|
|
MANIFEST_FILE = "manifest.json"
|
|
|
|
#: ``make_tiles.py``'s default output, and ``vidsend.py --tiles``'s default
|
|
#: input: the demo's committed cache.
|
|
DEFAULT_CACHE = os.path.join(os.path.dirname(os.path.dirname(
|
|
os.path.dirname(os.path.abspath(__file__)))), "harness", "udpstream",
|
|
"tiles")
|
|
|
|
#: One frame's plan: what goes on the wire, or SAME with an empty payload.
|
|
Record = namedtuple("Record", "frame_no kind payload changed")
|
|
|
|
#: A built clip: the dictionary, the quantised frames, the plan, the errors.
|
|
Tiles = namedtuple("Tiles", "dictionary indices records bit_errors")
|
|
|
|
#: A cache read back from disk. ``manifest`` is the parsed JSON, verbatim.
|
|
Cache = namedtuple("Cache", "dictionary indices records manifest")
|
|
|
|
|
|
# --- the synthetic clip ---------------------------------------------------
|
|
|
|
_MASK = (1 << ROW_PIXELS) - 1
|
|
|
|
|
|
def _span(left: int, right: int) -> int:
|
|
"""The :data:`ROW_PIXELS`-bit mask of pixels ``left``..``right`` exclusive."""
|
|
left = max(0, left)
|
|
right = min(ROW_PIXELS, right)
|
|
if right <= left:
|
|
return 0
|
|
return (_MASK ^ ((1 << left) - 1)) & ~((1 << right) - 1) & _MASK
|
|
|
|
|
|
def _background_tile(tx: int, ty: int) -> list[int]:
|
|
"""One 32x32 background tile: a diagonal hatch, 8 phases, mirrored.
|
|
|
|
The phase is a function of the tile's place in the grid, so the background
|
|
is static across the clip (deltas then carry the *motion*, not the
|
|
wallpaper) and its vocabulary is small enough that k-means has something to
|
|
do but not so large that the dictionary is meaningless. Integer arithmetic
|
|
throughout, so the clip is byte-identical on any host.
|
|
"""
|
|
phase = (tx * 7 + ty * 13) % 64
|
|
rows = []
|
|
for r in range(32):
|
|
value = 0
|
|
for c in range(32):
|
|
if (c + 2 * r + phase) % 8 < 3:
|
|
value |= 1 << (31 - c)
|
|
if (phase >> 1) & 1:
|
|
value ^= 0xFFFF0000 # mirror the right half
|
|
if (phase >> 2) & 1:
|
|
value = ((value & 0xFFFF) << 16) | (value >> 16)
|
|
rows.append(value)
|
|
return rows
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def _background_rows() -> tuple[int, ...]:
|
|
"""The whole static background, one :data:`ROW_PIXELS`-bit int per row."""
|
|
rows = [0] * SCREEN_ROWS
|
|
for ty in range(SCREEN_TILE_ROWS):
|
|
for tx in range(TILE_COLS):
|
|
pattern = _background_tile(tx, ty)
|
|
shift = ROW_PIXELS - 32 * (tx + 1)
|
|
for r in range(32):
|
|
rows[ty * 32 + r] |= pattern[r] << shift
|
|
return tuple(rows)
|
|
|
|
|
|
def _silhouette(rows: list[int], n: int) -> None:
|
|
"""Draw frame ``n``'s moving silhouette over ``rows`` (black over paper).
|
|
|
|
An ellipse sweeping 7 px right per frame, with two horizontal cut-outs and
|
|
a slot for an "eye". The odd step is deliberate: it keeps the silhouette's
|
|
edges from landing on tile boundaries, so the dictionary has to represent
|
|
partly-covered tiles and the measured error is not zero by construction.
|
|
"""
|
|
x0 = 30 + 7 * n
|
|
for dy in range(340):
|
|
y = 200 + dy
|
|
if not 0 <= y < SCREEN_ROWS:
|
|
continue
|
|
# Integer form of 170 * (1 - ((dy - 170) / 180)^2), never negative.
|
|
half = 170 * (180 * 180 - (dy - 170) ** 2) // (180 * 180)
|
|
if half <= 0:
|
|
continue
|
|
rows[y] &= ~_span(x0 + 170 - half, x0 + 170 + half) & _MASK
|
|
if (dy // 47) % 3 == 1:
|
|
rows[y] = 0 # a cut-out across the body
|
|
if 120 <= dy <= 160:
|
|
rows[y] |= _span(x0 + 205, x0 + 250) & _MASK # the "eye"
|
|
|
|
|
|
def _synthetic_frame(n: int) -> bytes:
|
|
rows = list(_background_rows())
|
|
for y in range(SCREEN_ROWS - 160, SCREEN_ROWS):
|
|
rows[y] = 0 # a black ground band
|
|
_silhouette(rows, n)
|
|
return b"".join(row.to_bytes(ROW_BYTES, "big") for row in rows)
|
|
|
|
|
|
def synthetic_clip(frames: int = 30) -> list[bytes]:
|
|
"""Return ``frames`` deterministic full-screen 1-bpp frames.
|
|
|
|
This is the clip the pipeline is measured on because no Bad Apple source is
|
|
available on this machine (NETWORK_REPORT.md says so plainly). It is built
|
|
to exercise the codec rather than to look like the video: a static textured
|
|
background (so deltas carry motion), a silhouette moving 7 px per frame (so
|
|
tile boundaries are crossed), more distinct tiles than the dictionary can
|
|
hold (so the error bound is real), and no randomness at all.
|
|
"""
|
|
if not isinstance(frames, int) or isinstance(frames, bool) or frames < 1:
|
|
raise ValueError("frames is a positive int, got %r" % (frames,))
|
|
return [_synthetic_frame(n) for n in range(frames)]
|
|
|
|
|
|
# --- reading a real clip --------------------------------------------------
|
|
|
|
def _check_frame(data: bytes, name: str) -> bytes:
|
|
"""Validate one raw frame: whole rows, whole tile bands, top of screen."""
|
|
if not data:
|
|
raise ValueError("%s is empty" % name)
|
|
if len(data) % ROW_BYTES:
|
|
raise ValueError("%s is %d bytes, not whole %d-byte rows"
|
|
% (name, len(data), ROW_BYTES))
|
|
rows = len(data) // ROW_BYTES
|
|
if rows % 32:
|
|
raise ValueError("%s is %d rows, not whole 32-row tile bands"
|
|
% (name, rows))
|
|
if rows > SCREEN_ROWS:
|
|
raise ValueError("%s is %d rows, more than the %d-row screen"
|
|
% (name, rows, SCREEN_ROWS))
|
|
return data
|
|
|
|
|
|
def load_clip(directory) -> list[bytes]:
|
|
"""Read a clip directory's ``*.bin`` frames, sorted by name.
|
|
|
|
Each file is one raw packed 1-bpp frame, exactly what
|
|
:func:`harness.udpstream.tilecost.quantise` takes. A ``.bin`` that is not
|
|
whole rows and whole tile bands is an error naming the file, not a silent
|
|
reshape -- the terminal's 1,280-byte frame is only meaningful for a whole
|
|
grid. The sort is by name, so number the frames zero-padded: ``frame-0007``
|
|
sorts before ``frame-0010`` and ``frame-7`` before ``frame-10`` does not.
|
|
"""
|
|
paths = sorted(Path(directory).glob("*.bin"))
|
|
if not paths:
|
|
raise ValueError("no *.bin frames in %s" % (directory,))
|
|
return [_check_frame(path.read_bytes(), str(path)) for path in paths]
|
|
|
|
|
|
# --- the error, which is a bound ------------------------------------------
|
|
|
|
def _entries(dictionary: bytes) -> list[bytes]:
|
|
if len(dictionary) % TILE_BYTES:
|
|
raise ValueError("a dictionary is whole %d-byte tiles, got %d"
|
|
% (TILE_BYTES, len(dictionary)))
|
|
return [dictionary[i * TILE_BYTES:(i + 1) * TILE_BYTES]
|
|
for i in range(len(dictionary) // TILE_BYTES)]
|
|
|
|
|
|
def tile_errors(frame: bytes, indices: bytes, dictionary: bytes) -> list[int]:
|
|
"""Hamming distance from each source tile to the entry ``indices`` names.
|
|
|
|
These are the pixels the rendered screen will differ by, not an estimate:
|
|
the quantiser picks the nearest entry, so the distance is what the blit
|
|
reproduces. The tile split is :func:`tilecost._frame_tiles` -- the same one
|
|
``quantise`` used, so this measures the encoder's own tiles rather than a
|
|
second reading of the geometry (the *renderer* is the second reading).
|
|
"""
|
|
entries = _entries(dictionary)
|
|
tiles = _frame_tiles(frame)
|
|
if len(indices) != TILES_PER_SCREEN:
|
|
raise ValueError("an index frame is %d bytes, got %d"
|
|
% (TILES_PER_SCREEN, len(indices)))
|
|
return [(tile ^ int.from_bytes(entries[indices[position]], "big")).bit_count()
|
|
for position, tile in enumerate(tiles)]
|
|
|
|
|
|
def render(indices: bytes, dictionary: bytes) -> bytes:
|
|
"""The screen ``indices`` and ``dictionary`` require, as 1,024 x 160 bytes.
|
|
|
|
Only the 160 bytes a row that the tile grid covers: 40 tiles x 32 px. The
|
|
rest of a 256-byte VRAM row belongs to the resident OS and no tile blit
|
|
touches it (``tile_check.py`` draws the same line).
|
|
"""
|
|
entries = _entries(dictionary)
|
|
if len(indices) != TILES_PER_SCREEN:
|
|
raise ValueError("an index frame is %d bytes, got %d"
|
|
% (TILES_PER_SCREEN, len(indices)))
|
|
screen = bytearray(SCREEN_ROWS * ACTIVE_ROW_BYTES)
|
|
for ty in range(SCREEN_TILE_ROWS):
|
|
for tx in range(TILE_COLS):
|
|
entry = entries[indices[ty * TILE_COLS + tx]]
|
|
left = tx * 4
|
|
for row in range(32):
|
|
offset = (ty * 32 + row) * ACTIVE_ROW_BYTES + left
|
|
screen[offset:offset + 4] = entry[row * 4:row * 4 + 4]
|
|
return bytes(screen)
|
|
|
|
|
|
def ber(bit_errors: int, pixels: int = SCREEN_ROWS * ROW_PIXELS) -> float:
|
|
"""Bit-error rate: the fraction of pixels the render gets wrong."""
|
|
if bit_errors < 0 or pixels <= 0:
|
|
raise ValueError("ber needs a non-negative count over positive pixels")
|
|
return bit_errors / pixels
|
|
|
|
|
|
def psnr_db(bit_errors: int, pixels: int = SCREEN_ROWS * ROW_PIXELS):
|
|
"""PSNR for a 1-bpp reconstruction, or ``None`` when it is exact.
|
|
|
|
MSE = BER because the samples are 0/1 and the peak-to-peak is 1, so
|
|
PSNR = 10 log10(1/MSE) = -10 log10(BER). An exact reconstruction has no
|
|
finite PSNR; returning ``None`` (JSON ``null``) says that instead of a
|
|
made-up ceiling.
|
|
"""
|
|
value = ber(bit_errors, pixels)
|
|
if value == 0:
|
|
return None
|
|
return -10.0 * math.log10(value)
|
|
|
|
|
|
# --- the plan -------------------------------------------------------------
|
|
|
|
def plan(indices, every: int = KEYFRAME_EVERY, drops=None) -> list[Record]:
|
|
"""Plan one record per frame: full, delta, or nothing to send.
|
|
|
|
``drops`` maps a frame number to the terminal's *cumulative* drop count as
|
|
reported before that frame is sent (the status frame's field). The rule is
|
|
Task 5's :func:`tiledict.keyframe_due`: a full frame every ``every`` frames,
|
|
and immediately after a rise in ``drops``. The counter is consumed by the
|
|
decision, so one report forces exactly one keyframe.
|
|
"""
|
|
frames = [bytes(frame) for frame in indices]
|
|
if not frames:
|
|
raise ValueError("a plan needs at least one frame")
|
|
reported = {} if drops is None else dict(drops)
|
|
records = []
|
|
previous = None
|
|
seen = 0
|
|
for n, current in enumerate(frames):
|
|
now = max(seen, int(reported.get(n, 0)))
|
|
changed = 0 if previous is None else sum(
|
|
1 for a, b in zip(previous, current) if a != b)
|
|
if keyframe_due(n, now, seen, every):
|
|
records.append(Record(n, KIND_FULL, current, changed))
|
|
else:
|
|
payload = delta_frame(previous, current)
|
|
if payload is None:
|
|
records.append(Record(n, KIND_SAME, b"", 0))
|
|
else:
|
|
records.append(Record(n, KIND_DELTA, payload,
|
|
(len(payload) - 2) // 3))
|
|
seen = now
|
|
previous = current
|
|
return records
|
|
|
|
|
|
def build(frames, k: int = MAX_TILES, seed: int = 0,
|
|
every: int = KEYFRAME_EVERY, drops=None) -> Tiles:
|
|
"""Quantise ``frames`` through a new dictionary and plan the stream."""
|
|
frames = [_check_frame(bytes(frame), "frame %d" % n)
|
|
for n, frame in enumerate(frames)]
|
|
if not frames:
|
|
raise ValueError("build needs at least one frame")
|
|
dictionary = build_dictionary(frames, k=k, seed=seed)
|
|
indices = [quantise(frame, dictionary) for frame in frames]
|
|
records = plan(indices, every=every, drops=drops)
|
|
errors = [sum(tile_errors(frame, index, dictionary))
|
|
for frame, index in zip(frames, indices)]
|
|
return Tiles(dictionary, indices, records, errors)
|
|
|
|
|
|
# --- the cache ------------------------------------------------------------
|
|
|
|
def _stream_bytes(records, frames: int) -> bytes:
|
|
out = [STREAM_HEADER.pack(STREAM_MAGIC, frames)]
|
|
for record in records:
|
|
out.append(RECORD_HEADER.pack(record.kind, record.frame_no & 0xFFFF,
|
|
len(record.payload)))
|
|
out.append(record.payload)
|
|
return b"".join(out)
|
|
|
|
|
|
def _decode(records, frames: int):
|
|
"""Apply full frames and deltas, returning one index frame per frame.
|
|
|
|
This is the *writer's* reading of its own stream, used only to check the
|
|
cache is self-consistent. The replay checker decodes ``stream.bin`` with
|
|
its own code on purpose; an encoder that blessed itself would prove
|
|
nothing.
|
|
"""
|
|
state = None
|
|
if len(records) != frames:
|
|
raise ValueError("stream has %d records for %d frames"
|
|
% (len(records), frames))
|
|
decoded = []
|
|
for n, record in enumerate(records):
|
|
if record.frame_no != n:
|
|
raise ValueError("record %d names frame %d" % (n, record.frame_no))
|
|
if record.kind == KIND_FULL:
|
|
if len(record.payload) != TILES_PER_SCREEN:
|
|
raise ValueError("full frame %d is %d bytes, not %d"
|
|
% (n, len(record.payload), TILES_PER_SCREEN))
|
|
state = bytearray(record.payload)
|
|
elif record.kind == KIND_DELTA:
|
|
if state is None:
|
|
raise ValueError("delta at frame %d has no full frame before it"
|
|
% n)
|
|
for position, value in unpack_delta(record.payload):
|
|
state[position] = value
|
|
elif record.kind != KIND_SAME:
|
|
raise ValueError("frame %d has unknown kind %d" % (n, record.kind))
|
|
decoded.append(bytes(state) if state is not None else None)
|
|
if any(frame is None for frame in decoded):
|
|
raise ValueError("the stream does not begin with a full frame")
|
|
return decoded
|
|
|
|
|
|
def write_cache(directory, tiles: Tiles, clip: str = "synthetic",
|
|
every: int = KEYFRAME_EVERY, seed: int = 0) -> dict:
|
|
"""Write the four cache files and return the manifest that was written."""
|
|
path = Path(directory)
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
frames = len(tiles.indices)
|
|
if len(tiles.records) != frames or len(tiles.bit_errors) != frames:
|
|
raise ValueError("tiles has %d indices, %d records, %d error counts"
|
|
% (frames, len(tiles.records), len(tiles.bit_errors)))
|
|
|
|
(path / DICTIONARY_FILE).write_bytes(tiles.dictionary)
|
|
(path / INDICES_FILE).write_bytes(b"".join(tiles.indices))
|
|
(path / STREAM_FILE).write_bytes(_stream_bytes(tiles.records, frames))
|
|
|
|
payloads = [len(record.payload) for record in tiles.records]
|
|
full = [n for n, record in enumerate(tiles.records)
|
|
if record.kind == KIND_FULL]
|
|
deltas = [n for n, record in enumerate(tiles.records)
|
|
if record.kind == KIND_DELTA]
|
|
manifest = {
|
|
"version": CACHE_VERSION,
|
|
"clip": clip,
|
|
"frames": frames,
|
|
"screen_rows": SCREEN_ROWS,
|
|
"active_row_bytes": ACTIVE_ROW_BYTES,
|
|
"k": len(tiles.dictionary) // TILE_BYTES,
|
|
"seed": seed,
|
|
"keyframe_every": every,
|
|
"dictionary_bytes": len(tiles.dictionary),
|
|
"entries_used": len(set(b"".join(tiles.indices))),
|
|
"keyframes": full,
|
|
"delta_frames": deltas,
|
|
"unchanged_frames": [n for n, record in enumerate(tiles.records)
|
|
if record.kind == KIND_SAME],
|
|
"payload_bytes": payloads,
|
|
"changed_tiles": [record.changed for record in tiles.records],
|
|
"bit_errors": tiles.bit_errors,
|
|
"psnr_db": [psnr_db(bits) for bits in tiles.bit_errors],
|
|
"full_payload_bytes": sum(payloads[n] for n in full),
|
|
"delta_payload_bytes": sum(payloads[n] for n in deltas),
|
|
"total_payload_bytes": sum(payloads),
|
|
}
|
|
(path / MANIFEST_FILE).write_text(json.dumps(manifest, indent=2,
|
|
sort_keys=True) + "\n")
|
|
return manifest
|
|
|
|
|
|
def read_cache(directory) -> Cache:
|
|
"""Read a cache back and check it is self-consistent.
|
|
|
|
The sender and the replay both take a cache, so a stream that does not
|
|
decode to ``indices.bin`` is caught here -- once, on the host -- instead of
|
|
showing up as a wrong picture in MAME or on the terminal.
|
|
"""
|
|
path = Path(directory)
|
|
try:
|
|
dictionary = (path / DICTIONARY_FILE).read_bytes()
|
|
raw = (path / INDICES_FILE).read_bytes()
|
|
stream = (path / STREAM_FILE).read_bytes()
|
|
manifest = json.loads((path / MANIFEST_FILE).read_text())
|
|
except OSError as exc:
|
|
raise ValueError("cannot read the tile cache in %s: %s"
|
|
% (directory, exc))
|
|
if len(dictionary) % TILE_BYTES or not dictionary:
|
|
raise ValueError("dictionary.bin is %d bytes, not whole %d-byte tiles"
|
|
% (len(dictionary), TILE_BYTES))
|
|
if len(raw) % TILES_PER_SCREEN:
|
|
raise ValueError("indices.bin is %d bytes, not whole %d-byte frames"
|
|
% (len(raw), TILES_PER_SCREEN))
|
|
indices = [raw[i:i + TILES_PER_SCREEN]
|
|
for i in range(0, len(raw), TILES_PER_SCREEN)]
|
|
|
|
if len(stream) < STREAM_HEADER.size:
|
|
raise ValueError("stream.bin is shorter than its %d-byte header"
|
|
% STREAM_HEADER.size)
|
|
magic, frames = STREAM_HEADER.unpack_from(stream, 0)
|
|
if magic != STREAM_MAGIC:
|
|
raise ValueError("stream.bin magic is %r, expected %r"
|
|
% (magic, STREAM_MAGIC))
|
|
records = []
|
|
offset = STREAM_HEADER.size
|
|
while offset < len(stream):
|
|
if offset + RECORD_HEADER.size > len(stream):
|
|
raise ValueError("stream.bin is truncated in a record header")
|
|
kind, frame_no, length = RECORD_HEADER.unpack_from(stream, offset)
|
|
offset += RECORD_HEADER.size
|
|
if offset + length > len(stream):
|
|
raise ValueError("stream.bin is truncated in frame %d's payload"
|
|
% frame_no)
|
|
records.append(Record(frame_no, kind, stream[offset:offset + length],
|
|
0))
|
|
offset += length
|
|
|
|
decoded = _decode(records, frames)
|
|
if decoded != indices:
|
|
raise ValueError("stream.bin does not paint indices.bin (frame %d "
|
|
"differs)" % next(n for n, (a, b)
|
|
in enumerate(zip(decoded, indices))
|
|
if a != b))
|
|
if manifest.get("frames") != frames:
|
|
raise ValueError("manifest says %r frames, stream.bin says %d"
|
|
% (manifest.get("frames"), frames))
|
|
if manifest.get("dictionary_bytes") != len(dictionary):
|
|
raise ValueError("manifest says %r dictionary bytes, the file is %d"
|
|
% (manifest.get("dictionary_bytes"), len(dictionary)))
|
|
# The changed-tile counts are informational in the stream file, so read
|
|
# them back from the manifest for the sender's reporting.
|
|
changed = manifest.get("changed_tiles") or []
|
|
if len(changed) != frames:
|
|
raise ValueError("manifest lists %d changed-tile counts for %d frames"
|
|
% (len(changed), frames))
|
|
records = [record._replace(changed=changed[n])
|
|
for n, record in enumerate(records)]
|
|
return Cache(dictionary, indices, records, manifest)
|
|
|
|
|
|
# --- the CLI --------------------------------------------------------------
|
|
|
|
def parse_drops(text: str) -> dict[int, int]:
|
|
"""Parse ``--drops 7=3,40=9``: frame -> cumulative drops reported then."""
|
|
drops = {}
|
|
if not text:
|
|
return drops
|
|
for item in text.split(","):
|
|
try:
|
|
frame, count = item.split("=", 1)
|
|
drops[int(frame)] = int(count)
|
|
except ValueError:
|
|
raise argparse.ArgumentTypeError(
|
|
"expected frame=count[,frame=count], got %r" % text)
|
|
return drops
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
ap = argparse.ArgumentParser(
|
|
description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--clip", metavar="DIR",
|
|
help="directory of raw 1-bpp *.bin frames (default: the "
|
|
"synthetic clip)")
|
|
ap.add_argument("--frames", type=int, default=30,
|
|
help="frames of the synthetic clip (default 30)")
|
|
ap.add_argument("--k", type=int, default=MAX_TILES,
|
|
help="dictionary entries (default %d, an index is a byte)"
|
|
% MAX_TILES)
|
|
ap.add_argument("--seed", type=int, default=0,
|
|
help="initial-centroid seed (default 0)")
|
|
ap.add_argument("--keyframe-every", type=int, default=KEYFRAME_EVERY,
|
|
help="full frame cadence (default %d = ~2 s at 30 fps)"
|
|
% KEYFRAME_EVERY)
|
|
ap.add_argument("--drops", type=parse_drops, default={},
|
|
help="plan for a reported drop: frame=count[,frame=count]")
|
|
ap.add_argument("--out", default=DEFAULT_CACHE,
|
|
help="cache directory (default %(default)s)")
|
|
ap.add_argument("--quiet", action="store_true",
|
|
help="write the cache only; no per-frame CSV")
|
|
args = ap.parse_args(argv)
|
|
|
|
try:
|
|
if args.clip:
|
|
frames = load_clip(args.clip)
|
|
clip = os.path.abspath(args.clip)
|
|
else:
|
|
frames = synthetic_clip(args.frames)
|
|
clip = "synthetic_clip"
|
|
tiles = build(frames, k=args.k, seed=args.seed,
|
|
every=args.keyframe_every, drops=args.drops)
|
|
manifest = write_cache(args.out, tiles, clip=clip,
|
|
every=args.keyframe_every, seed=args.seed)
|
|
except (OSError, ValueError) as exc:
|
|
print("make_tiles.py: %s" % exc, file=sys.stderr)
|
|
return 2
|
|
|
|
if not args.quiet:
|
|
print("frame,kind,payload_bytes,changed_tiles,bit_errors,psnr_db")
|
|
names = {KIND_FULL: "full", KIND_DELTA: "delta", KIND_SAME: "same"}
|
|
for record, bits in zip(tiles.records, tiles.bit_errors):
|
|
psnr = psnr_db(bits)
|
|
print("%d,%s,%d,%d,%d,%s"
|
|
% (record.frame_no, names[record.kind], len(record.payload),
|
|
record.changed, bits,
|
|
"inf" if psnr is None else "%.2f" % psnr))
|
|
|
|
frames_n = len(frames)
|
|
pixels = frames_n * SCREEN_ROWS * ROW_PIXELS
|
|
total_bits = sum(tiles.bit_errors)
|
|
keyframes = manifest["keyframes"]
|
|
deltas = manifest["delta_frames"]
|
|
payloads = manifest["payload_bytes"]
|
|
delta_payloads = [payloads[n] for n in deltas] or [0]
|
|
print("make_tiles: %s, %d frames, %d dictionary entries (%d used)"
|
|
% (clip, frames_n, args.k, manifest["entries_used"]),
|
|
file=sys.stderr)
|
|
print("make_tiles: %d keyframe(s) at %s, %d delta(s), %d unchanged, "
|
|
"cadence every %d"
|
|
% (len(keyframes), keyframes[:8], len(deltas),
|
|
len(manifest["unchanged_frames"]), args.keyframe_every),
|
|
file=sys.stderr)
|
|
print("make_tiles: payload %d B full, %d B mean delta (%d..%d), %d B total"
|
|
% (TILES_PER_SCREEN, sum(delta_payloads) // len(delta_payloads),
|
|
min(delta_payloads), max(delta_payloads),
|
|
manifest["total_payload_bytes"]),
|
|
file=sys.stderr)
|
|
print("make_tiles: reconstruction %d bit(s) over %d frame(s), BER %.6f, "
|
|
"PSNR %s"
|
|
% (total_bits, frames_n, ber(total_bits, pixels),
|
|
"inf" if total_bits == 0 else "%.2f dB" % psnr_db(total_bits,
|
|
pixels)),
|
|
file=sys.stderr)
|
|
print("make_tiles: cache %s (%d B dictionary, %d B indices, %d B stream)"
|
|
% (os.path.abspath(args.out), manifest["dictionary_bytes"],
|
|
frames_n * TILES_PER_SCREEN,
|
|
os.path.getsize(os.path.join(args.out, STREAM_FILE))),
|
|
file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|