diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 0de07e6..cd41d54 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -106,6 +106,7 @@ "filter_off": "Not filtering (raw readings)", "ok": "OK", "cancel": "Cancel", + "save": "Save", "close": "Close" }, "toolbar": { @@ -214,6 +215,7 @@ "view_arrows_pure": "Arrows", "view_arrows_stitched": "Arrows / Captured", "view_depth": "Depth map", + "view_depth_arrows": "Depth + arrows", "view_recorded": "Recorded image", "view_mode_tip": "Image display mode while running", "arrow_settings_tip": "Finger 2D presentation (orientation; display only)", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index ef3b650..b11984b 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -106,6 +106,7 @@ "filter_off": "未滤波(原始读数)", "ok": "确定", "cancel": "取消", + "save": "保存", "close": "关闭" }, "toolbar": { @@ -214,6 +215,7 @@ "view_arrows_pure": "箭头", "view_arrows_stitched": "箭头/采集图像", "view_depth": "深度图", + "view_depth_arrows": "深度 + 箭头", "view_recorded": "原始图像", "view_mode_tip": "运行时的图像显示模式", "arrow_settings_tip": "手指 2D 显示方向(只影响画面)", diff --git a/apps/studio/docs/ui-elements.json b/apps/studio/docs/ui-elements.json index c4c6f75..b8d82cb 100644 --- a/apps/studio/docs/ui-elements.json +++ b/apps/studio/docs/ui-elements.json @@ -284,8 +284,8 @@ "purpose": "write the calibration panel's coefficients (the panel's own action)" }, { "id": "dialog_apply", "path": "sensor_dialog.btn_apply", "objectName": null, "class": "QPushButton", "purpose": "push the last export into the running device" }, - { "id": "dialog_close", "path": "sensor_dialog.btn_close", "objectName": null, "class": "QPushButton", - "purpose": "close the dialog" } + { "id": "dialog_save", "path": "sensor_dialog.btn_save", "objectName": null, "class": "QPushButton", + "purpose": "keep the current sensor and calibration choice and dismiss the dialog" } ] } ], diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 5ba280b..b534741 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -1855,21 +1855,22 @@ class StudioMainWindow(QMainWindow): entry = self.sensor_picker.selected_entry() return None if entry is None else entry.ref - def live_calibration_choice(self) -> tuple[str, Path | None, str]: - """``(role, file_path_or_None, device_id)`` remembered for the selected bundle. + def live_calibration_for( + self, ref: str + ) -> tuple[str, Path | None, str] | None: + """Saved live choice for one catalog ref, or ``None`` when nothing was saved. - ``file_path`` is only set for the browsed-file role; the profile default is - resolved when the chain opens. A missing file falls back to raw force. + ``None`` is not raw force: a caller that has its own default (the analysis + prompt) keeps that default until the sensor dialog has stored a choice. """ from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE - key = self._live_calibration_prefs_key() store = self.prefs.get("live_calibration") - if not key or not isinstance(store, dict): - return ROLE_NONE, None, "" - entry = store.get(key) + if not ref or not isinstance(store, dict): + return None + entry = store.get(ref) if not isinstance(entry, dict): - return ROLE_NONE, None, "" + return None role = str(entry.get("role") or ROLE_NONE) if role not in (ROLE_DEFAULT, ROLE_FILE, ROLE_NONE): role = ROLE_NONE @@ -1882,6 +1883,20 @@ class StudioMainWindow(QMainWindow): path = candidate return role, path, str(entry.get("device_id") or "") + def live_calibration_choice(self) -> tuple[str, Path | None, str]: + """``(role, file_path_or_None, device_id)`` remembered for the selected bundle. + + ``file_path`` is only set for the browsed-file role; the profile default is + resolved when the chain opens. No saved choice is raw force for a live open. + """ + from orisys_studio.ui.sensor_dialog import ROLE_NONE + + key = self._live_calibration_prefs_key() + found = None if key is None else self.live_calibration_for(key) + if found is None: + return ROLE_NONE, None, "" + return found + def set_live_calibration_choice( self, *, @@ -2948,6 +2963,7 @@ class StudioMainWindow(QMainWindow): initial_reference_frame=initial_reference_frame, initial_device_id=initial_device_id, theme=self._theme, + remembered_calibration=self.live_calibration_for, ) if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted: return None @@ -2990,6 +3006,7 @@ class StudioMainWindow(QMainWindow): initial_reference_frame=getattr(self, "_analysis_reference_override", None), lock_profile=True, theme=self._theme, + remembered_calibration=self.live_calibration_for, ) if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted: return False diff --git a/apps/studio/src/orisys_studio/frame_view.py b/apps/studio/src/orisys_studio/frame_view.py index 0f2a707..8c8bf63 100644 --- a/apps/studio/src/orisys_studio/frame_view.py +++ b/apps/studio/src/orisys_studio/frame_view.py @@ -14,6 +14,7 @@ import numpy as np from orisys_app_shared.acquisition.arrows import arrow_display_scale, hex_to_bgr from orisys_app_shared.acquisition.image_backend import ( DEPTH, + DEPTH_ARROWS, DomainMaskLatch, ImageFrame, plan_image, @@ -232,7 +233,7 @@ class FrameView: self._last_depth_frame = frame.depth span = self.active_depth_span() self.publish_shared_depth_scale(span) - if mode == DEPTH: + if mode in (DEPTH, DEPTH_ARROWS): self._arrow_panel.set_depth_scale( self.depth_scale_text(frame), tip_key=self.depth_scale_tip_key(), @@ -267,7 +268,7 @@ class FrameView: # Never substitute another picture here: a missing depth map that showed # 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: + if mode in (DEPTH, DEPTH_ARROWS) and rendered is None: message = t("panels.depth_unavailable") elif diagnostic is not None: message = t("panels.arrow_render_failed") diff --git a/apps/studio/src/orisys_studio/ui/arrow_panel.py b/apps/studio/src/orisys_studio/ui/arrow_panel.py index 6961741..50b0a29 100644 --- a/apps/studio/src/orisys_studio/ui/arrow_panel.py +++ b/apps/studio/src/orisys_studio/ui/arrow_panel.py @@ -14,6 +14,7 @@ from orisys_app_shared.acquisition.image_backend import ( ARROW_PURE, ARROW_STITCHED, DEPTH, + DEPTH_ARROWS, ImagePlan, ) from orisys_app_shared.i18n import t @@ -32,7 +33,7 @@ from PySide6.QtWidgets import ( from orisys_studio.ui import icons from orisys_studio.ui.fit_text import ShrinkableLabel -VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH) +VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH, DEPTH_ARROWS) #: While replaying a recording there is no optical flow to draw and no depth is #: stored: the panel shows the recorded frames, and nothing else. REPLAY_VIEW_MODES = ("recorded",) @@ -53,6 +54,7 @@ _VIEW_LABELS = { ARROW_PURE: "panels.view_arrows_pure", ARROW_STITCHED: "panels.view_arrows_stitched", DEPTH: "panels.view_depth", + DEPTH_ARROWS: "panels.view_depth_arrows", "recorded": "panels.view_recorded", } _ROTATION_STEPS = (0, 90, 180, 270) @@ -433,15 +435,17 @@ class ArrowPanel(QFrame): stays regardless is what the picture *is*: the plan's own line ("this recording stores only the raw camera image"), the depth scale, and the colour bar. """ - depth = self.view_mode() == DEPTH + mode = self.view_mode() + depth = mode == DEPTH + overlay = mode == DEPTH_ARROWS modes = self.view_modes() - has_arrows = ARROW_PURE in modes or ARROW_STITCHED in modes + has_arrows = ARROW_PURE in modes or ARROW_STITCHED in modes or overlay explain = bool(self._show_diagnostics) self.scale_label.setVisible(explain and not depth and has_arrows) self.pipeline_label.setVisible(explain and not depth) self.area_label.setVisible(explain and not depth and has_arrows) - self.colorbar.setVisible(depth) - self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text)) + self.colorbar.setVisible(depth or overlay) + self.depth_scale_label.setVisible((depth or overlay) and bool(self._depth_scale_text)) self.note_label.setVisible(bool(self._plan_note)) def set_diagnostics_visible(self, visible: bool) -> None: diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index 810ee53..645f9da 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -1057,15 +1057,14 @@ class ForcePlotsPanel(QFrame): next to cards reading the live 0.0, which reads as "the replay is not open". Availability is decided per sample here as well, so a cursor on a row with no value in this unit shows a dash, never a stale number. + + A zoomed window is still the cursor: the strip may name how long that + window is, but the big numbers stay on the sample under the playhead. """ if self._mode != "browse" and not self.is_recording_source(): return if not self._source_ready: return - if self._selection_active(): - # The cards are stating the selection's peak / mean / σ; the cursor is - # inside that window and must not overwrite them. - return first, last = self._source.extent() t0 = max(first, self._cursor_t - CURSOR_READOUT_S) t1 = min(last, self._cursor_t + CURSOR_READOUT_S) @@ -1095,12 +1094,11 @@ class ForcePlotsPanel(QFrame): self._render_value_cards() def _update_selection_readout(self) -> None: - """Refresh what the selection is worth: the cards, and the strip's size. + """Refresh the strip's window size; the cards stay on the cursor. - The per-channel numbers live on the cards (peak, then mean · σ); the strip - under the charts keeps only how long the selection is and how many samples - it holds, because that is all that fits there. The full line stays in the - strip's tooltip. + The strip under the charts keeps how long the selection is and how many + samples it holds. Peak / mean / σ used to sit on the value cards when the + window was inset; those cards now always follow the playhead. """ if not self._source_ready: self._selection = None @@ -1109,12 +1107,10 @@ class ForcePlotsPanel(QFrame): self._render_value_cards() return if not self._selection_active(): - # Following live, or fitted to the whole session: the cards are the - # readout of the cursor, not of a window. self._selection = None self.stats_label.setText("") self.stats_label.setToolTip("") - self._render_value_cards() + self._readout_at_cursor() return t0, t1 = self._window stats = self._source.statistics(t0, t1, unit=self._unit) @@ -1122,37 +1118,17 @@ class ForcePlotsPanel(QFrame): self._selection = None self.stats_label.setText(t("chart.selection_empty")) self.stats_label.setToolTip("") - self._render_value_cards() + self._readout_at_cursor() return self._selection = stats - self.stats_label.setText( - t( - "chart.selection_summary", - duration=f"{stats.duration_s:.2f}", - samples=stats.samples, - ) - ) - self.stats_label.setToolTip(self._selection_tooltip(stats)) - self._render_value_cards() - - def _selection_tooltip(self, stats) -> str: - """The full selection readout, for the strip's tooltip.""" - unit = unit_display(self._unit) - parts = [] - for index, label in enumerate(CHANNEL_LABELS): - peak = float(stats.peaks[index]) - if not np.isfinite(peak): - continue - parts.append( - f"{label} 峰 {peak:.3g}{unit} · 均 {float(stats.means[index]):.3g} " - f"· σ {float(stats.stds[index]):.3g}" - ) - return t( - "chart.selection", + summary = t( + "chart.selection_summary", duration=f"{stats.duration_s:.2f}", samples=stats.samples, - detail=" ".join(parts), ) + self.stats_label.setText(summary) + self.stats_label.setToolTip(summary) + self._readout_at_cursor() def _selection_active(self) -> bool: """Whether the visible window is a *selection* and not the whole session. @@ -1421,20 +1397,8 @@ class ForcePlotsPanel(QFrame): curve.setData([], []) def _render_value_cards(self) -> None: - """One card per channel: what it reads *now*, in the active unit. - - While a selection is being inspected the big number is that window's peak - and the line under it its mean and σ — the selection statistics used to be - a single long line under the charts, which could not fit and pushed the - panel wider. - """ - decimals = ( - self.presentation.newton_decimals - if self._unit == "N" else self.presentation.raw_decimals - ) - fmt = "{value:." + str(decimals) + "f}" + """One card per channel: the sample under the cursor, in the active unit.""" unavailable = t("chart.value_unavailable") - selecting = self._selection for index, (value_label, unit_label, detail_label, value) in enumerate( zip( self.value_labels, @@ -1444,11 +1408,7 @@ class ForcePlotsPanel(QFrame): ) ): available = self._available[index] - if selecting is not None: - peak = float(selecting.peaks[index]) - available = bool(np.isfinite(peak)) - value = peak - elif self.presentation.enabled: + if self.presentation.enabled: value = self._display_values[index] if not available: # An axis with no curve in this unit: a dash, never a raw count @@ -1462,44 +1422,18 @@ class ForcePlotsPanel(QFrame): continue value_label.setText( format_force(value, self._unit, presentation=self.presentation) - if selecting is None - else fmt.format(value=value) ) - tip = t("chart.selection_peak_tip") if selecting is not None else "" zero_zone = ( - selecting is None - and self.presentation.enabled + self.presentation.enabled and abs(float(value)) == 0.0 and index < len(self._display_suppressed) and self._display_suppressed[index] ) - if zero_zone: - tip = t("chart.zero_zone_tip") - value_label.setToolTip(tip) + value_label.setToolTip(t("chart.zero_zone_tip") if zero_zone else "") unit_label.setText(unit_display(self._unit)) - if selecting is not None: - detail_label.setText(self._selection_detail(index)) - elif zero_zone: - detail_label.setText(t("chart.zero_zone")) - else: - detail_label.setText("") + detail_label.setText(t("chart.zero_zone") if zero_zone else "") self.unit_badge.setText(unit_display(self._unit)) - def _selection_detail(self, index: int) -> str: - """``mean · σ`` for one channel of the current selection.""" - stats = self._selection - if stats is None: - return "" - mean = float(stats.means[index]) - std = float(stats.stds[index]) - if not np.isfinite(mean): - return "" - return t( - "chart.selection_detail", - mean=f"{mean:.4g}", - std=f"{std:.4g}" if np.isfinite(std) else "-", - ) - def retranslate(self) -> None: self.title.setText(t("panels.plots")) for badge, label in zip(self.channel_badges, CHANNEL_LABELS): diff --git a/apps/studio/src/orisys_studio/ui/sensor_dialog.py b/apps/studio/src/orisys_studio/ui/sensor_dialog.py index c5c3608..4078b35 100644 --- a/apps/studio/src/orisys_studio/ui/sensor_dialog.py +++ b/apps/studio/src/orisys_studio/ui/sensor_dialog.py @@ -200,13 +200,13 @@ class SensorDialog(QDialog): self.btn_export.clicked.connect(self._on_export) self.btn_apply = QPushButton() self.btn_apply.clicked.connect(self._on_apply) - self.btn_close = QPushButton() - self.btn_close.setProperty("compact", "true") - self.btn_close.clicked.connect(self.close) + self.btn_save = QPushButton() + self.btn_save.setProperty("compact", "true") + self.btn_save.clicked.connect(self._on_save) actions.addWidget(self.btn_export) actions.addWidget(self.btn_apply) actions.addStretch(1) - actions.addWidget(self.btn_close) + actions.addWidget(self.btn_save) root.addLayout(actions) # The picker's own changes change the calibration context, so the dialog @@ -244,6 +244,11 @@ class SensorDialog(QDialog): apply() self.refresh() + def _on_save(self) -> None: + """Keep the current sensor/calibration choice and dismiss the dialog.""" + self._persist_choice() + self.close() + def _on_browse(self) -> None: chosen, _filter = QFileDialog.getOpenFileName( self, @@ -554,7 +559,7 @@ class SensorDialog(QDialog): self.btn_export.setToolTip(t("cal.export_tip")) self.btn_apply.setText(t("cal.apply")) self.btn_apply.setToolTip(t("sensor.live_calibration_apply_tip")) - self.btn_close.setText(t("dialog.cancel")) + self.btn_save.setText(t("dialog.save")) self.picker.retranslate() self.refresh() diff --git a/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py b/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py index e75dacc..6031ccd 100644 --- a/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py +++ b/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py @@ -22,6 +22,7 @@ reason it cannot be used is one line, both in the language the catalog already s from __future__ import annotations import json +from collections.abc import Callable from pathlib import Path from orisys_app_shared.analysis.bare_video import ( @@ -92,6 +93,10 @@ class VideoAnalysisDialog(QDialog): initial_device_id: str | None = None, lock_profile: bool = False, theme: str = "dark", + remembered_calibration: Callable[ + [str], tuple[str, Path | None, str] | None + ] + | None = None, ) -> None: super().__init__(window) self._video = Path(video) @@ -110,6 +115,10 @@ class VideoAnalysisDialog(QDialog): 1 if initial_reference_frame is None else int(initial_reference_frame) ) self._restore_device = str(initial_device_id or "") + #: ``(role, path, device_id)`` saved by the sensor dialog for a catalog ref, + #: or ``None`` when that bundle has no saved choice. Missing means "keep + #: this dialog's own default", which is not the same as an explicit raw-force choice. + self._remembered_calibration = remembered_calibration self._device_source: Path | None = None self._lock_profile = bool(lock_profile) self.setObjectName("videoAnalysisDialog") @@ -300,7 +309,10 @@ class VideoAnalysisDialog(QDialog): ) self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not blocked) self._refresh_time_label() + device_id = self._apply_remembered_calibration() self._sync_device_row() + if device_id: + self.device_edit.setText(device_id) def _refresh_calibrations(self, *, custom: Path | None) -> None: """The calibration field: the profile's default, raw force, or a chosen file.""" @@ -328,6 +340,32 @@ class VideoAnalysisDialog(QDialog): self.calibration_combo.blockSignals(False) self._sync_device_row() + def _apply_remembered_calibration(self) -> str: + """Select the sensor dialog's saved choice for this profile. + + Returns a device serial to put back after the row sync, or ``""`` when + there is nothing saved (the combo keeps the profile default or raw force). + """ + entry = self.entry() + memory = self._remembered_calibration + if entry is None or not callable(memory): + return "" + found = memory(entry.ref) + if found is None: + return "" + role, path, device_id = found + if role == ROLE_FILE and path is not None and Path(path).is_file(): + self.select_calibration(Path(path)) + return str(device_id or "") + if role == ROLE_NONE: + self.select_calibration(None) + return "" + if role == ROLE_DEFAULT: + index = self.calibration_combo.findData(ROLE_DEFAULT) + if index >= 0: + self.calibration_combo.setCurrentIndex(index) + return "" + def _chosen_calibration(self) -> Path | None: role = str(self.calibration_combo.currentData() or "") entry = self.entry() diff --git a/apps/studio/tests/test_live_calibration_dialog.py b/apps/studio/tests/test_live_calibration_dialog.py index 853a1f1..85c0505 100644 --- a/apps/studio/tests/test_live_calibration_dialog.py +++ b/apps/studio/tests/test_live_calibration_dialog.py @@ -6,11 +6,13 @@ import json from pathlib import Path import pytest +from orisys_app_shared.analysis.bare_video import VideoFacts from orisys_app_shared.i18n import t from orisys_app_shared.sensors import scan_sensor_catalog from orisys_studio.ui import sensor_picker as picker_module from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE, SensorDialog from orisys_studio.ui.sensor_picker import SensorPickerWidget +from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog from PySide6.QtWidgets import QWidget CONFIG = { @@ -319,3 +321,71 @@ def test_apply_while_running_queues_set_pending_calibration(make_window, tmp_pat window.apply_live_calibration_choice() assert pending == [] assert window.statusBar().currentMessage() == t("sensor.live_calibration_remembered") + + +def _facts(path: Path) -> VideoFacts: + return VideoFacts(path=path, frame_count=4, fps=10.0, width=8, height=8) + + +def test_analysis_prompt_defaults_to_the_saved_sensor_file(make_window, tmp_path): + """The file saved from the sensor dialog is the analysis prompt's starting choice.""" + window = make_window() + entry = window.sensor_picker.selected_entry() + assert entry is not None + assert window.live_calibration_for(entry.ref) is None + cal = tmp_path / "picked.json" + cal.write_text("{}", encoding="utf-8") + window.set_live_calibration_choice(role=ROLE_FILE, path=cal) + dialog = VideoAnalysisDialog( + video=tmp_path / "clip.mp4", + facts=_facts(tmp_path / "clip.mp4"), + catalog=scan_sensor_catalog(), + window=window, + initial_profile=entry.ref, + remembered_calibration=window.live_calibration_for, + ) + try: + assert dialog.calibration_combo.currentData() == ROLE_FILE + assert dialog._chosen_calibration() == cal + finally: + dialog.deleteLater() + + +def test_analysis_prompt_keeps_its_default_until_a_choice_is_saved( + make_window, tmp_path +): + window = make_window() + dialog = VideoAnalysisDialog( + video=tmp_path / "clip.mp4", + facts=_facts(tmp_path / "clip.mp4"), + catalog=scan_sensor_catalog(), + window=window, + initial_profile="shipped/box", + remembered_calibration=window.live_calibration_for, + ) + try: + assert dialog.calibration_combo.currentData() == ROLE_DEFAULT + finally: + dialog.deleteLater() + + +def test_a_reopened_analysis_setup_still_beats_the_saved_file(make_window, tmp_path): + window = make_window() + entry = window.sensor_picker.selected_entry() + assert entry is not None + cal = tmp_path / "picked.json" + cal.write_text("{}", encoding="utf-8") + window.set_live_calibration_choice(role=ROLE_FILE, path=cal) + dialog = VideoAnalysisDialog( + video=tmp_path / "clip.mp4", + facts=_facts(tmp_path / "clip.mp4"), + catalog=scan_sensor_catalog(), + window=window, + initial_profile=entry.ref, + prefer_no_calibration=True, + remembered_calibration=window.live_calibration_for, + ) + try: + assert dialog.calibration_combo.currentData() == ROLE_NONE + finally: + dialog.deleteLater() diff --git a/apps/studio/tests/test_scene3d_overlays.py b/apps/studio/tests/test_scene3d_overlays.py index 31ef6f1..1c65281 100644 --- a/apps/studio/tests/test_scene3d_overlays.py +++ b/apps/studio/tests/test_scene3d_overlays.py @@ -120,9 +120,9 @@ def test_2d_3d_flow_sample_points_match() -> None: composed = composer.compose( assets, _depth_frame(assets, flow=flow), settings ) - arrows_3d = ( - 0 if composed.flow_arrows is None else int(composed.flow_arrows.origins.shape[0]) - ) + arrows = None if composed.flow_arrows is None else composed.flow_arrows.lengths + # Positive lengths are arrows. Negative lengths are the idle points. + arrows_3d = 0 if arrows is None else int(np.count_nonzero(arrows > 0.0)) # Exactly what the 2D canvas draws from: ``draw_arrow_display`` calls # ``select_flow_samples`` with the policy the window passes it, and draws the # samples the flag marks active. diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 9958231..fd51bb5 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -1741,7 +1741,12 @@ def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_rec assert panel.mode() == "follow" assert window.toolbar.run_role() == "acquire", "the rail kept the transport" assert window.toolbar.btn_run.text() == t("toolbar.run_label") - assert window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth") + assert window.arrow_panel.view_modes() == ( + "arrows_pure", + "arrows_stitched", + "depth", + "depth_arrows", + ) assert "raw_0001" not in window.toolbar.source_summary.text(), ( "the replay's video is still named in the live view" ) @@ -2747,14 +2752,15 @@ def test_a_recording_without_a_video_says_it_cannot_be_played(make_window, recor # ------------------------------------------- selection readout and notices -def test_a_selection_moves_its_statistics_onto_the_value_cards(make_window): - """The per-channel line under the charts did not fit; the cards hold it now. +def test_a_zoomed_window_keeps_the_cursor_on_the_value_cards(make_window): + """Zooming names the window in the strip; the cards stay on the playhead. - Selecting a window makes the big number that window's peak and the line under - it its mean · σ. The strip keeps only how big the selection is, and leaving - the selection puts the cursor readout back. + Selecting a window used to put that window's peak and mean · σ on the cards. + The big numbers are the sample under the cursor again; the strip keeps only + how big the selection is. """ from orisys_app_shared.i18n import t + from orisys_studio.ui.force_plots import format_force window, stuck = _running_window(make_window) window.toolbar.units_combo.setCurrentIndex(0) # newton @@ -2768,6 +2774,7 @@ def test_a_selection_moves_its_statistics_onto_the_value_cards(make_window): assert plots.stats_label.text() == "" assert plots.value_details[0].text() == "", "a live reading carries a mean line" + plots.set_cursor(2.0) plots.set_window(1.0, 3.0) assert plots.mode() == "browse" assert plots._selection is not None, "the window was not read as a selection" @@ -2777,17 +2784,17 @@ def test_a_selection_moves_its_statistics_onto_the_value_cards(make_window): assert t("chart.selection_summary", duration="1", samples=2)[:4] in summary or ( "选区" in summary or "Selection" in summary ), summary - # The numbers a reader cannot fit in the strip are on the cards instead. - expected_peak = float(plots._selection.peaks[0]) - expected = f"{expected_peak:.2f}" - assert plots.value_labels[0].text() == expected, ( - f"the FN card is not the selection's peak: {plots.value_labels[0].text()!r}" + assert "峰" not in summary and "均" not in summary, summary + + expected = format_force( + plots._display_values[0], plots.unit(), presentation=plots.presentation + ) + assert plots.value_labels[0].text() == expected, ( + f"the FN card is not the cursor sample: {plots.value_labels[0].text()!r}" + ) + assert plots.value_details[0].text() == "", ( + f"the card still carries a mean line: {plots.value_details[0].text()!r}" ) - detail = plots.value_details[0].text() - assert detail and detail != "", detail - assert f"{float(plots._selection.means[0]):.4g}" in detail, (detail, plots._selection.means[0]) - assert "均" in detail or "mean" in detail, detail - assert plots.value_labels[0].toolTip(), "the peak is not explained anywhere" # "Whole session" is the default fit, so it is not a selection. plots.show_all() diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index 18fdffb..8801be3 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -2775,9 +2775,8 @@ def main() -> int: plots.overview_curve.getData()[0].size > 0, "the whole-session overview strip is empty", ) - # A window narrower than the session is a selection: its peak becomes the big - # number on each card and its mean/σ the line under it, while the strip keeps - # only the size of the selection (the long per-channel line did not fit). + # A window narrower than the session is a selection in the strip only: the + # cards stay on the sample under the cursor. check(plots._selection is not None, "zooming in did not record a selection") summary = plots.stats_label.text() check(summary != "", "the strip does not state the size of the selection") @@ -2789,34 +2788,25 @@ def main() -> int: "选区" in summary or "Selection" in summary, f"the strip does not say that the numbers describe a selection: {summary!r}", ) - expected_peak = float(plots._selection.peaks[0]) - peak_text = ( - f"{expected_peak:.3f}" if plots.unit() == "N" else f"{expected_peak:.1f}" + from orisys_studio.ui.force_plots import format_force + + expected_cursor = format_force( + plots._display_values[0], plots.unit(), presentation=plots.presentation ) check( - plots.value_labels[0].text() == peak_text, - f"the FN card is not the selection's peak: {plots.value_labels[0].text()!r}", + plots.value_labels[0].text() == expected_cursor, + f"the FN card is not the cursor sample: {plots.value_labels[0].text()!r}", ) check( - plots.value_details[0].text() != "" - and ("均" in plots.value_details[0].text() or "mean" in plots.value_details[0].text()), - f"the card does not state the selection's mean: " - f"{plots.value_details[0].text()!r}", + plots.value_details[0].text() == "", + f"the card still carries a mean line: {plots.value_details[0].text()!r}", ) - for card, label, detail in zip( - plots.value_cards, plots.value_labels, plots.value_details - ): - check( - detail.width() >= detail.sizeHint().width(), - f"the selection detail line is clipped: " - f"{detail.width()} < {detail.sizeHint().width()}", - ) - # Leaving the selection hands the cards back to the cursor readout. + # Leaving the selection clears the strip; the cards stay a cursor readout. plots.follow_latest() check(plots._selection is None, "the selection survived 'back to latest'") check( plots.value_details[0].text() == "", - f"the mean line survived leaving the selection: " + f"a detail line appeared after leaving the selection: " f"{plots.value_details[0].text()!r}", ) @@ -3673,7 +3663,12 @@ def main() -> int: f"{replay_window.toolbar.source_summary.text()!r}", ) check( - replay_window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth"), + replay_window.arrow_panel.view_modes() == ( + "arrows_pure", + "arrows_stitched", + "depth", + "depth_arrows", + ), "the 2D panel did not return to its live views", ) check( diff --git a/shared/src/orisys_app_shared/acquisition/image_backend.py b/shared/src/orisys_app_shared/acquisition/image_backend.py index b66f962..04051db 100644 --- a/shared/src/orisys_app_shared/acquisition/image_backend.py +++ b/shared/src/orisys_app_shared/acquisition/image_backend.py @@ -47,9 +47,10 @@ from orisys_app_shared.acquisition.models import ensure_bgr ARROW_PURE = "arrows_pure" ARROW_STITCHED = "arrows_stitched" DEPTH = "depth" +DEPTH_ARROWS = "depth_arrows" RECORDED = "recorded" -DERIVED_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH) +DERIVED_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH, DEPTH_ARROWS) ALL_MODES = (*DERIVED_MODES, RECORDED) #: Where a frame came from. Only used for wording and for the recording; the modes @@ -294,6 +295,8 @@ class ImagePlan: return "flow" if wanted == DEPTH and not self.has_depth(): return "depth" + if wanted == DEPTH_ARROWS and not self.has_depth() and not self.has_flow(): + return "depth" return None def has_flow(self) -> bool: @@ -374,6 +377,52 @@ def plan_image(frame: ImageFrame) -> ImagePlan: ) +def _orient_drawn(drawn: np.ndarray, turn: str) -> np.ndarray: + """Apply a display orientation without dropping a draw-failure mark.""" + diagnostic = getattr(drawn, "frame_diagnostic", None) + oriented = apply_orientation(drawn, turn) + if diagnostic is None: + return oriented + return carry_frame_diagnostic(oriented, diagnostic) + + +def _draw_flow( + frame: ImageFrame, + base: np.ndarray, + *, + background: str, + theme: str, + draw_style: str, + domain_mask: np.ndarray | None, + arrow_policy, + canvas_bgr: Sequence[int] | None = None, + sdk_result: bool | None = None, +) -> np.ndarray: + """Arrows on ``base``, using the same painter the pure-arrow views use.""" + if sdk_result is None: + box_style = not str(draw_style).lower().startswith("f") + sdk_result = ( + box_style + and str(frame.source or "") in RESULT_SOURCES + and uses_sdk_box_grid(arrow_policy) + ) + flow = frame.flow + if sdk_result and flow is not None: + flow = np.nan_to_num(np.asarray(flow, dtype=np.float64), nan=0.0) + return draw_arrow_display( + base, + flow, + background=background, + 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, + ) + + def render_image( frame: ImageFrame, plan: ImagePlan | None = None, @@ -418,59 +467,55 @@ def render_image( wanted = plan.resolve(mode) boundary = frame.valid_mask if domain_mask is None else domain_mask turn = normalise_orientation(orientation) if wanted != RECORDED else ORIENTATION_IDENTITY - if wanted == DEPTH: - # Never substitute another picture for a missing depth map: that made "no - # depth" look like a depth reading. The panel says so instead. - if frame.depth_valid is False: - return None + depth_image = None + if wanted in (DEPTH, DEPTH_ARROWS) and frame.depth_valid is not False: depth_image = depth_map_to_bgr( frame.depth, flow_colormap(theme), value_range=depth_range, invalid_mask=boundary, ) + if wanted == DEPTH: + # Never substitute another picture for a missing depth map: that made "no + # depth" look like a depth reading. The panel says so instead. if depth_image is None: return None return apply_orientation(depth_image, turn) + if wanted == DEPTH_ARROWS: + if depth_image is None: + wanted = ARROW_PURE + elif frame.flow is None or frame.flow_valid is False: + return apply_orientation(depth_image, turn) + else: + drawn = _draw_flow( + frame, + depth_image, + background="captured", + theme=theme, + draw_style=draw_style, + domain_mask=domain_mask, + arrow_policy=arrow_policy, + ) + return _orient_drawn(drawn, turn) if frame.image_bgr is None: return None if wanted in (ARROW_PURE, ARROW_STITCHED): if frame.flow is None or frame.flow_valid is False: # The base picture with no arrows, and the plan's ``missing`` says why. return apply_orientation(ensure_bgr(np.asarray(frame.image_bgr)), turn) - # A recovered field is finite only at the cells the pass stored. The shared - # painter draws those cells; the SDK painter walks its own grid and finds - # nothing wherever that grid and the stored cells disagree. A box result whose - # grid *is* the SDK grid is the exception: fill the gaps with zero (the cells - # the painter does not read) and call the same ``draw_arrows`` live uses. - box_style = not str(draw_style).lower().startswith("f") - sdk_result = ( - box_style - and str(frame.source or "") in RESULT_SOURCES - and uses_sdk_box_grid(arrow_policy) - ) - flow = frame.flow - if sdk_result: - flow = np.nan_to_num(np.asarray(flow, dtype=np.float64), nan=0.0) - drawn = draw_arrow_display( + # A recovered field is finite only at the cells the pass stored. ``_draw_flow`` + # fills a box result's gaps with zero and keeps the shared painter otherwise. + drawn = _draw_flow( + frame, frame.image_bgr, - flow, background="captured" if wanted == ARROW_STITCHED else "pure_color", - canvas_bgr=canvas_bgr, + theme=theme, draw_style=draw_style, - shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result, - valid_mask=frame.valid_mask, + canvas_bgr=canvas_bgr, domain_mask=domain_mask, - colormap=flow_colormap(theme), - policy=arrow_policy, + arrow_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) + return _orient_drawn(drawn, turn) # ``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/composer.py b/shared/src/orisys_app_shared/scene3d/composer.py index 8137825..0fc28b8 100644 --- a/shared/src/orisys_app_shared/scene3d/composer.py +++ b/shared/src/orisys_app_shared/scene3d/composer.py @@ -282,11 +282,10 @@ class SceneComposer: frame.flow_valid_mask, policy, ) - # The selector returns every candidate location plus an ``active`` flag; - # this figure draws the active ones, which is what it drew before the - # split (the selector used to filter and this figure drew the lot). + # Inactive candidates are grey points; active ones are coloured arrows. + # Same samples the 2D canvas paints, including the quiet ones. flow_arrows, extra = build_flow_overlay( - samples.active_only(), + samples, mapping, style=policy, normal_offset_m=mesh_span_from_mapping(mapping) * 0.002, diff --git a/shared/src/orisys_app_shared/scene3d/overlays.py b/shared/src/orisys_app_shared/scene3d/overlays.py index 521ce0c..04f33e2 100644 --- a/shared/src/orisys_app_shared/scene3d/overlays.py +++ b/shared/src/orisys_app_shared/scene3d/overlays.py @@ -19,6 +19,14 @@ from orisys_app_shared.scene3d.contracts import ( error, freeze, ) +from orisys_app_shared.acquisition.arrows import ( + FLOW_IDLE_HEX, + FLOW_MAGNITUDE_RANGE, + hex_to_bgr, + magnitude_to_ramp_index, + ramp_for_theme, + ramp_rgba_table, +) from orisys_app_shared.scene3d.visual_policy import FlowSamples, VisualPolicy ForceStrengthMode = Literal["log", "power", "linear"] @@ -43,8 +51,11 @@ class ContactArrowStyle: DEFAULT_CONTACT_ARROW_STYLE = ContactArrowStyle() -FLOW_TAIL_RGBA = (38 / 255.0, 235 / 255.0, 89 / 255.0, 0.85) -FLOW_HEAD_RGBA = (13 / 255.0, 115 / 255.0, 31 / 255.0, 0.95) + +def _idle_rgba() -> np.ndarray: + """The 2D idle-dot tint, as linear RGBA. Same hex, so a quiet sample matches.""" + blue, green, red = hex_to_bgr(FLOW_IDLE_HEX) + return np.asarray((red, green, blue, 230), dtype=np.float32) / 255.0 def _empty_arrows(kind: str) -> ArrowInstances: @@ -190,6 +201,75 @@ def flow_arrow_shaft_radius_m( return float(np.clip(radius, span * low, span * high)) +def _merge_arrows(first: ArrowInstances, second: ArrowInstances) -> ArrowInstances: + """One flow batch: coloured arrows, then the grey idle points.""" + if int(first.origins.shape[0]) == 0: + return second + if int(second.origins.shape[0]) == 0: + return first + return ArrowInstances( + origins=freeze(np.vstack([first.origins, second.origins]), dtype=np.float32), + directions=freeze( + np.vstack([first.directions, second.directions]), dtype=np.float32 + ), + lengths=freeze(np.concatenate([first.lengths, second.lengths]), dtype=np.float32), + colors=freeze(np.vstack([first.colors, second.colors]), dtype=np.float32), + kind="flow", + shaft_radius_m=float(first.shaft_radius_m or second.shaft_radius_m), + ) + + +def _idle_flow_points( + samples: FlowSamples, + mapping: SurfaceMapping, + *, + normal_offset_m: float, +) -> ArrowInstances: + """A fixed grey point at every candidate the threshold has not turned on. + + A negative length is the mesh builder's mark for that point (see + :func:`arrow_instances_to_mesh`). The colour is the 2D idle dot, not the ramp. + """ + active = np.asarray(samples.active, dtype=bool) + if active.shape != samples.rows.shape or not np.any(~active): + return _empty_arrows("flow") + rows = np.asarray(samples.rows, dtype=np.int32)[~active] + cols = np.asarray(samples.cols, dtype=np.int32)[~active] + h, w = mapping.shape_hw + inside = (rows >= 0) & (rows < h) & (cols >= 0) & (cols < w) + if not np.any(inside): + return _empty_arrows("flow") + rows = rows[inside] + cols = cols[inside] + valid = np.asarray(mapping.valid, dtype=bool) + keep = valid[rows, cols] + if not np.any(keep): + return _empty_arrows("flow") + rows = rows[keep] + cols = cols[keep] + positions = np.asarray(mapping.positions_sensor_m, dtype=np.float64) + normals = np.asarray(mapping.normals_sensor, dtype=np.float64) + origins = positions[rows, cols].copy() + directions = normals[rows, cols].copy() + norms = np.linalg.norm(directions, axis=1) + bad = norms < 1e-8 + directions[bad] = (0.0, 0.0, 1.0) + norms = np.where(bad, 1.0, norms) + directions = directions / norms[:, None] + if normal_offset_m: + origins = origins + normal_offset_m * directions + radius = flow_arrow_shaft_radius_m(mapping) + count = int(rows.size) + return ArrowInstances( + origins=freeze(origins, dtype=np.float32), + directions=freeze(directions, dtype=np.float32), + lengths=freeze(np.full(count, -radius, dtype=np.float32), dtype=np.float32), + colors=freeze(np.tile(_idle_rgba(), (count, 1)), dtype=np.float32), + kind="flow", + shaft_radius_m=radius, + ) + + def build_flow_overlay( samples: FlowSamples, mapping: SurfaceMapping, @@ -197,7 +277,31 @@ def build_flow_overlay( style: VisualPolicy | None = None, normal_offset_m: float = 0.0, ) -> tuple[ArrowInstances, tuple[Diagnostic, ...]]: - """Map shared UV flow samples onto the sensing surface.""" + """Map shared UV flow samples onto the sensing surface. + + Inactive candidates are grey points. Active ones are arrows coloured by the + same magnitude ramp the 2D canvas uses. + """ + points = _idle_flow_points( + samples, mapping, normal_offset_m=normal_offset_m + ) + arrows, diags = _active_flow_overlay( + samples.active_only(), + mapping, + style=style, + normal_offset_m=normal_offset_m, + ) + return _merge_arrows(arrows, points), diags + + +def _active_flow_overlay( + samples: FlowSamples, + mapping: SurfaceMapping, + *, + style: VisualPolicy | None = None, + normal_offset_m: float = 0.0, +) -> tuple[ArrowInstances, tuple[Diagnostic, ...]]: + """Arrows for samples that cleared the flow threshold.""" del style # lengths come from UV tip deltas projected into scene metres if samples.rows.size == 0: return _empty_arrows("flow"), () @@ -216,6 +320,7 @@ def build_flow_overlay( rows = np.asarray(samples.rows, dtype=np.int32) cols = np.asarray(samples.cols, dtype=np.int32) + magnitude = np.asarray(samples.magnitude, dtype=np.float64) end_x = np.clip(np.round(cols + samples.dx).astype(np.int32), 0, w - 1) end_y = np.clip(np.round(rows + samples.dy).astype(np.int32), 0, h - 1) @@ -232,6 +337,7 @@ def build_flow_overlay( rows = rows[inside] cols = cols[inside] + magnitude = magnitude[inside] end_x = end_x[inside] end_y = end_y[inside] origins = positions[rows, cols].copy() @@ -248,13 +354,15 @@ def build_flow_overlay( origins = origins[keep] vectors = vectors[keep] lengths = lengths[keep] + magnitude = magnitude[keep] directions = vectors / lengths[:, None] - colors = np.tile(np.asarray(FLOW_TAIL_RGBA, dtype=np.float32), (len(origins), 1)) - # Stronger arrows tip toward the head colour. - t = np.clip(lengths / max(float(np.percentile(lengths, 95)), 1e-9), 0.0, 1.0) - head = np.asarray(FLOW_HEAD_RGBA, dtype=np.float32) - colors = colors * (1.0 - t)[:, None] + head * t[:, None] + # Same ramp index the 2D canvas asks for, so an active arrow is the same colour + # in both views. Quiet samples never reach here: they are the grey points. + index = magnitude_to_ramp_index(magnitude, value_range=FLOW_MAGNITUDE_RANGE) + table = ramp_rgba_table(ramp_for_theme("dark")).reshape(-1, 4).astype(np.float32) + colors = table[index] / 255.0 + colors[:, 3] = 0.9 return ( ArrowInstances( @@ -499,6 +607,35 @@ def _cylinder_template(resolution: int = 8) -> tuple[np.ndarray, np.ndarray]: ) +def _point_template() -> tuple[np.ndarray, np.ndarray]: + """Unit octahedron. A negative arrow length asks the mesh builder for one of these.""" + vertices = np.asarray( + [ + [1.0, 0.0, 0.0], + [-1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, -1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, -1.0], + ], + dtype=np.float64, + ) + triangles = np.asarray( + [ + [0, 2, 4], + [2, 1, 4], + [1, 3, 4], + [3, 0, 4], + [2, 0, 5], + [1, 2, 5], + [3, 1, 5], + [0, 3, 5], + ], + dtype=np.int32, + ) + return vertices, triangles + + def _cone_template(resolution: int = 8) -> tuple[np.ndarray, np.ndarray]: """Unit-radius cone with apex at z=1 and base at z=0.""" angles = np.linspace(0.0, 2.0 * np.pi, resolution, endpoint=False) @@ -534,6 +671,7 @@ def arrow_instances_to_mesh( cyl_v, cyl_t = _cylinder_template() cone_v, cone_t = _cone_template() + point_v, point_t = _point_template() origins = np.asarray(arrows.origins, dtype=np.float64) directions = np.asarray(arrows.directions, dtype=np.float64) lengths = np.asarray(arrows.lengths, dtype=np.float64) @@ -544,7 +682,8 @@ def arrow_instances_to_mesh( else np.ones(count, dtype=np.float64) ) - mean_length = float(np.mean(lengths)) if count else 1.0 + positive = lengths[lengths > 0.0] + mean_length = float(np.mean(positive)) if positive.size else 1.0 # An explicit stroke radius wins over the proportional one: see # ``flow_arrow_shaft_radius_m`` for why a flow arrow cannot use a fraction of its # own length. The contact arrow keeps the proportional template. @@ -561,11 +700,20 @@ def arrow_instances_to_mesh( offset = 0 for i in range(count): length = float(lengths[i]) + origin = origins[i] + radius = base_radius * float(max(thickness[i], 0.05)) + if length < 0.0: + # Idle sample: a fixed grey point, not an arrow. ``length`` is unused; + # the radius is the same stroke the active arrows use. + verts = point_v * radius + origin + parts_v.append(verts) + parts_t.append(point_t + offset) + parts_c.append(np.tile(colors[i], (len(verts), 1))) + offset += len(verts) + continue if length <= 1e-9: continue direction = directions[i] - origin = origins[i] - radius = base_radius * float(max(thickness[i], 0.05)) rotation = _rotation_matrix_z_to(direction) # Tail: 75% cylinder, head: 25% cone. tail_len = length * 0.75 diff --git a/shared/tests/test_image_backend.py b/shared/tests/test_image_backend.py index 5e8a007..713f85a 100644 --- a/shared/tests/test_image_backend.py +++ b/shared/tests/test_image_backend.py @@ -36,12 +36,22 @@ def test_a_live_frame_offers_the_derived_modes_and_needs_no_note(): plan = backend.plan_image( backend.ImageFrame(_image(), flow=_flow(), depth=np.ones((12, 16))) ) - assert plan.available_modes == ("arrows_pure", "arrows_stitched", "depth") + assert plan.available_modes == ("arrows_pure", "arrows_stitched", "depth", "depth_arrows") assert plan.default_mode == "arrows_pure" assert plan.missing == () assert plan.note_key == "" +def test_depth_arrows_draws_the_flow_on_the_depth_picture(): + depth = np.linspace(0.0, 1.0, 12 * 16, dtype=np.float32).reshape(12, 16) + frame = backend.ImageFrame(_image(), flow=_flow(), depth=depth) + plain = backend.render_image(frame, mode="depth", draw_style="finger") + over = backend.render_image(frame, mode="depth_arrows", draw_style="finger") + assert plain is not None and over is not None + assert over.shape == plain.shape + assert not np.array_equal(over, plain) + + def test_a_recorded_frame_is_raw_and_says_so(): """No flow and no depth: the frame *is* the picture, and the note explains it.""" plan = backend.plan_image(backend.ImageFrame(_image(), source=backend.SOURCE_REPLAY)) @@ -723,9 +733,12 @@ def test_hiding_the_idle_dots_leaves_the_3d_figure_where_it_was(): f"{int(active.sum())} active of {samples.rows.size} candidates" ) assert overlay_on is not None and overlay_off is not None - assert overlay_on.origins.shape[0] == int(active.sum()), ( + assert int(np.count_nonzero(overlay_on.lengths > 0.0)) == int(active.sum()), ( "the 3D overlay is not drawing the active samples the 2D canvas draws" ) + assert int(np.count_nonzero(overlay_on.lengths < 0.0)) > 0, ( + "inactive samples are not drawn as grey points" + ) assert np.array_equal(overlay_on.origins, overlay_off.origins) assert np.array_equal(overlay_on.directions, overlay_off.directions) assert not np.array_equal(dots_on, dots_off), "the switch changed nothing to hide" diff --git a/shared/tests/test_overlays.py b/shared/tests/test_overlays.py index 2e4404c..53cf038 100644 --- a/shared/tests/test_overlays.py +++ b/shared/tests/test_overlays.py @@ -3,6 +3,7 @@ from __future__ import annotations import numpy as np +import pytest from orisys_app_shared.scene3d.overlays import ( arrow_instances_to_mesh, build_flow_overlay, @@ -10,7 +11,7 @@ from orisys_app_shared.scene3d.overlays import ( force_visual_strength, ) from orisys_app_shared.scene3d.synthetic import poc_scene -from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples +from orisys_app_shared.scene3d.visual_policy import FlowSamples, VisualPolicy, select_flow_samples def test_force_visual_strength_is_capped() -> None: @@ -58,6 +59,30 @@ def test_flow_and_normal_overlays_on_poc_mapping() -> None: assert colors.shape[0] == positions.shape[0] +def test_an_inactive_sample_is_a_grey_point_and_an_active_one_is_an_arrow() -> None: + assets = poc_scene() + mapping = assets.surface_mapping + assert mapping is not None + samples = FlowSamples( + rows=np.asarray([1, 1], dtype=np.int32), + cols=np.asarray([1, 2], dtype=np.int32), + dx=np.asarray([0.8, 0.0]), + dy=np.asarray([0.0, 0.0]), + magnitude=np.asarray([1.6, 0.0]), + active=np.asarray([True, False]), + ) + arrows, diags = build_flow_overlay(samples, mapping) + assert not diags + assert int(np.count_nonzero(arrows.lengths > 0.0)) == 1 + assert int(np.count_nonzero(arrows.lengths < 0.0)) == 1 + idle = arrows.colors[arrows.lengths < 0.0][0] + # Brand idle tint #8A93A6, not a ramp colour. + assert idle[0] == pytest.approx(0x8A / 255.0, abs=0.02) + assert idle[2] == pytest.approx(0xA6 / 255.0, abs=0.02) + positions, _triangles, colors = arrow_instances_to_mesh(arrows) + assert positions.shape[0] == colors.shape[0] > 0 + + def test_missing_contact_yields_no_arrow() -> None: assets = poc_scene() mapping = assets.surface_mapping