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:
ykcheungab
2026-10-05 15:01:48 +08:00
parent 28e955a817
commit 3433eb0fec
10 changed files with 1614 additions and 94 deletions
@@ -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),
+198 -13
View File
@@ -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"),
+141
View File
@@ -919,3 +919,144 @@ def test_resetting_the_latch_clears_the_session_boundary():
assert np.array_equal(latch.update(other), other), (
"a new session kept the previous session's island"
)
# --------------------------------------------- a field recovered from a stored result
def _policy():
from orisys_app_shared.scene3d.visual_policy import VisualPolicy
return VisualPolicy(flow_grid_spacing=10, max_arrows=400).normalised()
def _recovered_flow(height: int = 60, width: int = 80):
"""A flow field as an analysis result hands one over: finite only at its own cells.
This is what the replay's reconstruction produces — the pass recorded candidate
*locations* and their displacements, and the field is rebuilt with those values at
those cells and ``NaN`` everywhere else. A dense field is the opposite case, and the
difference between them is what the painter has to be chosen by.
"""
from orisys_app_shared.scene3d.visual_policy import select_flow_samples
dense = np.zeros((height, width, 2), dtype=np.float64)
dense[..., 0] = 1.5
dense[..., 1] = -0.75
samples = select_flow_samples(dense, None, _policy())
sparse = np.full((height, width, 2), np.nan, dtype=np.float64)
sparse[samples.rows, samples.cols, 0] = samples.dx
sparse[samples.rows, samples.cols, 1] = samples.dy
return sparse, samples
def _painted(image, canvas=(0x16, 0x13, 0x12)) -> int:
"""Pixels the painter changed from the canvas colour."""
array = np.asarray(image)
return int(
np.count_nonzero(np.any(array != np.asarray(canvas, np.uint8), axis=-1))
)
def test_a_recovered_field_is_drawn_by_the_shared_painter_whatever_the_style_is():
"""Where a frame came from decides the painter, not which sensor's style is selected.
The SDK painter walks the dense sampling grid, so on a field that is finite only at a
stored result's candidate cells it finds no sample at all and paints nothing: measured
on a real box recording, **0 px** against the shared painter's **1,486 px** on the same
frame. So the routing is by ``ImageFrame.source`` — the frame came from a result — and
the box *style* still owns the magnification and the idle-dot tint.
"""
from orisys_app_shared.scene3d.visual_policy import select_flow_samples
sparse, samples = _recovered_flow()
base = np.zeros((60, 80, 3), dtype=np.uint8)
base[:, :] = (0x16, 0x13, 0x12)
result_frame = backend.ImageFrame(
base, flow=sparse, source=backend.SOURCE_ANALYSIS, flow_valid=True
)
live_frame = backend.ImageFrame(
base, flow=sparse, source=backend.SOURCE_LIVE, flow_valid=True
)
drawn = backend.render_image(
result_frame, mode=backend.ARROW_PURE, draw_style="box", arrow_policy=_policy()
)
expected = draw_arrow_display(
base,
sparse,
background="pure_color",
draw_style="box",
# The backend hands the theme's ramp to the painter, so the reference call has to
# as well; the default is ``cv2.COLORMAP_JET``, which is a different picture.
colormap=flow_colormap("dark"),
policy=_policy(),
shared_painter=True,
)
assert np.array_equal(np.asarray(drawn), np.asarray(expected)), (
"a result's canvas is not the shared painter's output"
)
assert _painted(drawn) > 0, "the recovered field painted nothing"
# The same array handed over as a *live* field keeps the SDK path — which is what the
# sensor family's draw style has always meant, and which paints nothing here.
sdk = backend.render_image(
live_frame, mode=backend.ARROW_PURE, draw_style="box", arrow_policy=_policy()
)
assert _painted(sdk) == 0, "the SDK painter drew a recovered field"
assert not np.array_equal(np.asarray(drawn), np.asarray(sdk))
# One marker per recorded cell at most: the recovered field carries no other value, so
# nothing outside those cells can become a sample.
assert select_flow_samples(sparse, None, _policy()).rows.size == samples.rows.size
def test_a_box_result_on_the_sdk_grid_matches_the_live_painter():
"""A box pass stores the live painter's grid, so filling the gaps and calling it
back draws the same picture as the dense field."""
from orisys_app_shared.acquisition.arrows import sdk_box_policy
from orisys_app_shared.scene3d.visual_policy import (
DISPLACEMENT_PX_PER_FLOW,
select_flow_samples,
)
policy = sdk_box_policy()
dense = np.zeros((80, 100, 2), dtype=np.float64)
dense[20:60, 20:80, 0] = 1.5
dense[30:40, 30:50, 0] = 8.0
samples = select_flow_samples(dense, None, policy)
assert samples.rows.size > 0
dx = np.asarray(samples.dx, np.float64)
dy = np.asarray(samples.dy, np.float64)
length = np.sqrt(dx * dx + dy * dy)
flow_mag = np.asarray(samples.magnitude, np.float64) / DISPLACEMENT_PX_PER_FLOW
safe = np.maximum(length, 1e-12)
moving = length > 0.0
sparse = np.full(dense.shape, np.nan, dtype=np.float64)
sparse[samples.rows, samples.cols, 0] = np.where(moving, dx / safe * flow_mag, 0.0)
sparse[samples.rows, samples.cols, 1] = np.where(moving, dy / safe * flow_mag, 0.0)
base = np.zeros(dense.shape[:2] + (3,), np.uint8)
base[:] = (0x16, 0x13, 0x12)
cmap = flow_colormap("dark")
live = draw_arrow_display(
base,
dense,
draw_style="box",
background="pure_color",
canvas_bgr=(0x16, 0x13, 0x12),
colormap=cmap,
)
frame = backend.ImageFrame(
base,
flow=sparse,
source=backend.SOURCE_ANALYSIS,
flow_valid=True,
result_available=True,
)
drawn = backend.render_image(
frame,
mode=backend.ARROW_PURE,
draw_style="box",
arrow_policy=policy,
canvas_bgr=(0x16, 0x13, 0x12),
)
assert np.array_equal(np.asarray(drawn), np.asarray(live))
assert _painted(drawn) > 0
+10 -9
View File
@@ -13,9 +13,8 @@ The inputs are real: the 33-frame box recording under ``dist/`` (its own ``frame
``session.json`` and ``raw_0001.mp4``) and the shipped finger configuration and asset
bundle. The pass is driven with the **box** chain on it for the offset and round-trip
checks, and with the **finger** chain on the same footage for the keyframe, reset and
size checks — the finger chain over box footage is exactly what the Step 0 measurement
did, and it is the only way to get a validity mask (and therefore a static candidate
grid) out of this recording.
size checks. The finger chain is what produces a validity mask; the box chain's
painter grid is static without one.
"""
from __future__ import annotations
@@ -530,14 +529,16 @@ def test_the_stored_size_is_the_measured_encoding(box_pass, finger_pass):
_, box = box_pass
box_reader = AnalysisReader(box.directory)
try:
# The box chain has no validity mask, so there is no digest and every frame
# carries its own grid keyframe: 4,882 B of record per frame at 400 candidates,
# plus whatever depth it carries.
# The box chain has no validity mask. Its sample grid is the live painter's
# (spacing 20 over the 365 stitch, every cell kept): 18 x 18 = 324 candidates,
# one keyframe, and the per-frame record is values plus whatever depth it carries.
frames = list(box_reader.frames())
assert {item.candidates for item in frames} == {324}
assert box_reader.frame_record_bytes == sum(
32 + 400 * 12 + 50 + item.depth_stored_bytes
for item in box_reader.frames()
frame_payload_bytes(324, with_validity=False) + item.depth_stored_bytes
for item in frames
)
assert box_reader.keyframe_counts == {"grid": 26}
assert box_reader.keyframe_counts == {"grid": 1}
assert box_reader.mask() is None
finally:
box_reader.close()