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