三维未激活采样显示灰点,二维增加深度叠箭头。
分析对话框默认使用传感器对话框保存的标定,关闭按钮改为保存。框选曲线时数值卡片仍显示光标读数。相关单测通过。
This commit is contained in:
@@ -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)",
|
||||
|
||||
@@ -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 显示方向(只影响画面)",
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
summary = 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",
|
||||
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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user