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
+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"),