diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 201ea49..613657f 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -218,6 +218,8 @@ "replay_empty": "No recording open\nPress “Open recording…” below", "arrow_empty": "No image yet\nPick a video or camera, then press Run", "arrow_empty_tip": "The deformation arrow image appears here while running or previewing", + "arrow_render_failed": "Flow arrows could not be drawn for this frame\nTry another view, or reload the video", + "arrow_capture_unavailable": "The captured frame is not available for this overlay\nThe raw camera view is still one selection away", "depth_unavailable": "No depth data for this frame\n(not a depth of zero, and no silent fallback to the processed image)", "depth_scale": "Depth scale {low} – {high}", "depth_empty": "This frame's depth is all zeros (0 of {pixels} px non-zero): the ramp's low colour is a real zero response, not missing data", @@ -448,6 +450,11 @@ "phase_running": "Acquiring", "phase_error": "Error", "rail_video_name": "Video file: {name}", + "rail_result_profile": "Result profile: {type}", + "rail_result_reused": "Stored result", + "rail_result_complete": "Analysis complete", + "rail_result_running": "Analysing…", + "rail_result_ready": "Result ready", "data_valid": "Data valid", "data_invalid": "{count} invalid sample(s)", "calibrated": "Calibrated", @@ -546,6 +553,10 @@ }, "analysis": { "title": "Analysing the recording", + "title_reused": "Using a stored analysis result", + "title_complete": "Analysis complete", + "title_cancelled": "Analysis cancelled", + "title_failed": "Analysis failed", "frames": "{written} / {expected} frames", "segment": "segment {index} / {count}", "elapsed": "{seconds:.1f} s elapsed", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index d93f9f9..9a3a2e7 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -218,6 +218,8 @@ "replay_empty": "尚未打开录制\n点击下方「打开录制…」", "arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」", "arrow_empty_tip": "运行或预览后在此显示形变箭头图", + "arrow_render_failed": "本帧无法绘制形变箭头\n请换一种视图,或重新加载视频", + "arrow_capture_unavailable": "当前无法取得采集图像用于叠加\n原始相机画面仍可在同一选择器中查看", "depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)", "depth_scale": "深度色标 {low} – {high}", "depth_empty": "本帧深度全为 0({pixels} px 中 0 个非零):色标最低颜色是真实的零响应,不是缺数据", @@ -448,6 +450,11 @@ "phase_running": "采集中", "phase_error": "出错", "rail_video_name": "视频文件: {name}", + "rail_result_profile": "结果配置: {type}", + "rail_result_reused": "已复用缓存结果", + "rail_result_complete": "分析完成", + "rail_result_running": "正在分析…", + "rail_result_ready": "结果可用", "data_valid": "数据有效", "data_invalid": "无效样本 {count}", "calibrated": "已标定", @@ -546,6 +553,10 @@ }, "analysis": { "title": "正在分析录像", + "title_reused": "正在使用已缓存的分析结果", + "title_complete": "分析完成", + "title_cancelled": "分析已取消", + "title_failed": "分析失败", "frames": "{written} / {expected} 帧", "segment": "分段 {index} / {count}", "elapsed": "已用 {seconds:.1f} s", diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 4b43876..51ca051 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -994,7 +994,9 @@ class StudioMainWindow(QMainWindow): self.toolbar.set_source_mode(str(self.prefs.get("source_mode") or "camera")) self.toolbar.set_video_path(str(self.prefs.get("video_path") or "")) self.toolbar.set_units(str(self.prefs.get("units") or "newton")) - self.arrow_panel.set_view_mode(str(self.prefs.get("image_view_mode") or "arrows_pure")) + self.arrow_panel.set_view_mode( + str(self.prefs.get("image_view_mode") or "arrows_stitched") + ) self.arrow_panel.set_rotation_deg(self.prefs.get("arrow_rotation_deg", 90)) # The finger's 2D presentation: a *display* convention for the unrolled canvas # (see ``acquisition.display_orientation``), stored as a preference and applied @@ -1487,6 +1489,32 @@ class StudioMainWindow(QMainWindow): path = self.toolbar.video_path() if path: detail = t("status.rail_video_name", name=Path(path).name) + # Name the *result's* profile and whether it was reused or just finished, so + # changing the sensor picker cannot look like the open figures came from the + # newly selected product. + session = self.replay_ctl.available_result() + if session is not None: + provenance = getattr(getattr(session, "reader", None), "provenance", None) + result_type = str(getattr(provenance, "sensor_type", "") or "").strip() + if result_type: + detail = ( + f"{detail} · {t('status.rail_result_profile', type=result_type)}" + if detail + else t("status.rail_result_profile", type=result_type) + ) + progress = getattr(self.replay_ctl, "_progress", None) + progress_state = ( + str(progress.state()) if progress is not None and hasattr(progress, "state") else "" + ) + if progress_state == "reused": + status_text = t("status.rail_result_reused") + elif progress_state == "complete": + status_text = t("status.rail_result_complete") + elif progress_state == "running": + status_text = t("status.rail_result_running") + else: + status_text = t("status.rail_result_ready") + detail = f"{detail} · {status_text}" if detail else status_text if self._show_fps and self._latest_fps is not None: fps = f"{self._latest_fps:.1f} FPS" # The cost goes next to the rate because one without the other cannot @@ -1975,6 +2003,9 @@ class StudioMainWindow(QMainWindow): sensor_type=str(sensor_type), config_ref=self._resolve_config_ref(), ) + # A different product is a different UV island; the previous latch must not + # paint the new canvas (a box result has no mask and would inherit a finger's). + self._domain_mask.reset() # The pane follows the sensor: a box sensor has no 3D scene, and a finger's # missing package must not be reported while looking at a box. self.layout_ctl.sync_3d_source() @@ -1982,8 +2013,12 @@ class StudioMainWindow(QMainWindow): self._apply_arrow_rotation_for_sensor() self._update_run_enabled() # The rail's sensor summary and the sensor dialog both state the product - # and its config, so both follow a change. + # and its config, so both follow a change. Replay still names the *result's* + # own profile when a recording is open, so the rail cannot imply old figures + # came from the newly selected product. self._refresh_status_rail() + if self._view_preset == "replay" and self.replay.has_video(): + self.replay_ctl.render_frame() self._persist_prefs() # ----------------------------------------------------------- shortcuts @@ -2641,6 +2676,10 @@ class StudioMainWindow(QMainWindow): return f"{text} · {empty}" if text else empty return text + def reset_domain_mask(self) -> None: + """Drop the latched UV island: a new recording or sensor has its own geometry.""" + self._domain_mask.reset() + def _latched_domain_mask(self, frame: ImageFrame): """The 2D domain boundary to draw, latched for the session. @@ -2651,7 +2690,15 @@ class StudioMainWindow(QMainWindow): that had not moved while any difference in ``frame.valid_mask`` repainted the whole boundary, which reads as lag. The latch holds the boundary until a frame no longer describes the same island (see ``DomainMaskLatch``). + + A frame with no mask (the box chain) must not keep a previous finger island: that + shape fails the domain check and left a silent black arrow canvas on reload. """ + held = self._domain_mask.mask + if frame.valid_mask is None and held is not None and frame.image_bgr is not None: + image_hw = tuple(int(v) for v in np.asarray(frame.image_bgr).shape[:2]) + if tuple(int(v) for v in held.shape[:2]) != image_hw: + self._domain_mask.reset() return self._domain_mask.update(frame.valid_mask) def _render_run_image(self, packet) -> None: @@ -2727,14 +2774,20 @@ class StudioMainWindow(QMainWindow): self._finger_orientation, draw_style=draw_style ), ) - if rendered is None: + diagnostic = ( + None if rendered is None else getattr(rendered, "frame_diagnostic", None) + ) + if rendered is None or diagnostic is not None: # Never substitute another picture here: a missing depth map that showed - # the processed frame made "no depth" look like a depth reading. - self.arrow_panel.clear_preview( - t("panels.depth_unavailable") - if mode == DEPTH - else t("panels.arrow_empty") - ) + # the processed frame made "no depth" look like a depth reading. A draw + # failure used to return the black base image; say so instead. + if mode == DEPTH and rendered is None: + message = t("panels.depth_unavailable") + elif diagnostic is not None: + message = t("panels.arrow_render_failed") + else: + message = t("panels.arrow_empty") + self.arrow_panel.clear_preview(message) return self.arrow_panel.show_frame(bgr_to_qimage(rendered)) diff --git a/apps/studio/src/orisys_studio/replay_analysis.py b/apps/studio/src/orisys_studio/replay_analysis.py index ce9d75e..45ba764 100644 --- a/apps/studio/src/orisys_studio/replay_analysis.py +++ b/apps/studio/src/orisys_studio/replay_analysis.py @@ -180,7 +180,12 @@ class AnalysisSession: ) self.reader = reader self.policy = (policy or VisualPolicy()).normalised() - self.display_policy = (display_policy or self.policy).normalised() + # The pane's display settings may carry a different sample grid than the pass + # recorded. Location keys stay with the stored result so re-selection hits the + # same cells; only the activity threshold follows the display. + self.display_policy = self._align_display_policy( + (display_policy or self.policy).normalised() + ) self.session_id = int(session_id) self.recording = None if recording is None else Path(recording) self._frame_ids = np.asarray(reader.frame_ids, dtype=np.int64) @@ -237,18 +242,44 @@ class AnalysisSession: return dict(getattr(self.reader.provenance, "policy", {}) or {}) # ------------------------------------------------------------------- policy + def _align_display_policy(self, display_policy: VisualPolicy) -> VisualPolicy: + """Keep the pass's sample locations; take activity thresholds from ``display``.""" + wanted = display_policy.normalised() + stored = self.stored_policy() + if not stored: + return wanted + return VisualPolicy( + flow_on_px=float(wanted.flow_on_px), + flow_off_px=float(wanted.flow_off_px), + flow_grid_spacing=int( + stored.get("flow_grid_spacing", wanted.flow_grid_spacing) + ), + flow_arrow_scale=float( + stored.get("flow_arrow_scale", wanted.flow_arrow_scale) + ), + flow_edge_margin=int( + stored.get("flow_edge_margin", wanted.flow_edge_margin) + ), + flow_length_margin=float( + stored.get("flow_length_margin", wanted.flow_length_margin) + ), + max_arrows=int(stored.get("max_arrows", wanted.max_arrows)), + ).normalised() + def serve_policy(self, display_policy: VisualPolicy) -> bool: """Adopt a display policy, if the stored grid can still serve it. - Returns ``False`` — and changes nothing — when the policy moves a location - setting (see :data:`GRID_KEYS`). Those decide where the candidates are, and the - recorded locations are the ones the pass measured. The threshold is not one of - them: it decides which recorded samples are active. + Returns ``False`` when the policy moves a location setting (see + :data:`GRID_KEYS`). Those decide where the candidates are, and the recorded + locations are the ones the pass measured. The activity threshold is still + adopted in that case — it decides which recorded samples are active, and a + display dialog that only changes ``flow_on_px`` must not be ignored because + its default grid disagrees with the pass. """ - if policy_divergence(self.stored_policy(), display_policy): - return False - self.display_policy = display_policy.normalised() - return True + wanted = display_policy.normalised() + moved = policy_divergence(self.stored_policy(), wanted) + self.display_policy = self._align_display_policy(wanted) + return not moved def threshold(self) -> float: return float(self.display_policy.flow_on_px) diff --git a/apps/studio/src/orisys_studio/replay_controller.py b/apps/studio/src/orisys_studio/replay_controller.py index b8dfc2e..0f5d9b3 100644 --- a/apps/studio/src/orisys_studio/replay_controller.py +++ b/apps/studio/src/orisys_studio/replay_controller.py @@ -29,7 +29,7 @@ from typing import Any import numpy as np from orisys_app_shared.acquisition.image_backend import ( - ARROW_PURE, + ARROW_STITCHED, DERIVED_MODES, RESULT_FAILED, RESULT_IMPOSSIBLE, @@ -335,6 +335,7 @@ class ReplayController(QObject): # with a video source and no replay renderer. self._host.notify(t("toolbar.replay_view_only")) return False + self._reset_display_geometry() folder = video.parent if video.is_file() else video if not (folder / "forces.csv").is_file(): if video.is_file() and self._engine.open_video(video): @@ -374,6 +375,7 @@ class ReplayController(QObject): # A recording is a recording: whatever workspace the previous bare video was # analysed in stops being this timeline's input. self._video_workspace = None + self._reset_display_geometry() opened = self._engine.open_recording(Path(directory)) if not opened: self._host.notify(t("playback.open_failed", error=Path(directory).name)) @@ -384,6 +386,12 @@ class ReplayController(QObject): self.on_analysis_requested() return True + def _reset_display_geometry(self) -> None: + """A new clip owns its own UV island; the previous latch must not survive.""" + reset = getattr(self._host, "reset_domain_mask", None) + if callable(reset): + reset() + # ------------------------------------------------------------- bare video setup def on_analysis_setup(self) -> None: """Reopen the analysis setup dialog after a first pass (or before one). @@ -1011,15 +1019,16 @@ class ReplayController(QObject): picture stays in the same selector, one step away, and the recompute switch is not involved. - Answers whether the selection moved. A derived selection is left alone: the mode - the operator chose is not this method's to override. + The first processed view is arrows over the captured frame: the camera picture + gives immediate context. A derived selection the operator already chose is left + alone — that mode is not this method's to override. """ current = str(getattr(self._image_panel, "view_mode", lambda: "")() or "") if current in DERIVED_MODES: return False # The plan for the frame just drawn offers the derived modes (the frame carries # ``result_available``), so this selection is one the panel can honour. - self._image_panel.set_view_mode(ARROW_PURE) + self._image_panel.set_view_mode(ARROW_STITCHED) self.render_frame() return True @@ -1222,7 +1231,36 @@ class ReplayController(QObject): return held # ------------------------------------------------------------------ drawing - def _analysis_image_frame(self, frame, *, source: str) -> ImageFrame: + def _captured_base_for(self, frame) -> np.ndarray | None: + """The camera picture at the result's domain size, or ``None`` when unavailable. + + Stored analysis frames keep a black placeholder as ``base_bgr``: the pass does + not embed the video. Overlay views need the decoded frame resized onto the + measurement grid — painting the stitch grid onto a larger camera frame would + leave the arrows in a corner (see :meth:`AnalysisSession.domain_hw`). + """ + video = self._engine.video_frame_bgr() + if video is None: + return None + height, width = (int(frame.flow.shape[0]), int(frame.flow.shape[1])) + image = np.asarray(video) + if image.ndim == 2: + import cv2 + + image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) + elif image.ndim == 3 and image.shape[2] == 4: + import cv2 + + image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) + if int(image.shape[0]) == height and int(image.shape[1]) == width: + return np.ascontiguousarray(image) + import cv2 + + return cv2.resize(image, (width, height), interpolation=cv2.INTER_AREA) + + def _analysis_image_frame( + self, frame, *, source: str, base_bgr: np.ndarray | None = None + ) -> ImageFrame: """One stored frame in the shape the window's single 2D sink takes. The *only* place a replay builds a 2D frame from a result, so the analysis @@ -1230,9 +1268,13 @@ class ReplayController(QObject): build two slightly different pictures. ``source`` says which timeline the operator is on — ``replay`` keeps the raw view on offer while the derived modes are drawn from the result, so choosing a derived view is not a one-way door. + + ``base_bgr`` is the camera picture scaled to the domain when the overlay view + needs it; without one the stored black placeholder remains (pure-colour arrows + replace that canvas anyway). """ return ImageFrame( - frame.base_bgr, + frame.base_bgr if base_bgr is None else base_bgr, flow=frame.flow, depth=frame.depth, valid_mask=frame.valid_mask, @@ -1304,10 +1346,18 @@ class ReplayController(QObject): else: frame = None if frame is not None: + captured = self._captured_base_for(frame) + if wanted == ARROW_STITCHED and captured is None: + # Overlay without a camera picture is a black canvas with dots — say + # so instead of implying the capture is missing from the result. + self._image_panel.clear_preview(t("panels.arrow_capture_unavailable")) + self._host.show_analysis_packet(session.packet(frame)) + return self._host.show_frame( self._analysis_image_frame( frame, source=SOURCE_ANALYSIS if self.analysis_active() else SOURCE_REPLAY, + base_bgr=captured, ) ) # The same frame, in the shape the 3D pane's producer consumes: one diff --git a/apps/studio/src/orisys_studio/ui/analysis_progress.py b/apps/studio/src/orisys_studio/ui/analysis_progress.py index ef69232..fac3582 100644 --- a/apps/studio/src/orisys_studio/ui/analysis_progress.py +++ b/apps/studio/src/orisys_studio/ui/analysis_progress.py @@ -57,6 +57,17 @@ _STATE_TOKEN = { FAILED: "danger", } +#: Headline key per state. Running keeps ``analysis.title``; the rest name the outcome +#: so a full frame counter without a progress bar is not read as a stuck pass. +_STATE_TITLE = { + IDLE: "analysis.title", + REUSED: "analysis.title_reused", + RUNNING: "analysis.title", + COMPLETE: "analysis.title_complete", + CANCELLED: "analysis.title_cancelled", + FAILED: "analysis.title_failed", +} + #: The progress bar's thousandths (Qt's own percentage unit, 0–1000). _FULL = 1000 @@ -222,7 +233,6 @@ class AnalysisProgress(QFrame): self._refresh() def retranslate(self) -> None: - self.title_label.setText(t("analysis.title")) self.cancel_button.setText( t("analysis.cancelling") if self._cancelling else t("analysis.cancel") ) @@ -238,18 +248,18 @@ class AnalysisProgress(QFrame): self.cancel_requested.emit() def _refresh(self) -> None: - """Repaint from the state: colour, which chrome is offered, and the tooltip. + """Repaint from the state: colour, title, which chrome is offered, tooltip. - The title is one string in every state — what a pass is doing does not change - its name — so the *state* reaches the operator through the colour token, the - detail line and whether cancel is offered, not through a second vocabulary. + Running keeps the progress bar and cancel. Reused / complete / cancelled / + failed change the headline so a full frame counter is not read as "still + analysing". """ token = _STATE_TOKEN.get(self._state, "hint_text") running = self._state == RUNNING self.cancel_button.setVisible(running) self.cancel_button.setEnabled(running and not self._cancelling) self.progress_bar.setVisible(running) - self.title_label.setText(t("analysis.title")) + self.title_label.setText(t(_STATE_TITLE.get(self._state, "analysis.title"))) self.title_label.setStyleSheet( f"color: {color(self._theme, token)}; font-weight: 600;" ) diff --git a/apps/studio/src/orisys_studio/ui/sensor_dialog.py b/apps/studio/src/orisys_studio/ui/sensor_dialog.py index 3142278..c208ffa 100644 --- a/apps/studio/src/orisys_studio/ui/sensor_dialog.py +++ b/apps/studio/src/orisys_studio/ui/sensor_dialog.py @@ -24,13 +24,17 @@ from orisys_app_shared.sensors import ( SHAPE_UNREADABLE, STATE_UNREADABLE, ) +from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QDialog, QFrame, QHBoxLayout, QLabel, QPushButton, + QScrollArea, + QSizePolicy, QVBoxLayout, + QWidget, ) from orisys_studio.ui.fit_text import ShrinkableLabel @@ -79,10 +83,38 @@ class SensorDialog(QDialog): #: finding — plus, when it must not be started, why. It sits directly under the selector #: on purpose: the picker row carries the name alone, so this is the surface the rest has #: to be readable on, without a hover and without hunting for it. + #: + #: Long warning lists used to grow the label into the calibration section and clip at + #: the next rule; the scroll area keeps that text readable without covering the rest. self.profile_label = ShrinkableLabel() self.profile_label.setObjectName("panelMeta") self.profile_label.setWordWrap(True) - root.addWidget(self.profile_label) + self.profile_label.setAlignment( + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop + ) + self.profile_label.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum + ) + profile_host = QWidget() + profile_host_layout = QVBoxLayout(profile_host) + profile_host_layout.setContentsMargins(0, 0, 0, 0) + profile_host_layout.addWidget(self.profile_label) + self.profile_scroll = QScrollArea() + self.profile_scroll.setObjectName("sensorProfileScroll") + self.profile_scroll.setWidgetResizable(True) + self.profile_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.profile_scroll.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + self.profile_scroll.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + self.profile_scroll.setWidget(profile_host) + self.profile_scroll.setMaximumHeight(160) + self.profile_scroll.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum + ) + root.addWidget(self.profile_scroll) root.addWidget(self._section_rule()) self.calib_title = QLabel() diff --git a/apps/studio/tests/test_analysis_progress.py b/apps/studio/tests/test_analysis_progress.py new file mode 100644 index 0000000..1df9dfa --- /dev/null +++ b/apps/studio/tests/test_analysis_progress.py @@ -0,0 +1,30 @@ +"""Analysis progress banner titles follow the result state.""" + +from __future__ import annotations + +from orisys_app_shared.i18n import set_locale, t + +from orisys_studio.ui.analysis_progress import AnalysisProgress + + +def test_analysis_progress_titles_name_the_outcome(qt_app) -> None: + set_locale("en_US") + banner = AnalysisProgress(theme="dark") + banner.start() + assert banner.title_label.text() == t("analysis.title") + assert banner.progress_bar.isVisible() + assert banner.cancel_button.isVisible() + + banner.reused(frames=12) + assert banner.title_label.text() == t("analysis.title_reused") + assert not banner.progress_bar.isVisible() + assert not banner.cancel_button.isVisible() + + banner.complete(frames=12, elapsed_s=1.5) + assert banner.title_label.text() == t("analysis.title_complete") + + banner.cancelled(frames=3) + assert banner.title_label.text() == t("analysis.title_cancelled") + + banner.failed(reason="boom", frames=1) + assert banner.title_label.text() == t("analysis.title_failed") diff --git a/apps/studio/tests/test_bare_video_analysis.py b/apps/studio/tests/test_bare_video_analysis.py index adaa0e5..c648acf 100644 --- a/apps/studio/tests/test_bare_video_analysis.py +++ b/apps/studio/tests/test_bare_video_analysis.py @@ -42,6 +42,7 @@ from orisys_app_shared.acquisition.arrows import ( ) from orisys_app_shared.acquisition.image_backend import ( ARROW_PURE, + ARROW_STITCHED, DEPTH, RECORDED, RESULT_FAILED, @@ -395,6 +396,18 @@ def test_a_bare_video_is_analysed_and_scrubbed_in_both_directions( "the arrow view is the video's raw picture" ) + # ---- overlay: arrows on the captured frame, not on the black placeholder - + set_mode(window, ARROW_STITCHED) + QApplication.processEvents() + overlay = panel_bgr(window) + assert painted(overlay, canvas) > 0, "the overlay painted no arrows" + assert float(np.mean(overlay)) > 8.0, ( + "the overlay is still a near-black canvas; the captured frame was not under the arrows" + ) + assert not np.array_equal(overlay, middle_shot), ( + "the overlay view is the pure-colour canvas" + ) + # ---- the depth view, from the same stored frame ------------------------- set_mode(window, DEPTH) QApplication.processEvents() diff --git a/shared/src/orisys_app_shared/acquisition/arrows.py b/shared/src/orisys_app_shared/acquisition/arrows.py index 7d19666..1b03063 100644 --- a/shared/src/orisys_app_shared/acquisition/arrows.py +++ b/shared/src/orisys_app_shared/acquisition/arrows.py @@ -280,6 +280,18 @@ def magnitude_to_ramp_index( return np.clip(scaled * 255.0, 0.0, 255.0).astype(np.uint8) +def carry_frame_diagnostic(image: np.ndarray, diagnostic=None) -> np.ndarray: + """``image`` as a draw result that still carries ``frame_diagnostic``. + + Used by :func:`draw_arrow_display` and by callers that must re-wrap after a + transform (orientation) that would otherwise drop the attribute. ``None`` means + the draw succeeded. + """ + out = np.asarray(image).view(_DrawResult) + out.frame_diagnostic = diagnostic + return out + + def _flow_result(image: np.ndarray, diagnostic=None) -> np.ndarray: """``image`` as the result of :func:`draw_arrow_display`, carrying ``diagnostic``. @@ -295,9 +307,7 @@ def _flow_result(image: np.ndarray, diagnostic=None) -> np.ndarray: panel's job is ``code -> localized text``; this module deliberately carries no locale string (see :data:`ARROW_RENDER_FAILED_MESSAGE`). """ - out = image.view(_DrawResult) - out.frame_diagnostic = diagnostic - return out + return carry_frame_diagnostic(image, diagnostic) class _DrawResult(np.ndarray): diff --git a/shared/src/orisys_app_shared/acquisition/image_backend.py b/shared/src/orisys_app_shared/acquisition/image_backend.py index cb5035b..b66f962 100644 --- a/shared/src/orisys_app_shared/acquisition/image_backend.py +++ b/shared/src/orisys_app_shared/acquisition/image_backend.py @@ -29,6 +29,7 @@ from typing import Any import numpy as np from orisys_app_shared.acquisition.arrows import ( + carry_frame_diagnostic, depth_map_to_bgr, draw_arrow_display, flow_colormap, @@ -451,21 +452,25 @@ def render_image( flow = frame.flow if sdk_result: flow = np.nan_to_num(np.asarray(flow, dtype=np.float64), nan=0.0) - return apply_orientation( - draw_arrow_display( - frame.image_bgr, - flow, - background="captured" if wanted == ARROW_STITCHED else "pure_color", - canvas_bgr=canvas_bgr, - draw_style=draw_style, - shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result, - valid_mask=frame.valid_mask, - domain_mask=domain_mask, - colormap=flow_colormap(theme), - policy=arrow_policy, - ), - turn, + drawn = draw_arrow_display( + frame.image_bgr, + flow, + background="captured" if wanted == ARROW_STITCHED else "pure_color", + canvas_bgr=canvas_bgr, + draw_style=draw_style, + shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result, + valid_mask=frame.valid_mask, + domain_mask=domain_mask, + colormap=flow_colormap(theme), + policy=arrow_policy, ) + # Orientation may rebuild the array; keep the draw failure on the result so the + # panel can say so instead of showing a silent black canvas. + diagnostic = getattr(drawn, "frame_diagnostic", None) + oriented = apply_orientation(drawn, turn) + if diagnostic is None: + return oriented + return carry_frame_diagnostic(oriented, diagnostic) # ``recorded``: the frame is the picture, with nothing drawn over it — and the # camera's own orientation, which no domain convention applies to. return ensure_bgr(np.asarray(frame.image_bgr)) diff --git a/shared/src/orisys_app_shared/scene3d/visual_policy.py b/shared/src/orisys_app_shared/scene3d/visual_policy.py index 8393695..5bd825f 100644 --- a/shared/src/orisys_app_shared/scene3d/visual_policy.py +++ b/shared/src/orisys_app_shared/scene3d/visual_policy.py @@ -248,6 +248,11 @@ def select_flow_samples( ) * margin dx = dx * scale dy = dy * scale + # Non-finite tips are discarded by ``valid`` below; zero them before the integer + # cast so numpy does not warn on every sparse recovered frame during replay. + finite_tip = np.isfinite(dx) & np.isfinite(dy) + dx = np.where(finite_tip, dx, 0.0) + dy = np.where(finite_tip, dy, 0.0) end_x = np.clip(np.round(cc + dx).astype(np.int32), 0, w - 1) end_y = np.clip(np.round(rr + dy).astype(np.int32), 0, h - 1) diff --git a/shared/tests/test_visual_policy.py b/shared/tests/test_visual_policy.py index 7d24b32..9cb5529 100644 --- a/shared/tests/test_visual_policy.py +++ b/shared/tests/test_visual_policy.py @@ -145,3 +145,16 @@ def test_paint_domain_background_colours() -> None: ) assert tuple(canvas[0, 0]) == (1, 2, 3) assert tuple(canvas[1, 1]) == (10, 20, 30) + + +def test_select_flow_samples_screens_non_finite_tips() -> None: + """Sparse recovered fields put NaN off the recorded cells; cast must stay quiet.""" + h, w = 30, 30 + flow = np.full((h, w, 2), np.nan, dtype=np.float64) + flow[15, 15] = (0.5, 0.0) + policy = VisualPolicy(flow_on_px=0.4, flow_grid_spacing=10, flow_edge_margin=0) + with np.errstate(invalid="raise"): + samples = select_flow_samples(flow, None, policy) + pairs = set(zip(samples.rows.tolist(), samples.cols.tolist())) + assert (15, 15) in pairs + assert samples.active_count >= 1