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:
@@ -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