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:
@@ -24,15 +24,12 @@ ever speaks the second one.
|
||||
and never a new pass. The colour range and the idle-dot switch are not even inputs
|
||||
here: they live in the draw call, so changing one redraws and writes nothing.
|
||||
|
||||
The picture is rebuilt from the stored samples: ``dx``/``dy`` are the canvas
|
||||
displacements the pass selected (uv pixels, already divided by the policy's arrow
|
||||
scale), so the flow field is recovered by dividing them back out by
|
||||
:data:`~orisys_app_shared.scene3d.visual_policy.DISPLACEMENT_PX_PER_FLOW` and writing
|
||||
those values at the recorded candidate locations. The 2D canvas then runs its ordinary
|
||||
path — the shared selector, the same idle dots, the same colour ramp — over that field.
|
||||
This is exact, not approximate, and ``apps/studio/tests/test_replay_analysis.py``
|
||||
asserts it against a pass over real footage: re-selecting the recovered field through
|
||||
the display policy reproduces the stored sample set element for element.
|
||||
The picture is rebuilt from the stored samples. For the shared painter, ``dx``/``dy``
|
||||
are the drawn displacements, and the field is recovered by dividing them back out so
|
||||
re-selecting it reproduces those offsets. A box result on the live painter's grid is
|
||||
recovered from ``magnitude`` instead, because that painter colours by the displacement
|
||||
before the length cap. ``apps/studio/tests/test_replay_analysis.py`` asserts the shared
|
||||
path against a pass over real footage.
|
||||
|
||||
The 3D pane is handed the **stored** sample set directly (:attr:`AnalysedFrame.samples`)
|
||||
— the object Part A documents as "what the canvas and the composer already draw" — so
|
||||
@@ -49,6 +46,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.acquisition.arrows import uses_sdk_box_grid
|
||||
from orisys_app_shared.acquisition.models import SdkPacket
|
||||
from orisys_app_shared.analysis.reader import AnalysisReader
|
||||
from orisys_app_shared.scene3d.visual_policy import (
|
||||
@@ -67,11 +65,9 @@ __all__ = [
|
||||
"policy_divergence",
|
||||
]
|
||||
|
||||
#: The UV domain to rebuild the flow field in when the result carries no validity mask
|
||||
#: keyframe (the box chain has none). Only the recorded candidate locations are ever
|
||||
#: read back out of it, so its size is a canvas for the samples rather than a claim
|
||||
#: about the device — but it has to hold every candidate, which
|
||||
#: :meth:`AnalysisSession._recover_flow` checks.
|
||||
#: Last-resort canvas when a result has no mask, no depth plane and no grid. The box
|
||||
#: chain's samples live on ``resolution_stitch``, not on this raw-camera frame; using it
|
||||
#: whenever the mask is absent paints that grid into one corner.
|
||||
DEFAULT_DOMAIN_HW = (1080, 1920)
|
||||
|
||||
#: The policy keys that decide *where* the candidate locations are. A display setting
|
||||
@@ -191,6 +187,7 @@ class AnalysisSession:
|
||||
self._times = np.asarray(reader.times, dtype=np.float64)
|
||||
self._mask: np.ndarray | None = None
|
||||
self._mask_loaded = False
|
||||
self._domain_hw: tuple[int, int] | None = None
|
||||
self._cache_id: int | None = None
|
||||
self._cache: AnalysedFrame | None = None
|
||||
|
||||
@@ -256,6 +253,42 @@ class AnalysisSession:
|
||||
def threshold(self) -> float:
|
||||
return float(self.display_policy.flow_on_px)
|
||||
|
||||
def paint_policy(self) -> VisualPolicy:
|
||||
"""The policy this result's **recovered** flow field can be drawn with.
|
||||
|
||||
The two halves come from different places, on purpose. *Where* the candidates are is
|
||||
the pass's own answer — the field is finite only at those cells, and
|
||||
:meth:`serve_policy` refuses a display setting that moves the grid — so the grid
|
||||
travels from the result's provenance. *Which* candidates are active is a display
|
||||
question, and ``magnitude`` is stored precisely so it can be asked again later, so
|
||||
the threshold travels from the display policy.
|
||||
|
||||
Without this, a display setting that moves the grid would be refused by
|
||||
``serve_policy`` and then applied to the drawing anyway, re-selecting the recovered
|
||||
field on a grid whose cells mostly hold ``NaN``: the canvas would go blank rather
|
||||
than draw the result's own samples.
|
||||
"""
|
||||
stored = self.stored_policy()
|
||||
display = self.display_policy
|
||||
policy = VisualPolicy(
|
||||
flow_on_px=display.flow_on_px,
|
||||
flow_off_px=display.flow_off_px,
|
||||
flow_grid_spacing=int(
|
||||
stored.get("flow_grid_spacing", self.policy.flow_grid_spacing)
|
||||
),
|
||||
flow_arrow_scale=float(
|
||||
stored.get("flow_arrow_scale", self.policy.flow_arrow_scale)
|
||||
),
|
||||
flow_edge_margin=int(
|
||||
stored.get("flow_edge_margin", self.policy.flow_edge_margin)
|
||||
),
|
||||
flow_length_margin=float(
|
||||
stored.get("flow_length_margin", self.policy.flow_length_margin)
|
||||
),
|
||||
max_arrows=int(stored.get("max_arrows", self.policy.max_arrows)),
|
||||
)
|
||||
return policy.normalised()
|
||||
|
||||
# ------------------------------------------------------------------ looking
|
||||
def frame_id_at(self, t_rel_s: float) -> int | None:
|
||||
"""The stored frame at or just before ``t_rel_s``, by *signal* time."""
|
||||
@@ -351,10 +384,35 @@ class AnalysisSession:
|
||||
return self._mask
|
||||
|
||||
def domain_hw(self) -> tuple[int, int]:
|
||||
"""The image the stored samples were measured on.
|
||||
|
||||
A validity mask is that image. Without one (the box chain), the depth plane is
|
||||
the stitch grid and the candidate coordinates live on it. The raw camera frame
|
||||
is larger, and painting the stitch grid onto it leaves the arrows in a corner.
|
||||
"""
|
||||
mask = self.mask()
|
||||
if mask is None:
|
||||
return DEFAULT_DOMAIN_HW
|
||||
return (int(mask.shape[0]), int(mask.shape[1]))
|
||||
if mask is not None:
|
||||
return (int(mask.shape[0]), int(mask.shape[1]))
|
||||
if self._domain_hw is None:
|
||||
self._domain_hw = self._unmasked_domain_hw()
|
||||
return self._domain_hw
|
||||
|
||||
def _unmasked_domain_hw(self) -> tuple[int, int]:
|
||||
height = width = 0
|
||||
if self._frame_ids.size:
|
||||
depth = getattr(self.reader.frame(int(self._frame_ids[0])), "depth", None)
|
||||
if depth is not None and int(getattr(depth, "ndim", 0)) == 2 and depth.size:
|
||||
height, width = int(depth.shape[0]), int(depth.shape[1])
|
||||
for reference in self.reader.keyframes.values():
|
||||
if getattr(reference, "kind", "") != "grid":
|
||||
continue
|
||||
rows, cols = self.reader.grid(int(reference.id))
|
||||
if rows.size:
|
||||
height = max(height, int(np.max(rows)) + 1)
|
||||
width = max(width, int(np.max(cols)) + 1)
|
||||
if height > 0 and width > 0:
|
||||
return (height, width)
|
||||
return DEFAULT_DOMAIN_HW
|
||||
|
||||
def _compose(self, payload: Any) -> AnalysedFrame:
|
||||
mask = self.mask()
|
||||
@@ -387,15 +445,17 @@ class AnalysisSession:
|
||||
invalid, so no neighbouring cell can contribute a sample the pass did not
|
||||
record.
|
||||
|
||||
``dx``/``dy`` are the **drawn** displacements, which the selector produced as
|
||||
``DISPLACEMENT_PX_PER_FLOW * flow / flow_arrow_scale`` and then scaled by
|
||||
``flow_length_margin`` when the arrow was longer than its cell. Recovering the
|
||||
field is therefore a division by the arrow scale, the display constant *and*
|
||||
that margin — measured, not assumed: dividing by the first two alone leaves every
|
||||
arrow 20% short (the finger margin is 0.8), which changes 319 of 330 samples and
|
||||
one frame's active set. With the margin included the selector's own
|
||||
``np.where(length > spacing * margin, ...)`` branch is idempotent, so
|
||||
re-selecting the recovered field reproduces the stored samples exactly.
|
||||
``dx``/``dy`` are the **drawn** displacements: ``DISPLACEMENT_PX_PER_FLOW * flow
|
||||
/ flow_arrow_scale``, then multiplied by ``flow_length_margin`` (and capped).
|
||||
The shared painter re-selects this field, so the inverse divides by that
|
||||
product and the cap stays idempotent: the stored offsets come back exactly.
|
||||
At ``flow_arrow_scale == 1`` that divisor is ``DISPLACEMENT_PX_PER_FLOW`` times
|
||||
the margin.
|
||||
|
||||
The box painter does not re-select. It colours by the displacement *before*
|
||||
the cap, which ``magnitude`` still holds (``|DISPLACEMENT_PX_PER_FLOW * flow|``).
|
||||
Direction comes from ``dx``/``dy``. Float32 magnitude is not exact enough to
|
||||
reproduce a capped offset, which is why only the box grid uses it.
|
||||
"""
|
||||
rows = np.asarray(samples.rows, np.int64)
|
||||
cols = np.asarray(samples.cols, np.int64)
|
||||
@@ -405,18 +465,32 @@ class AnalysisSession:
|
||||
"the result's candidate locations do not fit the domain it declares "
|
||||
f"({height}x{width}); the mask keyframe and the grid keyframe disagree"
|
||||
)
|
||||
divisor = (
|
||||
DISPLACEMENT_PX_PER_FLOW
|
||||
* float(self.policy.flow_arrow_scale)
|
||||
* float(self.policy.flow_length_margin)
|
||||
)
|
||||
dx = np.asarray(samples.dx, np.float64)
|
||||
dy = np.asarray(samples.dy, np.float64)
|
||||
flow = np.full((height, width, 2), np.nan, dtype=np.float64)
|
||||
with np.errstate(invalid="ignore"):
|
||||
# ``NaN`` is the point of the mask, and the selector's tip arithmetic on a
|
||||
# discarded cell is defined to be discarded: say so here rather than let
|
||||
# numpy warn about an invalid cast on every frame.
|
||||
flow[rows, cols, 0] = np.asarray(samples.dx, np.float64) / divisor
|
||||
flow[rows, cols, 1] = np.asarray(samples.dy, np.float64) / divisor
|
||||
if uses_sdk_box_grid(self.policy):
|
||||
magnitude = np.asarray(samples.magnitude, np.float64)
|
||||
length = np.sqrt(dx * dx + dy * dy)
|
||||
flow_mag = magnitude / DISPLACEMENT_PX_PER_FLOW
|
||||
safe = np.maximum(length, 1e-12)
|
||||
moving = length > 0.0
|
||||
flow[rows, cols, 0] = np.where(moving, dx / safe * flow_mag, 0.0)
|
||||
flow[rows, cols, 1] = np.where(moving, dy / safe * flow_mag, 0.0)
|
||||
else:
|
||||
scale = float(self.policy.flow_arrow_scale)
|
||||
if scale <= 0.0:
|
||||
scale = 1.0
|
||||
divisor = (
|
||||
DISPLACEMENT_PX_PER_FLOW
|
||||
* float(self.policy.flow_length_margin)
|
||||
/ scale
|
||||
)
|
||||
flow[rows, cols, 0] = dx / divisor
|
||||
flow[rows, cols, 1] = dy / divisor
|
||||
return flow
|
||||
|
||||
|
||||
|
||||
@@ -698,8 +698,16 @@ class ReplayController(QObject):
|
||||
|
||||
policy = policy_for_settings(settings)
|
||||
if not session.serve_policy(policy):
|
||||
moved = policy_divergence(session.stored_policy(), policy)
|
||||
self._host.notify(t("playback.analysis_grid_mismatch", keys="·".join(moved)))
|
||||
# A box result's grid is the live painter's, not a display preference.
|
||||
# The display policy is the shared selector's, so the two always differ,
|
||||
# and saying so would scold the operator for a grid they cannot change.
|
||||
from orisys_app_shared.acquisition.arrows import uses_sdk_box_grid
|
||||
|
||||
if not uses_sdk_box_grid(session.stored_policy()):
|
||||
moved = policy_divergence(session.stored_policy(), policy)
|
||||
self._host.notify(
|
||||
t("playback.analysis_grid_mismatch", keys="·".join(moved))
|
||||
)
|
||||
self._engine.redraw()
|
||||
|
||||
def on_analysis_requested(self) -> None:
|
||||
@@ -810,6 +818,19 @@ class ReplayController(QObject):
|
||||
return folder.parent
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _host_chain_is_box(service) -> bool:
|
||||
chain = getattr(service, "chain", None)
|
||||
return str(getattr(chain, "sensor_type", "") or "") == "box"
|
||||
|
||||
@staticmethod
|
||||
def _stored_grid_is_sdk_box(reader) -> bool:
|
||||
from orisys_app_shared.acquisition.arrows import uses_sdk_box_grid
|
||||
|
||||
provenance = getattr(reader, "provenance", None)
|
||||
stored = dict(getattr(provenance, "policy", {}) or {})
|
||||
return uses_sdk_box_grid(stored)
|
||||
|
||||
def _activate_stored_result(
|
||||
self, recording: Path, *, notify_reuse: bool = False, reanalyse: bool = False
|
||||
) -> bool:
|
||||
@@ -844,6 +865,17 @@ class ReplayController(QObject):
|
||||
reader = None
|
||||
if reader is None:
|
||||
return False
|
||||
# A box result recorded on the shared selector's grid cannot be drawn by the
|
||||
# live painter. Treat it as a miss so the pass runs again and records the
|
||||
# painter's own cells. The key does not include the grid, so the new pass
|
||||
# replaces this directory.
|
||||
if self._host_chain_is_box(service) and not self._stored_grid_is_sdk_box(reader):
|
||||
logger.info("stored box result uses a different sample grid; re-running")
|
||||
try:
|
||||
reader.close()
|
||||
except Exception: # noqa: BLE001 - releasing is best effort
|
||||
logger.debug("could not close the stale box result", exc_info=True)
|
||||
return False
|
||||
try:
|
||||
session = AnalysisSession(
|
||||
reader,
|
||||
@@ -1153,7 +1185,8 @@ class ReplayController(QObject):
|
||||
# drawable at all; the plan reads it to decide what to offer.
|
||||
result_available=True,
|
||||
# ...and the field it carries is *recovered*: finite only at the candidate
|
||||
# cells the pass recorded, so it can only be re-selected on the recorded grid.
|
||||
# cells the pass recorded. A finger result is re-selected on that grid; a
|
||||
# box result recorded on the live painter's grid is filled and drawn by it.
|
||||
# The display's own policy is the caller's for a live field, and wrong here.
|
||||
arrow_policy=self._session_policy(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
"""T1: a bare video, analysed end to end, through the window's own action.
|
||||
|
||||
The workflow this defends is the minimum one an operator needs today: point Studio at a
|
||||
video that has no recording beside it, answer one prompt, and get a **measurement** — arrows,
|
||||
a depth map and a force trace that can be scrubbed in both directions, because they are read
|
||||
from a stored result rather than from a re-run of the chain.
|
||||
|
||||
What makes it a *bare* video is the input: the clip is copied into a folder of its own with
|
||||
nothing else in it (no ``forces.csv``, no ``frames.csv``, no ``session.json``). What makes it
|
||||
analysable is the workspace the app generates for it (``analysis.bare_video``), written under
|
||||
the app's own derived-data root — which this module points at a temporary directory, so the
|
||||
test can assert *where* it went instead of trusting it.
|
||||
|
||||
Where a failure would be invisible:
|
||||
|
||||
* the prompt's answers decide what is measured, so the profile, the calibration and the
|
||||
reference frame are read back from the dialog's own widgets;
|
||||
* a recomputed force trace and a recorded one are different claims, so the charts' source
|
||||
kind and the provenance's ``sources``/``synthetic`` are asserted, not just the pixels;
|
||||
* a box result is recorded on the live painter's grid, so the canvas is compared with
|
||||
``draw_arrows`` on the recovered field once its gaps are filled with zero.
|
||||
|
||||
The fixture clip is the project's recorded box footage under ``dist/``, copied into the test's
|
||||
own directory. If it is absent the test **fails** (``_require_clip``) rather than skipping: a
|
||||
silently skipped end-to-end test is how a broken workflow ships.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from orisys_app_shared import paths as paths_module
|
||||
from orisys_app_shared.acquisition.arrows import (
|
||||
depth_map_to_bgr,
|
||||
draw_arrow_display,
|
||||
flow_colormap,
|
||||
hex_to_bgr,
|
||||
)
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
ARROW_PURE,
|
||||
DEPTH,
|
||||
RECORDED,
|
||||
RESULT_FAILED,
|
||||
SOURCE_REPLAY,
|
||||
ImageFrame,
|
||||
render_image,
|
||||
)
|
||||
from orisys_app_shared.analysis import bare_video
|
||||
from orisys_app_shared.analysis.cache import scan_results
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
||||
from orisys_app_shared.sensors import (
|
||||
CatalogEntry,
|
||||
SensorCatalog,
|
||||
UserRootDecision,
|
||||
scan_sensor_catalog,
|
||||
)
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox
|
||||
|
||||
from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
|
||||
|
||||
APP = Path(__file__).resolve().parents[1]
|
||||
REPO = APP.parents[1]
|
||||
EVIDENCE = (
|
||||
REPO
|
||||
/ "dist"
|
||||
/ "OrisysStudio"
|
||||
/ "outputs"
|
||||
/ "recordings"
|
||||
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011"
|
||||
)
|
||||
SOURCE_CLIP = EVIDENCE / "raw_0001.mp4"
|
||||
|
||||
|
||||
def _require_clip() -> Path:
|
||||
"""The one real input: recorded box footage, copied rather than faked.
|
||||
|
||||
A synthetic clip cannot stand in for it. The box chain's stitch probe finds its lenslet
|
||||
grid in a real image; on generated pixels it does not converge — measured, a synthetic
|
||||
896² clip sent the chain into minutes of work where the real 33-frame clip takes two
|
||||
seconds. So the input is the project's own recording, and its absence is a **failure**:
|
||||
skipping would leave "a bare video can be analysed" untested, which is the state this
|
||||
task exists to change.
|
||||
"""
|
||||
assert SOURCE_CLIP.is_file(), (
|
||||
"the bare-video test needs recorded box footage at "
|
||||
f"{SOURCE_CLIP}; copy it there or run the build that produces dist/"
|
||||
)
|
||||
pytest.importorskip("orisys", reason="the analysis pass drives the SDK chain")
|
||||
return SOURCE_CLIP
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ fixtures
|
||||
@pytest.fixture(scope="module")
|
||||
def cache_root(tmp_path_factory):
|
||||
"""The analysis cache for this module, in place of the user's own."""
|
||||
root = tmp_path_factory.mktemp("bare-video-cache") / "analysis-cache"
|
||||
root.mkdir(parents=True)
|
||||
patch = pytest.MonkeyPatch()
|
||||
patch.setattr(paths_module, "analysis_cache_dir", lambda: root)
|
||||
yield root
|
||||
patch.undo()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def own_cache(tmp_path, monkeypatch) -> Path:
|
||||
"""A cache root this test alone owns, so its first open really does run a pass."""
|
||||
root = tmp_path / "own-cache"
|
||||
root.mkdir()
|
||||
monkeypatch.setattr(paths_module, "analysis_cache_dir", lambda: root)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def workspace_root(tmp_path_factory):
|
||||
"""The app-owned root the generated workspace goes to, in place of the user's own."""
|
||||
root = tmp_path_factory.mktemp("bare-video-workspaces") / "video-workspaces"
|
||||
patch = pytest.MonkeyPatch()
|
||||
patch.setattr(paths_module, "video_workspace_dir", lambda: root)
|
||||
yield root
|
||||
patch.undo()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bare_clip(tmp_path) -> Path:
|
||||
"""One video, alone in a folder: a bare video and nothing else."""
|
||||
folder = tmp_path / "operator-footage"
|
||||
folder.mkdir()
|
||||
clip = folder / "session-clip.mp4"
|
||||
shutil.copy2(_require_clip(), clip)
|
||||
return clip
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window(make_window, qt_app, cache_root, workspace_root):
|
||||
"""The application, in the replay view, as the operator meets it."""
|
||||
view = make_window(sensor_type="box", source_mode="camera")
|
||||
view.resize(1440, 880)
|
||||
view.show()
|
||||
QApplication.processEvents()
|
||||
view.brand_bar.btn_playback.click()
|
||||
QApplication.processEvents()
|
||||
assert view.layout_ctl.in_replay_view(), "the window did not switch to the replay view"
|
||||
return view
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- helpers
|
||||
def answer_prompt(
|
||||
monkeypatch,
|
||||
*,
|
||||
profile: str = "shipped/box",
|
||||
calibration: str = "default",
|
||||
reference: int | None = None,
|
||||
accept: bool = True,
|
||||
seen: list | None = None,
|
||||
):
|
||||
"""Answer the setup prompt through the real dialog's own widgets.
|
||||
|
||||
The dialog class is the real one and the window builds it; only ``exec`` is replaced, so
|
||||
what a test answers is exactly what an operator could have answered, and the defaults can
|
||||
be read back from the widgets the operator sees.
|
||||
"""
|
||||
|
||||
def _answer(dialog_self):
|
||||
if seen is not None:
|
||||
seen.append(dialog_self)
|
||||
assert isinstance(dialog_self, VideoAnalysisDialog)
|
||||
assert dialog_self.select_profile(profile), (
|
||||
f"the prompt does not offer {profile}: "
|
||||
f"{[entry.ref for entry in dialog_self.entries]}"
|
||||
)
|
||||
if reference is not None:
|
||||
dialog_self.reference_spin.setValue(int(reference))
|
||||
if calibration == "none":
|
||||
index = dialog_self.calibration_combo.findData("none")
|
||||
assert index >= 0
|
||||
dialog_self.calibration_combo.setCurrentIndex(index)
|
||||
elif calibration != "default":
|
||||
dialog_self.select_calibration(Path(calibration))
|
||||
return QDialog.DialogCode.Accepted if accept else QDialog.DialogCode.Rejected
|
||||
|
||||
monkeypatch.setattr(VideoAnalysisDialog, "exec", _answer)
|
||||
|
||||
|
||||
def panel_bgr(window) -> np.ndarray:
|
||||
"""The 2D panel's picture, in BGR, from the end of the real display path."""
|
||||
image = window.arrow_panel._source_image
|
||||
assert image is not None and not image.isNull(), "the 2D panel shows no picture"
|
||||
width, height = image.width(), image.height()
|
||||
rows = np.frombuffer(bytes(image.constBits()), dtype=np.uint8)
|
||||
rows = rows.reshape(height, image.bytesPerLine())
|
||||
return rows[:, : width * 3].reshape(height, width, 3)[:, :, ::-1].copy()
|
||||
|
||||
|
||||
def sink_frames(window, monkeypatch) -> list:
|
||||
captured: list = []
|
||||
original = window._show_image_frame
|
||||
|
||||
def spy(frame):
|
||||
captured.append(frame)
|
||||
return original(frame)
|
||||
|
||||
monkeypatch.setattr(window, "_show_image_frame", spy)
|
||||
return captured
|
||||
|
||||
|
||||
def set_mode(window, mode: str) -> None:
|
||||
index = window.arrow_panel.view_combo.findData(mode)
|
||||
assert index >= 0, f"the mode {mode} is not offered: {window.arrow_panel.view_modes()}"
|
||||
window.arrow_panel.view_combo.setCurrentIndex(index)
|
||||
QApplication.processEvents()
|
||||
assert window.arrow_panel.view_mode() == mode
|
||||
|
||||
|
||||
def canvas_bgr(window) -> tuple[int, int, int]:
|
||||
return hex_to_bgr(color(window._theme, "arrow_canvas", "#0B1830"))
|
||||
|
||||
|
||||
def painted(image, canvas) -> int:
|
||||
array = np.asarray(image)
|
||||
return int(np.count_nonzero(np.any(array != np.asarray(canvas, np.uint8), axis=-1)))
|
||||
|
||||
|
||||
def entry_set(directory: Path) -> set:
|
||||
"""Every entry under ``directory`` as ``(relative path, size)``."""
|
||||
found = set()
|
||||
for path in Path(directory).rglob("*"):
|
||||
relative = str(path.relative_to(directory))
|
||||
found.add((relative, -1 if path.is_dir() else path.stat().st_size))
|
||||
return found
|
||||
|
||||
|
||||
def open_bare_video(window, clip: Path, monkeypatch, **answer) -> None:
|
||||
"""The operator's sequence up to "the pass is running": open, and answer the prompt."""
|
||||
answer_prompt(monkeypatch, **answer)
|
||||
assert window.replay_ctl.load_video(clip) is True, "the video did not open"
|
||||
QApplication.processEvents()
|
||||
|
||||
|
||||
def settle(window, *, timeout_s: float = 300.0) -> None:
|
||||
"""Let a pass finish if one is running, and check that a result came out of it."""
|
||||
if window.replay_ctl.analysis_running():
|
||||
assert window.replay_ctl.wait_for_analysis(timeout_s), "the pass did not finish"
|
||||
QApplication.processEvents()
|
||||
assert window.replay_ctl.analysis_error() == "", (
|
||||
f"the pass left no usable result: {window.replay_ctl.analysis_error()}"
|
||||
)
|
||||
assert window.replay_ctl.available_result() is not None, "no stored result was adopted"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- the workflow
|
||||
def test_a_bare_video_is_analysed_and_scrubbed_in_both_directions(
|
||||
bare_clip, window, monkeypatch, own_cache, workspace_root
|
||||
):
|
||||
"""Open a bare video → answer the prompt → the pass runs → scrub it backwards."""
|
||||
before = entry_set(bare_clip.parent)
|
||||
captured = sink_frames(window, monkeypatch)
|
||||
seen: list = []
|
||||
open_bare_video(window, bare_clip, monkeypatch, seen=seen)
|
||||
|
||||
assert window.replay_ctl.analysis_running(), (
|
||||
"no pass was started for a bare video with no stored result"
|
||||
)
|
||||
assert window.analysis_progress.state() == "running"
|
||||
settle(window)
|
||||
|
||||
# ---- the prompt's fields and their defaults -----------------------------
|
||||
dialog = seen[0]
|
||||
assert [entry.ref for entry in dialog.entries] == [
|
||||
entry.ref for entry in bare_video.profiles_for_video(scan_sensor_catalog().entries)
|
||||
], "the prompt offered a different set of profiles than the catalog can analyse with"
|
||||
assert "flow" in dialog.capability_label.text(), (
|
||||
"the row does not say what the profile can do"
|
||||
)
|
||||
assert dialog.calibration_combo.currentData() == "default"
|
||||
assert dialog.calibration_combo.currentText() == t(
|
||||
"video_analysis.calibration_default", name="force_calibration.json"
|
||||
), "the calibration did not default to the profile's declared one"
|
||||
assert dialog.reference_spin.value() == 1, (
|
||||
"the reference frame did not default to the first"
|
||||
)
|
||||
chosen = dialog.choice()
|
||||
assert chosen is not None and chosen.reference_frame is None, (
|
||||
"the first frame must stay the pass's own default, not a reset it cannot apply"
|
||||
)
|
||||
assert chosen.calibration is not None and chosen.calibration.name == "force_calibration.json"
|
||||
|
||||
# ---- the workspace, and where it went -----------------------------------
|
||||
workspace = Path(window.replay_ctl.recording_directory() or "")
|
||||
assert workspace.is_dir(), "the pass was not given a generated workspace"
|
||||
assert workspace.parent == workspace_root, f"the workspace went to {workspace.parent}"
|
||||
assert workspace.parent != bare_clip.parent
|
||||
assert sorted(path.name for path in workspace.iterdir()) == [
|
||||
"frames.csv",
|
||||
"raw_0001.mp4",
|
||||
"session.json",
|
||||
]
|
||||
assert entry_set(bare_clip.parent) == before, (
|
||||
"opening and analysing a bare video wrote beside the operator's footage"
|
||||
)
|
||||
|
||||
session = window.replay_ctl.available_result()
|
||||
provenance = session.reader.provenance
|
||||
assert provenance.synthetic is True, (
|
||||
"the result does not say its input was a generated workspace"
|
||||
)
|
||||
assert provenance.synthetic_source == str(bare_clip), (
|
||||
"the provenance does not name the video the numbers were computed from"
|
||||
)
|
||||
assert provenance.sources["flow"] == "recomputed"
|
||||
assert provenance.sources["forces"] == "recomputed", (
|
||||
"a recomputed force trace was recorded as a recorded measurement"
|
||||
)
|
||||
|
||||
# ---- the charts: the recomputed force trace -----------------------------
|
||||
assert window.force_plots.curve_kind() == "analysis", (
|
||||
"the charts are not reading the result the pass wrote"
|
||||
)
|
||||
assert window.force_plots.is_recording_source()
|
||||
assert window.force_plots.mode_label.text() == t("chart.mode_recomputed"), (
|
||||
"the charts do not say that this trace is a recomputation"
|
||||
)
|
||||
table = session.reader.forces()
|
||||
assert table is not None and len(table) > 1, "the result stores no force at all"
|
||||
assert np.isfinite(table.newtons).any(), "the stored force trace holds no number"
|
||||
unit = window.force_plots.unit()
|
||||
xs, ys = window.force_plots.curves[0].getData()
|
||||
assert xs is not None and len(xs) == len(table), "the FN curve does not hold the trace"
|
||||
assert np.allclose(
|
||||
np.asarray(ys, float), table.value_for_unit(unit)[:, 0], equal_nan=True
|
||||
)
|
||||
|
||||
# ---- the 2D canvas: arrows and depth, from the cache --------------------
|
||||
canvas = canvas_bgr(window)
|
||||
ids = [int(value) for value in session.frame_ids]
|
||||
last_id, middle_id = ids[-1], ids[len(ids) // 2]
|
||||
assert session.frame(last_id).active_count > 0
|
||||
|
||||
set_mode(window, ARROW_PURE)
|
||||
window.replay.set_position(session.time_of(middle_id))
|
||||
QApplication.processEvents()
|
||||
assert captured[-1].frame_id == middle_id
|
||||
assert captured[-1].source == SOURCE_REPLAY
|
||||
assert captured[-1].result_available is True
|
||||
|
||||
# ...then scrub *backwards*, from the end of the timeline to that same frame: it must
|
||||
# resolve to the stored frame for the position, drawn from the cache, with no pass.
|
||||
window.replay.set_position(window.replay.duration_s() + 1.0)
|
||||
QApplication.processEvents()
|
||||
assert captured[-1].frame_id == last_id, (
|
||||
"the cursor at the end of the timeline did not resolve to the last stored frame"
|
||||
)
|
||||
window.replay.set_position(session.time_of(middle_id))
|
||||
QApplication.processEvents()
|
||||
assert captured[-1].frame_id == middle_id, (
|
||||
"scrubbing backwards did not resolve to the stored frame at that position"
|
||||
)
|
||||
assert not window.replay_ctl.analysis_running(), "scrubbing ran an analysis pass"
|
||||
middle_shot = panel_bgr(window)
|
||||
|
||||
frame = session.frame(middle_id)
|
||||
policy = session.paint_policy()
|
||||
expected = render_image(
|
||||
ImageFrame(
|
||||
frame.base_bgr,
|
||||
flow=frame.flow,
|
||||
depth=frame.depth,
|
||||
valid_mask=frame.valid_mask,
|
||||
source=SOURCE_REPLAY,
|
||||
frame_id=int(frame.frame_id),
|
||||
flow_valid=True,
|
||||
depth_valid=bool(frame.has_depth),
|
||||
result_available=True,
|
||||
arrow_policy=policy,
|
||||
),
|
||||
mode=ARROW_PURE,
|
||||
theme=window._theme,
|
||||
draw_style=window._draw_style(),
|
||||
canvas_bgr=canvas,
|
||||
domain_mask=window._domain_mask.mask,
|
||||
arrow_policy=policy,
|
||||
)
|
||||
assert expected is not None
|
||||
assert painted(expected, canvas) > 0, "the cached arrows painted nothing"
|
||||
assert np.array_equal(middle_shot, np.asarray(expected)), (
|
||||
"the panel's arrow canvas is not the stored frame's own drawing"
|
||||
)
|
||||
raw = window.replay.video_frame_bgr()
|
||||
assert raw is not None and not np.array_equal(middle_shot, np.asarray(raw)), (
|
||||
"the arrow view is the video's raw picture"
|
||||
)
|
||||
|
||||
# ---- the depth view, from the same stored frame -------------------------
|
||||
set_mode(window, DEPTH)
|
||||
QApplication.processEvents()
|
||||
expected_depth = depth_map_to_bgr(
|
||||
frame.depth,
|
||||
flow_colormap(window._theme),
|
||||
value_range=window._depth_range(),
|
||||
invalid_mask=window._domain_mask.mask,
|
||||
)
|
||||
assert expected_depth is not None
|
||||
assert np.array_equal(panel_bgr(window), expected_depth), (
|
||||
"the depth view is not depth_map_to_bgr of the stored frame"
|
||||
)
|
||||
|
||||
# ---- the recording's own picture is still one step away -----------------
|
||||
set_mode(window, RECORDED)
|
||||
assert np.array_equal(panel_bgr(window), np.asarray(raw))
|
||||
assert window.arrow_panel.note_label.text() == "", (
|
||||
"the panel says there is nothing to draw while it is drawing a result"
|
||||
)
|
||||
|
||||
|
||||
def test_the_box_arrow_canvas_is_painted_by_the_live_painter(
|
||||
bare_clip, window, monkeypatch
|
||||
):
|
||||
"""A box pass stores the live painter's grid, so the window draws ``draw_arrows``.
|
||||
|
||||
The recovered field is still NaN off the recorded cells. Filling those gaps with zero
|
||||
is what the live painter reads as "no motion", and that picture is the panel.
|
||||
"""
|
||||
open_bare_video(window, bare_clip, monkeypatch)
|
||||
settle(window)
|
||||
session = window.replay_ctl.available_result()
|
||||
canvas = canvas_bgr(window)
|
||||
policy = session.paint_policy()
|
||||
assert int(policy.flow_grid_spacing) == 20
|
||||
assert abs(float(policy.flow_arrow_scale) - 0.5) < 1e-9
|
||||
common = {
|
||||
"background": "pure_color",
|
||||
"canvas_bgr": canvas,
|
||||
"draw_style": window._draw_style(),
|
||||
"colormap": flow_colormap(window._theme),
|
||||
"policy": policy,
|
||||
"shared_painter": False,
|
||||
}
|
||||
|
||||
set_mode(window, ARROW_PURE)
|
||||
first = next(frame for frame in session.frames() if frame.active_count > 0)
|
||||
window.replay.set_position(session.time_of(first.frame_id))
|
||||
QApplication.processEvents()
|
||||
shown = panel_bgr(window)
|
||||
assert painted(shown, canvas) > 0, "the operator's arrow canvas is blank"
|
||||
filled = np.nan_to_num(np.asarray(first.flow, dtype=np.float64), nan=0.0)
|
||||
expected = draw_arrow_display(
|
||||
first.base_bgr,
|
||||
filled,
|
||||
valid_mask=first.valid_mask,
|
||||
**common,
|
||||
)
|
||||
assert np.array_equal(shown, np.asarray(expected)), (
|
||||
"the panel's canvas is not the live painter's output for this frame"
|
||||
)
|
||||
|
||||
|
||||
def test_declining_the_prompt_leaves_the_video_playing_with_nothing_promised(
|
||||
bare_clip, window, monkeypatch
|
||||
):
|
||||
"""Cancelling is a state, not a crash: the picture plays and the panel says no pass ran."""
|
||||
before = entry_set(bare_clip.parent)
|
||||
captured = sink_frames(window, monkeypatch)
|
||||
open_bare_video(window, bare_clip, monkeypatch, accept=False)
|
||||
|
||||
assert window.replay.has_video(), "declining the prompt closed the video"
|
||||
assert window.replay_ctl.available_result() is None
|
||||
assert window.replay_ctl.recording_directory() is None, (
|
||||
"a dismissed prompt still claimed a workspace as this timeline's input"
|
||||
)
|
||||
assert not window.replay_ctl.analysis_running(), "a pass ran after the prompt was dismissed"
|
||||
state, reason = window.replay_ctl.result_state()
|
||||
assert state == RESULT_FAILED
|
||||
assert reason == t("playback.analysis_setup_declined")
|
||||
assert captured and captured[-1].result_state == RESULT_FAILED
|
||||
assert entry_set(bare_clip.parent) == before
|
||||
|
||||
|
||||
def test_a_split_recording_is_refused_with_a_clear_message(
|
||||
bare_clip, window, monkeypatch
|
||||
):
|
||||
"""A split recording is a measurement error: refuse it rather than half-analyse it.
|
||||
|
||||
The files are named the way a split recording is (``take_0001.mp4`` beside
|
||||
``take_0002.mp4``), and the chosen one is opened as a video first — the refusal comes
|
||||
from the setup step, which is where "what exactly is this input" is asked.
|
||||
"""
|
||||
folder = bare_clip.parent
|
||||
bare_clip.unlink()
|
||||
chosen = folder / "take_0001.mp4"
|
||||
shutil.copy2(_require_clip(), chosen)
|
||||
shutil.copy2(_require_clip(), folder / "take_0002.mp4")
|
||||
captured = sink_frames(window, monkeypatch)
|
||||
|
||||
open_bare_video(window, chosen, monkeypatch)
|
||||
|
||||
assert not window.replay_ctl.analysis_running(), "a split recording was analysed anyway"
|
||||
_state, reason = window.replay_ctl.result_state()
|
||||
assert "split recording" in reason, f"the refusal does not say why: {reason!r}"
|
||||
assert "take_0001.mp4" in reason and "take_0002.mp4" in reason
|
||||
assert window.replay.has_video(), "the refusal closed the video"
|
||||
assert captured, "the refusal left the panel with nothing"
|
||||
|
||||
|
||||
def test_reopening_the_video_reuses_the_result_and_runs_no_second_pass(
|
||||
bare_clip, window, monkeypatch, cache_root
|
||||
):
|
||||
"""The cache is the point: a second open is a file read, not a second pass.
|
||||
|
||||
``recording_identity`` is derived from the video and the chosen profile, so a workspace
|
||||
generated again is the *same* input — the key does not move and no pass runs.
|
||||
"""
|
||||
open_bare_video(window, bare_clip, monkeypatch)
|
||||
settle(window)
|
||||
directories = {entry.directory for entry in scan_results(cache_root)}
|
||||
assert directories, "the first open stored no result"
|
||||
workspace = window.replay_ctl.recording_directory()
|
||||
|
||||
window.replay.close()
|
||||
QApplication.processEvents()
|
||||
assert window.replay_ctl.recording_directory() is None
|
||||
|
||||
started: list = []
|
||||
original_start = window.analysis_progress.start
|
||||
|
||||
def spy_start(*args, **kwargs):
|
||||
started.append(True)
|
||||
return original_start(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(window.analysis_progress, "start", spy_start)
|
||||
open_bare_video(window, bare_clip, monkeypatch)
|
||||
settle(window)
|
||||
|
||||
assert not started, "a second pass ran for a video that already had a result"
|
||||
assert window.analysis_progress.state() == "reused"
|
||||
assert {entry.directory for entry in scan_results(cache_root)} == directories
|
||||
assert window.replay_ctl.recording_directory() == workspace, (
|
||||
"the second open generated a different workspace"
|
||||
)
|
||||
assert window.force_plots.curve_kind() == "analysis"
|
||||
|
||||
|
||||
def test_a_setting_that_moves_the_grid_redraws_and_writes_nothing(
|
||||
bare_clip, window, monkeypatch, cache_root
|
||||
):
|
||||
"""A display setting is a read; a grid setting cannot be served and must not blank it."""
|
||||
open_bare_video(window, bare_clip, monkeypatch)
|
||||
settle(window)
|
||||
session = window.replay_ctl.available_result()
|
||||
set_mode(window, ARROW_PURE)
|
||||
before = {entry.directory for entry in scan_results(cache_root)}
|
||||
captured = sink_frames(window, monkeypatch)
|
||||
|
||||
window.replay_ctl.on_display_settings(DisplaySettings(flow_on_px=0.9))
|
||||
QApplication.processEvents()
|
||||
assert captured, "the threshold change drew nothing"
|
||||
assert session.threshold() == pytest.approx(0.9)
|
||||
assert {entry.directory for entry in scan_results(cache_root)} == before, (
|
||||
"a display change wrote an analysis result"
|
||||
)
|
||||
|
||||
window.replay_ctl.on_display_settings(DisplaySettings(max_arrows=50))
|
||||
QApplication.processEvents()
|
||||
policy = captured[-1].arrow_policy
|
||||
assert policy is not None, (
|
||||
"the frame does not say which policy its recovered field can be sampled with"
|
||||
)
|
||||
assert policy.max_arrows == session.paint_policy().max_arrows, (
|
||||
"the display's grid setting reached the recovered field's sampling policy"
|
||||
)
|
||||
assert painted(panel_bgr(window), canvas_bgr(window)) > 0, (
|
||||
"a refused grid setting blanked the canvas"
|
||||
)
|
||||
assert {entry.directory for entry in scan_results(cache_root)} == before
|
||||
|
||||
|
||||
def test_the_workspace_carries_the_profile_the_operator_chose(
|
||||
bare_clip, window, monkeypatch
|
||||
):
|
||||
"""The prompt's answer is what the pass is driven with — read back from the workspace."""
|
||||
open_bare_video(window, bare_clip, monkeypatch, calibration="none")
|
||||
settle(window)
|
||||
workspace = Path(window.replay_ctl.recording_directory())
|
||||
document = json.loads((workspace / "session.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert document["device"]["sensor_type"] == "box"
|
||||
config_ref = Path(document["device"]["config_ref"])
|
||||
assert config_ref.name == "sensor.json" and "sensors" in config_ref.parts
|
||||
assert document["synthetic"]["profile_ref"] == "shipped/box"
|
||||
assert document["synthetic"]["calibration"] == ""
|
||||
assert document["calibration_id"] == "", (
|
||||
"choosing raw force must not leave a calibration in the result's key"
|
||||
)
|
||||
assert document["stats"]["segments"] == 1
|
||||
assert (workspace / "frames.csv").is_file()
|
||||
# The chain was told, too: no calibration means the raw triplet, not a substitute curve.
|
||||
assert window.replay_ctl.available_result() is not None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- the prompt itself
|
||||
def _catalog(entry: CatalogEntry) -> SensorCatalog:
|
||||
return SensorCatalog(
|
||||
roots=(),
|
||||
entries=(entry,),
|
||||
user_root_decision=UserRootDecision(
|
||||
path=Path("."), kind="explicit", writable=True, reason="test"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _blocked_entry() -> CatalogEntry:
|
||||
"""The shipped box bundle as the catalog reports one the SDK's validation rejected.
|
||||
|
||||
Built from the *real* entry so the profile behind it is real too: the verdict is what is
|
||||
replaced, and the reason is the sentence the SDK would have produced.
|
||||
"""
|
||||
import dataclasses
|
||||
|
||||
real = next(
|
||||
entry for entry in scan_sensor_catalog().entries if entry.folder == "box"
|
||||
)
|
||||
return dataclasses.replace(
|
||||
real,
|
||||
valid=False,
|
||||
state="failed",
|
||||
reason="assets.computational-missing: the required unroll map is missing",
|
||||
)
|
||||
|
||||
|
||||
def test_a_profile_the_sdk_rejected_cannot_be_chosen_and_says_why(
|
||||
bare_clip, window, qt_app, tmp_path
|
||||
):
|
||||
"""The catalog's ``start_blocked_reason`` reaches the prompt instead of no caller."""
|
||||
facts = bare_video.probe(bare_video.resolve_video(bare_clip))
|
||||
dialog = VideoAnalysisDialog(
|
||||
video=bare_clip, facts=facts, catalog=_catalog(_blocked_entry()), window=window
|
||||
)
|
||||
try:
|
||||
entry = dialog.entries[0]
|
||||
blocked = bare_video.setup_blocked_reason(entry)
|
||||
assert blocked is not None and "unroll map" in blocked
|
||||
item = dialog.profile_combo.model().item(0)
|
||||
assert not item.isEnabled(), "a profile the SDK rejected is offered as startable"
|
||||
assert not dialog.buttons.button(QDialogButtonBox.StandardButton.Ok).isEnabled(), (
|
||||
"OK is offered for a profile that cannot be started"
|
||||
)
|
||||
assert dialog.note_label.text() == t("video_analysis.blocked", reason=blocked), (
|
||||
"the prompt does not carry the SDK's own reason"
|
||||
)
|
||||
assert dialog.choice() is None, "the refused profile produced a setup anyway"
|
||||
finally:
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_the_first_frame_is_the_prompt_s_default_reference(
|
||||
bare_clip, window, qt_app
|
||||
):
|
||||
"""A reference frame that is not asked for must not become a reset the pass cannot make."""
|
||||
facts = bare_video.probe(bare_video.resolve_video(bare_clip))
|
||||
dialog = VideoAnalysisDialog(
|
||||
video=bare_clip, facts=facts, catalog=scan_sensor_catalog(), window=window
|
||||
)
|
||||
try:
|
||||
assert dialog.reference_spin.minimum() == 1
|
||||
assert dialog.reference_spin.maximum() == facts.frame_count
|
||||
assert dialog.reference_spin.value() == 1
|
||||
assert dialog.choice().reference_frame is None
|
||||
dialog.reference_spin.setValue(4)
|
||||
assert dialog.choice().reference_frame == 4
|
||||
assert dialog.reference_spin.value() == 4
|
||||
finally:
|
||||
dialog.deleteLater()
|
||||
@@ -40,19 +40,26 @@ from orisys_app_shared.acquisition.image_backend import (
|
||||
RESULT_NONE,
|
||||
RESULT_RUNNING,
|
||||
SOURCE_ANALYSIS,
|
||||
SOURCE_LIVE,
|
||||
SOURCE_REPLAY,
|
||||
ImageFrame,
|
||||
plan_image,
|
||||
render_image,
|
||||
)
|
||||
from orisys_app_shared.analysis import service as analysis_service_mod
|
||||
from orisys_app_shared.analysis.cache import ResultCache, scan_results
|
||||
from orisys_app_shared.analysis.reader import (
|
||||
AnalysisIncompleteError,
|
||||
AnalysisNotFoundError,
|
||||
AnalysisReader,
|
||||
)
|
||||
from orisys_app_shared.analysis.service import AnalysisService, ChainSpec
|
||||
from orisys_app_shared.analysis.service import (
|
||||
AnalysisService,
|
||||
ChainSpec,
|
||||
chain_spec_for,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.record.playback import RecordingReader
|
||||
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
||||
from orisys_app_shared.scene3d.visual_policy import (
|
||||
policy_for_settings,
|
||||
@@ -141,6 +148,33 @@ def session(reader) -> AnalysisSession:
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def box_pass(tmp_path_factory):
|
||||
"""One box-chain pass over the evidence recording, for live/replay paint checks."""
|
||||
_require_inputs()
|
||||
root = tmp_path_factory.mktemp("replay-analysis-box")
|
||||
chain = chain_spec_for(RecordingReader(RECORDING).session())
|
||||
service = AnalysisService(RECORDING, chain=chain, cache=ResultCache(root))
|
||||
outcome = service.run()
|
||||
assert outcome.complete, outcome.reason
|
||||
return service, outcome
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def box_session(box_pass):
|
||||
service, outcome = box_pass
|
||||
reader = AnalysisReader(outcome.directory)
|
||||
session = AnalysisSession(
|
||||
reader,
|
||||
policy=service.policy,
|
||||
display_policy=service.policy,
|
||||
session_id=2,
|
||||
recording=RECORDING,
|
||||
)
|
||||
yield session
|
||||
reader.close()
|
||||
|
||||
|
||||
def _same_samples(left, right) -> dict[str, bool]:
|
||||
"""Compare two ``FlowSamples`` (or two frames that carry one) array by array."""
|
||||
left = getattr(left, "samples", left)
|
||||
@@ -341,13 +375,27 @@ class _Progress:
|
||||
self.states.append("idle")
|
||||
|
||||
|
||||
|
||||
def _wire_replay(controller, host):
|
||||
controller.view_requested.connect(lambda preset: (
|
||||
host.presets.append(preset),
|
||||
setattr(host, "view", preset),
|
||||
controller.set_active_view(preset),
|
||||
))
|
||||
controller.acquire_requested.connect(host.start_acquire)
|
||||
controller.acquire_stop_requested.connect(
|
||||
lambda reason: host.stop_acquire(reason=reason)
|
||||
)
|
||||
controller.set_active_view(host.view)
|
||||
return controller
|
||||
|
||||
class _Host:
|
||||
def __init__(self, service_factory=None) -> None:
|
||||
self.prefs: dict = {}
|
||||
self.view = "replay"
|
||||
self._running = False
|
||||
self._preview_open = False
|
||||
self._session_active = False
|
||||
self._running_flag = False
|
||||
self._preview_open_flag = False
|
||||
self._session_active_flag = False
|
||||
self.notes: list[str] = []
|
||||
self.frames: list = []
|
||||
self.analysis_packets: list = []
|
||||
@@ -356,52 +404,99 @@ class _Host:
|
||||
self.service_factory = service_factory
|
||||
self.services_built = 0
|
||||
|
||||
def _in_replay_view(self) -> bool:
|
||||
return self.view == "replay"
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._running_flag
|
||||
|
||||
def _current_source_kind(self) -> str:
|
||||
@running.setter
|
||||
def running(self, value: bool) -> None:
|
||||
self._running_flag = bool(value)
|
||||
|
||||
@property
|
||||
def preview_open(self) -> bool:
|
||||
return self._preview_open_flag
|
||||
|
||||
@preview_open.setter
|
||||
def preview_open(self, value: bool) -> None:
|
||||
self._preview_open_flag = bool(value)
|
||||
|
||||
@property
|
||||
def session_active(self) -> bool:
|
||||
return self._session_active_flag
|
||||
|
||||
@session_active.setter
|
||||
def session_active(self, value: bool) -> None:
|
||||
self._session_active_flag = bool(value)
|
||||
|
||||
@property
|
||||
def _running(self) -> bool:
|
||||
return self._running_flag
|
||||
|
||||
@_running.setter
|
||||
def _running(self, value: bool) -> None:
|
||||
self._running_flag = bool(value)
|
||||
|
||||
@property
|
||||
def _preview_open(self) -> bool:
|
||||
return self._preview_open_flag
|
||||
|
||||
@_preview_open.setter
|
||||
def _preview_open(self, value: bool) -> None:
|
||||
self._preview_open_flag = bool(value)
|
||||
|
||||
@property
|
||||
def _session_active(self) -> bool:
|
||||
return self._session_active_flag
|
||||
|
||||
@_session_active.setter
|
||||
def _session_active(self, value: bool) -> None:
|
||||
self._session_active_flag = bool(value)
|
||||
|
||||
def current_source_kind(self) -> str:
|
||||
return "replay"
|
||||
|
||||
def _apply_view_preset(self, preset: str) -> None:
|
||||
self.presets.append(preset)
|
||||
self.view = preset
|
||||
|
||||
def _set_display_unit(self, unit: str) -> None:
|
||||
def set_display_unit(self, unit: str) -> None:
|
||||
self.units.append(unit)
|
||||
|
||||
def _set_source_mode(self, mode: str) -> None:
|
||||
def set_source_mode(self, mode: str) -> None:
|
||||
pass
|
||||
|
||||
def _show_image_frame(self, frame) -> None:
|
||||
def show_frame(self, frame) -> None:
|
||||
self.frames.append(frame)
|
||||
|
||||
def _show_analysis_frame(self, packet) -> None:
|
||||
def show_analysis_packet(self, packet) -> None:
|
||||
self.analysis_packets.append(packet)
|
||||
|
||||
def _drop_render_marker(self) -> None:
|
||||
def drop_render_marker(self) -> None:
|
||||
pass
|
||||
|
||||
def _notify(self, text: str, ms: int = 0) -> None:
|
||||
def notify(self, text: str, ms: int = 0) -> None:
|
||||
self.notes.append(str(text))
|
||||
|
||||
def _start_run(self) -> None:
|
||||
def start_acquire(self) -> None:
|
||||
pass
|
||||
|
||||
def _stop_run(self, *, reason: str = "user_stop", wait: bool = False) -> None:
|
||||
def stop_acquire(self, *, reason: str = "user_stop", wait: bool = False) -> None:
|
||||
pass
|
||||
|
||||
def _update_run_enabled(self) -> None:
|
||||
def update_run_enabled(self) -> None:
|
||||
pass
|
||||
|
||||
def _refresh_status_rail(self) -> None:
|
||||
def refresh_status_rail(self) -> None:
|
||||
pass
|
||||
|
||||
def _analysis_service(self, recording: Path, cancelled=None, *, on_progress=None):
|
||||
def persist_prefs(self) -> None:
|
||||
pass
|
||||
|
||||
def analysis_service(self, recording: Path, cancelled=None, *, on_progress=None):
|
||||
self.services_built += 1
|
||||
if self.service_factory is None:
|
||||
raise AssertionError("the test did not provide an analysis service")
|
||||
return self.service_factory(recording, cancelled, on_progress)
|
||||
|
||||
def bare_video_workspace(self, video):
|
||||
return None
|
||||
|
||||
|
||||
class _RecordingReader:
|
||||
"""The recording reader as the engine and the controller use it."""
|
||||
@@ -446,14 +541,17 @@ def _controller(engine=None, host=None, **kwargs):
|
||||
panel = kwargs.pop("image_panel", None) or _ImagePanel()
|
||||
mesh = kwargs.pop("mesh_panel", None) or _Mesh()
|
||||
progress = kwargs.pop("progress", None) or _Progress()
|
||||
controller = ReplayController(
|
||||
engine=engine,
|
||||
host=host,
|
||||
toolbar=toolbar,
|
||||
charts=charts,
|
||||
image_panel=panel,
|
||||
mesh_panel=mesh,
|
||||
analysis_progress=progress,
|
||||
controller = _wire_replay(
|
||||
ReplayController(
|
||||
engine=engine,
|
||||
host=host,
|
||||
toolbar=toolbar,
|
||||
charts=charts,
|
||||
image_panel=panel,
|
||||
mesh_panel=mesh,
|
||||
analysis_progress=progress,
|
||||
),
|
||||
host,
|
||||
)
|
||||
actions = {
|
||||
"open_video": _Action(),
|
||||
@@ -610,6 +708,74 @@ def test_the_recovered_field_reselects_to_the_stored_samples(session):
|
||||
assert checked == 33
|
||||
|
||||
|
||||
def test_box_live_and_replay_arrow_canvases_do_not_diverge(box_pass, box_session, monkeypatch):
|
||||
"""Live ``draw_arrows`` on the dense field equals the replay canvas, frame for frame.
|
||||
|
||||
This is the discrepancy the box arrow work exists to close: the operator's live view
|
||||
paints the chain's dense flow, and the replay paints a field recovered from the
|
||||
stored samples. Both go through :func:`render_image` with ``draw_style="box"``.
|
||||
"""
|
||||
service, outcome = box_pass
|
||||
assert outcome.complete, outcome.reason
|
||||
original = analysis_service_mod.select_flow_samples
|
||||
dense_flows: list[np.ndarray] = []
|
||||
|
||||
def _capture(flow, mask, policy, **kwargs):
|
||||
dense_flows.append(np.asarray(flow, dtype=np.float64).copy())
|
||||
return original(flow, mask, policy, **kwargs)
|
||||
|
||||
monkeypatch.setattr(analysis_service_mod, "select_flow_samples", _capture)
|
||||
lives = list(service.recompute())
|
||||
assert len(lives) == len(dense_flows) == outcome.frames_written
|
||||
|
||||
canvas_bgr = (0x16, 0x13, 0x12)
|
||||
policy = box_session.paint_policy()
|
||||
assert int(policy.flow_grid_spacing) == 20
|
||||
painted_any = 0
|
||||
for live, dense in zip(lives, dense_flows, strict=True):
|
||||
frame_id = int(live.row.frame_id)
|
||||
analysed = box_session.frame(frame_id)
|
||||
height, width = int(dense.shape[0]), int(dense.shape[1])
|
||||
base = np.empty((height, width, 3), dtype=np.uint8)
|
||||
base[:] = canvas_bgr
|
||||
live_canvas = render_image(
|
||||
ImageFrame(
|
||||
base,
|
||||
flow=dense,
|
||||
source=SOURCE_LIVE,
|
||||
flow_valid=True,
|
||||
valid_mask=live.mask,
|
||||
),
|
||||
mode=ARROW_PURE,
|
||||
draw_style="box",
|
||||
arrow_policy=policy,
|
||||
canvas_bgr=canvas_bgr,
|
||||
)
|
||||
replay_canvas = render_image(
|
||||
ImageFrame(
|
||||
base,
|
||||
flow=analysed.flow,
|
||||
source=SOURCE_ANALYSIS,
|
||||
flow_valid=True,
|
||||
result_available=True,
|
||||
valid_mask=analysed.valid_mask,
|
||||
),
|
||||
mode=ARROW_PURE,
|
||||
draw_style="box",
|
||||
arrow_policy=policy,
|
||||
canvas_bgr=canvas_bgr,
|
||||
)
|
||||
assert live_canvas is not None and replay_canvas is not None
|
||||
assert np.array_equal(np.asarray(live_canvas), np.asarray(replay_canvas)), (
|
||||
f"frame {frame_id}: live and replay arrow canvases diverge"
|
||||
)
|
||||
painted_any = max(
|
||||
painted_any,
|
||||
int(np.count_nonzero(np.any(np.asarray(live_canvas) != canvas_bgr, axis=2))),
|
||||
)
|
||||
assert painted_any > 0, "every frame painted nothing; the discrepancy was vacuous"
|
||||
|
||||
|
||||
def test_a_seek_equals_playing_forward_to_the_same_frame(session):
|
||||
"""Seek to *N* and play to *N* must produce the same picture.
|
||||
|
||||
@@ -1372,3 +1538,46 @@ def test_a_derived_mode_is_served_from_the_result_even_on_the_recording_timeline
|
||||
assert plan_image(host.frames[-1]).note_key == ""
|
||||
finally:
|
||||
reader.close()
|
||||
|
||||
|
||||
def test_a_stitch_grid_is_not_painted_onto_the_camera_frame():
|
||||
"""Box results have no mask. The canvas is the stitch image, not 1080p.
|
||||
|
||||
Candidates and the depth plane live on ``resolution_stitch``. The raw camera
|
||||
frame is the old stand-in, and it leaves the arrows in one corner.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
class _Reader:
|
||||
complete = True
|
||||
key = "stitch"
|
||||
directory = Path(".")
|
||||
frame_ids = np.array([1], dtype=np.int64)
|
||||
times = np.array([0.0])
|
||||
keyframes: dict = {}
|
||||
|
||||
def mask(self):
|
||||
return None
|
||||
|
||||
def frame(self, frame_id):
|
||||
return SimpleNamespace(depth=np.zeros((365, 365), dtype=np.float32))
|
||||
|
||||
def grid(self, grid_id):
|
||||
raise AssertionError("a depth plane already names the canvas")
|
||||
|
||||
session = AnalysisSession(_Reader())
|
||||
assert session.domain_hw() == (365, 365)
|
||||
|
||||
rows = np.array([15, 355], dtype=np.int32)
|
||||
cols = np.array([15, 300], dtype=np.int32)
|
||||
|
||||
class _GridReader(_Reader):
|
||||
keyframes = {0: SimpleNamespace(id=0, kind="grid")}
|
||||
|
||||
def frame(self, frame_id):
|
||||
return SimpleNamespace(depth=None)
|
||||
|
||||
def grid(self, grid_id):
|
||||
return rows, cols
|
||||
|
||||
assert AnalysisSession(_GridReader()).domain_hw() == (356, 301)
|
||||
|
||||
Reference in New Issue
Block a user