Files
vt1200/harness/udpstream/make_tiles.py
T

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())