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