Present replay 3D with the cursor and open Analyze on a remembered video

Replay left the 3D pane on the rest mesh or a sparse overlay, and Analyze skipped the setup dialog unless the file was re-picked. Live Run no longer starts a chain on a video path. Verified with Studio tests for flush, overlay mask, Analyze open, live-video refusal, and locales.
This commit is contained in:
ykcheungab
2026-10-05 16:52:27 +08:00
parent 3433eb0fec
commit c0a10e6adb
26 changed files with 2226 additions and 158 deletions
+24 -2
View File
@@ -12,6 +12,7 @@ broken application (the same rule ``apps/autocal`` follows).
from __future__ import annotations
import itertools
import logging
import os
import shutil
import sys
@@ -91,8 +92,29 @@ def make_window(qt_app, tmp_path, monkeypatch):
for window in created:
try:
window.close()
except Exception:
pass
except Exception: # noqa: BLE001 - teardown must not fail a test that passed
# Reported rather than swallowed: a window that cannot close is a Qt lifetime
# problem worth seeing, but it must not turn a green test red.
logging.getLogger(__name__).debug("could not close a test window", exc_info=True)
@pytest.fixture
def decline_video_prompt(monkeypatch):
"""Answer the bare-video setup prompt with "no", so it cannot block a test.
Opening a video that is not a recording now asks *how* to analyse it, and the dialog is
modal: a test that drives ``load_video`` on a bare video without answering waits for a
person who is not there. Declining is the answer that leaves the old behaviour intact —
the video plays, nothing is analysed — which is what the tests predating the prompt are
about.
"""
from PySide6.QtWidgets import QDialog
from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
monkeypatch.setattr(
VideoAnalysisDialog, "exec", lambda self: QDialog.DialogCode.Rejected
)
def visible_descendants(widget):
@@ -674,3 +674,52 @@ def test_the_first_frame_is_the_prompt_s_default_reference(
assert dialog.reference_spin.value() == 4
finally:
dialog.deleteLater()
def test_the_prompt_restores_a_prior_reference_and_no_calibration(
bare_clip, window, qt_app
):
"""Reopening Analysis setup must show the answers from the last pass."""
facts = bare_video.probe(bare_video.resolve_video(bare_clip))
dialog = VideoAnalysisDialog(
video=bare_clip,
facts=facts,
catalog=scan_sensor_catalog(),
window=window,
initial_profile="shipped/box",
prefer_no_calibration=True,
initial_reference_frame=4,
)
try:
assert dialog.reference_spin.value() == 4
assert dialog.choice().reference_frame == 4
assert dialog.calibration_combo.currentData() == "none"
assert dialog.choice().calibration is None
finally:
dialog.deleteLater()
def test_analysis_setup_reopens_the_prompt_after_the_first_pass(
bare_clip, window, monkeypatch
):
"""View ▸ Analysis setup… asks again; a new reference frame starts a new pass."""
open_bare_video(window, bare_clip, monkeypatch, reference=None)
settle(window)
first = window.replay_ctl.recording_directory()
assert first is not None
assert bare_video.is_synthetic(first)
# The box stitch probe consumes the first decoded frames; a reference that names
# one of them is refused. Frame 10 is past that offset on this clip.
seen: list = []
answer_prompt(monkeypatch, profile="shipped/box", reference=10, seen=seen)
window.replay_ctl.on_analysis_setup()
settle(window)
assert seen, "Analysis setup did not open the dialog"
second = window.replay_ctl.recording_directory()
assert second is not None
block = bare_video.read_synthetic(second)
assert block is not None
assert int(block["reference_frame"]) == 10
assert window.replay_ctl.available_result() is not None
+45 -3
View File
@@ -494,9 +494,12 @@ class _Host:
raise AssertionError("the test did not provide an analysis service")
return self.service_factory(recording, cancelled, on_progress)
def bare_video_workspace(self, video):
def bare_video_workspace(self, video, **kwargs):
return None
def recording_analysis_overrides(self, recording):
return False
class _RecordingReader:
"""The recording reader as the engine and the controller use it."""
@@ -816,7 +819,7 @@ def test_a_seek_equals_playing_forward_to_the_same_frame(session):
def test_the_3d_pane_is_fed_the_same_frame_the_canvas_draws(session):
"""One cursor, two figures: the packet's id, flow and mask are the frame's own."""
"""One cursor, two figures: the packet's id, flow, depth and mask are the frame's own."""
frame = session.frame_at(session.time_of(LAST_FRAME_ID - 2))
assert frame is not None
packet = session.packet(frame)
@@ -828,10 +831,49 @@ def test_the_3d_pane_is_fed_the_same_frame_the_canvas_draws(session):
)
assert np.array_equal(np.asarray(packet.valid_mask), np.asarray(frame.valid_mask))
assert packet.flow_valid is True and packet.flow_frame_id == int(frame.frame_id)
assert bool(packet.depth_valid) is bool(frame.has_depth)
if frame.has_depth:
assert np.array_equal(np.asarray(packet.depth), np.asarray(frame.depth))
assert packet.depth_frame_id == int(frame.frame_id)
else:
assert packet.depth is None and packet.depth_frame_id is None
assert packet.forces_valid is False, "a result stores no force, so none may be claimed"
assert float(packet.mono_t) == pytest.approx(frame.t_rel_s)
def test_paint_policy_matches_the_2d_painter_threshold(session, box_session):
"""3D overlays use ``paint_policy``: finger keeps the display floor, box keeps 2 px."""
from orisys_app_shared.acquisition.arrows import sdk_box_policy
from orisys_app_shared.scene3d.visual_policy import VisualPolicy
finger = session.paint_policy()
assert finger.flow_on_px == pytest.approx(session.display_policy.flow_on_px)
assert int(finger.flow_grid_spacing) == int(
session.stored_policy().get("flow_grid_spacing", session.policy.flow_grid_spacing)
)
# Keep the stored grid; only the activity threshold moves.
adopted = session.display_policy
assert session.serve_policy(
VisualPolicy(
flow_on_px=0.9,
flow_off_px=0.6,
flow_grid_spacing=adopted.flow_grid_spacing,
flow_arrow_scale=adopted.flow_arrow_scale,
flow_edge_margin=adopted.flow_edge_margin,
flow_length_margin=adopted.flow_length_margin,
max_arrows=adopted.max_arrows,
)
)
assert session.paint_policy().flow_on_px == pytest.approx(0.9)
painter = sdk_box_policy()
box = box_session.paint_policy()
assert box.flow_on_px == pytest.approx(painter.flow_on_px)
assert box.flow_off_px == pytest.approx(painter.flow_off_px)
assert int(box.flow_grid_spacing) == int(painter.flow_grid_spacing)
# --------------------------------------------------------------- display settings
def test_a_display_change_is_a_read_and_writes_no_result(pass_root, session):
"""The result key is blind to display settings, so a change must not write.
@@ -1450,7 +1492,7 @@ def test_the_nothing_to_draw_line_names_the_action_that_produces_a_result():
seen[locale] = text
# The action that produces a result is named: the pass runs on open, and the
# toolbar action re-runs it.
assert "由视频重算" in text or "Recompute from the video" in text, text
assert "分析视频" in text or "Analyze video" in text, text
assert (
"分析" in text or "analysis result" in text
), f"{locale} does not say a result is what is missing: {text}"
+27 -1
View File
@@ -184,6 +184,8 @@ class _Host:
#: has no dialog, so the answer is the field a test sets.
self.workspace = None
self.workspace_requests: list = []
self.workspace_kwargs: dict = {}
self.override_requests: list = []
# AcquireGate reads (legacy private names kept as writeable fields for tests)
@property
@@ -238,10 +240,15 @@ class _Host:
def current_source_kind(self) -> str:
return "recompute"
def bare_video_workspace(self, video):
def bare_video_workspace(self, video, **kwargs):
self.workspace_requests.append(video)
self.workspace_kwargs = dict(kwargs)
return self.workspace
def recording_analysis_overrides(self, recording):
self.override_requests.append(recording)
return False
def analysis_service(self, recording, cancelled=None, *, on_progress=None):
raise AssertionError("the test did not provide an analysis service")
@@ -577,3 +584,22 @@ def test_the_replay_entries_exist_only_in_the_replay_view(qt_app):
assert actions["open_video"].enabled is True
assert actions["open_video"].tooltips[-1] == ""
assert parts[0].recompute_enabled[-1] is True
def test_analyze_opens_a_remembered_video_that_is_not_loaded_yet(qt_app, tmp_path):
"""A toolbar path alone used to notify ``playback.no_video`` and skip the dialog.
The rail can show a file name from a previous session while the engine has not opened
anything. 「分析视频」 must call ``load_video`` so the setup prompt appears — the same
path as picking the file again in the explorer.
"""
bare = tmp_path / "1.mp4"
bare.write_bytes(b"not really a video")
engine = _Engine(has_video=False, video_only=False)
toolbar = _Toolbar(video_path=str(bare))
controller, host, engine, parts = _controller(engine=engine, toolbar=toolbar)
controller.on_recompute_toggled(True)
assert engine.opened == [("video", bare)], "the remembered file was not opened"
assert host.workspace_requests == [bare], "the setup dialog was not shown"
assert t("playback.no_video") not in host.notes
+20 -7
View File
@@ -180,7 +180,7 @@ def _replay_window(make_window):
QApplication.processEvents()
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window._in_replay_view(), "the window did not switch to the replay view"
assert window.layout_ctl.in_replay_view(), "the window did not switch to the replay view"
return window
@@ -716,11 +716,20 @@ def test_the_panel_states_a_failed_pass_with_its_reason(
)
def test_the_panel_says_a_bare_video_cannot_be_analysed(tmp_path, own_window, monkeypatch):
"""No session metadata: no pass exists to offer, and the line must not name one."""
def test_a_bare_video_with_a_declined_prompt_promises_no_pass(
tmp_path, own_window, monkeypatch, decline_video_prompt
):
"""A bare video whose setup prompt was dismissed: playback, and one honest line.
A bare video *can* be analysed now — that is what ``test_bare_video_analysis.py`` is
about, and it asks the prompt and answers it. This test is the other answer: the operator
opened the video and dismissed the question, so no pass is coming and the panel must not
say one is. The line names the action instead (the setup prompt), which is what the
"failed or refused, with its reason" state is for.
"""
from orisys_app_shared.acquisition.image_backend import (
RAW_NOTE_KEY,
RESULT_IMPOSSIBLE,
RESULT_FAILED,
)
from orisys_app_shared.i18n import t
@@ -734,9 +743,13 @@ def test_the_panel_says_a_bare_video_cannot_be_analysed(tmp_path, own_window, mo
QApplication.processEvents()
assert window.replay.is_video_only(), "the clip did not open as a bare video"
assert window.replay_ctl.result_state() == (RESULT_IMPOSSIBLE, "")
assert not window.replay_ctl.analysis_running(), "a pass ran after a dismissed prompt"
assert window.replay_ctl.result_state() == (
RESULT_FAILED,
t("playback.analysis_setup_declined"),
)
assert window.arrow_panel.note_label.text() == t(
"panels.replay_analysis_unavailable"
"panels.replay_analysis_failed", reason=t("playback.analysis_setup_declined")
)
assert t(RAW_NOTE_KEY) not in window.arrow_panel.note_label.text()
assert plan_image(captured[-1]).note_key == "panels.replay_analysis_unavailable"
assert plan_image(captured[-1]).note_key == "panels.replay_analysis_failed"
@@ -173,6 +173,36 @@ def test_the_finger_adapter_publishes_a_field_already_in_the_domain(scene):
)
def test_a_recovered_flow_keeps_the_island_as_the_overlay_mask(scene):
"""Replay flow is finite only at stored cells; the overlay mask must stay the island.
The selector already drops ``NaN`` samples. If the mask is those finite cells too,
an arrow tip one neighbour over is rejected and 3D keeps a handful of rim dots
while 2D draws the full grid.
"""
adapter = FingerFrameAdapter(
geometry_id=scene.geometry_id,
field_domain_id=scene.field_domain_id,
shape_hw=scene.shape_hw,
)
h, w = scene.shape_hw
flow = np.full((h, w, 2), np.nan, dtype=np.float32)
flow[1, 1, 0] = 7.0
island = np.ones((h, w), dtype=bool)
depth = np.zeros((h, w), dtype=np.float32)
frame = adapter.snapshot(
FakePacket(flow=flow, depth=depth, valid_mask=island),
runtime=None,
identity=_identity(),
)
assert frame is not None
assert frame.flow_uv is not None and frame.flow_valid_mask is not None
mask = np.asarray(frame.flow_valid_mask)
assert mask.all(), "the overlay mask shrank to the recovered sample cells"
finite = np.isfinite(np.asarray(frame.flow_uv)[..., 0])
assert int(finite.sum()) == 1
def test_the_finger_adapter_remaps_through_the_unroll_map():
map_x = np.array([[0, 1, 2], [0, 1, 2], [0, 1, 2]], dtype=np.float32)
map_y = np.array([[2, 2, 2], [1, 1, 1], [0, 0, 0]], dtype=np.float32)
@@ -599,6 +599,54 @@ def test_repeated_start_and_stop_never_leaves_an_old_measurement(scene, pool):
coordinator.close()
def test_flush_presents_a_submitted_field_without_a_run_timer(scene, pool):
"""Replay stops the run timer; flush must wait for compose and present the field."""
widget = FakeWidget()
composer = CountingComposer(delay_s=0.02)
coordinator = ThreeDCoordinator(widget, composer, pool, max_fps=1)
coordinator.set_visible(True)
coordinator.activate(scene, "live-A")
assert coordinator.submit(_frame(scene, frame_id=3, value=4.0)) == "accepted"
# Two blind ticks used to leave the compose in flight when the pool is slow.
coordinator.tick()
coordinator.tick()
assert not widget.frames or not np.allclose(
np.asarray(widget.frames[-1].field.scalar), 4.0
), "two ticks already presented a slow compose; the flush regression is moot"
coordinator.flush()
assert widget.frames, "flush left the rest model on screen"
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 4.0)
assert coordinator._inflight is None # noqa: SLF001
assert not coordinator.has_pending_frame()
coordinator.close()
def test_flush_presents_the_newer_frame_after_an_overlay_policy_change(scene):
"""Replay sets overlay policy then submits; the cursor frame must reach the widget.
``set_overlay_policy`` dirties settings so a slider change can recompose. If that
recompose of the *held* field wins over the mailbox, scrubbing leaves 3D on the
first (often empty) depth while 2D moves.
"""
widget = FakeWidget()
coordinator = ThreeDCoordinator(widget, SceneComposer(), ImmediateExecutor())
coordinator.set_visible(True)
coordinator.activate(scene, "live-A")
assert coordinator.submit(_frame(scene, frame_id=1, value=0.0)) == "accepted"
coordinator.flush()
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 0.0)
coordinator.set_overlay_policy(object())
assert coordinator.submit(_frame(scene, frame_id=80, value=7.0)) == "accepted"
coordinator.flush()
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 7.0), (
"the pane stayed on the first field after overlay policy dirtied settings"
)
assert int(widget.frames[-1].identity.frame_id) == 80
coordinator.close()
def test_an_older_request_id_cannot_move_the_picture_backwards(scene):
"""R7: request ids are monotonic within a session, and the pane must obey."""
widget = FakeWidget()
@@ -2,6 +2,8 @@
from __future__ import annotations
from dataclasses import replace
import numpy as np
from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import (
@@ -152,3 +154,36 @@ def test_both_views_build_their_policy_from_the_same_settings() -> None:
# Normalised, so a hand-edited preference cannot invert the hysteresis band.
inverted = policy_for_settings(DisplaySettings(flow_on_px=0.1, flow_off_px=0.9))
assert inverted.flow_on_px >= inverted.flow_off_px
def test_a_sparse_recovered_field_still_draws_3d_arrows() -> None:
"""A recovered analysis field is ``NaN`` off the stored grid; tips land there.
Handing ``isfinite(flow)`` as the overlay mask required the tip to sit on another
stored cell, so replay 3D kept a few rim dots while 2D drew the island grid.
"""
assets = poc_scene()
h, w = assets.shape_hw
flow = np.full((h, w, 2), np.nan, dtype=np.float32)
flow[1, 1] = (0.9, 0.0)
settings = DisplaySettings(
flow_arrows_enabled=True,
contact_arrow_enabled=False,
flow_grid_spacing=1,
flow_on_px=0.4,
flow_off_px=0.3,
max_arrows=50,
)
dropped = SceneComposer().compose(
assets,
replace(
_depth_frame(assets, flow=flow),
flow_valid_mask=freeze(np.isfinite(flow[..., 0]), dtype=np.bool_),
),
settings,
)
n_dropped = 0 if dropped.flow_arrows is None else int(dropped.flow_arrows.origins.shape[0])
drawn = SceneComposer().compose(assets, _depth_frame(assets, flow=flow), settings)
n_drawn = 0 if drawn.flow_arrows is None else int(drawn.flow_arrows.origins.shape[0])
assert n_drawn >= 1, "the island mask still produced no 3D arrows"
assert n_drawn > n_dropped, (n_drawn, n_dropped)
+30
View File
@@ -208,6 +208,36 @@ def test_a_platform_without_a_context_says_so_instead_of_showing_an_empty_view(
# --------------------------------------------------------- one state, two controls
def test_an_analysis_packet_ticks_the_3d_pane(make_window, monkeypatch):
"""Replay has no run timer: submit must flush the compose so the frame is presented."""
window = make_window(sensor_type="finger")
try:
flushes: list[int] = []
submitted: list[object] = []
policies: list[object] = []
class _View:
def set_overlay_policy(self, policy) -> None:
policies.append(policy)
def submit_packet(self, packet) -> None:
submitted.append(packet)
def flush(self) -> None:
flushes.append(1)
# A full Scene3DView needs GL; only the submit/flush seam is under test here.
window.mesh3d_panel._view = _View() # noqa: SLF001
monkeypatch.setattr(window, "_connect_analysis_display", lambda panel: True)
window.show_analysis_packet(object())
assert submitted, "the analysis packet never reached the 3D producer"
assert policies, "the overlay policy was not handed to the 3D view"
assert flushes, "the pane was not flushed after submit"
finally:
window.mesh3d_panel._view = None # noqa: SLF001
window.close()
def test_the_pane_and_the_view_menu_are_the_same_flag(make_window):
window = make_window(sensor_type="finger")
try:
+27
View File
@@ -1509,6 +1509,33 @@ def test_the_source_picker_follows_the_view(make_window):
_finish(window, stuck)
def test_live_run_refuses_a_video_source(make_window, monkeypatch):
"""Live acquires from a camera; a leftover video mode must not start a chain."""
from orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
window = make_window(sensor_type="finger", source_mode="camera")
try:
window.brand_bar.btn_live.click()
QApplication.processEvents()
window.toolbar.set_video_path(r"C:\tmp\demo.mp4")
window._set_source_mode("video")
QApplication.processEvents()
assert not window.toolbar.btn_run.isEnabled(), "Run is offered for a video file in live"
called: list[str] = []
monkeypatch.setattr(
"orisys_app_shared.acquisition.sdk_worker.orisys_importable",
lambda: called.append("import") or (True, ""),
)
window.toolbar.set_run_checked(True)
window._start_run()
assert called == [], "a video source reached the acquisition path"
assert window.toolbar.btn_run.isChecked() is False
assert t("toolbar.run_video_in_replay") in window.statusBar().currentMessage()
finally:
window.close()
def test_the_nav_items_switch_the_main_window_layout(make_window):
window, stuck = _running_window(make_window)
for index in range(60):