Add an offline analysis pass and result cache for replay

Replay re-ran the SDK chain on every seek: 0.48-0.51 wall-seconds per video
second, about 4.8 minutes for a ten-minute recording. The new
orisys_app_shared.analysis package runs that pass once and stores the flow
samples a replay draws - 4,034 B per frame at the shipped finger grid instead of
the chain's 1,176,000 B; the result contract is in
apps/studio/docs/replay-analysis.md.

Verified: shared/tests 416 passed / 1 skipped (15 new) and apps/studio/tests
unchanged at 425 items / 0 failed / 18 skipped; tools/lint_gate.py clean; a
264-frame pass costs 0.5036 wall-s per video-s against 0.5052 for the bare chain
measured the same day.
This commit is contained in:
ykcheungab
2026-10-04 13:37:58 +08:00
parent 6fb2f72c6e
commit b6f6a06851
7 changed files with 3543 additions and 0 deletions
@@ -0,0 +1,27 @@
"""Offline analysis of a recording: run the chain once, read the result many times.
Why this exists: replaying a recording used to mean re-running the SDK chain every time
the operator moved the cursor, which costs 0.48–0.51 wall-seconds per video-second —
about 4.8 minutes for a ten-minute recording, every time. Part A runs that pass **once**
and stores the flow samples a replay draws, so seeking is a file read.
What is stored is deliberately *not* the chain's output. One frame of raw output is
1,176,000 B (``flow`` [300,280,2] float32, ``depth_map``, ``valid_mask``, the gray
image), which is 21.2 GB for ten minutes at 30 fps. The selected samples cost 4,034 B
per frame. The rest is recomputed — that is what makes the cache a cache.
The three modules have one job each:
``model``
The result and its byte encoding: the key, the provenance, the frame record.
``cache``
Writing it without ever leaving a partial result that looks finished, and evicting
whole results against a size cap.
``reader``
Serving it: by frame id, by timestamp, without replaying anything.
``service``
The pass itself — the only place that imports the SDK.
Nothing here imports Qt, so the pass and the reader are testable headless, and a
caller that only reads a stored result never loads the SDK at all.
"""
@@ -0,0 +1,624 @@
"""Writing and evicting analysis results: chunked, atomic, and never half-served.
A pass over a ten-minute recording produces tens of thousands of frame records. Two
properties matter more than speed:
* **A reader must never see a partial result as a finished one.** Every file is built
under a temporary name and moved into place with :func:`os.replace`, the metadata
that declares the result complete is written *last*, and an explicit ``INCOMPLETE``
marker exists from the first byte written until that move. A cancelled or crashed
pass leaves a directory that refuses to open by default.
* **Memory must not grow with the recording.** Frames accumulate into a fixed-size
chunk and the chunk is written once. The bound is
``chunk_frames * frame_payload_bytes(max candidates)`` — 256 frames of the shipped
finger grid is about 1.0 MB — and ``chunk_frames`` is clamped so a caller cannot
raise it without bound.
Storage is not free, so :func:`evict` removes whole results against a stated size cap.
It only ever deletes a direct child of the cache root that carries a ``meta.json``, so
a wrong path cannot turn into a recursive delete of something else.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import struct
import time
import zlib
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
from orisys_app_shared.analysis.model import (
ANALYSIS_FORMAT,
ANALYSIS_FORMAT_VERSION,
CHUNK_HEADER,
CHUNK_MAGIC,
CHUNK_TRAILER,
CODEC_RAW,
CODEC_ZLIB,
KEYFRAME_GRID,
KEYFRAME_HEADER,
KEYFRAME_MAGIC,
KEYFRAME_MASK,
RESULT_DIRNAME,
KeyframeRef,
Provenance,
ResultKey,
digest8,
keyframe_payload,
)
logger = logging.getLogger(__name__)
__all__ = [
"DEFAULT_MAX_BYTES",
"INCOMPLETE_MARKER",
"META_NAME",
"AnalysisWriter",
"ResultCache",
"ResultSummary",
"evict",
"scan_results",
]
#: The file that declares a finished result. Its presence is not enough — the
#: provenance inside must also say ``complete``.
META_NAME = "meta.json"
#: Written before the first data byte and removed after the last. A reader refuses a
#: directory that has one, whatever the metadata claims.
INCOMPLETE_MARKER = "INCOMPLETE"
CHUNK_PREFIX = "frames-"
CHUNK_SUFFIX = ".bin"
KEYFRAMES_NAME = "keyframes.bin"
DEFAULT_CHUNK_FRAMES = 256
#: A chunk is built in memory, so this is a real memory bound, not a tuning knob.
MAX_CHUNK_FRAMES = 4096
#: Stated cap for :func:`evict`. A ten-minute finger recording is about 70 MB, so this
#: holds roughly fourteen of them; the operator can lower it per call.
DEFAULT_MAX_BYTES = 1 << 30
#: Above this many remembered grid digests the writer stops deduplicating and gives a
#: deviating frame its own keyframe. Correct either way; the map exists to keep the
#: common case (one grid for the whole recording) at one keyframe.
MAX_GRID_DEDUP = 4096
_KIND_NAMES = {KEYFRAME_GRID: "grid", KEYFRAME_MASK: "mask"}
_KIND_CODES = {name: code for code, name in _KIND_NAMES.items()}
_CODEC_NAMES = {CODEC_RAW: "raw", CODEC_ZLIB: "zlib"}
_CODEC_CODES = {name: code for code, name in _CODEC_NAMES.items()}
def _atomic_write_json(path: Path, payload: Any) -> None:
"""Write JSON through a temporary sibling, so a reader sees all of it or none."""
text = json.dumps(
payload, indent=2, sort_keys=False, ensure_ascii=False, default=str
)
temporary = path.with_name(path.name + ".part")
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
def _atomic_replace_file(path: Path, data: bytes | bytearray) -> None:
temporary = path.with_name(path.name + ".part")
with temporary.open("wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
@dataclass(frozen=True)
class ResultSummary:
"""One result directory as :func:`scan_results` found it."""
directory: Path
key: str
complete: bool
bytes_used: int
modified: float
stopped_reason: str = ""
def _directory_bytes(directory: Path) -> int:
total = 0
for path in directory.rglob("*"):
try:
if path.is_file():
total += path.stat().st_size
except OSError:
# A file that vanished between listing and sizing is not a reason to stop
# measuring the rest of the directory.
logger.debug("could not size %s", path, exc_info=True)
return total
def scan_results(root: str | Path) -> list[ResultSummary]:
"""Every result directly under ``root``, newest last. Unreadable entries are listed
as incomplete rather than skipped: a directory nobody can read is worth seeing."""
base = Path(root)
found: list[ResultSummary] = []
if not base.is_dir():
return found
for child in sorted(base.iterdir()):
if not child.is_dir():
continue
meta_path = child / META_NAME
complete = False
key = child.name
reason = ""
modified = 0.0
if meta_path.is_file():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
complete = bool(meta.get("complete"))
key = str(meta.get("key") or child.name)
reason = str(meta.get("stopped_reason") or "")
modified = meta_path.stat().st_mtime
except (OSError, ValueError):
logger.warning(
"unreadable result metadata at %s", meta_path, exc_info=True
)
if not modified:
try:
modified = child.stat().st_mtime
except OSError:
modified = 0.0
if (child / INCOMPLETE_MARKER).exists():
complete = False
found.append(
ResultSummary(
directory=child,
key=key,
complete=complete,
bytes_used=_directory_bytes(child),
modified=modified,
stopped_reason=reason,
)
)
return found
def evict(
root: str | Path,
*,
max_bytes: int = DEFAULT_MAX_BYTES,
keep: Iterable[str | Path] = (),
dry_run: bool = False,
) -> list[Path]:
"""Remove whole results under ``root`` until the total fits ``max_bytes``.
Incomplete results go first — they are already unreadable by default, so they are
the cheapest thing to lose — and then complete ones oldest first. Any directory in
``keep`` is never touched, which is how a caller protects the result it is using.
Only a direct child of ``root`` carrying a ``meta.json`` is a candidate, so a bad
argument cannot delete an arbitrary tree.
"""
base = Path(root).resolve()
protected = {Path(item).resolve() for item in keep}
limit = max(0, int(max_bytes))
summaries = scan_results(base)
total = sum(item.bytes_used for item in summaries)
removed: list[Path] = []
order = sorted(
(item for item in summaries if item.directory.resolve() not in protected),
key=lambda item: (item.complete, item.modified),
)
for item in order:
if total <= limit:
break
if not (item.directory / META_NAME).is_file():
continue
if item.directory.parent.resolve() != base:
continue
removed.append(item.directory)
total -= item.bytes_used
if not dry_run:
shutil.rmtree(item.directory, ignore_errors=False)
if removed:
logger.info(
"analysis cache: %s %d result(s), %d bytes used against a %d byte cap",
"would remove" if dry_run else "removed",
len(removed),
total,
limit,
)
return removed
class AnalysisWriter:
"""Append-only writer for one result directory.
Usage is a fixed order — ``begin``, any number of keyframes and frames, then
either ``commit`` or ``abandon`` — and every exit path leaves the directory
honest: ``commit`` is the only thing that produces a result a reader will serve.
"""
def __init__(
self,
directory: str | Path,
*,
key: ResultKey,
chunk_frames: int = DEFAULT_CHUNK_FRAMES,
) -> None:
self.directory = Path(directory)
self.key = key
self.chunk_frames = max(1, min(int(chunk_frames), MAX_CHUNK_FRAMES))
self._keyframe_file = None
self._keyframe_offset = 0
self._keyframes: list[KeyframeRef] = []
self._grid_ids: dict[bytes, int] = {}
self._mask_id: int | None = None
self._next_keyframe_id = 0
self._chunk_buffer = bytearray()
self._chunk_offsets: list[int] = []
self._chunk_ids: list[int] = []
self._chunk_times: list[float] = []
self._chunk_index = 0
self._chunks: list[dict[str, Any]] = []
self._frame_count = 0
self.bytes_written = 0
self._committed = False
self._abandoned = False
self._started_at = ""
# ------------------------------------------------------------------- lifecycle
def begin(self) -> None:
"""Claim the directory: drop an older result, then mark this one incomplete.
The marker is written *before* the old data is removed, so an interruption
between the two steps still leaves a directory that will not be served.
"""
self.directory.mkdir(parents=True, exist_ok=True)
self._started_at = time.strftime("%Y-%m-%dT%H:%M:%S%z")
self._write_marker("running")
for stale in list(self.directory.glob(CHUNK_PREFIX + "*" + CHUNK_SUFFIX)):
stale.unlink()
for stale in list(self.directory.glob("*.part")):
stale.unlink()
keyframes = self.directory / KEYFRAMES_NAME
if keyframes.exists():
keyframes.unlink()
self._keyframe_file = (self.directory / (KEYFRAMES_NAME + ".part")).open("wb")
self._write_meta(complete=False, provenance=None, counts={}, reason="running")
def _write_marker(self, reason: str) -> None:
_atomic_write_json(
self.directory / INCOMPLETE_MARKER,
{"key": self.key.digest, "started_at": self._started_at, "reason": reason},
)
def _write_meta(
self,
*,
complete: bool,
provenance: Provenance | None,
counts: dict[str, Any],
reason: str,
) -> None:
record = dict(provenance.to_dict()) if provenance is not None else {}
record["complete"] = bool(complete)
record["stopped_reason"] = str(reason)
_atomic_write_json(
self.directory / META_NAME,
{
"format": ANALYSIS_FORMAT,
"format_version": ANALYSIS_FORMAT_VERSION,
"key": self.key.digest,
"key_components": self.key.components,
"complete": bool(complete),
"stopped_reason": str(reason),
"provenance": record,
"keyframes": [item.to_dict() for item in self._keyframes],
"chunks": list(self._chunks),
"counts": dict(counts),
},
)
def commit(self, *, provenance: Provenance, counts: dict[str, Any]) -> Path:
"""Finish the result: close the chunk and keyframes, then declare it complete.
The completion flag is written before the marker is removed, so an interruption
in between leaves a *complete-looking* directory that still refuses to open.
Failing closed is the only acceptable direction.
"""
if self._committed:
raise RuntimeError("this result was already committed")
if self._abandoned:
raise RuntimeError("this result was abandoned and cannot be committed")
self._flush_chunk()
if self._keyframe_file is not None:
self._keyframe_file.flush()
os.fsync(self._keyframe_file.fileno())
self._keyframe_file.close()
self._keyframe_file = None
part = self.directory / (KEYFRAMES_NAME + ".part")
if part.exists():
os.replace(part, self.directory / KEYFRAMES_NAME)
self._write_meta(complete=True, provenance=provenance, counts=counts, reason="")
marker = self.directory / INCOMPLETE_MARKER
if marker.exists():
marker.unlink()
self._committed = True
return self.directory
def abandon(self, reason: str, *, provenance: Provenance | None = None) -> Path:
"""Stop without a result. Whatever was written stays, and stays unusable.
The buffered chunk and the open keyframes are closed into real files rather than
dropped, so an interrupted pass is *inspectable* (a caller passing
``allow_incomplete=True`` can read the frames that were finished) while still
being refused by every default reader. Keeping the frames costs one chunk and
buys the only evidence of how far the pass got; discarding them would leave a
directory whose metadata claims five frames and whose files hold none.
The provenance is kept when the caller has one: a cancelled pass still has to say
which input it was working on.
"""
if self._committed:
raise RuntimeError("this result was already committed")
self._abandoned = True
try:
self._flush_chunk()
except OSError:
logger.warning(
"could not write the partial chunk of a stopped pass", exc_info=True
)
if self._keyframe_file is not None:
try:
self._keyframe_file.flush()
os.fsync(self._keyframe_file.fileno())
self._keyframe_file.close()
except OSError:
logger.debug("could not close the keyframe file", exc_info=True)
self._keyframe_file = None
part = self.directory / (KEYFRAMES_NAME + ".part")
try:
if part.exists():
os.replace(part, self.directory / KEYFRAMES_NAME)
except OSError:
logger.debug("could not publish the partial keyframes", exc_info=True)
if self.directory.is_dir():
self._write_marker(reason or "failed")
counts = {"frames_written": self._frame_count}
if provenance is not None:
counts = dict(provenance.counts)
self._write_meta(
complete=False,
provenance=provenance,
counts=counts,
reason=reason or "failed",
)
return self.directory
def __enter__(self) -> AnalysisWriter:
self.begin()
return self
def __exit__(self, exc_type, exc, tb) -> bool:
if exc_type is not None and not self._committed:
reason = f"{exc_type.__name__}: {exc}" if exc else "failed"
self.abandon(reason)
return False
# ------------------------------------------------------------------- keyframes
def _append_keyframe(
self, kind: int, payload: bytes, *, count: int, height: int, width: int
) -> int:
digest = digest8(payload)
codec = CODEC_ZLIB
stored = zlib.compress(payload, 6)
if len(stored) >= len(payload):
codec = CODEC_RAW
stored = payload
identifier = self._next_keyframe_id
self._next_keyframe_id += 1
header = KEYFRAME_HEADER.pack(
KEYFRAME_MAGIC,
kind,
codec,
int(count),
int(height),
int(width),
0,
0,
len(payload),
digest,
)
if self._keyframe_file is None:
raise RuntimeError("the writer is not open; call begin() first")
self._keyframe_file.write(header)
self._keyframe_file.write(stored)
shape = (int(count),) if kind == KEYFRAME_GRID else (int(height), int(width))
self._keyframes.append(
KeyframeRef(
id=identifier,
kind=_KIND_NAMES[kind],
digest=digest.hex(),
shape=shape,
codec=_CODEC_NAMES[codec],
raw_bytes=len(payload),
stored_bytes=len(stored),
offset=self._keyframe_offset,
)
)
self._keyframe_offset += len(header) + len(stored)
self.bytes_written += len(header) + len(stored)
return identifier
def grid_keyframe(self, rows: np.ndarray, cols: np.ndarray) -> int:
"""The candidate location set as a keyframe, reused when it repeats.
The finger chain produces one of these for a whole recording because its
validity mask is static; a chain whose candidate set moves gets one per
distinct set, which is the same cost as storing the locations inline.
"""
rows = np.ascontiguousarray(rows, dtype="<i2").reshape(-1)
cols = np.ascontiguousarray(cols, dtype="<i2").reshape(-1)
if rows.size != cols.size:
raise ValueError(f"rows/cols length mismatch: {rows.size} vs {cols.size}")
payload = keyframe_payload(KEYFRAME_GRID, np.concatenate([rows, cols]))
digest = digest8(payload)
known = self._grid_ids.get(digest)
if known is not None:
return known
if len(self._grid_ids) >= MAX_GRID_DEDUP:
for stale in list(self._grid_ids)[: MAX_GRID_DEDUP // 2]:
del self._grid_ids[stale]
identifier = self._append_keyframe(
KEYFRAME_GRID, payload, count=int(rows.size), height=0, width=0
)
self._grid_ids[digest] = identifier
return identifier
def mask_keyframe(self, mask: np.ndarray, digest: bytes) -> int:
"""The validity mask as a keyframe. One per result: it is static when it is not."""
array = np.asarray(mask, dtype=bool)
if array.ndim != 2:
raise ValueError(f"a validity mask is 2-D, got shape {array.shape}")
payload = keyframe_payload(KEYFRAME_MASK, array)
stored_digest = digest8(payload)
if stored_digest != digest:
raise ValueError("the mask digest does not describe the stored mask")
if self._mask_id is None:
self._mask_id = self._append_keyframe(
KEYFRAME_MASK,
payload,
count=0,
height=int(array.shape[0]),
width=int(array.shape[1]),
)
return self._mask_id
# ---------------------------------------------------------------------- frames
def add_frame(self, record: bytes, *, frame_id: int, t_rel_s: float) -> None:
"""Append one encoded frame. The chunk is written when it is full."""
if self._committed or self._abandoned:
raise RuntimeError("this result is closed")
# Absolute from the start of the file, like the offset table below it: a reader
# seeks once and reads one record, with no header arithmetic in between.
self._chunk_offsets.append(CHUNK_HEADER.size + len(self._chunk_buffer))
self._chunk_ids.append(int(frame_id))
self._chunk_times.append(float(t_rel_s))
self._chunk_buffer += record
self._frame_count += 1
if len(self._chunk_ids) >= self.chunk_frames:
self._flush_chunk()
def _flush_chunk(self) -> None:
if not self._chunk_ids:
return
offsets = struct.pack(f"<{len(self._chunk_offsets)}Q", *self._chunk_offsets)
ids = struct.pack(f"<{len(self._chunk_ids)}i", *self._chunk_ids)
times = struct.pack(f"<{len(self._chunk_times)}d", *self._chunk_times)
trailer = offsets + ids + times
# Absolute, from the start of the file: the reader seeks to it directly.
offset_table_offset = CHUNK_HEADER.size + len(self._chunk_buffer)
body = bytes(self._chunk_buffer)
header = CHUNK_HEADER.pack(
CHUNK_MAGIC,
ANALYSIS_FORMAT_VERSION,
0,
self._chunk_index,
len(self._chunk_ids),
CHUNK_HEADER.size,
)
payload = (
header
+ body
+ trailer
+ CHUNK_TRAILER.pack(offset_table_offset, len(self._chunk_ids), CHUNK_MAGIC)
)
name = f"{CHUNK_PREFIX}{self._chunk_index:04d}{CHUNK_SUFFIX}"
_atomic_replace_file(self.directory / name, payload)
self._chunks.append(
{
"index": self._chunk_index,
"file": name,
"frames": len(self._chunk_ids),
"first_frame_id": int(self._chunk_ids[0]),
"last_frame_id": int(self._chunk_ids[-1]),
"bytes": len(payload),
}
)
self.bytes_written += len(payload)
self._chunk_index += 1
self._chunk_buffer = bytearray()
self._chunk_offsets = []
self._chunk_ids = []
self._chunk_times = []
@property
def frames_written(self) -> int:
return self._frame_count
class ResultCache:
"""The results belonging to one recording, addressed by key.
``root`` is usually ``<recording>/analysis``: a result then travels with the
recording it describes, and deleting the recording deletes its cache. Nothing here
writes to the current working directory — that matters because opening a *box*
``Sensor`` drops a ~2 MB ``config/compound_eye.png`` relative to it (see
``service.py``).
"""
def __init__(self, root: str | Path, *, max_bytes: int = DEFAULT_MAX_BYTES) -> None:
self.root = Path(root)
self.max_bytes = int(max_bytes)
@classmethod
def for_recording(cls, recording_dir: str | Path, **kwargs: Any) -> ResultCache:
return cls(Path(recording_dir) / RESULT_DIRNAME, **kwargs)
def directory(self, key: ResultKey) -> Path:
return self.root / key.digest
def exists(self, key: ResultKey) -> bool:
return (self.directory(key) / META_NAME).is_file()
def is_complete(self, key: ResultKey) -> bool:
directory = self.directory(key)
if (directory / INCOMPLETE_MARKER).exists():
return False
meta = directory / META_NAME
if not meta.is_file():
return False
try:
return bool(json.loads(meta.read_text(encoding="utf-8")).get("complete"))
except (OSError, ValueError):
return False
def reader(self, key: ResultKey, *, allow_incomplete: bool = False):
"""Open the stored result. Imported lazily so this module stays import-light."""
from orisys_app_shared.analysis.reader import AnalysisReader
return AnalysisReader(self.directory(key), allow_incomplete=allow_incomplete)
def writer(
self, key: ResultKey, *, chunk_frames: int = DEFAULT_CHUNK_FRAMES
) -> AnalysisWriter:
return AnalysisWriter(self.directory(key), key=key, chunk_frames=chunk_frames)
def evict(
self, *, keep: Iterable[str | Path] = (), dry_run: bool = False
) -> list[Path]:
return evict(self.root, max_bytes=self.max_bytes, keep=keep, dry_run=dry_run)
def total_bytes(self) -> int:
return sum(item.bytes_used for item in scan_results(self.root))
@@ -0,0 +1,526 @@
"""What one offline analysis pass stores, and the byte encoding it uses.
The pass re-runs the SDK chain over the recording's ``raw_*.mp4`` segments and keeps
only what a replay draws: the flow samples the shared selector returns, not the flow
field they came from. The fields a frame produces are far too large to cache — the
measured cost is 1,176,000 B per frame (``flow`` 672,000 + ``depth_map`` 336,000 +
``valid_mask`` 84,000 + the gray ``img`` 84,000), which is 21.2 GB for ten minutes at
30 fps. The samples themselves cost 4,034 B per frame at the shipped finger grid.
Three structural facts decide the layout, and each one is a measured result rather
than a guess:
* the candidate **location** set is byte-identical on every frame of a finger
recording (330 candidates, one digest across two videos), so it is a **keyframe**
and the per-frame record carries only values;
* the **validity mask** is likewise static (0 of 327 frames differed), so it is a
keyframe too — and the per-frame record still carries an 8-byte digest of the mask
it was computed with, so a frame that *did* change is detectable instead of being
silently reconstructed against the wrong mask;
* a location set that is *not* static (the box chain has no valid mask, so its
``max_arrows`` cap orders by magnitude and moves frame to frame) gets its own
keyframe, deduplicated by digest. The format has one code path, and "stored once"
is true by construction wherever it can be.
Display settings are deliberately **not** part of the invalidation key. The record
keeps ``magnitude`` as well as the packed ``active`` bits, so a threshold change is
served by re-deriving activity from magnitude; only a change to the sample *grid*
(``flow_grid_spacing``, ``flow_edge_margin``, ``max_arrows``) moves the candidate
locations, and that policy is recorded in the provenance for comparison instead of
being baked into the key.
"""
from __future__ import annotations
import hashlib
import json
import struct
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from orisys_app_shared.scene3d.visual_policy import FlowSamples
__all__ = [
"ALGORITHM_VERSION",
"ANALYSIS_FORMAT",
"ANALYSIS_FORMAT_VERSION",
"CHUNK_HEADER",
"CHUNK_MAGIC",
"CHUNK_TRAILER",
"CODEC_RAW",
"CODEC_ZLIB",
"FRAME_FLAG_DROPPED",
"FRAME_FLAG_VALIDITY",
"FRAME_HEADER",
"KEYFRAME_GRID",
"KEYFRAME_HEADER",
"KEYFRAME_MAGIC",
"KEYFRAME_MASK",
"UNIT",
"FramePayload",
"KeyframeRef",
"Provenance",
"ResultKey",
"decode_frame",
"encode_frame",
"frame_payload_bytes",
"keyframe_payload",
"recording_identity",
"result_key",
"unpack_mask",
]
#: Directory name a result lives under, beside the recording's ``frames.csv``.
RESULT_DIRNAME = "analysis"
#: On-disk result format. Bump only for an incompatible layout change; a change to
#: what the *chain computes* belongs in :data:`ALGORITHM_VERSION` instead.
ANALYSIS_FORMAT = "orisys-replay-analysis"
ANALYSIS_FORMAT_VERSION = 1
#: Identity of the analysis the pass runs: the SDK calls it makes, in order.
#: ``compute_deformation(decompose=True, check_motion=False)`` then
#: ``compute_contact()`` — the same pair the live acquisition loop and the offline
#: depth replay use. A change to either call, or to the chain they drive, must change
#: this string or two different results would share one cache entry.
ALGORITHM_VERSION = "flow-hhd-contact-1"
#: Unit of ``dx``/``dy``/``magnitude``: UV pixels of arrow-tip displacement, the same
#: unit :class:`~orisys_app_shared.scene3d.visual_policy.FlowSamples` documents.
UNIT = "uv_px"
#: ``frame_id``, ``t_rel_s``, grid keyframe id, flags, candidate count, depth statistic.
#: The four trailing pad bytes keep the value arrays 8-byte aligned inside a record.
FRAME_HEADER = struct.Struct("<idHBHI3x")
FRAME_FLAG_VALIDITY = 0x01
FRAME_FLAG_DROPPED = 0x02
#: magic, kind, codec, count, height, width, flags, reserved, raw bytes, digest
KEYFRAME_HEADER = struct.Struct("<4sBBIIIBBQ8s")
KEYFRAME_MAGIC = b"ORAK"
KEYFRAME_GRID = 0
KEYFRAME_MASK = 1
CODEC_RAW = 0
CODEC_ZLIB = 1
#: magic, version, reserved, chunk index, frame count, header size
CHUNK_HEADER = struct.Struct("<4sHHIII")
CHUNK_MAGIC = b"ORAC"
#: offset-table offset, frame count, magic
CHUNK_TRAILER = struct.Struct("<QI4s")
def digest8(*parts: bytes) -> bytes:
"""An 8-byte blake2b over the concatenation of ``parts``."""
hasher = hashlib.blake2b(digest_size=8)
for part in parts:
hasher.update(part)
return hasher.digest()
def canonical_digest8(payload: Any) -> bytes:
"""An 8-byte digest of a JSON-representable structure, independent of key order."""
text = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
return digest8(text.encode("utf-8"))
# --------------------------------------------------------------------------- the key
@dataclass(frozen=True)
class ResultKey:
"""The five facts that decide whether a cached result still describes this input.
Display settings are absent on purpose: they are a property of the viewer, not of
the measurement, and the record keeps enough to serve a differently-thresholded
view (see the module docstring).
"""
recording: str
algorithm: str
sensor_config: str
calibration: str
reference_frame: int | None
@property
def components(self) -> dict[str, str]:
return {
"recording": self.recording,
"algorithm": self.algorithm,
"sensor_config": self.sensor_config,
"calibration": self.calibration,
"reference_frame": ""
if self.reference_frame is None
else str(self.reference_frame),
}
@property
def digest(self) -> str:
"""The directory name. Short because it is a name, not a security boundary."""
return canonical_digest8(self.components).hex()
def result_key(
*,
recording: str,
sensor_config: str,
calibration: str,
reference_frame: int | None = None,
algorithm: str = ALGORITHM_VERSION,
) -> ResultKey:
"""Build the invalidation key. A changed argument is a different key, by construction."""
return ResultKey(
recording=str(recording),
algorithm=str(algorithm),
sensor_config=str(sensor_config),
calibration=str(calibration),
reference_frame=None if reference_frame is None else int(reference_frame),
)
def recording_identity(
session: Mapping[str, Any], segments: Sequence[tuple[str, int]]
) -> str:
"""Identity of one recorded session, from its own metadata and segment sizes.
Deliberately not the directory path: a copied recording directory is the same
recording, and a re-used path is not. Segment *sizes* are included so a truncated
or extended ``raw_*.mp4`` cannot inherit an earlier result; modification times are
not, because copying a tree rewrites them.
"""
device = dict(session.get("device") or {})
stats = dict(session.get("stats") or {})
payload = {
"session_id": session.get("session_id"),
"started_at": session.get("started_at"),
"ended_at": session.get("ended_at"),
"sensor_type": device.get("sensor_type"),
"config_ref": device.get("config_ref"),
"config_sha256": device.get("config_sha256"),
"frames": stats.get("frames"),
"video_frames": stats.get("video_frames"),
"segments": stats.get("segments"),
"files": sorted((str(name), int(size)) for name, size in segments),
}
return canonical_digest8(payload).hex()
# ------------------------------------------------------------------- frame encoding
def encode_frame(
*,
frame_id: int,
t_rel_s: float,
grid_id: int,
dx: np.ndarray,
dy: np.ndarray,
magnitude: np.ndarray,
active: np.ndarray,
validity_digest: bytes | None = None,
depth_nonzero: int = 0,
dropped: bool = False,
) -> bytes:
"""One frame record: a fixed header, then the value arrays.
Arrays are little-endian regardless of host order, so a result written on one
machine reads back identically on another. ``dx``/``dy``/``magnitude`` are stored
as float32 — the precision the display needs and the reason a stored payload is
compared to a live selection *cast to float32* rather than in float64.
"""
dx = np.ascontiguousarray(dx, dtype="<f4").reshape(-1)
dy = np.ascontiguousarray(dy, dtype="<f4").reshape(-1)
magnitude = np.ascontiguousarray(magnitude, dtype="<f4").reshape(-1)
active = np.ascontiguousarray(active, dtype=bool).reshape(-1)
count = int(dx.size)
if not (dy.size == magnitude.size == active.size == count):
raise ValueError(
"dx/dy/magnitude/active must have the same length, got "
f"{dx.size}/{dy.size}/{magnitude.size}/{active.size}"
)
if not 0 <= int(grid_id) <= 0xFFFF:
raise ValueError(f"grid id {grid_id} does not fit the frame header")
if count > 0xFFFF:
raise ValueError(f"{count} candidates do not fit the frame header")
flags = 0
if validity_digest is not None:
flags |= FRAME_FLAG_VALIDITY
if dropped:
flags |= FRAME_FLAG_DROPPED
parts = [
FRAME_HEADER.pack(
int(frame_id),
float(t_rel_s),
int(grid_id),
flags,
count,
max(0, int(depth_nonzero)),
),
dx.tobytes(),
dy.tobytes(),
magnitude.tobytes(),
np.packbits(active).tobytes(),
]
if validity_digest is not None:
digest = bytes(validity_digest)
if len(digest) != 8:
raise ValueError(f"validity digest must be 8 bytes, got {len(digest)}")
parts.append(digest)
return b"".join(parts)
@dataclass(frozen=True)
class DecodedFrame:
"""The raw fields of one frame record, before the grid keyframe is applied."""
frame_id: int
t_rel_s: float
grid_id: int
count: int
depth_nonzero: int
dropped: bool
dx: np.ndarray
dy: np.ndarray
magnitude: np.ndarray
active: np.ndarray
validity_digest: bytes | None
def decode_frame(
buffer: bytes | bytearray | memoryview, offset: int = 0
) -> DecodedFrame:
"""Decode one frame record starting at ``offset``. Ignores any trailing bytes."""
view = memoryview(buffer)
frame_id, t_rel, grid_id, flags, count, depth_nonzero = FRAME_HEADER.unpack_from(
view, offset
)
cursor = offset + FRAME_HEADER.size
arrays = []
for _ in range(3):
end = cursor + count * 4
arrays.append(np.frombuffer(view[cursor:end], dtype="<f4").copy())
cursor = end
bits_end = cursor + (count + 7) // 8
active = np.unpackbits(
np.frombuffer(view[cursor:bits_end], dtype=np.uint8), count=count
)
cursor = bits_end
validity = None
if flags & FRAME_FLAG_VALIDITY:
validity = bytes(view[cursor : cursor + 8])
return DecodedFrame(
frame_id=int(frame_id),
t_rel_s=float(t_rel),
grid_id=int(grid_id),
count=int(count),
depth_nonzero=int(depth_nonzero),
dropped=bool(flags & FRAME_FLAG_DROPPED),
dx=arrays[0],
dy=arrays[1],
magnitude=arrays[2],
active=active.astype(bool),
validity_digest=validity,
)
def frame_payload_bytes(candidates: int, *, with_validity: bool = True) -> int:
"""Stored bytes of one frame record at ``candidates`` sample locations."""
total = FRAME_HEADER.size + int(candidates) * 12 + (int(candidates) + 7) // 8
return total + 8 if with_validity else total
def keyframe_payload(kind: int, array: np.ndarray) -> bytes:
"""The uncompressed payload of a grid or mask keyframe.
A grid is ``rows`` (int16) followed by ``cols`` (int16); a mask is bit-packed
along its flattened order. Both are the exact bytes the digest is taken over, so
a reader can verify a keyframe before trusting it.
"""
if kind == KEYFRAME_GRID:
return array.astype("<i2").tobytes()
if kind == KEYFRAME_MASK:
return np.packbits(np.asarray(array, dtype=bool).reshape(-1)).tobytes()
raise ValueError(f"unknown keyframe kind {kind}")
def unpack_mask(payload: bytes, height: int, width: int) -> np.ndarray:
"""Bit-packed mask payload back to a ``(height, width)`` boolean array."""
count = int(height) * int(width)
bits = np.unpackbits(np.frombuffer(payload, dtype=np.uint8), count=count)
return bits.astype(bool).reshape(int(height), int(width))
# ------------------------------------------------------------------- decoded frames
@dataclass(frozen=True)
class FramePayload:
"""One analysed frame, as the reader serves it.
``rows``/``cols`` come from the frame's grid keyframe, so they are identical for
every frame that shares one; ``dx``/``dy``/``magnitude``/``active`` are this
frame's own values. Arrays are float32 and int32 because that is what is stored —
a comparison against a live selection must cast to float32 to be exact.
"""
frame_id: int
t_rel_s: float
grid_id: int
rows: np.ndarray
cols: np.ndarray
dx: np.ndarray
dy: np.ndarray
magnitude: np.ndarray
active: np.ndarray
validity_digest: bytes | None = None
depth_nonzero: int = 0
dropped: bool = False
@property
def candidates(self) -> int:
return int(self.rows.size)
@property
def active_count(self) -> int:
return int(np.count_nonzero(self.active))
def samples(self, *, threshold: float | None = None) -> FlowSamples:
"""This frame as the shared selector's own type.
``threshold=None`` returns the activity the pass recorded. Any other value
re-derives it from ``magnitude``, which is what lets a display-settings change
be served without invalidating the result.
"""
active = self.active
if threshold is not None:
active = np.asarray(self.magnitude >= float(threshold), dtype=bool)
return FlowSamples(
rows=self.rows.copy(),
cols=self.cols.copy(),
dx=self.dx.copy(),
dy=self.dy.copy(),
magnitude=self.magnitude.copy(),
active=active.copy(),
)
def canonical_bytes(self) -> bytes:
"""A byte image of the payload, for exact comparison between two results."""
return encode_frame(
frame_id=self.frame_id,
t_rel_s=self.t_rel_s,
grid_id=self.grid_id,
dx=self.dx,
dy=self.dy,
magnitude=self.magnitude,
active=self.active,
validity_digest=self.validity_digest,
depth_nonzero=self.depth_nonzero,
dropped=self.dropped,
)
@dataclass(frozen=True)
class KeyframeRef:
"""Inventory entry for one keyframe blob inside ``keyframes.bin``."""
id: int
kind: str
digest: str
shape: tuple[int, ...]
codec: str
raw_bytes: int
stored_bytes: int
offset: int
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"kind": self.kind,
"digest": self.digest,
"shape": list(self.shape),
"codec": self.codec,
"raw_bytes": self.raw_bytes,
"stored_bytes": self.stored_bytes,
"offset": self.offset,
}
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> KeyframeRef:
return cls(
id=int(data["id"]),
kind=str(data["kind"]),
digest=str(data["digest"]),
shape=tuple(int(v) for v in data.get("shape") or ()),
codec=str(data["codec"]),
raw_bytes=int(data["raw_bytes"]),
stored_bytes=int(data["stored_bytes"]),
offset=int(data["offset"]),
)
# ------------------------------------------------------------------- provenance
@dataclass
class Provenance:
"""Everything needed to say what a stored result is and what it is not.
``complete`` is duplicated from the result's top-level metadata on purpose: one is
the gate a reader checks, the other travels with the record. They are written from
a single source and compared on read, so they cannot disagree silently.
"""
format_version: int = ANALYSIS_FORMAT_VERSION
algorithm: str = ALGORITHM_VERSION
unit: str = UNIT
key: str = ""
key_components: dict[str, str] = field(default_factory=dict)
recording: str = ""
recording_identity: str = ""
session_id: int = 0
sensor_type: str = ""
chain: str = ""
sensor_config_ref: str = ""
sensor_config_sha256: str = ""
calibration_id: str = ""
#: The frame whose processing started from a clean tracker reference. Equal to
#: ``first_output_frame_id`` when no explicit reset was requested.
reference_frame_id: int | None = None
reference_is_explicit: bool = False
first_output_frame_id: int | None = None
last_output_frame_id: int | None = None
#: Decoded frames the chain consumed *before* the first frame a caller can read.
#: Measured per segment; ``None`` when the checked segments disagree.
decode_order_offset: int | None = None
decode_order_offsets: dict[str, int] = field(default_factory=dict)
#: ``measured`` when the offset came from the capture's own decode position,
#: ``declared`` when the backend could not report one and the chain's constant was
#: used instead. A result built on a declared offset is weaker evidence.
decode_order_offset_source: str = "measured"
segments: list[int] = field(default_factory=list)
segments_declared: int = 0
segments_missing: list[int] = field(default_factory=list)
#: The selection policy used while recording samples. Not part of the key.
policy: dict[str, Any] = field(default_factory=dict)
#: Per channel: ``recomputed`` (re-run from the video) or ``recorded`` (read from a
#: CSV the recorder wrote). No channel is both.
sources: dict[str, str] = field(default_factory=dict)
versions: dict[str, str] = field(default_factory=dict)
config: dict[str, Any] = field(default_factory=dict)
open_working_directory: str = ""
debug_files_written: list[str] = field(default_factory=list)
started_at: str = ""
finished_at: str = ""
elapsed_s: float = 0.0
#: Wall-clock cost of opening the SDK chain, summed over segments. It is separate
#: from ``elapsed_s`` because a box open spends 1.6 s before the first frame and
#: that time is not throughput.
open_seconds: float = 0.0
warnings: list[str] = field(default_factory=list)
complete: bool = False
stopped_reason: str = ""
counts: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return dict(self.__dict__)
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> Provenance:
known = {f for f in cls().__dict__}
return cls(**{key: value for key, value in dict(data).items() if key in known})
@@ -0,0 +1,567 @@
"""Reading a stored result: by frame id, by timestamp, and nothing else.
A seek must not re-run the pass. Two levels of index make that true:
* ``meta.json`` lists the chunks in order with their first and last frame id, so a
frame id picks a chunk by binary search;
* each chunk ends with a trailer holding the frame's own byte offset, frame id and
timestamp, so a position inside that chunk is another binary search and one read.
The trailer is read the first time a chunk is needed and then cached, which costs
20 bytes per frame of memory — 360 KB for a ten-minute recording at 30 fps. Everything
else (the sample grid, the validity mask) is loaded from its keyframe on demand and
verified against the digest recorded for it.
"""
from __future__ import annotations
import json
import logging
import zlib
from collections import OrderedDict
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
from orisys_app_shared.analysis.cache import INCOMPLETE_MARKER, META_NAME
from orisys_app_shared.analysis.model import (
ANALYSIS_FORMAT,
ANALYSIS_FORMAT_VERSION,
CHUNK_HEADER,
CHUNK_MAGIC,
CHUNK_TRAILER,
CODEC_RAW,
CODEC_ZLIB,
KEYFRAME_GRID,
KEYFRAME_HEADER,
KEYFRAME_MAGIC,
KEYFRAME_MASK,
FramePayload,
KeyframeRef,
Provenance,
decode_frame,
digest8,
unpack_mask,
)
from orisys_app_shared.scene3d.visual_policy import FlowSamples
logger = logging.getLogger(__name__)
__all__ = [
"AnalysisFormatError",
"AnalysisIncompleteError",
"AnalysisNotFoundError",
"AnalysisReader",
"AnalysisResultError",
]
#: How many keyframe payloads to keep decoded. One grid and one mask is the norm; the
#: box chain needs more, and re-reading a 1.3 KB blob is cheap when it falls out.
_GRID_CACHE = 16
class AnalysisResultError(RuntimeError):
"""Base for every reason a stored result cannot be served."""
class AnalysisNotFoundError(AnalysisResultError):
"""There is no result at that path."""
class AnalysisIncompleteError(AnalysisResultError):
"""The result exists but was cancelled, failed, or never finished.
Raised by default rather than served with a warning: a partial result that looks
finished is worse than no result, because every chart drawn from it would be
missing an unknown number of frames without saying so.
"""
class AnalysisFormatError(AnalysisResultError):
"""The result is not in a format this build can read, or it is corrupt."""
@dataclass
class _ChunkIndex:
"""One chunk's trailer, loaded on first use."""
index: int
path: Path
offsets: np.ndarray # uint64, per frame, relative to the file start
frame_ids: np.ndarray # int32
times: np.ndarray # float64
sizes: np.ndarray # uint64, per frame record length
first_frame_id: int
last_frame_id: int
class AnalysisReader:
"""Random access into one analysis result directory."""
def __init__(
self, directory: str | Path, *, allow_incomplete: bool = False
) -> None:
self.directory = Path(directory)
self.allow_incomplete = bool(allow_incomplete)
meta_path = self.directory / META_NAME
if not meta_path.is_file():
raise AnalysisNotFoundError(f"no analysis result at {self.directory}")
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise AnalysisFormatError(f"{meta_path} is unreadable: {exc}") from exc
if str(meta.get("format") or "") != ANALYSIS_FORMAT:
raise AnalysisFormatError(
f"{meta_path} is not an {ANALYSIS_FORMAT} result "
f"(format={meta.get('format')!r})"
)
version = int(meta.get("format_version") or 0)
if version != ANALYSIS_FORMAT_VERSION:
raise AnalysisFormatError(
f"result format version {version} cannot be read by this build "
f"(it reads {ANALYSIS_FORMAT_VERSION})"
)
self.meta: dict[str, Any] = dict(meta)
self.key: str = str(meta.get("key") or self.directory.name)
self.key_components: dict[str, str] = dict(meta.get("key_components") or {})
self.provenance = Provenance.from_dict(meta.get("provenance") or {})
marker = self.directory / INCOMPLETE_MARKER
declared = bool(meta.get("complete"))
if declared != bool(self.provenance.complete):
raise AnalysisFormatError(
f"{meta_path} disagrees with its own provenance about completeness"
)
if marker.exists() or not declared:
if not self.allow_incomplete:
reason = self.provenance.stopped_reason or "unfinished"
raise AnalysisIncompleteError(
f"{self.directory} is not a complete result ({reason}); "
"pass allow_incomplete=True only to inspect a partial one"
)
self.complete = False
else:
self.complete = True
self.counts: dict[str, Any] = dict(meta.get("counts") or {})
self.keyframes: dict[int, KeyframeRef] = {
item.id: item
for item in (
KeyframeRef.from_dict(entry) for entry in meta.get("keyframes") or []
)
}
self.chunks: list[dict[str, Any]] = [
dict(entry) for entry in meta.get("chunks") or []
]
self._chunk_index: dict[int, _ChunkIndex] = {}
self._mask_cache: np.ndarray | None = None
self._mask_loaded = False
self._grid_cache: OrderedDict[int, tuple[np.ndarray, np.ndarray]] = (
OrderedDict()
)
self._handle = None
self._handle_chunk = -1
# ------------------------------------------------------------------- lifecycle
def close(self) -> None:
if self._handle is not None:
try:
self._handle.close()
finally:
self._handle = None
self._handle_chunk = -1
def __enter__(self) -> AnalysisReader:
return self
def __exit__(self, exc_type, exc, tb) -> bool:
self.close()
return False
# -------------------------------------------------------------------- indexing
def __len__(self) -> int:
return int(sum(int(entry.get("frames") or 0) for entry in self.chunks))
@property
def frame_ids(self) -> np.ndarray:
"""Every stored frame id, in stored order."""
parts = [self._chunk(chunk["index"]).frame_ids for chunk in self.chunks]
if not parts:
return np.empty(0, dtype=np.int64)
return np.concatenate(parts).astype(np.int64)
@property
def times(self) -> np.ndarray:
"""Every stored ``t_rel_s``, in stored order."""
parts = [self._chunk(chunk["index"]).times for chunk in self.chunks]
if not parts:
return np.empty(0, dtype=np.float64)
return np.concatenate(parts)
@property
def frame_record_bytes(self) -> int:
"""Stored bytes of the frame records alone.
Excludes the chunk offset tables, the keyframes and the metadata, so a caller can
compare it with :func:`~orisys_app_shared.analysis.model.frame_payload_bytes`
instead of guessing what the overhead was.
"""
return int(
sum(int(self._chunk(entry["index"]).sizes.sum()) for entry in self.chunks)
)
@property
def bytes_per_frame(self) -> float:
"""Frame-record bytes divided by frames — the number to quote per frame."""
count = len(self)
return 0.0 if not count else self.frame_record_bytes / count
@property
def keyframe_counts(self) -> dict[str, int]:
"""How many keyframes of each kind the result holds."""
counts: dict[str, int] = {}
for reference in self.keyframes.values():
counts[reference.kind] = counts.get(reference.kind, 0) + 1
return counts
def _chunk(self, index: int) -> _ChunkIndex:
cached = self._chunk_index.get(index)
if cached is not None:
return cached
entry = next(
(item for item in self.chunks if int(item.get("index", -1)) == int(index)),
None,
)
if entry is None:
raise AnalysisFormatError(f"chunk {index} is not in the result inventory")
path = self.directory / str(entry["file"])
if not path.is_file():
raise AnalysisFormatError(f"chunk file {path} is missing")
data = path.read_bytes()
if len(data) < CHUNK_HEADER.size + CHUNK_TRAILER.size:
raise AnalysisFormatError(f"{path} is shorter than a chunk header")
magic, version, _reserved, chunk_index, count, header_size = (
CHUNK_HEADER.unpack_from(data)
)
if magic != CHUNK_MAGIC:
raise AnalysisFormatError(f"{path} is not a frame chunk")
if version != ANALYSIS_FORMAT_VERSION or int(chunk_index) != int(index):
raise AnalysisFormatError(
f"{path} declares version {version} chunk {chunk_index}, expected "
f"{ANALYSIS_FORMAT_VERSION} and {index}"
)
declared = int(entry.get("frames") or 0)
if declared != int(count):
raise AnalysisFormatError(
f"{path} holds {count} frames, the inventory says {declared}"
)
table_offset, trailer_count, trailer_magic = CHUNK_TRAILER.unpack_from(
data, len(data) - CHUNK_TRAILER.size
)
if trailer_magic != CHUNK_MAGIC or int(trailer_count) != int(count):
raise AnalysisFormatError(f"{path} has a damaged trailer")
offsets = np.frombuffer(
data, dtype="<u8", count=count, offset=int(table_offset)
).copy()
cursor = int(table_offset) + count * 8
frame_ids = np.frombuffer(data, dtype="<i4", count=count, offset=cursor).copy()
cursor += count * 4
times = np.frombuffer(data, dtype="<f8", count=count, offset=cursor).copy()
if int(header_size) > int(table_offset):
raise AnalysisFormatError(
f"{path} declares a header past its own frame data"
)
sizes = np.empty(count, dtype=np.uint64)
if count:
sizes[:-1] = offsets[1:] - offsets[:-1]
sizes[-1] = np.uint64(int(table_offset) - int(offsets[-1]))
record = _ChunkIndex(
index=int(index),
path=path,
offsets=offsets,
frame_ids=frame_ids,
times=times,
sizes=sizes,
first_frame_id=int(frame_ids[0]) if count else 0,
last_frame_id=int(frame_ids[-1]) if count else 0,
)
self._chunk_index[int(index)] = record
return record
def _chunk_for_frame(self, frame_id: int) -> _ChunkIndex | None:
"""The chunk holding ``frame_id``.
The inventory is ordered by frame id for every result this code writes, so the
binary search answers in ``log(chunks)``. A hand-edited or foreign result whose
ranges are not monotonic falls back to a scan rather than answering with the
wrong chunk.
"""
low, high = 0, len(self.chunks) - 1
while low <= high:
middle = (low + high) // 2
record = self._chunk(int(self.chunks[middle]["index"]))
if not len(record.frame_ids):
low = middle + 1
continue
if frame_id < record.first_frame_id:
high = middle - 1
elif frame_id > record.last_frame_id:
low = middle + 1
else:
return record
for entry in self.chunks:
record = self._chunk(int(entry["index"]))
if len(record.frame_ids) and bool(
np.any(record.frame_ids == int(frame_id))
):
return record
return None
# ------------------------------------------------------------------ keyframes
def _keyframe_payload(self, reference: KeyframeRef) -> bytes:
path = self.directory / "keyframes.bin"
if not path.is_file():
raise AnalysisFormatError("keyframes.bin is missing from the result")
with path.open("rb") as handle:
handle.seek(reference.offset)
header = handle.read(KEYFRAME_HEADER.size)
if len(header) != KEYFRAME_HEADER.size:
raise AnalysisFormatError(f"keyframe {reference.id} is truncated")
(
magic,
kind,
codec,
count,
height,
width,
_flags,
_reserved,
raw_bytes,
digest,
) = KEYFRAME_HEADER.unpack(header)
if magic != KEYFRAME_MAGIC:
raise AnalysisFormatError(f"keyframe {reference.id} has no magic")
stored = handle.read(reference.stored_bytes)
if len(stored) != reference.stored_bytes:
raise AnalysisFormatError(f"keyframe {reference.id} is truncated")
payload = zlib.decompress(stored) if codec == CODEC_ZLIB else stored
if len(payload) != int(raw_bytes):
raise AnalysisFormatError(
f"keyframe {reference.id} decoded to {len(payload)} bytes, "
f"its header says {raw_bytes}"
)
if digest8(payload).hex() != reference.digest:
raise AnalysisFormatError(
f"keyframe {reference.id} does not match its recorded digest"
)
expected_kind = {KEYFRAME_GRID: "grid", KEYFRAME_MASK: "mask"}.get(kind)
if expected_kind != reference.kind:
raise AnalysisFormatError(
f"keyframe {reference.id} is a {expected_kind}, the inventory says "
f"{reference.kind}"
)
if codec not in (CODEC_RAW, CODEC_ZLIB):
raise AnalysisFormatError(f"keyframe {reference.id} uses codec {codec}")
declared = (int(height), int(width)) if kind == KEYFRAME_MASK else (int(count),)
if reference.kind == "grid" and declared != reference.shape:
raise AnalysisFormatError(
f"keyframe {reference.id} shape disagrees with it"
)
return payload
def grid(self, grid_id: int) -> tuple[np.ndarray, np.ndarray]:
"""The candidate locations of one grid keyframe, as ``(rows, cols)`` int32."""
cached = self._grid_cache.get(grid_id)
if cached is not None:
self._grid_cache.move_to_end(grid_id)
return cached
reference = self.keyframes.get(int(grid_id))
if reference is None or reference.kind != "grid":
raise AnalysisFormatError(f"no grid keyframe {grid_id} in this result")
payload = self._keyframe_payload(reference)
count = int(reference.shape[0]) if reference.shape else 0
if len(payload) != count * 4:
raise AnalysisFormatError(
f"grid keyframe {grid_id} holds {len(payload)} bytes for {count} locations"
)
rows = np.frombuffer(payload, dtype="<i2", count=count, offset=0).astype(
np.int32
)
cols = np.frombuffer(
payload, dtype="<i2", count=count, offset=count * 2
).astype(np.int32)
rows.flags.writeable = False
cols.flags.writeable = False
self._grid_cache[grid_id] = (rows, cols)
if len(self._grid_cache) > _GRID_CACHE:
self._grid_cache.popitem(last=False)
return rows, cols
def mask(self) -> np.ndarray | None:
"""The validity mask this result was built with, or ``None`` when there was none."""
if self._mask_loaded:
return self._mask_cache
self._mask_loaded = True
self._mask_cache = None
for reference in self.keyframes.values():
if reference.kind != "mask":
continue
payload = self._keyframe_payload(reference)
height = int(reference.shape[0]) if len(reference.shape) > 0 else 0
width = int(reference.shape[1]) if len(reference.shape) > 1 else 0
self._mask_cache = unpack_mask(payload, height, width)
break
return self._mask_cache
@property
def mask_digest(self) -> str | None:
for reference in self.keyframes.values():
if reference.kind == "mask":
return reference.digest
return None
# ----------------------------------------------------------------- frame access
def _read_record(self, chunk: _ChunkIndex, slot: int) -> bytes:
if self._handle_chunk != chunk.index or self._handle is None:
if self._handle is not None:
self._handle.close()
self._handle = chunk.path.open("rb")
self._handle_chunk = chunk.index
self._handle.seek(int(chunk.offsets[slot]))
return self._handle.read(int(chunk.sizes[slot]))
def _slot_for(self, chunk: _ChunkIndex, frame_id: int) -> int:
ids = chunk.frame_ids
if ids.size and bool(np.all(np.diff(ids) > 0)):
return int(np.searchsorted(ids, int(frame_id)))
hits = np.flatnonzero(ids == int(frame_id))
if not hits.size:
raise AnalysisNotFoundError(
f"frame {frame_id} is not in chunk {chunk.path.name}"
)
return int(hits[0])
def has(self, frame_id: int) -> bool:
return self._chunk_for_frame(int(frame_id)) is not None
def frame(self, frame_id: int) -> FramePayload:
"""One frame by its recorded id. Random access; no replay of the pass."""
chunk = self._chunk_for_frame(int(frame_id))
if chunk is None:
raise AnalysisNotFoundError(f"frame {frame_id} is not in this result")
slot = self._slot_for(chunk, int(frame_id))
decoded = decode_frame(self._read_record(chunk, slot))
if decoded.frame_id != int(frame_id):
raise AnalysisFormatError(
f"frame {frame_id} resolved to a record for {decoded.frame_id}"
)
rows, cols = self.grid(decoded.grid_id)
if rows.size != decoded.count:
raise AnalysisFormatError(
f"frame {frame_id} has {decoded.count} values but grid "
f"{decoded.grid_id} has {rows.size} locations"
)
return FramePayload(
frame_id=decoded.frame_id,
t_rel_s=decoded.t_rel_s,
grid_id=decoded.grid_id,
rows=rows,
cols=cols,
dx=decoded.dx,
dy=decoded.dy,
magnitude=decoded.magnitude,
active=decoded.active,
validity_digest=decoded.validity_digest,
depth_nonzero=decoded.depth_nonzero,
dropped=decoded.dropped,
)
def frame_at(self, t_rel_s: float) -> FramePayload | None:
"""The stored frame at or just before ``t_rel_s``, or ``None`` for an empty result.
Uses the recording's own time base, which is *signal* time: the container
duration and the recorded duration are different numbers and neither is
derived from the other.
"""
times = self.times
if times.size == 0:
return None
if bool(np.all(np.diff(times) >= 0)):
slot = int(np.searchsorted(times, float(t_rel_s), side="right")) - 1
slot = max(0, min(slot, times.size - 1))
else:
candidates = np.flatnonzero(times <= float(t_rel_s))
slot = int(candidates[-1]) if candidates.size else 0
ids = self.frame_ids
return self.frame(int(ids[slot]))
def samples(self, frame_id: int, *, policy: Any | None = None) -> FlowSamples:
"""The frame's flow samples, as the shared selector would return them.
A ``policy`` is honoured only for its activation threshold: the candidate
*locations* are the ones recorded, because those are what the pass selected.
Change the grid and this result no longer describes the view — compare
:attr:`provenance.policy` rather than expecting the reader to re-grid.
"""
threshold = None if policy is None else float(policy.normalised().flow_on_px)
return self.frame(int(frame_id)).samples(threshold=threshold)
def frames(
self, *, start: int | None = None, stop: int | None = None
) -> Iterator[FramePayload]:
"""Frames in stored order, optionally bounded by frame id (both inclusive)."""
for entry in self.chunks:
chunk = self._chunk(int(entry["index"]))
for slot in range(len(chunk.frame_ids)):
frame_id = int(chunk.frame_ids[slot])
if start is not None and frame_id < int(start):
continue
if stop is not None and frame_id > int(stop):
continue
yield self.frame(frame_id)
# ------------------------------------------------------------------- freshness
def stale_reasons(
self,
*,
sensor_config: str = "",
algorithm: str = "",
calibration: str = "",
sdk_version: str = "",
) -> list[str]:
"""Why this result may no longer describe what would be computed now.
The key covers the recording, the algorithm, the sensor configuration, the
calibration and the reference frame. The SDK version is deliberately **not** in
the key — a patch release does not change the key, so it would otherwise be
served silently. It is reported here instead, next to the caller's other
warnings, exactly as the replay already reports a changed recorded config.
"""
reasons: list[str] = []
recorded = self.key_components
if (
sensor_config
and recorded.get("sensor_config")
and sensor_config != recorded["sensor_config"]
):
reasons.append("sensor_config_changed")
if (
algorithm
and recorded.get("algorithm")
and algorithm != recorded["algorithm"]
):
reasons.append("algorithm_changed")
if (
calibration
and recorded.get("calibration")
and calibration != recorded["calibration"]
):
reasons.append("calibration_changed")
if sdk_version:
stored = str(self.provenance.versions.get("sdk") or "")
if stored and stored != sdk_version:
reasons.append("sdk_changed")
if not self.complete:
reasons.append("incomplete")
return reasons
File diff suppressed because it is too large Load Diff