三维未激活采样显示灰点,二维增加深度叠箭头。

分析对话框默认使用传感器对话框保存的标定,关闭按钮改为保存。框选曲线时数值卡片仍显示光标读数。相关单测通过。
This commit is contained in:
ykcheungab
2026-10-08 17:04:30 +08:00
parent 3d2fb0c4eb
commit 091d0fda35
18 changed files with 507 additions and 202 deletions
+2
View File
@@ -106,6 +106,7 @@
"filter_off": "Not filtering (raw readings)", "filter_off": "Not filtering (raw readings)",
"ok": "OK", "ok": "OK",
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save",
"close": "Close" "close": "Close"
}, },
"toolbar": { "toolbar": {
@@ -214,6 +215,7 @@
"view_arrows_pure": "Arrows", "view_arrows_pure": "Arrows",
"view_arrows_stitched": "Arrows / Captured", "view_arrows_stitched": "Arrows / Captured",
"view_depth": "Depth map", "view_depth": "Depth map",
"view_depth_arrows": "Depth + arrows",
"view_recorded": "Recorded image", "view_recorded": "Recorded image",
"view_mode_tip": "Image display mode while running", "view_mode_tip": "Image display mode while running",
"arrow_settings_tip": "Finger 2D presentation (orientation; display only)", "arrow_settings_tip": "Finger 2D presentation (orientation; display only)",
+2
View File
@@ -106,6 +106,7 @@
"filter_off": "未滤波(原始读数)", "filter_off": "未滤波(原始读数)",
"ok": "确定", "ok": "确定",
"cancel": "取消", "cancel": "取消",
"save": "保存",
"close": "关闭" "close": "关闭"
}, },
"toolbar": { "toolbar": {
@@ -214,6 +215,7 @@
"view_arrows_pure": "箭头", "view_arrows_pure": "箭头",
"view_arrows_stitched": "箭头/采集图像", "view_arrows_stitched": "箭头/采集图像",
"view_depth": "深度图", "view_depth": "深度图",
"view_depth_arrows": "深度 + 箭头",
"view_recorded": "原始图像", "view_recorded": "原始图像",
"view_mode_tip": "运行时的图像显示模式", "view_mode_tip": "运行时的图像显示模式",
"arrow_settings_tip": "手指 2D 显示方向(只影响画面)", "arrow_settings_tip": "手指 2D 显示方向(只影响画面)",
+2 -2
View File
@@ -284,8 +284,8 @@
"purpose": "write the calibration panel's coefficients (the panel's own action)" }, "purpose": "write the calibration panel's coefficients (the panel's own action)" },
{ "id": "dialog_apply", "path": "sensor_dialog.btn_apply", "objectName": null, "class": "QPushButton", { "id": "dialog_apply", "path": "sensor_dialog.btn_apply", "objectName": null, "class": "QPushButton",
"purpose": "push the last export into the running device" }, "purpose": "push the last export into the running device" },
{ "id": "dialog_close", "path": "sensor_dialog.btn_close", "objectName": null, "class": "QPushButton", { "id": "dialog_save", "path": "sensor_dialog.btn_save", "objectName": null, "class": "QPushButton",
"purpose": "close the dialog" } "purpose": "keep the current sensor and calibration choice and dismiss the dialog" }
] ]
} }
], ],
+26 -9
View File
@@ -1855,21 +1855,22 @@ class StudioMainWindow(QMainWindow):
entry = self.sensor_picker.selected_entry() entry = self.sensor_picker.selected_entry()
return None if entry is None else entry.ref return None if entry is None else entry.ref
def live_calibration_choice(self) -> tuple[str, Path | None, str]: def live_calibration_for(
"""``(role, file_path_or_None, device_id)`` remembered for the selected bundle. 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 ``None`` is not raw force: a caller that has its own default (the analysis
resolved when the chain opens. A missing file falls back to raw force. 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 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") store = self.prefs.get("live_calibration")
if not key or not isinstance(store, dict): if not ref or not isinstance(store, dict):
return ROLE_NONE, None, "" return None
entry = store.get(key) entry = store.get(ref)
if not isinstance(entry, dict): if not isinstance(entry, dict):
return ROLE_NONE, None, "" return None
role = str(entry.get("role") or ROLE_NONE) role = str(entry.get("role") or ROLE_NONE)
if role not in (ROLE_DEFAULT, ROLE_FILE, ROLE_NONE): if role not in (ROLE_DEFAULT, ROLE_FILE, ROLE_NONE):
role = ROLE_NONE role = ROLE_NONE
@@ -1882,6 +1883,20 @@ class StudioMainWindow(QMainWindow):
path = candidate path = candidate
return role, path, str(entry.get("device_id") or "") 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( def set_live_calibration_choice(
self, self,
*, *,
@@ -2948,6 +2963,7 @@ class StudioMainWindow(QMainWindow):
initial_reference_frame=initial_reference_frame, initial_reference_frame=initial_reference_frame,
initial_device_id=initial_device_id, initial_device_id=initial_device_id,
theme=self._theme, theme=self._theme,
remembered_calibration=self.live_calibration_for,
) )
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted: if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
return None return None
@@ -2990,6 +3006,7 @@ class StudioMainWindow(QMainWindow):
initial_reference_frame=getattr(self, "_analysis_reference_override", None), initial_reference_frame=getattr(self, "_analysis_reference_override", None),
lock_profile=True, lock_profile=True,
theme=self._theme, theme=self._theme,
remembered_calibration=self.live_calibration_for,
) )
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted: if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
return False return False
+3 -2
View File
@@ -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.arrows import arrow_display_scale, hex_to_bgr
from orisys_app_shared.acquisition.image_backend import ( from orisys_app_shared.acquisition.image_backend import (
DEPTH, DEPTH,
DEPTH_ARROWS,
DomainMaskLatch, DomainMaskLatch,
ImageFrame, ImageFrame,
plan_image, plan_image,
@@ -232,7 +233,7 @@ class FrameView:
self._last_depth_frame = frame.depth self._last_depth_frame = frame.depth
span = self.active_depth_span() span = self.active_depth_span()
self.publish_shared_depth_scale(span) self.publish_shared_depth_scale(span)
if mode == DEPTH: if mode in (DEPTH, DEPTH_ARROWS):
self._arrow_panel.set_depth_scale( self._arrow_panel.set_depth_scale(
self.depth_scale_text(frame), self.depth_scale_text(frame),
tip_key=self.depth_scale_tip_key(), tip_key=self.depth_scale_tip_key(),
@@ -267,7 +268,7 @@ class FrameView:
# Never substitute another picture here: a missing depth map that showed # Never substitute another picture here: a missing depth map that showed
# the processed frame made "no depth" look like a depth reading. A draw # the processed frame made "no depth" look like a depth reading. A draw
# failure used to return the black base image; say so instead. # 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") message = t("panels.depth_unavailable")
elif diagnostic is not None: elif diagnostic is not None:
message = t("panels.arrow_render_failed") message = t("panels.arrow_render_failed")
@@ -14,6 +14,7 @@ from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE, ARROW_PURE,
ARROW_STITCHED, ARROW_STITCHED,
DEPTH, DEPTH,
DEPTH_ARROWS,
ImagePlan, ImagePlan,
) )
from orisys_app_shared.i18n import t 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 import icons
from orisys_studio.ui.fit_text import ShrinkableLabel 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 #: 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. #: stored: the panel shows the recorded frames, and nothing else.
REPLAY_VIEW_MODES = ("recorded",) REPLAY_VIEW_MODES = ("recorded",)
@@ -53,6 +54,7 @@ _VIEW_LABELS = {
ARROW_PURE: "panels.view_arrows_pure", ARROW_PURE: "panels.view_arrows_pure",
ARROW_STITCHED: "panels.view_arrows_stitched", ARROW_STITCHED: "panels.view_arrows_stitched",
DEPTH: "panels.view_depth", DEPTH: "panels.view_depth",
DEPTH_ARROWS: "panels.view_depth_arrows",
"recorded": "panels.view_recorded", "recorded": "panels.view_recorded",
} }
_ROTATION_STEPS = (0, 90, 180, 270) _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 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. 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() 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) explain = bool(self._show_diagnostics)
self.scale_label.setVisible(explain and not depth and has_arrows) self.scale_label.setVisible(explain and not depth and has_arrows)
self.pipeline_label.setVisible(explain and not depth) self.pipeline_label.setVisible(explain and not depth)
self.area_label.setVisible(explain and not depth and has_arrows) self.area_label.setVisible(explain and not depth and has_arrows)
self.colorbar.setVisible(depth) self.colorbar.setVisible(depth or overlay)
self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text)) self.depth_scale_label.setVisible((depth or overlay) and bool(self._depth_scale_text))
self.note_label.setVisible(bool(self._plan_note)) self.note_label.setVisible(bool(self._plan_note))
def set_diagnostics_visible(self, visible: bool) -> None: def set_diagnostics_visible(self, visible: bool) -> None:
+18 -84
View File
@@ -1057,15 +1057,14 @@ class ForcePlotsPanel(QFrame):
next to cards reading the live 0.0, which reads as "the replay is not 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 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. 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(): if self._mode != "browse" and not self.is_recording_source():
return return
if not self._source_ready: if not self._source_ready:
return 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() first, last = self._source.extent()
t0 = max(first, self._cursor_t - CURSOR_READOUT_S) t0 = max(first, self._cursor_t - CURSOR_READOUT_S)
t1 = min(last, 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() self._render_value_cards()
def _update_selection_readout(self) -> None: 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 The strip under the charts keeps how long the selection is and how many
under the charts keeps only how long the selection is and how many samples samples it holds. Peak / mean / σ used to sit on the value cards when the
it holds, because that is all that fits there. The full line stays in the window was inset; those cards now always follow the playhead.
strip's tooltip.
""" """
if not self._source_ready: if not self._source_ready:
self._selection = None self._selection = None
@@ -1109,12 +1107,10 @@ class ForcePlotsPanel(QFrame):
self._render_value_cards() self._render_value_cards()
return return
if not self._selection_active(): 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._selection = None
self.stats_label.setText("") self.stats_label.setText("")
self.stats_label.setToolTip("") self.stats_label.setToolTip("")
self._render_value_cards() self._readout_at_cursor()
return return
t0, t1 = self._window t0, t1 = self._window
stats = self._source.statistics(t0, t1, unit=self._unit) stats = self._source.statistics(t0, t1, unit=self._unit)
@@ -1122,37 +1118,17 @@ class ForcePlotsPanel(QFrame):
self._selection = None self._selection = None
self.stats_label.setText(t("chart.selection_empty")) self.stats_label.setText(t("chart.selection_empty"))
self.stats_label.setToolTip("") self.stats_label.setToolTip("")
self._render_value_cards() self._readout_at_cursor()
return return
self._selection = stats self._selection = stats
self.stats_label.setText( summary = t(
t(
"chart.selection_summary", "chart.selection_summary",
duration=f"{stats.duration_s:.2f}", duration=f"{stats.duration_s:.2f}",
samples=stats.samples, samples=stats.samples,
) )
) self.stats_label.setText(summary)
self.stats_label.setToolTip(self._selection_tooltip(stats)) self.stats_label.setToolTip(summary)
self._render_value_cards() self._readout_at_cursor()
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),
)
def _selection_active(self) -> bool: def _selection_active(self) -> bool:
"""Whether the visible window is a *selection* and not the whole session. """Whether the visible window is a *selection* and not the whole session.
@@ -1421,20 +1397,8 @@ class ForcePlotsPanel(QFrame):
curve.setData([], []) curve.setData([], [])
def _render_value_cards(self) -> None: def _render_value_cards(self) -> None:
"""One card per channel: what it reads *now*, in the active unit. """One card per channel: the sample under the cursor, 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}"
unavailable = t("chart.value_unavailable") unavailable = t("chart.value_unavailable")
selecting = self._selection
for index, (value_label, unit_label, detail_label, value) in enumerate( for index, (value_label, unit_label, detail_label, value) in enumerate(
zip( zip(
self.value_labels, self.value_labels,
@@ -1444,11 +1408,7 @@ class ForcePlotsPanel(QFrame):
) )
): ):
available = self._available[index] available = self._available[index]
if selecting is not None: if self.presentation.enabled:
peak = float(selecting.peaks[index])
available = bool(np.isfinite(peak))
value = peak
elif self.presentation.enabled:
value = self._display_values[index] value = self._display_values[index]
if not available: if not available:
# An axis with no curve in this unit: a dash, never a raw count # An axis with no curve in this unit: a dash, never a raw count
@@ -1462,44 +1422,18 @@ class ForcePlotsPanel(QFrame):
continue continue
value_label.setText( value_label.setText(
format_force(value, self._unit, presentation=self.presentation) 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 = ( zero_zone = (
selecting is None self.presentation.enabled
and self.presentation.enabled
and abs(float(value)) == 0.0 and abs(float(value)) == 0.0
and index < len(self._display_suppressed) and index < len(self._display_suppressed)
and self._display_suppressed[index] and self._display_suppressed[index]
) )
if zero_zone: value_label.setToolTip(t("chart.zero_zone_tip") if zero_zone else "")
tip = t("chart.zero_zone_tip")
value_label.setToolTip(tip)
unit_label.setText(unit_display(self._unit)) unit_label.setText(unit_display(self._unit))
if selecting is not None: detail_label.setText(t("chart.zero_zone") if zero_zone else "")
detail_label.setText(self._selection_detail(index))
elif zero_zone:
detail_label.setText(t("chart.zero_zone"))
else:
detail_label.setText("")
self.unit_badge.setText(unit_display(self._unit)) 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: def retranslate(self) -> None:
self.title.setText(t("panels.plots")) self.title.setText(t("panels.plots"))
for badge, label in zip(self.channel_badges, CHANNEL_LABELS): 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_export.clicked.connect(self._on_export)
self.btn_apply = QPushButton() self.btn_apply = QPushButton()
self.btn_apply.clicked.connect(self._on_apply) self.btn_apply.clicked.connect(self._on_apply)
self.btn_close = QPushButton() self.btn_save = QPushButton()
self.btn_close.setProperty("compact", "true") self.btn_save.setProperty("compact", "true")
self.btn_close.clicked.connect(self.close) self.btn_save.clicked.connect(self._on_save)
actions.addWidget(self.btn_export) actions.addWidget(self.btn_export)
actions.addWidget(self.btn_apply) actions.addWidget(self.btn_apply)
actions.addStretch(1) actions.addStretch(1)
actions.addWidget(self.btn_close) actions.addWidget(self.btn_save)
root.addLayout(actions) root.addLayout(actions)
# The picker's own changes change the calibration context, so the dialog # The picker's own changes change the calibration context, so the dialog
@@ -244,6 +244,11 @@ class SensorDialog(QDialog):
apply() apply()
self.refresh() 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: def _on_browse(self) -> None:
chosen, _filter = QFileDialog.getOpenFileName( chosen, _filter = QFileDialog.getOpenFileName(
self, self,
@@ -554,7 +559,7 @@ class SensorDialog(QDialog):
self.btn_export.setToolTip(t("cal.export_tip")) self.btn_export.setToolTip(t("cal.export_tip"))
self.btn_apply.setText(t("cal.apply")) self.btn_apply.setText(t("cal.apply"))
self.btn_apply.setToolTip(t("sensor.live_calibration_apply_tip")) 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.picker.retranslate()
self.refresh() 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 from __future__ import annotations
import json import json
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from orisys_app_shared.analysis.bare_video import ( from orisys_app_shared.analysis.bare_video import (
@@ -92,6 +93,10 @@ class VideoAnalysisDialog(QDialog):
initial_device_id: str | None = None, initial_device_id: str | None = None,
lock_profile: bool = False, lock_profile: bool = False,
theme: str = "dark", theme: str = "dark",
remembered_calibration: Callable[
[str], tuple[str, Path | None, str] | None
]
| None = None,
) -> None: ) -> None:
super().__init__(window) super().__init__(window)
self._video = Path(video) self._video = Path(video)
@@ -110,6 +115,10 @@ class VideoAnalysisDialog(QDialog):
1 if initial_reference_frame is None else int(initial_reference_frame) 1 if initial_reference_frame is None else int(initial_reference_frame)
) )
self._restore_device = str(initial_device_id or "") 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._device_source: Path | None = None
self._lock_profile = bool(lock_profile) self._lock_profile = bool(lock_profile)
self.setObjectName("videoAnalysisDialog") self.setObjectName("videoAnalysisDialog")
@@ -300,7 +309,10 @@ class VideoAnalysisDialog(QDialog):
) )
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not blocked) self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not blocked)
self._refresh_time_label() self._refresh_time_label()
device_id = self._apply_remembered_calibration()
self._sync_device_row() self._sync_device_row()
if device_id:
self.device_edit.setText(device_id)
def _refresh_calibrations(self, *, custom: Path | None) -> None: def _refresh_calibrations(self, *, custom: Path | None) -> None:
"""The calibration field: the profile's default, raw force, or a chosen file.""" """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.calibration_combo.blockSignals(False)
self._sync_device_row() 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: def _chosen_calibration(self) -> Path | None:
role = str(self.calibration_combo.currentData() or "") role = str(self.calibration_combo.currentData() or "")
entry = self.entry() entry = self.entry()
@@ -6,11 +6,13 @@ import json
from pathlib import Path from pathlib import Path
import pytest import pytest
from orisys_app_shared.analysis.bare_video import VideoFacts
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import scan_sensor_catalog from orisys_app_shared.sensors import scan_sensor_catalog
from orisys_studio.ui import sensor_picker as picker_module 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_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE, SensorDialog
from orisys_studio.ui.sensor_picker import SensorPickerWidget from orisys_studio.ui.sensor_picker import SensorPickerWidget
from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
from PySide6.QtWidgets import QWidget from PySide6.QtWidgets import QWidget
CONFIG = { CONFIG = {
@@ -319,3 +321,71 @@ def test_apply_while_running_queues_set_pending_calibration(make_window, tmp_pat
window.apply_live_calibration_choice() window.apply_live_calibration_choice()
assert pending == [] assert pending == []
assert window.statusBar().currentMessage() == t("sensor.live_calibration_remembered") 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()
+3 -3
View File
@@ -120,9 +120,9 @@ def test_2d_3d_flow_sample_points_match() -> None:
composed = composer.compose( composed = composer.compose(
assets, _depth_frame(assets, flow=flow), settings assets, _depth_frame(assets, flow=flow), settings
) )
arrows_3d = ( arrows = None if composed.flow_arrows is None else composed.flow_arrows.lengths
0 if composed.flow_arrows is None else int(composed.flow_arrows.origins.shape[0]) # 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 # Exactly what the 2D canvas draws from: ``draw_arrow_display`` calls
# ``select_flow_samples`` with the policy the window passes it, and draws the # ``select_flow_samples`` with the policy the window passes it, and draws the
# samples the flag marks active. # samples the flag marks active.
+23 -16
View File
@@ -1741,7 +1741,12 @@ def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_rec
assert panel.mode() == "follow" assert panel.mode() == "follow"
assert window.toolbar.run_role() == "acquire", "the rail kept the transport" assert window.toolbar.run_role() == "acquire", "the rail kept the transport"
assert window.toolbar.btn_run.text() == t("toolbar.run_label") 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(), ( assert "raw_0001" not in window.toolbar.source_summary.text(), (
"the replay's video is still named in the live view" "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 # ------------------------------------------- selection readout and notices
def test_a_selection_moves_its_statistics_onto_the_value_cards(make_window): def test_a_zoomed_window_keeps_the_cursor_on_the_value_cards(make_window):
"""The per-channel line under the charts did not fit; the cards hold it now. """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 Selecting a window used to put that window's peak and mean · σ on the cards.
it its mean · σ. The strip keeps only how big the selection is, and leaving The big numbers are the sample under the cursor again; the strip keeps only
the selection puts the cursor readout back. how big the selection is.
""" """
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_studio.ui.force_plots import format_force
window, stuck = _running_window(make_window) window, stuck = _running_window(make_window)
window.toolbar.units_combo.setCurrentIndex(0) # newton 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.stats_label.text() == ""
assert plots.value_details[0].text() == "", "a live reading carries a mean line" 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) plots.set_window(1.0, 3.0)
assert plots.mode() == "browse" assert plots.mode() == "browse"
assert plots._selection is not None, "the window was not read as a selection" 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 ( assert t("chart.selection_summary", duration="1", samples=2)[:4] in summary or (
"选区" in summary or "Selection" in summary "选区" in summary or "Selection" in summary
), summary ), summary
# The numbers a reader cannot fit in the strip are on the cards instead. assert "峰" not in summary and "均" not in summary, summary
expected_peak = float(plots._selection.peaks[0])
expected = f"{expected_peak:.2f}" expected = format_force(
assert plots.value_labels[0].text() == expected, ( plots._display_values[0], plots.unit(), presentation=plots.presentation
f"the FN card is not the selection's peak: {plots.value_labels[0].text()!r}" )
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. # "Whole session" is the default fit, so it is not a selection.
plots.show_all() plots.show_all()
+18 -23
View File
@@ -2775,9 +2775,8 @@ def main() -> int:
plots.overview_curve.getData()[0].size > 0, plots.overview_curve.getData()[0].size > 0,
"the whole-session overview strip is empty", "the whole-session overview strip is empty",
) )
# A window narrower than the session is a selection: its peak becomes the big # A window narrower than the session is a selection in the strip only: the
# number on each card and its mean/σ the line under it, while the strip keeps # cards stay on the sample under the cursor.
# only the size of the selection (the long per-channel line did not fit).
check(plots._selection is not None, "zooming in did not record a selection") check(plots._selection is not None, "zooming in did not record a selection")
summary = plots.stats_label.text() summary = plots.stats_label.text()
check(summary != "", "the strip does not state the size of the selection") 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, "选区" in summary or "Selection" in summary,
f"the strip does not say that the numbers describe a selection: {summary!r}", f"the strip does not say that the numbers describe a selection: {summary!r}",
) )
expected_peak = float(plots._selection.peaks[0]) from orisys_studio.ui.force_plots import format_force
peak_text = (
f"{expected_peak:.3f}" if plots.unit() == "N" else f"{expected_peak:.1f}" expected_cursor = format_force(
plots._display_values[0], plots.unit(), presentation=plots.presentation
) )
check( check(
plots.value_labels[0].text() == peak_text, plots.value_labels[0].text() == expected_cursor,
f"the FN card is not the selection's peak: {plots.value_labels[0].text()!r}", f"the FN card is not the cursor sample: {plots.value_labels[0].text()!r}",
) )
check( check(
plots.value_details[0].text() != "" plots.value_details[0].text() == "",
and ("均" in plots.value_details[0].text() or "mean" in plots.value_details[0].text()), f"the card still carries a mean line: {plots.value_details[0].text()!r}",
f"the card does not state the selection's mean: "
f"{plots.value_details[0].text()!r}",
) )
for card, label, detail in zip( # Leaving the selection clears the strip; the cards stay a cursor readout.
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.
plots.follow_latest() plots.follow_latest()
check(plots._selection is None, "the selection survived 'back to latest'") check(plots._selection is None, "the selection survived 'back to latest'")
check( check(
plots.value_details[0].text() == "", 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}", f"{plots.value_details[0].text()!r}",
) )
@@ -3673,7 +3663,12 @@ def main() -> int:
f"{replay_window.toolbar.source_summary.text()!r}", f"{replay_window.toolbar.source_summary.text()!r}",
) )
check( 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", "the 2D panel did not return to its live views",
) )
check( check(
@@ -47,9 +47,10 @@ from orisys_app_shared.acquisition.models import ensure_bgr
ARROW_PURE = "arrows_pure" ARROW_PURE = "arrows_pure"
ARROW_STITCHED = "arrows_stitched" ARROW_STITCHED = "arrows_stitched"
DEPTH = "depth" DEPTH = "depth"
DEPTH_ARROWS = "depth_arrows"
RECORDED = "recorded" RECORDED = "recorded"
DERIVED_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH) DERIVED_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH, DEPTH_ARROWS)
ALL_MODES = (*DERIVED_MODES, RECORDED) ALL_MODES = (*DERIVED_MODES, RECORDED)
#: Where a frame came from. Only used for wording and for the recording; the modes #: Where a frame came from. Only used for wording and for the recording; the modes
@@ -294,6 +295,8 @@ class ImagePlan:
return "flow" return "flow"
if wanted == DEPTH and not self.has_depth(): if wanted == DEPTH and not self.has_depth():
return "depth" return "depth"
if wanted == DEPTH_ARROWS and not self.has_depth() and not self.has_flow():
return "depth"
return None return None
def has_flow(self) -> bool: 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( def render_image(
frame: ImageFrame, frame: ImageFrame,
plan: ImagePlan | None = None, plan: ImagePlan | None = None,
@@ -418,59 +467,55 @@ def render_image(
wanted = plan.resolve(mode) wanted = plan.resolve(mode)
boundary = frame.valid_mask if domain_mask is None else domain_mask boundary = frame.valid_mask if domain_mask is None else domain_mask
turn = normalise_orientation(orientation) if wanted != RECORDED else ORIENTATION_IDENTITY turn = normalise_orientation(orientation) if wanted != RECORDED else ORIENTATION_IDENTITY
if wanted == DEPTH: depth_image = None
# Never substitute another picture for a missing depth map: that made "no if wanted in (DEPTH, DEPTH_ARROWS) and frame.depth_valid is not False:
# depth" look like a depth reading. The panel says so instead.
if frame.depth_valid is False:
return None
depth_image = depth_map_to_bgr( depth_image = depth_map_to_bgr(
frame.depth, frame.depth,
flow_colormap(theme), flow_colormap(theme),
value_range=depth_range, value_range=depth_range,
invalid_mask=boundary, 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: if depth_image is None:
return None return None
return apply_orientation(depth_image, turn) 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: if frame.image_bgr is None:
return None return None
if wanted in (ARROW_PURE, ARROW_STITCHED): if wanted in (ARROW_PURE, ARROW_STITCHED):
if frame.flow is None or frame.flow_valid is False: if frame.flow is None or frame.flow_valid is False:
# The base picture with no arrows, and the plan's ``missing`` says why. # The base picture with no arrows, and the plan's ``missing`` says why.
return apply_orientation(ensure_bgr(np.asarray(frame.image_bgr)), turn) 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 # A recovered field is finite only at the cells the pass stored. ``_draw_flow``
# painter draws those cells; the SDK painter walks its own grid and finds # fills a box result's gaps with zero and keeps the shared painter otherwise.
# nothing wherever that grid and the stored cells disagree. A box result whose drawn = _draw_flow(
# grid *is* the SDK grid is the exception: fill the gaps with zero (the cells frame,
# 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(
frame.image_bgr, frame.image_bgr,
flow,
background="captured" if wanted == ARROW_STITCHED else "pure_color", background="captured" if wanted == ARROW_STITCHED else "pure_color",
canvas_bgr=canvas_bgr, theme=theme,
draw_style=draw_style, draw_style=draw_style,
shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result, canvas_bgr=canvas_bgr,
valid_mask=frame.valid_mask,
domain_mask=domain_mask, domain_mask=domain_mask,
colormap=flow_colormap(theme), arrow_policy=arrow_policy,
policy=arrow_policy,
) )
# Orientation may rebuild the array; keep the draw failure on the result so the return _orient_drawn(drawn, turn)
# panel can say so instead of showing a silent black canvas.
diagnostic = getattr(drawn, "frame_diagnostic", None)
oriented = apply_orientation(drawn, turn)
if diagnostic is None:
return oriented
return carry_frame_diagnostic(oriented, diagnostic)
# ``recorded``: the frame is the picture, with nothing drawn over it — and the # ``recorded``: the frame is the picture, with nothing drawn over it — and the
# camera's own orientation, which no domain convention applies to. # camera's own orientation, which no domain convention applies to.
return ensure_bgr(np.asarray(frame.image_bgr)) return ensure_bgr(np.asarray(frame.image_bgr))
@@ -282,11 +282,10 @@ class SceneComposer:
frame.flow_valid_mask, frame.flow_valid_mask,
policy, policy,
) )
# The selector returns every candidate location plus an ``active`` flag; # Inactive candidates are grey points; active ones are coloured arrows.
# this figure draws the active ones, which is what it drew before the # Same samples the 2D canvas paints, including the quiet ones.
# split (the selector used to filter and this figure drew the lot).
flow_arrows, extra = build_flow_overlay( flow_arrows, extra = build_flow_overlay(
samples.active_only(), samples,
mapping, mapping,
style=policy, style=policy,
normal_offset_m=mesh_span_from_mapping(mapping) * 0.002, normal_offset_m=mesh_span_from_mapping(mapping) * 0.002,
+159 -11
View File
@@ -19,6 +19,14 @@ from orisys_app_shared.scene3d.contracts import (
error, error,
freeze, 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 from orisys_app_shared.scene3d.visual_policy import FlowSamples, VisualPolicy
ForceStrengthMode = Literal["log", "power", "linear"] ForceStrengthMode = Literal["log", "power", "linear"]
@@ -43,8 +51,11 @@ class ContactArrowStyle:
DEFAULT_CONTACT_ARROW_STYLE = 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: 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)) 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( def build_flow_overlay(
samples: FlowSamples, samples: FlowSamples,
mapping: SurfaceMapping, mapping: SurfaceMapping,
@@ -197,7 +277,31 @@ def build_flow_overlay(
style: VisualPolicy | None = None, style: VisualPolicy | None = None,
normal_offset_m: float = 0.0, normal_offset_m: float = 0.0,
) -> tuple[ArrowInstances, tuple[Diagnostic, ...]]: ) -> 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 del style # lengths come from UV tip deltas projected into scene metres
if samples.rows.size == 0: if samples.rows.size == 0:
return _empty_arrows("flow"), () return _empty_arrows("flow"), ()
@@ -216,6 +320,7 @@ def build_flow_overlay(
rows = np.asarray(samples.rows, dtype=np.int32) rows = np.asarray(samples.rows, dtype=np.int32)
cols = np.asarray(samples.cols, 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_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) 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] rows = rows[inside]
cols = cols[inside] cols = cols[inside]
magnitude = magnitude[inside]
end_x = end_x[inside] end_x = end_x[inside]
end_y = end_y[inside] end_y = end_y[inside]
origins = positions[rows, cols].copy() origins = positions[rows, cols].copy()
@@ -248,13 +354,15 @@ def build_flow_overlay(
origins = origins[keep] origins = origins[keep]
vectors = vectors[keep] vectors = vectors[keep]
lengths = lengths[keep] lengths = lengths[keep]
magnitude = magnitude[keep]
directions = vectors / lengths[:, None] directions = vectors / lengths[:, None]
colors = np.tile(np.asarray(FLOW_TAIL_RGBA, dtype=np.float32), (len(origins), 1)) # Same ramp index the 2D canvas asks for, so an active arrow is the same colour
# Stronger arrows tip toward the head colour. # in both views. Quiet samples never reach here: they are the grey points.
t = np.clip(lengths / max(float(np.percentile(lengths, 95)), 1e-9), 0.0, 1.0) index = magnitude_to_ramp_index(magnitude, value_range=FLOW_MAGNITUDE_RANGE)
head = np.asarray(FLOW_HEAD_RGBA, dtype=np.float32) table = ramp_rgba_table(ramp_for_theme("dark")).reshape(-1, 4).astype(np.float32)
colors = colors * (1.0 - t)[:, None] + head * t[:, None] colors = table[index] / 255.0
colors[:, 3] = 0.9
return ( return (
ArrowInstances( 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]: def _cone_template(resolution: int = 8) -> tuple[np.ndarray, np.ndarray]:
"""Unit-radius cone with apex at z=1 and base at z=0.""" """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) 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() cyl_v, cyl_t = _cylinder_template()
cone_v, cone_t = _cone_template() cone_v, cone_t = _cone_template()
point_v, point_t = _point_template()
origins = np.asarray(arrows.origins, dtype=np.float64) origins = np.asarray(arrows.origins, dtype=np.float64)
directions = np.asarray(arrows.directions, dtype=np.float64) directions = np.asarray(arrows.directions, dtype=np.float64)
lengths = np.asarray(arrows.lengths, 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) 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 # 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 # ``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. # own length. The contact arrow keeps the proportional template.
@@ -561,11 +700,20 @@ def arrow_instances_to_mesh(
offset = 0 offset = 0
for i in range(count): for i in range(count):
length = float(lengths[i]) 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: if length <= 1e-9:
continue continue
direction = directions[i] direction = directions[i]
origin = origins[i]
radius = base_radius * float(max(thickness[i], 0.05))
rotation = _rotation_matrix_z_to(direction) rotation = _rotation_matrix_z_to(direction)
# Tail: 75% cylinder, head: 25% cone. # Tail: 75% cylinder, head: 25% cone.
tail_len = length * 0.75 tail_len = length * 0.75
+15 -2
View File
@@ -36,12 +36,22 @@ def test_a_live_frame_offers_the_derived_modes_and_needs_no_note():
plan = backend.plan_image( plan = backend.plan_image(
backend.ImageFrame(_image(), flow=_flow(), depth=np.ones((12, 16))) 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.default_mode == "arrows_pure"
assert plan.missing == () assert plan.missing == ()
assert plan.note_key == "" 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(): 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.""" """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)) 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" 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 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" "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.origins, overlay_off.origins)
assert np.array_equal(overlay_on.directions, overlay_off.directions) 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" assert not np.array_equal(dots_on, dots_off), "the switch changed nothing to hide"
+26 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
import pytest
from orisys_app_shared.scene3d.overlays import ( from orisys_app_shared.scene3d.overlays import (
arrow_instances_to_mesh, arrow_instances_to_mesh,
build_flow_overlay, build_flow_overlay,
@@ -10,7 +11,7 @@ from orisys_app_shared.scene3d.overlays import (
force_visual_strength, force_visual_strength,
) )
from orisys_app_shared.scene3d.synthetic import poc_scene 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: 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] 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: def test_missing_contact_yields_no_arrow() -> None:
assets = poc_scene() assets = poc_scene()
mapping = assets.surface_mapping mapping = assets.surface_mapping