Record the live box arrow grid so replay matches live paint
Box analysis used the finger sample grid, so replay packed arrows into the contact zone. The pass now stores draw_arrows cells; stale box caches miss and re-run. Verified with shared and Studio tests, including a live-vs-replay canvas check.
This commit is contained in:
@@ -77,6 +77,52 @@ _ARROW_DRAW_KW = dict(
|
||||
arrow_scale=0.5,
|
||||
below_threshold_color=hex_to_bgr(FLOW_IDLE_HEX),
|
||||
)
|
||||
|
||||
#: ``draw_arrows`` reads every point of this grid, including those under its threshold
|
||||
#: (they become the idle dots). The cap is only a guard: a stitch image does not have
|
||||
#: this many points, and a lower cap would drop cells the live painter still draws.
|
||||
SDK_BOX_MAX_ARROWS = 100_000
|
||||
|
||||
|
||||
def sdk_box_policy():
|
||||
"""The sample grid :func:`orisys.util.draw_arrows` walks for a box frame.
|
||||
|
||||
Live box arrows are not the shared selector's default (spacing 10, 400 strongest).
|
||||
A stored result has to record *this* grid or the replay cannot hand the same cells
|
||||
back to the same painter.
|
||||
"""
|
||||
from orisys_app_shared.scene3d.visual_policy import VisualPolicy
|
||||
|
||||
return VisualPolicy(
|
||||
flow_on_px=float(_ARROW_DRAW_KW["threshold"]),
|
||||
flow_off_px=float(_ARROW_DRAW_KW["threshold"]) * 0.75,
|
||||
flow_grid_spacing=int(_ARROW_DRAW_KW["grid_spacing"]),
|
||||
flow_arrow_scale=float(_ARROW_DRAW_KW["arrow_scale"]),
|
||||
# ``draw_arrows`` still folds the border cells into its colour range, then
|
||||
# refuses to draw them. Recording them (edge 0) is what keeps that range.
|
||||
flow_edge_margin=0,
|
||||
flow_length_margin=0.8,
|
||||
max_arrows=SDK_BOX_MAX_ARROWS,
|
||||
).normalised()
|
||||
|
||||
|
||||
def uses_sdk_box_grid(policy) -> bool:
|
||||
"""True when ``policy`` is the box painter's grid, not the shared selector's."""
|
||||
if policy is None:
|
||||
return False
|
||||
spacing = getattr(policy, "flow_grid_spacing", None)
|
||||
scale = getattr(policy, "flow_arrow_scale", None)
|
||||
if spacing is None and isinstance(policy, dict):
|
||||
spacing = policy.get("flow_grid_spacing")
|
||||
scale = policy.get("flow_arrow_scale", _ARROW_DRAW_KW["arrow_scale"])
|
||||
try:
|
||||
return int(spacing) == int(_ARROW_DRAW_KW["grid_spacing"]) and abs(
|
||||
float(scale) - float(_ARROW_DRAW_KW["arrow_scale"])
|
||||
) < 1e-9
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
_FINGER_ARROW_DRAW_KW = dict(
|
||||
threshold=0.4,
|
||||
grid_spacing=10,
|
||||
@@ -426,6 +472,7 @@ def draw_arrow_display(
|
||||
magnitude_range: tuple[float, float] | None = None,
|
||||
per_frame_colour: bool = False,
|
||||
idle_dots: bool | None = None,
|
||||
shared_painter: bool = False,
|
||||
) -> np.ndarray:
|
||||
"""Draw flow arrows on a solid canvas or over the processed frame.
|
||||
|
||||
@@ -464,11 +511,19 @@ def draw_arrow_display(
|
||||
that shows it without reading the attribute reports "nothing measured" for "nothing
|
||||
could be drawn". A ``flow`` of ``None`` is a legitimate empty frame and leaves the
|
||||
attribute ``None``.
|
||||
|
||||
``shared_painter`` forces this module's per-sample painter for a field that came from
|
||||
somewhere other than a live chain — a *recovered* field, finite only at the cells a
|
||||
stored analysis result recorded. The SDK painter walks the dense grid, which on such a
|
||||
field is almost entirely ``NaN``; measured on one stored box frame it paints **0 px**
|
||||
where the shared painter paints **1,486 px** (the frame's own candidate cells). It is a
|
||||
statement about the input, not about the sensor: the box *style* still decides the
|
||||
magnification and the idle-dot tint, and a live box frame keeps the SDK path.
|
||||
"""
|
||||
base = _ensure_bgr(processed_frame)
|
||||
if flow is None:
|
||||
return _flow_result(base)
|
||||
finger = str(draw_style).lower().startswith("f")
|
||||
finger = str(draw_style).lower().startswith("f") or bool(shared_painter)
|
||||
draw_kw = dict(_FINGER_ARROW_DRAW_KW if finger else _ARROW_DRAW_KW)
|
||||
if colormap is not None:
|
||||
draw_kw["colormap"] = colormap
|
||||
|
||||
@@ -24,6 +24,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -31,6 +32,7 @@ from orisys_app_shared.acquisition.arrows import (
|
||||
depth_map_to_bgr,
|
||||
draw_arrow_display,
|
||||
flow_colormap,
|
||||
uses_sdk_box_grid,
|
||||
)
|
||||
from orisys_app_shared.acquisition.display_orientation import (
|
||||
ORIENTATION_IDENTITY,
|
||||
@@ -96,6 +98,14 @@ RESULT_NOTE_KEYS = {
|
||||
#: Sources whose frames carry derived fields.
|
||||
DERIVED_SOURCES = (SOURCE_LIVE, SOURCE_RECOMPUTE, SOURCE_ANALYSIS)
|
||||
|
||||
#: Sources whose flow field was **recovered from a stored analysis result**: it holds a
|
||||
#: value at the recorded candidate cells and ``NaN`` everywhere else. ``SOURCE_ANALYSIS`` is
|
||||
#: the result's own timeline, ``SOURCE_REPLAY`` a derived view drawn from the result while
|
||||
#: the cursor is on the recording's. Both are read from a result, and the distinction that
|
||||
#: matters to a painter is *where the field came from* — a dense live field and a recovered
|
||||
#: one are not the same input, though they are the same array type.
|
||||
RESULT_SOURCES = (SOURCE_ANALYSIS, SOURCE_REPLAY)
|
||||
|
||||
#: How much a frame's domain mask must overlap the latched one before the latched one
|
||||
#: is kept. Measured on the shipped finger island (32937 px, 39.2% of the 300x280
|
||||
#: domain): a rebuilt mask that wobbles by one pixel of rim overlap the held one at
|
||||
@@ -225,6 +235,13 @@ class ImageFrame:
|
||||
#: the states were told apart.
|
||||
result_state: str = ""
|
||||
result_reason: str = ""
|
||||
#: The sample policy this frame may be drawn with, when the producer knows better than
|
||||
#: the caller. A field recovered from a stored result is finite only at the pass's
|
||||
#: recorded candidate cells, so it can only be re-selected on the *recorded* grid: a
|
||||
#: display setting that moves the grid would otherwise sample between those cells and
|
||||
#: draw nothing. ``None`` means "the caller's own policy", which is right for a field
|
||||
#: the chain computed just now — it is dense, so any grid finds it.
|
||||
arrow_policy: Any | None = None
|
||||
|
||||
def has_flow(self) -> bool:
|
||||
if self.flow is None:
|
||||
@@ -420,13 +437,28 @@ def render_image(
|
||||
if frame.flow is None or frame.flow_valid is False:
|
||||
# The base picture with no arrows, and the plan's ``missing`` says why.
|
||||
return apply_orientation(ensure_bgr(np.asarray(frame.image_bgr)), turn)
|
||||
# A recovered field is finite only at the cells the pass stored. The shared
|
||||
# painter draws those cells; the SDK painter walks its own grid and finds
|
||||
# nothing wherever that grid and the stored cells disagree. A box result whose
|
||||
# grid *is* the SDK grid is the exception: fill the gaps with zero (the cells
|
||||
# the painter does not read) and call the same ``draw_arrows`` live uses.
|
||||
box_style = not str(draw_style).lower().startswith("f")
|
||||
sdk_result = (
|
||||
box_style
|
||||
and str(frame.source or "") in RESULT_SOURCES
|
||||
and uses_sdk_box_grid(arrow_policy)
|
||||
)
|
||||
flow = frame.flow
|
||||
if sdk_result:
|
||||
flow = np.nan_to_num(np.asarray(flow, dtype=np.float64), nan=0.0)
|
||||
return apply_orientation(
|
||||
draw_arrow_display(
|
||||
frame.image_bgr,
|
||||
frame.flow,
|
||||
flow,
|
||||
background="captured" if wanted == ARROW_STITCHED else "pure_color",
|
||||
canvas_bgr=canvas_bgr,
|
||||
draw_style=draw_style,
|
||||
shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result,
|
||||
valid_mask=frame.valid_mask,
|
||||
domain_mask=domain_mask,
|
||||
colormap=flow_colormap(theme),
|
||||
|
||||
@@ -17,10 +17,11 @@ than a guess:
|
||||
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.
|
||||
* a location set that is *not* static gets its own keyframe, deduplicated by digest.
|
||||
The box painter's grid is static (spacing 20, every cell kept). A grid that is
|
||||
still cut by ``max_arrows`` moves with the strongest cells and is stored once per
|
||||
distinct set. The format has one code path, and "stored once" is true wherever
|
||||
the set does not change.
|
||||
|
||||
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
|
||||
@@ -43,6 +44,19 @@ the largest error that can be introduced is half a quantisation step,
|
||||
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.
|
||||
|
||||
**Force is stored too, and it is a recomputation, not a recording.** When the input is
|
||||
not a recording — a bare video in a generated workspace — ``forces.csv`` does not exist,
|
||||
so the only force there is the one the chain computes from the frames, read through the
|
||||
same ``read_forces_from_sensor`` the live loop uses. It is stored *per frame*, behind
|
||||
:data:`FRAME_FLAG_FORCE` and appended after the depth payload, so a build that does not
|
||||
know the flag still reads every record correctly: a chunk's own offset table delimits a
|
||||
record, and trailing bytes were always ignored. Six float32 values plus two flags cost
|
||||
28 B/frame — 15 MB per ten minutes, against the 4,048 B the frame already costs — and
|
||||
the format version is unchanged because nothing that could be read before reads
|
||||
differently now. This is the one channel whose ``Provenance.sources`` entry is
|
||||
``recomputed`` on the *synthetic* path and ``recorded`` beside a real ``forces.csv``:
|
||||
the two must never be confused, which is what the field is for.
|
||||
|
||||
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
|
||||
@@ -78,8 +92,10 @@ __all__ = [
|
||||
"CODEC_ZLIB",
|
||||
"DEPTH_BITS",
|
||||
"DEPTH_HEADER",
|
||||
"FORCE_PAYLOAD",
|
||||
"FRAME_FLAG_DEPTH",
|
||||
"FRAME_FLAG_DROPPED",
|
||||
"FRAME_FLAG_FORCE",
|
||||
"FRAME_FLAG_VALIDITY",
|
||||
"FRAME_HEADER",
|
||||
"KEYFRAME_GRID",
|
||||
@@ -89,6 +105,7 @@ __all__ = [
|
||||
"MAX_DEPTH_PIXELS",
|
||||
"UNIT",
|
||||
"DecodedDepth",
|
||||
"ForceSample",
|
||||
"FramePayload",
|
||||
"KeyframeRef",
|
||||
"Provenance",
|
||||
@@ -134,6 +151,18 @@ FRAME_FLAG_DROPPED = 0x02
|
||||
#: 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
|
||||
#: The chain produced a force sample for this frame and a :data:`FORCE_PAYLOAD`
|
||||
#: follows the depth payload. Absent means the frame carries no force — either the
|
||||
#: pass was not asked to collect it (a recording, whose forces live in its own
|
||||
#: ``forces.csv``) or the chain exposed none.
|
||||
FRAME_FLAG_FORCE = 0x08
|
||||
|
||||
#: The force payload: FN, FX, FY in newtons, then the raw triplet, then one byte of
|
||||
#: validity and one of "a calibration curve was applied". Appended **last** in the
|
||||
#: record so a reader that does not know this flag still decodes every earlier field
|
||||
#: at the same offsets: a chunk's offset table delimits a record and the decoder has
|
||||
#: always ignored trailing bytes.
|
||||
FORCE_PAYLOAD = struct.Struct("<ffffffBB2x")
|
||||
|
||||
#: magic, kind, codec, count, height, width, flags, reserved, raw bytes, digest
|
||||
KEYFRAME_HEADER = struct.Struct("<4sBBIIIBBQ8s")
|
||||
@@ -380,6 +409,10 @@ def decode_depth(
|
||||
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.
|
||||
|
||||
Bytes after the depth payload belong to the next field (a force sample). They are
|
||||
not part of this channel: ``stored_bytes`` is only what the codec consumed, so the
|
||||
caller can resume at the following field.
|
||||
"""
|
||||
view = memoryview(buffer)
|
||||
if len(view) < offset + DEPTH_HEADER.size:
|
||||
@@ -391,7 +424,6 @@ def decode_depth(
|
||||
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),
|
||||
@@ -401,15 +433,28 @@ def decode_depth(
|
||||
minimum=0.0,
|
||||
maximum=0.0,
|
||||
)
|
||||
if int(codec) not in (CODEC_RAW, CODEC_ZLIB):
|
||||
raise ValueError(f"a depth payload uses codec {codec}")
|
||||
region = view[cursor:]
|
||||
if int(codec) == CODEC_ZLIB:
|
||||
decompressor = zlib.decompressobj()
|
||||
try:
|
||||
raw = zlib.decompress(stored)
|
||||
raw = decompressor.decompress(region)
|
||||
except zlib.error as exc:
|
||||
raise ValueError(f"a depth payload does not decompress: {exc}") from exc
|
||||
# ``eof`` is the end of the zlib stream, not the end of the record. A truncated
|
||||
# stream can still inflate every pixel (the missing bytes are the checksum) and
|
||||
# must be refused; bytes after a finished stream belong to the next field.
|
||||
if not decompressor.eof:
|
||||
raise ValueError("a depth payload does not decompress: truncated stream")
|
||||
stored_bytes = len(region) - len(decompressor.unused_data)
|
||||
elif int(codec) == CODEC_RAW:
|
||||
if len(region) < pixels:
|
||||
raise ValueError(
|
||||
f"a depth payload holds {len(region)} bytes for a {height}x{width} map"
|
||||
)
|
||||
raw = bytes(region[:pixels])
|
||||
stored_bytes = pixels
|
||||
else:
|
||||
raw = stored
|
||||
raise ValueError(f"a depth payload uses codec {codec}")
|
||||
if len(raw) != pixels:
|
||||
raise ValueError(
|
||||
f"a depth payload decoded to {len(raw)} bytes for a {height}x{width} map"
|
||||
@@ -421,8 +466,8 @@ def decode_depth(
|
||||
nonzero=pixels,
|
||||
minimum=float(low),
|
||||
maximum=float(high),
|
||||
stored=raw,
|
||||
stored_bytes=len(stored),
|
||||
stored=bytes(raw),
|
||||
stored_bytes=stored_bytes,
|
||||
)
|
||||
|
||||
|
||||
@@ -440,6 +485,110 @@ def depth_values(decoded: DecodedDepth, *, low: float, high: float) -> np.ndarra
|
||||
return _dequantise(codes, float(low), float(high))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ force channel
|
||||
_NAN = float("nan")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForceSample:
|
||||
"""One frame's force, as the chain computed it for that frame.
|
||||
|
||||
Six values: the calibrated triplet in newtons and the raw triplet in device counts.
|
||||
A non-finite value is *absent* — ``read_forces_from_sensor`` reports a failed readout
|
||||
as ``NaN`` rather than zero, because a zero is a measurement and "no measurement" is
|
||||
not. ``calibrated`` and ``valid`` are the chain's own verdicts, so a chart can label a
|
||||
trace instead of inferring one from the numbers it happens to hold.
|
||||
"""
|
||||
|
||||
normal: float = _NAN
|
||||
shear_x: float = _NAN
|
||||
shear_y: float = _NAN
|
||||
normal_raw: float = _NAN
|
||||
shear_x_raw: float = _NAN
|
||||
shear_y_raw: float = _NAN
|
||||
valid: bool = True
|
||||
calibrated: bool = False
|
||||
|
||||
@property
|
||||
def any_finite(self) -> bool:
|
||||
"""True when at least one of the six values is a real number."""
|
||||
return any(
|
||||
value == value
|
||||
for value in (
|
||||
self.normal,
|
||||
self.shear_x,
|
||||
self.shear_y,
|
||||
self.normal_raw,
|
||||
self.shear_x_raw,
|
||||
self.shear_y_raw,
|
||||
)
|
||||
)
|
||||
|
||||
def newtons(self) -> tuple[float, float, float]:
|
||||
return (self.normal, self.shear_x, self.shear_y)
|
||||
|
||||
def raw(self) -> tuple[float, float, float]:
|
||||
return (self.normal_raw, self.shear_x_raw, self.shear_y_raw)
|
||||
|
||||
def values(self, unit: str = "raw") -> tuple[float, float, float]:
|
||||
"""The triplet in ``unit``: newtons when a calibration was applied, else raw.
|
||||
|
||||
Asking for newtons from a sample the chain produced without a curve returns the
|
||||
missing values rather than the raw counts in their place: a count is not a force.
|
||||
"""
|
||||
return self.newtons() if str(unit).upper().startswith("N") else self.raw()
|
||||
|
||||
@classmethod
|
||||
def from_reading(cls, reading: Mapping[str, Any]) -> ForceSample:
|
||||
"""One :func:`~orisys_app_shared.acquisition.sdk_worker.read_forces_from_sensor`
|
||||
reading, as this record's own type."""
|
||||
return cls(
|
||||
normal=float(reading.get("normal_force", _NAN)),
|
||||
shear_x=float(reading.get("shear_x", _NAN)),
|
||||
shear_y=float(reading.get("shear_y", _NAN)),
|
||||
normal_raw=float(reading.get("normal_force_raw", _NAN)),
|
||||
shear_x_raw=float(reading.get("shear_x_raw", _NAN)),
|
||||
shear_y_raw=float(reading.get("shear_y_raw", _NAN)),
|
||||
valid=bool(reading.get("valid", True)),
|
||||
calibrated=bool(reading.get("force_calibrated", False)),
|
||||
)
|
||||
|
||||
def encode(self) -> bytes:
|
||||
return FORCE_PAYLOAD.pack(
|
||||
float(self.normal),
|
||||
float(self.shear_x),
|
||||
float(self.shear_y),
|
||||
float(self.normal_raw),
|
||||
float(self.shear_x_raw),
|
||||
float(self.shear_y_raw),
|
||||
1 if self.valid else 0,
|
||||
1 if self.calibrated else 0,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, buffer: bytes | bytearray | memoryview, offset: int = 0) -> ForceSample:
|
||||
(
|
||||
normal,
|
||||
shear_x,
|
||||
shear_y,
|
||||
normal_raw,
|
||||
shear_x_raw,
|
||||
shear_y_raw,
|
||||
valid,
|
||||
calibrated,
|
||||
) = FORCE_PAYLOAD.unpack_from(memoryview(buffer), offset)
|
||||
return cls(
|
||||
normal=float(normal),
|
||||
shear_x=float(shear_x),
|
||||
shear_y=float(shear_y),
|
||||
normal_raw=float(normal_raw),
|
||||
shear_x_raw=float(shear_x_raw),
|
||||
shear_y_raw=float(shear_y_raw),
|
||||
valid=bool(valid),
|
||||
calibrated=bool(calibrated),
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- frame encoding
|
||||
def encode_frame(
|
||||
*,
|
||||
@@ -454,8 +603,9 @@ def encode_frame(
|
||||
depth: Any | None = None,
|
||||
depth_nonzero: int = 0,
|
||||
dropped: bool = False,
|
||||
force: ForceSample | None = None,
|
||||
) -> bytes:
|
||||
"""One frame record: a fixed header, then the value arrays and the depth payload.
|
||||
"""One frame record: a fixed header, then the value arrays, depth and force.
|
||||
|
||||
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
|
||||
@@ -467,6 +617,10 @@ def encode_frame(
|
||||
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.
|
||||
|
||||
``force`` is the chain's own sample for this frame, or ``None`` when the pass did not
|
||||
collect one (``FRAME_FLAG_FORCE``). It is written last, after every field an older
|
||||
reader knows, so the record stays readable by a build without this flag.
|
||||
"""
|
||||
dx = np.ascontiguousarray(dx, dtype="<f4").reshape(-1)
|
||||
dy = np.ascontiguousarray(dy, dtype="<f4").reshape(-1)
|
||||
@@ -487,6 +641,8 @@ def encode_frame(
|
||||
flags |= FRAME_FLAG_VALIDITY
|
||||
if dropped:
|
||||
flags |= FRAME_FLAG_DROPPED
|
||||
if force is not None:
|
||||
flags |= FRAME_FLAG_FORCE
|
||||
depth_payload = b""
|
||||
depth_low = depth_high = 0.0
|
||||
if depth is not None:
|
||||
@@ -514,6 +670,8 @@ def encode_frame(
|
||||
raise ValueError(f"validity digest must be 8 bytes, got {len(digest)}")
|
||||
parts.append(digest)
|
||||
parts.append(depth_payload)
|
||||
if force is not None:
|
||||
parts.append(force.encode())
|
||||
return b"".join(parts)
|
||||
|
||||
|
||||
@@ -535,6 +693,9 @@ class DecodedFrame:
|
||||
#: ``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
|
||||
#: ``None`` when the frame carried no force (an older result, a recording whose forces
|
||||
#: live in its own ``forces.csv``, or a chain that produced none).
|
||||
force: ForceSample | None = None
|
||||
|
||||
def depth_map(self) -> np.ndarray | None:
|
||||
"""This frame's depth map, or ``None`` when it had no depth channel."""
|
||||
@@ -580,6 +741,12 @@ def decode_frame(
|
||||
depth = decode_depth(
|
||||
view, cursor, low=float(depth_low), high=float(depth_high)
|
||||
)
|
||||
cursor += depth.payload_bytes
|
||||
force = None
|
||||
if flags & FRAME_FLAG_FORCE:
|
||||
if len(view) < cursor + FORCE_PAYLOAD.size:
|
||||
raise ValueError("a frame says it carries force but is shorter than one sample")
|
||||
force = ForceSample.decode(view, cursor)
|
||||
return DecodedFrame(
|
||||
frame_id=int(frame_id),
|
||||
t_rel_s=float(t_rel),
|
||||
@@ -593,6 +760,7 @@ def decode_frame(
|
||||
active=active.astype(bool),
|
||||
validity_digest=validity,
|
||||
depth=depth,
|
||||
force=force,
|
||||
)
|
||||
|
||||
|
||||
@@ -602,16 +770,19 @@ def frame_payload_bytes(
|
||||
with_validity: bool = True,
|
||||
with_depth: bool = False,
|
||||
depth_stored: int = 0,
|
||||
with_force: bool = False,
|
||||
) -> 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.
|
||||
memory bound rather than the common case. ``with_force`` adds the fixed force sample.
|
||||
"""
|
||||
total = FRAME_HEADER.size + int(candidates) * 12 + (int(candidates) + 7) // 8
|
||||
if with_validity:
|
||||
total += 8
|
||||
if with_force:
|
||||
total += FORCE_PAYLOAD.size
|
||||
if with_depth:
|
||||
total += DEPTH_HEADER.size + max(0, int(depth_stored))
|
||||
return total
|
||||
@@ -672,6 +843,11 @@ class FramePayload:
|
||||
#: 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
|
||||
#: The chain's own force sample for this frame, or ``None`` when the pass stored none.
|
||||
#: It is a **recomputation** whenever it is present (see :class:`Provenance.synthetic`),
|
||||
#: which is why the provenance — not this field — is what a chart must label a trace
|
||||
#: from.
|
||||
force: ForceSample | None = None
|
||||
|
||||
@property
|
||||
def has_depth(self) -> bool:
|
||||
@@ -727,6 +903,7 @@ class FramePayload:
|
||||
depth=self.depth,
|
||||
depth_nonzero=self.depth_nonzero,
|
||||
dropped=self.dropped,
|
||||
force=self.force,
|
||||
)
|
||||
|
||||
|
||||
@@ -814,6 +991,14 @@ class Provenance:
|
||||
#: 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)
|
||||
#: True when the input was not a recording: a bare video whose ``frames.csv`` and
|
||||
#: ``session.json`` were **manufactured** for it (see
|
||||
#: :mod:`orisys_app_shared.analysis.bare_video`). Every number in such a result is a
|
||||
#: recomputation, so this flag is what keeps one from ever being read as a recorded
|
||||
#: measurement — and ``synthetic_source`` names the video it was computed from, which
|
||||
#: the workspace's own path cannot say (that path is app-owned and disposable).
|
||||
synthetic: bool = False
|
||||
synthetic_source: str = ""
|
||||
versions: dict[str, str] = field(default_factory=dict)
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
open_working_directory: str = ""
|
||||
|
||||
@@ -62,6 +62,7 @@ from orisys_app_shared.analysis.cache import (
|
||||
from orisys_app_shared.analysis.model import (
|
||||
ALGORITHM_VERSION,
|
||||
KEYFRAME_MASK,
|
||||
ForceSample,
|
||||
Provenance,
|
||||
ResultKey,
|
||||
digest8,
|
||||
@@ -166,6 +167,14 @@ class ChainSpec:
|
||||
export_dir: Path | None = None
|
||||
flip_source_vertical: bool = True
|
||||
mirror: bool = False
|
||||
#: An explicit ``force_calibration.json`` for the box chain, or ``None`` for whatever
|
||||
#: the configuration's own bundle resolves. It is *not* part of :meth:`identity`,
|
||||
#: deliberately: a recording was taken under one calibration and the identity of *what
|
||||
#: it is* must not move when a different curve is tried against it. The calibration
|
||||
#: half of the invalidation key is the recording's own ``calibration_id`` (see
|
||||
#: :meth:`AnalysisService.dependency_report`), which the generated workspace sets from
|
||||
#: the chosen file's content.
|
||||
calibration: Path | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.sensor_type not in ("finger", "box"):
|
||||
@@ -200,12 +209,16 @@ def chain_spec_for(
|
||||
sensor_type: str = "",
|
||||
config_ref: str | Path | None = None,
|
||||
export_dir: str | Path | None = None,
|
||||
calibration: str | Path | None = None,
|
||||
) -> ChainSpec:
|
||||
"""The chain a recording was made with, unless the caller names a different one.
|
||||
|
||||
Defaulting from ``session.json`` is what makes a stored result reproducible: the
|
||||
recorded ``device.config_ref`` is the configuration the footage was taken under,
|
||||
not whatever happens to be installed now.
|
||||
not whatever happens to be installed now. ``calibration`` is the one fact a *recording*
|
||||
cannot supply — it names the curve in force at record time, not the file it lived in —
|
||||
so a caller that knows which file it means (the generated workspace for a bare video)
|
||||
passes it, and everything else keeps the bundle's own resolution.
|
||||
"""
|
||||
device = dict(session.get("device") or {})
|
||||
kind = str(sensor_type or device.get("sensor_type") or "")
|
||||
@@ -221,7 +234,12 @@ def chain_spec_for(
|
||||
bundle = Path(str(export_dir))
|
||||
elif kind == "finger" and resolved is not None:
|
||||
bundle = resolve_finger_export_dir(resolved)
|
||||
return ChainSpec(sensor_type=kind, config_ref=resolved, export_dir=bundle)
|
||||
return ChainSpec(
|
||||
sensor_type=kind,
|
||||
config_ref=resolved,
|
||||
export_dir=bundle,
|
||||
calibration=None if calibration is None else Path(str(calibration)),
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -278,6 +296,15 @@ class _OpenChain:
|
||||
if self.spec.sensor_type == "finger":
|
||||
from orisys.finger import FingerRuntime
|
||||
|
||||
if self.spec.calibration is not None:
|
||||
# The finger runtime opens its ``Sensor`` without a calibration path,
|
||||
# so a chosen curve cannot be applied to this chain. Refusing is the
|
||||
# only honest answer: a pass that ran anyway would store numbers from a
|
||||
# different calibration than the one the caller asked for.
|
||||
raise RuntimeError(
|
||||
"the finger chain cannot take a chosen force calibration; it uses "
|
||||
"the one the bundle resolves"
|
||||
)
|
||||
kwargs: dict[str, Any] = {
|
||||
"video": str(self.video),
|
||||
"verbose": False,
|
||||
@@ -299,6 +326,14 @@ class _OpenChain:
|
||||
config_name=str(self.spec.config_ref)
|
||||
if self.spec.config_ref
|
||||
else "box",
|
||||
# ``cal_path`` overrides the bundle's own ``force_calibration.json``.
|
||||
# Without it a caller's chosen curve would be silently ignored, since
|
||||
# the SDK resolves the bundle by the configuration's *name*.
|
||||
cal_path=(
|
||||
None
|
||||
if self.spec.calibration is None
|
||||
else str(self.spec.calibration)
|
||||
),
|
||||
verbose=False,
|
||||
backend="auto",
|
||||
)
|
||||
@@ -374,6 +409,10 @@ class LiveFrame:
|
||||
#: 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
|
||||
#: The chain's own force sample for this frame, or ``None`` when the pass was not asked
|
||||
#: to collect one. It is a *recomputation* of what the chain says about this footage —
|
||||
#: never the recorded ``forces.csv`` — which the result's provenance labels.
|
||||
force: ForceSample | None = None
|
||||
|
||||
def payload_matches(self, stored: Any) -> bool:
|
||||
"""True when a stored payload equals this frame's selection, in stored precision.
|
||||
@@ -479,6 +518,7 @@ class AnalysisService:
|
||||
chunk_frames: int = DEFAULT_CHUNK_FRAMES,
|
||||
max_bytes: int = DEFAULT_MAX_BYTES,
|
||||
working_directory: str | Path | None = None,
|
||||
store_force: bool = False,
|
||||
on_progress: Callable[[Progress], None] | None = None,
|
||||
cancelled: threading.Event | None = None,
|
||||
) -> None:
|
||||
@@ -489,13 +529,24 @@ class AnalysisService:
|
||||
)
|
||||
#: The policy the samples are selected with. Recorded, never keyed (see
|
||||
#: ``model.py``): the shipped defaults make a stored result independent of the
|
||||
#: operator's display preferences.
|
||||
#: operator's display preferences. A box chain records the live painter's own
|
||||
#: grid; the shared defaults are the finger painter's and would drop every cell
|
||||
#: ``draw_arrows`` still draws.
|
||||
if policy is None and chain.sensor_type == "box":
|
||||
from orisys_app_shared.acquisition.arrows import sdk_box_policy
|
||||
|
||||
policy = sdk_box_policy()
|
||||
self.policy = policy or VisualPolicy()
|
||||
self.reference_frame = None if reference_frame is None else int(reference_frame)
|
||||
self.chunk_frames = int(chunk_frames)
|
||||
self.working_directory = (
|
||||
None if working_directory is None else Path(working_directory)
|
||||
)
|
||||
#: Whether each frame also stores the chain's own force sample. Off for a recording,
|
||||
#: whose force is a *measurement in its own file* (``forces.csv``) and must not be
|
||||
#: overwritten in the reader's mind by a recomputation of it; on for a bare video,
|
||||
#: where the chain's force is the only force there is (see ``bare_video``).
|
||||
self.store_force = bool(store_force)
|
||||
self.on_progress = on_progress
|
||||
self.cancelled = cancelled
|
||||
self._reader: RecordingReader | None = None
|
||||
@@ -689,6 +740,17 @@ class AnalysisService:
|
||||
state.reasons.append(f"{type(exc).__name__}: {exc}")
|
||||
finally:
|
||||
self._discard_working_directory(working)
|
||||
if (
|
||||
not state.reasons
|
||||
and self.reference_frame is not None
|
||||
and not provenance.counts.get("reset_count")
|
||||
):
|
||||
# The frame was checked against ``frames.csv`` and the chain never reached it:
|
||||
# a box link consumes the first decoded frames, so a reference that names one
|
||||
# of them is a request the pass cannot honour. Storing the result anyway would
|
||||
# claim ``reference_is_explicit`` while the tracker was never cleared, which is
|
||||
# the one thing the reference frame is *for*.
|
||||
state.reasons.append(f"reference_not_applied:{self.reference_frame}")
|
||||
counts = provenance.counts
|
||||
counts.update(state.plan)
|
||||
counts["frames_written"] = state.frames_written
|
||||
@@ -807,6 +869,7 @@ class AnalysisService:
|
||||
provenance.warnings.append(f"chain:{error}")
|
||||
state.reasons.append(f"chain_failed:{row.frame_id}")
|
||||
return
|
||||
force = self._read_force(chain)
|
||||
flow = getattr(chain.sensor, "flow", None)
|
||||
if flow is None or not getattr(flow, "size", 0):
|
||||
state.reasons.append(f"no_flow:{row.frame_id}")
|
||||
@@ -840,9 +903,28 @@ class AnalysisService:
|
||||
reset_applied=reset_applied,
|
||||
segment=int(number),
|
||||
depth=depth_array,
|
||||
force=force,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------- internals
|
||||
def _read_force(self, chain: _OpenChain) -> ForceSample | None:
|
||||
"""This frame's force sample, or ``None`` when the pass was not asked for one.
|
||||
|
||||
Read after the chain has computed, through the same
|
||||
:func:`~orisys_app_shared.acquisition.sdk_worker.read_forces_from_sensor` the live
|
||||
loop uses, so a recomputed trace is the live measurement re-run rather than a second
|
||||
opinion about it. A readout the sensor cannot give comes back as ``NaN`` with
|
||||
``valid=False`` — recorded as-is, because a zero in its place would be a measurement
|
||||
nothing made.
|
||||
"""
|
||||
if not self.store_force:
|
||||
return None
|
||||
from orisys_app_shared.acquisition.sdk_worker import read_forces_from_sensor
|
||||
|
||||
return ForceSample.from_reading(
|
||||
read_forces_from_sensor(chain.sensor, config_ref=self.chain.config_ref)
|
||||
)
|
||||
|
||||
def _make_working_directory(self, key: ResultKey) -> Path:
|
||||
if self.working_directory is not None:
|
||||
self.working_directory.mkdir(parents=True, exist_ok=True)
|
||||
@@ -934,9 +1016,12 @@ class AnalysisService:
|
||||
depth=live.depth,
|
||||
depth_nonzero=live.depth_nonzero,
|
||||
dropped=row.dropped,
|
||||
force=live.force,
|
||||
)
|
||||
counts = provenance.counts
|
||||
counts["frame_record_bytes"] = counts.get("frame_record_bytes", 0) + len(record)
|
||||
if live.force is not None:
|
||||
self._accumulate_force(counts, live.force)
|
||||
if live.depth is not None:
|
||||
counts["depth_frames"] = counts.get("depth_frames", 0) + 1
|
||||
counts["depth_pixels"] = int(
|
||||
@@ -989,9 +1074,29 @@ class AnalysisService:
|
||||
float(counts.get("depth_abs_sum", 0.0)) + float(depth_abs_sum), 6
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _accumulate_force(counts: dict[str, Any], force: ForceSample) -> None:
|
||||
"""Count what the stored force trace is, so a chart can promise it or say why not.
|
||||
|
||||
``force_finite_frames`` is the number a caller has to see before offering a trace:
|
||||
a chain that answers every frame with ``NaN`` has stored no measurement, and an
|
||||
empty chart with no explanation is what this counter exists to prevent.
|
||||
"""
|
||||
counts["force_frames"] = counts.get("force_frames", 0) + 1
|
||||
counts["force_valid_frames"] = counts.get("force_valid_frames", 0) + (
|
||||
1 if force.valid else 0
|
||||
)
|
||||
counts["force_finite_frames"] = counts.get("force_finite_frames", 0) + (
|
||||
1 if force.any_finite else 0
|
||||
)
|
||||
counts["force_calibrated_frames"] = counts.get(
|
||||
"force_calibrated_frames", 0
|
||||
) + (1 if force.calibrated else 0)
|
||||
|
||||
def _provenance(self, key: ResultKey, facts: dict[str, Any]) -> Provenance:
|
||||
session = facts["session"]
|
||||
policy = self.policy.normalised()
|
||||
synthetic = dict(session.get("synthetic") or {})
|
||||
return Provenance(
|
||||
format_version=model_module.ANALYSIS_FORMAT_VERSION,
|
||||
algorithm=ALGORITHM_VERSION,
|
||||
@@ -1019,6 +1124,7 @@ class AnalysisService:
|
||||
"flow_on_px": policy.flow_on_px,
|
||||
"flow_off_px": policy.flow_off_px,
|
||||
"flow_grid_spacing": policy.flow_grid_spacing,
|
||||
"flow_arrow_scale": policy.flow_arrow_scale,
|
||||
"flow_edge_margin": policy.flow_edge_margin,
|
||||
"flow_length_margin": policy.flow_length_margin,
|
||||
"max_arrows": policy.max_arrows,
|
||||
@@ -1026,8 +1132,16 @@ class AnalysisService:
|
||||
sources={
|
||||
"flow": "recomputed",
|
||||
"depth": "recomputed",
|
||||
"forces": "recorded",
|
||||
# The one channel whose answer depends on what the input *is*. Beside a
|
||||
# recording, ``forces.csv`` is the measurement and the result stores none of
|
||||
# it; over a bare video there is no CSV, so the chain's own recomputation is
|
||||
# what a chart can draw — and it says so.
|
||||
"forces": "recomputed"
|
||||
if self.store_force
|
||||
else "recorded",
|
||||
},
|
||||
synthetic=bool(synthetic),
|
||||
synthetic_source=str(synthetic.get("source_video") or ""),
|
||||
versions={
|
||||
"sdk": _version_of("orisys"),
|
||||
"shared": _version_of("orisys_app_shared"),
|
||||
|
||||
Reference in New Issue
Block a user