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
@@ -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()
+239 -30
View File
@@ -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)