Store the depth channel in an analysis result and serve it to the 2D depth view
A replay could draw arrows but not depth, and the recording view said there was nothing to draw even when a result existed. Depth is now quantised per frame (14 B empty, 486 B at the worst frame seen), read from the cache by the same draw path live uses, and the raw-recording note appears only when no result exists. Format version 2; see apps/studio/docs/replay-analysis.md.
This commit is contained in:
@@ -186,6 +186,13 @@ class ImageFrame:
|
||||
flow_frame_id: int | None = None
|
||||
depth_valid: bool | None = None
|
||||
depth_frame_id: int | None = None
|
||||
#: True when a stored **analysis result** exists for this recording, so the derived
|
||||
#: views have a frame of their own to draw even when *this* frame is the recording's
|
||||
#: raw picture. It is what keeps the panel from telling the operator there is nothing
|
||||
#: to draw while a result is sitting on disk: the modes are offered, and the view
|
||||
#: that needs derived data is served from the result. ``False`` means no result — the
|
||||
#: only case in which "a recording stores only the raw camera image" is the truth.
|
||||
result_available: bool = False
|
||||
|
||||
def has_flow(self) -> bool:
|
||||
if self.flow is None:
|
||||
@@ -243,16 +250,23 @@ class ImagePlan:
|
||||
|
||||
|
||||
def plan_image(frame: ImageFrame) -> ImagePlan:
|
||||
"""Derive the modes from the frame's *source* and its own fields.
|
||||
"""Derive the modes from the frame's *source*, its own fields, and any result behind it.
|
||||
|
||||
Two different questions, deliberately answered in two different places:
|
||||
|
||||
* **which views may the operator choose?** For a source that carries derived fields
|
||||
(live, recompute) all of them — a single dropped frame must not silently switch
|
||||
the operator's view. For a source that never does (a recording, a camera preview)
|
||||
exactly one, because offering the others would be offering a lie;
|
||||
(live, recompute, analysis) all of them — a single dropped frame must not silently
|
||||
switch the operator's view. A recording offers them too *when a stored analysis
|
||||
result exists for it* (``result_available``): the recording's own picture carries
|
||||
no flow, but the result does, and the view that needs it is drawn from the result.
|
||||
With no result there is nothing behind the picture at all, so the raw frame is the
|
||||
only thing it can honestly be;
|
||||
* **what can this frame actually be drawn as?** ``missing`` says which field is
|
||||
absent, and the panel states it in one line.
|
||||
|
||||
The raw-recording note is therefore an *absence* statement and appears only in the
|
||||
one case where it is true: no result, nothing to draw, and the panel says which
|
||||
action produces one.
|
||||
"""
|
||||
derived = str(frame.source or SOURCE_LIVE) in DERIVED_SOURCES
|
||||
missing: list[str] = []
|
||||
@@ -260,7 +274,7 @@ def plan_image(frame: ImageFrame) -> ImagePlan:
|
||||
missing.append("flow")
|
||||
if not frame.has_depth():
|
||||
missing.append("depth")
|
||||
if not derived:
|
||||
if not derived and not frame.result_available:
|
||||
return ImagePlan(
|
||||
available_modes=(RECORDED,),
|
||||
default_mode=RECORDED,
|
||||
@@ -271,6 +285,19 @@ def plan_image(frame: ImageFrame) -> ImagePlan:
|
||||
stale=bool(frame.stale),
|
||||
note_key=RAW_NOTE_KEY,
|
||||
)
|
||||
if not derived:
|
||||
# A recording with a result waiting behind it. ``RECORDED`` stays the default —
|
||||
# the picture on screen *is* the recorded image until the operator asks for
|
||||
# something else — and nothing is claimed to be missing, because nothing is.
|
||||
return ImagePlan(
|
||||
available_modes=(RECORDED, *DERIVED_MODES),
|
||||
default_mode=RECORDED,
|
||||
missing=(),
|
||||
source=str(frame.source or SOURCE_LIVE),
|
||||
frame_id=int(frame.frame_id),
|
||||
t_rel_s=float(frame.t_rel_s),
|
||||
stale=bool(frame.stale),
|
||||
)
|
||||
default = ARROW_PURE if frame.has_flow() else (DEPTH if frame.has_depth() else ARROW_PURE)
|
||||
return ImagePlan(
|
||||
available_modes=DERIVED_MODES,
|
||||
|
||||
@@ -7,8 +7,10 @@ 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.
|
||||
image), which is 21.2 GB for ten minutes at 30 fps. The selected samples cost 4,048 B
|
||||
per frame, and the depth channel — which is all zeros on the great majority of frames —
|
||||
costs 14 B more when it is empty. The rest is recomputed — that is what makes the cache
|
||||
a cache.
|
||||
|
||||
The three modules have one job each:
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ properties matter more than speed:
|
||||
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
|
||||
``chunk_frames * frame_payload_bytes(max candidates, with_depth=True)`` plus one
|
||||
depth plane per frame that carries a non-empty one — 256 frames of the shipped
|
||||
finger grid is about 1.1 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.
|
||||
|
||||
@@ -5,7 +5,7 @@ only what a replay draws: the flow samples the shared selector returns, not the
|
||||
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.
|
||||
30 fps. The samples themselves cost 4,048 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:
|
||||
@@ -28,6 +28,28 @@ 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.
|
||||
|
||||
**Depth is stored, and it is stored quantised.** A frame's own ``depth_map`` is
|
||||
336,000 B (300 × 280 float32) — 6.0 GB per ten minutes at 30 fps, which is why the
|
||||
obvious encoding is out — and on the two real recordings it is empty far more often
|
||||
than not: 241 of 264 frames of the eight-times clip and 32 of 33 frames of the box
|
||||
recording carry a map that is *exactly* all zeros, with the worst non-empty frame at
|
||||
336 of 84,000 pixels. So the record states the depth channel's own statistics every
|
||||
frame (present, non-zero count, minimum, maximum) and appends a payload only for a
|
||||
frame that has one, as uint8 over that frame's own range, zlib'd at level 6. The
|
||||
measured cost is 14 B per empty frame and 486 B for the worst non-empty frame seen;
|
||||
the largest error that can be introduced is half a quantisation step,
|
||||
``(max - min) / 510``, measured at 6.6e-5 on the clip whose peak is 0.034. That is
|
||||
below half of one step of the 256-entry colour ramp the depth view paints with, so a
|
||||
reconstructed frame cannot be coloured differently from the original.
|
||||
|
||||
Sparse storage was measured and rejected: 4-byte indices per non-zero pixel cost
|
||||
910 B for the worst non-empty frame against 486 B for the whole quantised map, because
|
||||
a mostly-zero byte plane is nearly free for zlib while pixel indices are not. Island
|
||||
restriction was measured and rejected too — 418 B against 486 B, a saving of 5.2 B per
|
||||
frame of a 4,048 B record — because it would make the reconstruction depend on the
|
||||
validity-mask keyframe (the box chain has none) and would silently drop a depth value
|
||||
that fell outside the island. See ``tests/test_analysis_depth.py`` for the numbers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -35,6 +57,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import zlib
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
@@ -50,8 +73,12 @@ __all__ = [
|
||||
"CHUNK_HEADER",
|
||||
"CHUNK_MAGIC",
|
||||
"CHUNK_TRAILER",
|
||||
"CODEC_EMPTY",
|
||||
"CODEC_RAW",
|
||||
"CODEC_ZLIB",
|
||||
"DEPTH_BITS",
|
||||
"DEPTH_HEADER",
|
||||
"FRAME_FLAG_DEPTH",
|
||||
"FRAME_FLAG_DROPPED",
|
||||
"FRAME_FLAG_VALIDITY",
|
||||
"FRAME_HEADER",
|
||||
@@ -59,12 +86,16 @@ __all__ = [
|
||||
"KEYFRAME_HEADER",
|
||||
"KEYFRAME_MAGIC",
|
||||
"KEYFRAME_MASK",
|
||||
"MAX_DEPTH_PIXELS",
|
||||
"UNIT",
|
||||
"DecodedDepth",
|
||||
"FramePayload",
|
||||
"KeyframeRef",
|
||||
"Provenance",
|
||||
"ResultKey",
|
||||
"decode_depth",
|
||||
"decode_frame",
|
||||
"encode_depth",
|
||||
"encode_frame",
|
||||
"frame_payload_bytes",
|
||||
"keyframe_payload",
|
||||
@@ -78,8 +109,9 @@ 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.
|
||||
#: Version 2 adds the depth channel to the frame record (see the module docstring).
|
||||
ANALYSIS_FORMAT = "orisys-replay-analysis"
|
||||
ANALYSIS_FORMAT_VERSION = 1
|
||||
ANALYSIS_FORMAT_VERSION = 2
|
||||
|
||||
#: Identity of the analysis the pass runs: the SDK calls it makes, in order.
|
||||
#: ``compute_deformation(decompose=True, check_motion=False)`` then
|
||||
@@ -92,11 +124,16 @@ ALGORITHM_VERSION = "flow-hhd-contact-1"
|
||||
#: 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_id``, ``t_rel_s``, grid keyframe id, flags, candidate count, depth
|
||||
#: non-zero count, depth minimum, depth maximum. The three trailing pad bytes keep
|
||||
#: the value arrays 8-byte aligned inside a record.
|
||||
FRAME_HEADER = struct.Struct("<idHBHIff3x")
|
||||
FRAME_FLAG_VALIDITY = 0x01
|
||||
FRAME_FLAG_DROPPED = 0x02
|
||||
#: The chain exposed a depth map for this frame: the three depth statistics in the
|
||||
#: header describe it and a depth payload follows. Absent flag means *no depth
|
||||
#: channel at all*, which is a different fact from "the channel was all zeros".
|
||||
FRAME_FLAG_DEPTH = 0x04
|
||||
|
||||
#: magic, kind, codec, count, height, width, flags, reserved, raw bytes, digest
|
||||
KEYFRAME_HEADER = struct.Struct("<4sBBIIIBBQ8s")
|
||||
@@ -104,8 +141,22 @@ KEYFRAME_MAGIC = b"ORAK"
|
||||
KEYFRAME_GRID = 0
|
||||
KEYFRAME_MASK = 1
|
||||
|
||||
#: codec, bits per stored value, height, width. A depth payload is this header
|
||||
#: followed by its stored bytes — none at all for :data:`CODEC_EMPTY`.
|
||||
DEPTH_HEADER = struct.Struct("<BBHH")
|
||||
DEPTH_BITS = 8
|
||||
|
||||
CODEC_RAW = 0
|
||||
CODEC_ZLIB = 1
|
||||
#: The map is exactly all zeros: ``height × width`` of them, and no payload. This is
|
||||
#: the common case by a wide margin, and saying so explicitly is what lets a reader
|
||||
#: explain an empty depth view instead of drawing a blank one.
|
||||
CODEC_EMPTY = 2
|
||||
|
||||
#: Refuse a depth header that asks for an implausible array before allocating it. The
|
||||
#: shipped grid is 300 × 280; this leaves room for a far larger sensor and no room for
|
||||
#: a corrupt header to become a 4 GB allocation.
|
||||
MAX_DEPTH_PIXELS = 1 << 26
|
||||
|
||||
#: magic, version, reserved, chunk index, frame count, header size
|
||||
CHUNK_HEADER = struct.Struct("<4sHHIII")
|
||||
@@ -207,6 +258,188 @@ def recording_identity(
|
||||
return canonical_digest8(payload).hex()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- depth channel
|
||||
def _depth_plane(depth: Any) -> np.ndarray:
|
||||
"""A depth map as a 2-D float32 array, whatever leading singletons it arrives with.
|
||||
|
||||
The finger chain hands ``depth_map`` over as ``(1, H, W)`` — a batch axis nobody
|
||||
batches — and the validity mask it belongs beside is ``(H, W)``. Squeezing only
|
||||
leading singletons keeps a genuinely 3-D field (a future multi-plane depth) an
|
||||
error rather than something silently reinterpreted.
|
||||
"""
|
||||
array = np.asarray(depth, dtype=np.float32)
|
||||
while array.ndim > 2 and array.shape[0] == 1:
|
||||
array = array[0]
|
||||
if array.ndim != 2:
|
||||
raise ValueError(f"a depth map is 2-D, got shape {np.shape(depth)}")
|
||||
return np.ascontiguousarray(array)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecodedDepth:
|
||||
"""One frame's depth channel: how it was stored, and the statistics that describe it."""
|
||||
|
||||
codec: int
|
||||
height: int
|
||||
width: int
|
||||
nonzero: int
|
||||
minimum: float
|
||||
maximum: float
|
||||
#: The stored bytes below the header — empty for :data:`CODEC_EMPTY`, and the
|
||||
#: quantised plane decompressed for the other codecs.
|
||||
stored: bytes = b""
|
||||
#: How many bytes those were on disk, before decompression. Kept separate from
|
||||
#: ``len(stored)`` because the compressed and the decoded length are different
|
||||
#: numbers and only one of them is what the record costs.
|
||||
stored_bytes: int = 0
|
||||
|
||||
@property
|
||||
def pixels(self) -> int:
|
||||
return int(self.height) * int(self.width)
|
||||
|
||||
@property
|
||||
def payload_bytes(self) -> int:
|
||||
"""Every byte this frame's depth channel occupies, header included."""
|
||||
return DEPTH_HEADER.size + int(self.stored_bytes)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.codec == CODEC_EMPTY
|
||||
|
||||
|
||||
def _quantise(depth: np.ndarray) -> tuple[np.ndarray, float, float]:
|
||||
"""uint8 codes over the array's own ``[min, max]``, plus that range.
|
||||
|
||||
The range is the frame's own rather than a session-wide one on purpose: a depth
|
||||
response that is large on one frame and small on the next keeps its full 256 levels
|
||||
either way. The price is that the range has to travel with the frame, which is why
|
||||
it lives in the frame header (8 B) instead of in each payload.
|
||||
"""
|
||||
low = float(depth.min())
|
||||
high = float(depth.max())
|
||||
span = high - low
|
||||
if span <= 0.0:
|
||||
return np.zeros(depth.shape, dtype=np.uint8), low, high
|
||||
scaled = (depth.astype(np.float64) - low) / span
|
||||
codes = np.clip(np.rint(scaled * 255.0), 0.0, 255.0).astype(np.uint8)
|
||||
return codes, low, high
|
||||
|
||||
|
||||
def _dequantise(codes: np.ndarray, low: float, high: float) -> np.ndarray:
|
||||
span = float(high) - float(low)
|
||||
if span <= 0.0:
|
||||
return np.full(codes.shape, float(low), dtype=np.float32)
|
||||
return (float(low) + codes.astype(np.float64) * (span / 255.0)).astype(np.float32)
|
||||
|
||||
|
||||
def encode_depth(depth: Any) -> tuple[bytes, int, float, float]:
|
||||
"""One frame's depth channel as ``(payload, nonzero, min, max)``.
|
||||
|
||||
The payload is a :data:`DEPTH_HEADER` and, unless the map is exactly all zeros, the
|
||||
zlib'd uint8 plane. An empty map stores the header alone: 6 bytes that state the
|
||||
shape, which is what a reader needs to rebuild the zeros rather than guess at them.
|
||||
"""
|
||||
array = _depth_plane(depth)
|
||||
height, width = int(array.shape[0]), int(array.shape[1])
|
||||
if height * width > MAX_DEPTH_PIXELS:
|
||||
raise ValueError(f"a {height}x{width} depth map is larger than this format holds")
|
||||
nonzero = int(np.count_nonzero(array))
|
||||
low = float(array.min())
|
||||
high = float(array.max())
|
||||
if nonzero == 0:
|
||||
return (
|
||||
DEPTH_HEADER.pack(CODEC_EMPTY, DEPTH_BITS, height, width),
|
||||
nonzero,
|
||||
low,
|
||||
high,
|
||||
)
|
||||
codes, low, high = _quantise(array)
|
||||
raw = codes.tobytes()
|
||||
stored = zlib.compress(raw, 6)
|
||||
codec = CODEC_ZLIB
|
||||
if len(stored) >= len(raw):
|
||||
codec = CODEC_RAW
|
||||
stored = raw
|
||||
return (
|
||||
DEPTH_HEADER.pack(codec, DEPTH_BITS, height, width) + stored,
|
||||
nonzero,
|
||||
low,
|
||||
high,
|
||||
)
|
||||
|
||||
|
||||
def decode_depth(
|
||||
buffer: bytes | bytearray | memoryview,
|
||||
offset: int = 0,
|
||||
*,
|
||||
low: float = 0.0,
|
||||
high: float = 0.0,
|
||||
) -> DecodedDepth:
|
||||
"""Read one depth payload. Raises :class:`ValueError` on anything inconsistent.
|
||||
|
||||
The stored plane is checked against the shape its own header declares, so a
|
||||
truncated or mis-sized payload is refused here rather than surfacing later as an
|
||||
array of the wrong shape.
|
||||
"""
|
||||
view = memoryview(buffer)
|
||||
if len(view) < offset + DEPTH_HEADER.size:
|
||||
raise ValueError("a depth payload is shorter than its header")
|
||||
codec, bits, height, width = DEPTH_HEADER.unpack_from(view, offset)
|
||||
cursor = offset + DEPTH_HEADER.size
|
||||
pixels = int(height) * int(width)
|
||||
if pixels <= 0 or pixels > MAX_DEPTH_PIXELS:
|
||||
raise ValueError(f"a depth payload declares an implausible shape {height}x{width}")
|
||||
if int(bits) != DEPTH_BITS:
|
||||
raise ValueError(f"a depth payload stores {bits} bits per value, not {DEPTH_BITS}")
|
||||
stored = bytes(view[cursor:])
|
||||
if int(codec) == CODEC_EMPTY:
|
||||
return DecodedDepth(
|
||||
codec=int(codec),
|
||||
height=int(height),
|
||||
width=int(width),
|
||||
nonzero=0,
|
||||
minimum=0.0,
|
||||
maximum=0.0,
|
||||
)
|
||||
if int(codec) not in (CODEC_RAW, CODEC_ZLIB):
|
||||
raise ValueError(f"a depth payload uses codec {codec}")
|
||||
if int(codec) == CODEC_ZLIB:
|
||||
try:
|
||||
raw = zlib.decompress(stored)
|
||||
except zlib.error as exc:
|
||||
raise ValueError(f"a depth payload does not decompress: {exc}") from exc
|
||||
else:
|
||||
raw = stored
|
||||
if len(raw) != pixels:
|
||||
raise ValueError(
|
||||
f"a depth payload decoded to {len(raw)} bytes for a {height}x{width} map"
|
||||
)
|
||||
return DecodedDepth(
|
||||
codec=int(codec),
|
||||
height=int(height),
|
||||
width=int(width),
|
||||
nonzero=pixels,
|
||||
minimum=float(low),
|
||||
maximum=float(high),
|
||||
stored=raw,
|
||||
stored_bytes=len(stored),
|
||||
)
|
||||
|
||||
|
||||
def depth_values(decoded: DecodedDepth, *, low: float, high: float) -> np.ndarray:
|
||||
"""The depth map a decoded payload describes, as float32.
|
||||
|
||||
A :data:`CODEC_EMPTY` payload becomes ``height × width`` exact zeros — the map the
|
||||
chain produced, not a placeholder — so the depth view and its statistics describe
|
||||
the same array.
|
||||
"""
|
||||
shape = (int(decoded.height), int(decoded.width))
|
||||
if decoded.codec == CODEC_EMPTY:
|
||||
return np.zeros(shape, dtype=np.float32)
|
||||
codes = np.frombuffer(decoded.stored, dtype=np.uint8).reshape(shape)
|
||||
return _dequantise(codes, float(low), float(high))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- frame encoding
|
||||
def encode_frame(
|
||||
*,
|
||||
@@ -218,15 +451,22 @@ def encode_frame(
|
||||
magnitude: np.ndarray,
|
||||
active: np.ndarray,
|
||||
validity_digest: bytes | None = None,
|
||||
depth: Any | None = None,
|
||||
depth_nonzero: int = 0,
|
||||
dropped: bool = False,
|
||||
) -> bytes:
|
||||
"""One frame record: a fixed header, then the value arrays.
|
||||
"""One frame record: a fixed header, then the value arrays and the depth payload.
|
||||
|
||||
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.
|
||||
|
||||
``depth`` is the frame's own map, or ``None`` when the chain exposed none; the two
|
||||
are different facts (``FRAME_FLAG_DEPTH``) and the header's statistics belong to the
|
||||
first. ``depth_nonzero`` is only consulted when ``depth`` is ``None``, which is how
|
||||
a caller that can count but cannot afford to hand the array over still records the
|
||||
count exactly.
|
||||
"""
|
||||
dx = np.ascontiguousarray(dx, dtype="<f4").reshape(-1)
|
||||
dy = np.ascontiguousarray(dy, dtype="<f4").reshape(-1)
|
||||
@@ -247,6 +487,11 @@ def encode_frame(
|
||||
flags |= FRAME_FLAG_VALIDITY
|
||||
if dropped:
|
||||
flags |= FRAME_FLAG_DROPPED
|
||||
depth_payload = b""
|
||||
depth_low = depth_high = 0.0
|
||||
if depth is not None:
|
||||
flags |= FRAME_FLAG_DEPTH
|
||||
depth_payload, depth_nonzero, depth_low, depth_high = encode_depth(depth)
|
||||
parts = [
|
||||
FRAME_HEADER.pack(
|
||||
int(frame_id),
|
||||
@@ -255,6 +500,8 @@ def encode_frame(
|
||||
flags,
|
||||
count,
|
||||
max(0, int(depth_nonzero)),
|
||||
float(depth_low),
|
||||
float(depth_high),
|
||||
),
|
||||
dx.tobytes(),
|
||||
dy.tobytes(),
|
||||
@@ -266,6 +513,7 @@ def encode_frame(
|
||||
if len(digest) != 8:
|
||||
raise ValueError(f"validity digest must be 8 bytes, got {len(digest)}")
|
||||
parts.append(digest)
|
||||
parts.append(depth_payload)
|
||||
return b"".join(parts)
|
||||
|
||||
|
||||
@@ -284,6 +532,17 @@ class DecodedFrame:
|
||||
magnitude: np.ndarray
|
||||
active: np.ndarray
|
||||
validity_digest: bytes | None
|
||||
#: ``None`` when the frame carried no depth channel; otherwise the payload, whose
|
||||
#: ``stored`` bytes are still quantised and need :func:`depth_values`.
|
||||
depth: DecodedDepth | None = None
|
||||
|
||||
def depth_map(self) -> np.ndarray | None:
|
||||
"""This frame's depth map, or ``None`` when it had no depth channel."""
|
||||
if self.depth is None:
|
||||
return None
|
||||
return depth_values(
|
||||
self.depth, low=self.depth.minimum, high=self.depth.maximum
|
||||
)
|
||||
|
||||
|
||||
def decode_frame(
|
||||
@@ -291,9 +550,16 @@ def decode_frame(
|
||||
) -> 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
|
||||
)
|
||||
(
|
||||
frame_id,
|
||||
t_rel,
|
||||
grid_id,
|
||||
flags,
|
||||
count,
|
||||
depth_nonzero,
|
||||
depth_low,
|
||||
depth_high,
|
||||
) = FRAME_HEADER.unpack_from(view, offset)
|
||||
cursor = offset + FRAME_HEADER.size
|
||||
arrays = []
|
||||
for _ in range(3):
|
||||
@@ -308,6 +574,12 @@ def decode_frame(
|
||||
validity = None
|
||||
if flags & FRAME_FLAG_VALIDITY:
|
||||
validity = bytes(view[cursor : cursor + 8])
|
||||
cursor += 8
|
||||
depth = None
|
||||
if flags & FRAME_FLAG_DEPTH:
|
||||
depth = decode_depth(
|
||||
view, cursor, low=float(depth_low), high=float(depth_high)
|
||||
)
|
||||
return DecodedFrame(
|
||||
frame_id=int(frame_id),
|
||||
t_rel_s=float(t_rel),
|
||||
@@ -320,13 +592,29 @@ def decode_frame(
|
||||
magnitude=arrays[2],
|
||||
active=active.astype(bool),
|
||||
validity_digest=validity,
|
||||
depth=depth,
|
||||
)
|
||||
|
||||
|
||||
def frame_payload_bytes(candidates: int, *, with_validity: bool = True) -> int:
|
||||
"""Stored bytes of one frame record at ``candidates`` sample locations."""
|
||||
def frame_payload_bytes(
|
||||
candidates: int,
|
||||
*,
|
||||
with_validity: bool = True,
|
||||
with_depth: bool = False,
|
||||
depth_stored: int = 0,
|
||||
) -> int:
|
||||
"""Stored bytes of one frame record at ``candidates`` sample locations.
|
||||
|
||||
``with_depth`` adds the 6-byte depth header every frame that carries a channel pays;
|
||||
``depth_stored`` adds a payload of that many bytes on top, for a caller sizing the
|
||||
memory bound rather than the common case.
|
||||
"""
|
||||
total = FRAME_HEADER.size + int(candidates) * 12 + (int(candidates) + 7) // 8
|
||||
return total + 8 if with_validity else total
|
||||
if with_validity:
|
||||
total += 8
|
||||
if with_depth:
|
||||
total += DEPTH_HEADER.size + max(0, int(depth_stored))
|
||||
return total
|
||||
|
||||
|
||||
def keyframe_payload(kind: int, array: np.ndarray) -> bytes:
|
||||
@@ -373,6 +661,30 @@ class FramePayload:
|
||||
validity_digest: bytes | None = None
|
||||
depth_nonzero: int = 0
|
||||
dropped: bool = False
|
||||
#: This frame's depth map at the chain's own resolution, or ``None`` when the frame
|
||||
#: carried no depth channel. An all-zero map is *not* ``None``: "the response was
|
||||
#: zero everywhere" and "there was no response" are different answers and a depth
|
||||
#: view that shows the same blank picture for both is the one that misleads.
|
||||
depth: np.ndarray | None = None
|
||||
depth_min: float = 0.0
|
||||
depth_max: float = 0.0
|
||||
#: Stored bytes of this frame's depth payload — the 6-byte header for an empty map,
|
||||
#: header plus plane otherwise, and 0 when the frame had no depth channel. It is
|
||||
#: what a caller adds to the fixed part of a record to say what depth cost.
|
||||
depth_stored_bytes: int = 0
|
||||
|
||||
@property
|
||||
def has_depth(self) -> bool:
|
||||
return self.depth is not None
|
||||
|
||||
@property
|
||||
def depth_is_empty(self) -> bool:
|
||||
"""True when a depth channel exists and is exactly zero everywhere."""
|
||||
return self.depth is not None and self.depth_nonzero == 0
|
||||
|
||||
@property
|
||||
def depth_pixels(self) -> int:
|
||||
return 0 if self.depth is None else int(self.depth.size)
|
||||
|
||||
@property
|
||||
def candidates(self) -> int:
|
||||
@@ -412,6 +724,7 @@ class FramePayload:
|
||||
magnitude=self.magnitude,
|
||||
active=self.active,
|
||||
validity_digest=self.validity_digest,
|
||||
depth=self.depth,
|
||||
depth_nonzero=self.depth_nonzero,
|
||||
dropped=self.dropped,
|
||||
)
|
||||
|
||||
@@ -462,6 +462,25 @@ class AnalysisReader:
|
||||
f"frame {frame_id} has {decoded.count} values but grid "
|
||||
f"{decoded.grid_id} has {rows.size} locations"
|
||||
)
|
||||
depth = None
|
||||
depth_min = depth_max = 0.0
|
||||
depth_stored = 0
|
||||
if decoded.depth is not None:
|
||||
try:
|
||||
depth = decoded.depth_map()
|
||||
except ValueError as exc:
|
||||
raise AnalysisFormatError(f"frame {frame_id}: {exc}") from exc
|
||||
depth_min = float(decoded.depth.minimum)
|
||||
depth_max = float(decoded.depth.maximum)
|
||||
depth_stored = decoded.depth.payload_bytes
|
||||
counted = int(np.count_nonzero(depth))
|
||||
if counted != decoded.depth_nonzero:
|
||||
# The header's count and the stored plane must describe one array. A
|
||||
# mismatch means the record is corrupt, not that the map is empty.
|
||||
raise AnalysisFormatError(
|
||||
f"frame {frame_id} stores {decoded.depth_nonzero} non-zero depth "
|
||||
f"pixels but its payload holds {counted}"
|
||||
)
|
||||
return FramePayload(
|
||||
frame_id=decoded.frame_id,
|
||||
t_rel_s=decoded.t_rel_s,
|
||||
@@ -475,6 +494,10 @@ class AnalysisReader:
|
||||
validity_digest=decoded.validity_digest,
|
||||
depth_nonzero=decoded.depth_nonzero,
|
||||
dropped=decoded.dropped,
|
||||
depth=depth,
|
||||
depth_min=depth_min,
|
||||
depth_max=depth_max,
|
||||
depth_stored_bytes=depth_stored,
|
||||
)
|
||||
|
||||
def frame_at(self, t_rel_s: float) -> FramePayload | None:
|
||||
|
||||
@@ -371,6 +371,9 @@ class LiveFrame:
|
||||
depth_abs_sum: float
|
||||
reset_applied: bool
|
||||
segment: int
|
||||
#: The chain's depth map for this frame, or ``None`` when it exposed none. Held only
|
||||
#: until the writer has encoded it: one frame at a time, never accumulated.
|
||||
depth: np.ndarray | None = None
|
||||
|
||||
def payload_matches(self, stored: Any) -> bool:
|
||||
"""True when a stored payload equals this frame's selection, in stored precision.
|
||||
@@ -836,6 +839,7 @@ class AnalysisService:
|
||||
),
|
||||
reset_applied=reset_applied,
|
||||
segment=int(number),
|
||||
depth=depth_array,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------- internals
|
||||
@@ -927,9 +931,17 @@ class AnalysisService:
|
||||
magnitude=samples.magnitude,
|
||||
active=samples.active,
|
||||
validity_digest=validity_digest,
|
||||
depth=live.depth,
|
||||
depth_nonzero=live.depth_nonzero,
|
||||
dropped=row.dropped,
|
||||
)
|
||||
counts = provenance.counts
|
||||
counts["frame_record_bytes"] = counts.get("frame_record_bytes", 0) + len(record)
|
||||
if live.depth is not None:
|
||||
counts["depth_frames"] = counts.get("depth_frames", 0) + 1
|
||||
counts["depth_pixels"] = int(
|
||||
np.shape(live.depth)[-2]
|
||||
) * int(np.shape(live.depth)[-1])
|
||||
writer.add_frame(record, frame_id=row.frame_id, t_rel_s=row.t_rel_s)
|
||||
if self.on_progress is not None:
|
||||
self.on_progress(
|
||||
|
||||
Reference in New Issue
Block a user