三维未激活采样显示灰点,二维增加深度叠箭头。
分析对话框默认使用传感器对话框保存的标定,关闭按钮改为保存。框选曲线时数值卡片仍显示光标读数。相关单测通过。
This commit is contained in:
@@ -1855,21 +1855,22 @@ class StudioMainWindow(QMainWindow):
|
||||
entry = self.sensor_picker.selected_entry()
|
||||
return None if entry is None else entry.ref
|
||||
|
||||
def live_calibration_choice(self) -> tuple[str, Path | None, str]:
|
||||
"""``(role, file_path_or_None, device_id)`` remembered for the selected bundle.
|
||||
def live_calibration_for(
|
||||
self, ref: str
|
||||
) -> tuple[str, Path | None, str] | None:
|
||||
"""Saved live choice for one catalog ref, or ``None`` when nothing was saved.
|
||||
|
||||
``file_path`` is only set for the browsed-file role; the profile default is
|
||||
resolved when the chain opens. A missing file falls back to raw force.
|
||||
``None`` is not raw force: a caller that has its own default (the analysis
|
||||
prompt) keeps that default until the sensor dialog has stored a choice.
|
||||
"""
|
||||
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE
|
||||
|
||||
key = self._live_calibration_prefs_key()
|
||||
store = self.prefs.get("live_calibration")
|
||||
if not key or not isinstance(store, dict):
|
||||
return ROLE_NONE, None, ""
|
||||
entry = store.get(key)
|
||||
if not ref or not isinstance(store, dict):
|
||||
return None
|
||||
entry = store.get(ref)
|
||||
if not isinstance(entry, dict):
|
||||
return ROLE_NONE, None, ""
|
||||
return None
|
||||
role = str(entry.get("role") or ROLE_NONE)
|
||||
if role not in (ROLE_DEFAULT, ROLE_FILE, ROLE_NONE):
|
||||
role = ROLE_NONE
|
||||
@@ -1882,6 +1883,20 @@ class StudioMainWindow(QMainWindow):
|
||||
path = candidate
|
||||
return role, path, str(entry.get("device_id") or "")
|
||||
|
||||
def live_calibration_choice(self) -> tuple[str, Path | None, str]:
|
||||
"""``(role, file_path_or_None, device_id)`` remembered for the selected bundle.
|
||||
|
||||
``file_path`` is only set for the browsed-file role; the profile default is
|
||||
resolved when the chain opens. No saved choice is raw force for a live open.
|
||||
"""
|
||||
from orisys_studio.ui.sensor_dialog import ROLE_NONE
|
||||
|
||||
key = self._live_calibration_prefs_key()
|
||||
found = None if key is None else self.live_calibration_for(key)
|
||||
if found is None:
|
||||
return ROLE_NONE, None, ""
|
||||
return found
|
||||
|
||||
def set_live_calibration_choice(
|
||||
self,
|
||||
*,
|
||||
@@ -2948,6 +2963,7 @@ class StudioMainWindow(QMainWindow):
|
||||
initial_reference_frame=initial_reference_frame,
|
||||
initial_device_id=initial_device_id,
|
||||
theme=self._theme,
|
||||
remembered_calibration=self.live_calibration_for,
|
||||
)
|
||||
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
|
||||
return None
|
||||
@@ -2990,6 +3006,7 @@ class StudioMainWindow(QMainWindow):
|
||||
initial_reference_frame=getattr(self, "_analysis_reference_override", None),
|
||||
lock_profile=True,
|
||||
theme=self._theme,
|
||||
remembered_calibration=self.live_calibration_for,
|
||||
)
|
||||
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
|
||||
return False
|
||||
|
||||
@@ -14,6 +14,7 @@ import numpy as np
|
||||
from orisys_app_shared.acquisition.arrows import arrow_display_scale, hex_to_bgr
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
DEPTH,
|
||||
DEPTH_ARROWS,
|
||||
DomainMaskLatch,
|
||||
ImageFrame,
|
||||
plan_image,
|
||||
@@ -232,7 +233,7 @@ class FrameView:
|
||||
self._last_depth_frame = frame.depth
|
||||
span = self.active_depth_span()
|
||||
self.publish_shared_depth_scale(span)
|
||||
if mode == DEPTH:
|
||||
if mode in (DEPTH, DEPTH_ARROWS):
|
||||
self._arrow_panel.set_depth_scale(
|
||||
self.depth_scale_text(frame),
|
||||
tip_key=self.depth_scale_tip_key(),
|
||||
@@ -267,7 +268,7 @@ class FrameView:
|
||||
# Never substitute another picture here: a missing depth map that showed
|
||||
# the processed frame made "no depth" look like a depth reading. A draw
|
||||
# failure used to return the black base image; say so instead.
|
||||
if mode == DEPTH and rendered is None:
|
||||
if mode in (DEPTH, DEPTH_ARROWS) and rendered is None:
|
||||
message = t("panels.depth_unavailable")
|
||||
elif diagnostic is not None:
|
||||
message = t("panels.arrow_render_failed")
|
||||
|
||||
@@ -14,6 +14,7 @@ from orisys_app_shared.acquisition.image_backend import (
|
||||
ARROW_PURE,
|
||||
ARROW_STITCHED,
|
||||
DEPTH,
|
||||
DEPTH_ARROWS,
|
||||
ImagePlan,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
@@ -32,7 +33,7 @@ from PySide6.QtWidgets import (
|
||||
from orisys_studio.ui import icons
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
|
||||
VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH)
|
||||
VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH, DEPTH_ARROWS)
|
||||
#: While replaying a recording there is no optical flow to draw and no depth is
|
||||
#: stored: the panel shows the recorded frames, and nothing else.
|
||||
REPLAY_VIEW_MODES = ("recorded",)
|
||||
@@ -53,6 +54,7 @@ _VIEW_LABELS = {
|
||||
ARROW_PURE: "panels.view_arrows_pure",
|
||||
ARROW_STITCHED: "panels.view_arrows_stitched",
|
||||
DEPTH: "panels.view_depth",
|
||||
DEPTH_ARROWS: "panels.view_depth_arrows",
|
||||
"recorded": "panels.view_recorded",
|
||||
}
|
||||
_ROTATION_STEPS = (0, 90, 180, 270)
|
||||
@@ -433,15 +435,17 @@ class ArrowPanel(QFrame):
|
||||
stays regardless is what the picture *is*: the plan's own line ("this recording
|
||||
stores only the raw camera image"), the depth scale, and the colour bar.
|
||||
"""
|
||||
depth = self.view_mode() == DEPTH
|
||||
mode = self.view_mode()
|
||||
depth = mode == DEPTH
|
||||
overlay = mode == DEPTH_ARROWS
|
||||
modes = self.view_modes()
|
||||
has_arrows = ARROW_PURE in modes or ARROW_STITCHED in modes
|
||||
has_arrows = ARROW_PURE in modes or ARROW_STITCHED in modes or overlay
|
||||
explain = bool(self._show_diagnostics)
|
||||
self.scale_label.setVisible(explain and not depth and has_arrows)
|
||||
self.pipeline_label.setVisible(explain and not depth)
|
||||
self.area_label.setVisible(explain and not depth and has_arrows)
|
||||
self.colorbar.setVisible(depth)
|
||||
self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text))
|
||||
self.colorbar.setVisible(depth or overlay)
|
||||
self.depth_scale_label.setVisible((depth or overlay) and bool(self._depth_scale_text))
|
||||
self.note_label.setVisible(bool(self._plan_note))
|
||||
|
||||
def set_diagnostics_visible(self, visible: bool) -> None:
|
||||
|
||||
@@ -1057,15 +1057,14 @@ class ForcePlotsPanel(QFrame):
|
||||
next to cards reading the live 0.0, which reads as "the replay is not
|
||||
open". Availability is decided per sample here as well, so a cursor on a
|
||||
row with no value in this unit shows a dash, never a stale number.
|
||||
|
||||
A zoomed window is still the cursor: the strip may name how long that
|
||||
window is, but the big numbers stay on the sample under the playhead.
|
||||
"""
|
||||
if self._mode != "browse" and not self.is_recording_source():
|
||||
return
|
||||
if not self._source_ready:
|
||||
return
|
||||
if self._selection_active():
|
||||
# The cards are stating the selection's peak / mean / σ; the cursor is
|
||||
# inside that window and must not overwrite them.
|
||||
return
|
||||
first, last = self._source.extent()
|
||||
t0 = max(first, self._cursor_t - CURSOR_READOUT_S)
|
||||
t1 = min(last, self._cursor_t + CURSOR_READOUT_S)
|
||||
@@ -1095,12 +1094,11 @@ class ForcePlotsPanel(QFrame):
|
||||
self._render_value_cards()
|
||||
|
||||
def _update_selection_readout(self) -> None:
|
||||
"""Refresh what the selection is worth: the cards, and the strip's size.
|
||||
"""Refresh the strip's window size; the cards stay on the cursor.
|
||||
|
||||
The per-channel numbers live on the cards (peak, then mean · σ); the strip
|
||||
under the charts keeps only how long the selection is and how many samples
|
||||
it holds, because that is all that fits there. The full line stays in the
|
||||
strip's tooltip.
|
||||
The strip under the charts keeps how long the selection is and how many
|
||||
samples it holds. Peak / mean / σ used to sit on the value cards when the
|
||||
window was inset; those cards now always follow the playhead.
|
||||
"""
|
||||
if not self._source_ready:
|
||||
self._selection = None
|
||||
@@ -1109,12 +1107,10 @@ class ForcePlotsPanel(QFrame):
|
||||
self._render_value_cards()
|
||||
return
|
||||
if not self._selection_active():
|
||||
# Following live, or fitted to the whole session: the cards are the
|
||||
# readout of the cursor, not of a window.
|
||||
self._selection = None
|
||||
self.stats_label.setText("")
|
||||
self.stats_label.setToolTip("")
|
||||
self._render_value_cards()
|
||||
self._readout_at_cursor()
|
||||
return
|
||||
t0, t1 = self._window
|
||||
stats = self._source.statistics(t0, t1, unit=self._unit)
|
||||
@@ -1122,37 +1118,17 @@ class ForcePlotsPanel(QFrame):
|
||||
self._selection = None
|
||||
self.stats_label.setText(t("chart.selection_empty"))
|
||||
self.stats_label.setToolTip("")
|
||||
self._render_value_cards()
|
||||
self._readout_at_cursor()
|
||||
return
|
||||
self._selection = stats
|
||||
self.stats_label.setText(
|
||||
t(
|
||||
"chart.selection_summary",
|
||||
duration=f"{stats.duration_s:.2f}",
|
||||
samples=stats.samples,
|
||||
)
|
||||
)
|
||||
self.stats_label.setToolTip(self._selection_tooltip(stats))
|
||||
self._render_value_cards()
|
||||
|
||||
def _selection_tooltip(self, stats) -> str:
|
||||
"""The full selection readout, for the strip's tooltip."""
|
||||
unit = unit_display(self._unit)
|
||||
parts = []
|
||||
for index, label in enumerate(CHANNEL_LABELS):
|
||||
peak = float(stats.peaks[index])
|
||||
if not np.isfinite(peak):
|
||||
continue
|
||||
parts.append(
|
||||
f"{label} 峰 {peak:.3g}{unit} · 均 {float(stats.means[index]):.3g} "
|
||||
f"· σ {float(stats.stds[index]):.3g}"
|
||||
)
|
||||
return t(
|
||||
"chart.selection",
|
||||
summary = t(
|
||||
"chart.selection_summary",
|
||||
duration=f"{stats.duration_s:.2f}",
|
||||
samples=stats.samples,
|
||||
detail=" ".join(parts),
|
||||
)
|
||||
self.stats_label.setText(summary)
|
||||
self.stats_label.setToolTip(summary)
|
||||
self._readout_at_cursor()
|
||||
|
||||
def _selection_active(self) -> bool:
|
||||
"""Whether the visible window is a *selection* and not the whole session.
|
||||
@@ -1421,20 +1397,8 @@ class ForcePlotsPanel(QFrame):
|
||||
curve.setData([], [])
|
||||
|
||||
def _render_value_cards(self) -> None:
|
||||
"""One card per channel: what it reads *now*, in the active unit.
|
||||
|
||||
While a selection is being inspected the big number is that window's peak
|
||||
and the line under it its mean and σ — the selection statistics used to be
|
||||
a single long line under the charts, which could not fit and pushed the
|
||||
panel wider.
|
||||
"""
|
||||
decimals = (
|
||||
self.presentation.newton_decimals
|
||||
if self._unit == "N" else self.presentation.raw_decimals
|
||||
)
|
||||
fmt = "{value:." + str(decimals) + "f}"
|
||||
"""One card per channel: the sample under the cursor, in the active unit."""
|
||||
unavailable = t("chart.value_unavailable")
|
||||
selecting = self._selection
|
||||
for index, (value_label, unit_label, detail_label, value) in enumerate(
|
||||
zip(
|
||||
self.value_labels,
|
||||
@@ -1444,11 +1408,7 @@ class ForcePlotsPanel(QFrame):
|
||||
)
|
||||
):
|
||||
available = self._available[index]
|
||||
if selecting is not None:
|
||||
peak = float(selecting.peaks[index])
|
||||
available = bool(np.isfinite(peak))
|
||||
value = peak
|
||||
elif self.presentation.enabled:
|
||||
if self.presentation.enabled:
|
||||
value = self._display_values[index]
|
||||
if not available:
|
||||
# An axis with no curve in this unit: a dash, never a raw count
|
||||
@@ -1462,44 +1422,18 @@ class ForcePlotsPanel(QFrame):
|
||||
continue
|
||||
value_label.setText(
|
||||
format_force(value, self._unit, presentation=self.presentation)
|
||||
if selecting is None
|
||||
else fmt.format(value=value)
|
||||
)
|
||||
tip = t("chart.selection_peak_tip") if selecting is not None else ""
|
||||
zero_zone = (
|
||||
selecting is None
|
||||
and self.presentation.enabled
|
||||
self.presentation.enabled
|
||||
and abs(float(value)) == 0.0
|
||||
and index < len(self._display_suppressed)
|
||||
and self._display_suppressed[index]
|
||||
)
|
||||
if zero_zone:
|
||||
tip = t("chart.zero_zone_tip")
|
||||
value_label.setToolTip(tip)
|
||||
value_label.setToolTip(t("chart.zero_zone_tip") if zero_zone else "")
|
||||
unit_label.setText(unit_display(self._unit))
|
||||
if selecting is not None:
|
||||
detail_label.setText(self._selection_detail(index))
|
||||
elif zero_zone:
|
||||
detail_label.setText(t("chart.zero_zone"))
|
||||
else:
|
||||
detail_label.setText("")
|
||||
detail_label.setText(t("chart.zero_zone") if zero_zone else "")
|
||||
self.unit_badge.setText(unit_display(self._unit))
|
||||
|
||||
def _selection_detail(self, index: int) -> str:
|
||||
"""``mean · σ`` for one channel of the current selection."""
|
||||
stats = self._selection
|
||||
if stats is None:
|
||||
return ""
|
||||
mean = float(stats.means[index])
|
||||
std = float(stats.stds[index])
|
||||
if not np.isfinite(mean):
|
||||
return ""
|
||||
return t(
|
||||
"chart.selection_detail",
|
||||
mean=f"{mean:.4g}",
|
||||
std=f"{std:.4g}" if np.isfinite(std) else "-",
|
||||
)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("panels.plots"))
|
||||
for badge, label in zip(self.channel_badges, CHANNEL_LABELS):
|
||||
|
||||
@@ -200,13 +200,13 @@ class SensorDialog(QDialog):
|
||||
self.btn_export.clicked.connect(self._on_export)
|
||||
self.btn_apply = QPushButton()
|
||||
self.btn_apply.clicked.connect(self._on_apply)
|
||||
self.btn_close = QPushButton()
|
||||
self.btn_close.setProperty("compact", "true")
|
||||
self.btn_close.clicked.connect(self.close)
|
||||
self.btn_save = QPushButton()
|
||||
self.btn_save.setProperty("compact", "true")
|
||||
self.btn_save.clicked.connect(self._on_save)
|
||||
actions.addWidget(self.btn_export)
|
||||
actions.addWidget(self.btn_apply)
|
||||
actions.addStretch(1)
|
||||
actions.addWidget(self.btn_close)
|
||||
actions.addWidget(self.btn_save)
|
||||
root.addLayout(actions)
|
||||
|
||||
# The picker's own changes change the calibration context, so the dialog
|
||||
@@ -244,6 +244,11 @@ class SensorDialog(QDialog):
|
||||
apply()
|
||||
self.refresh()
|
||||
|
||||
def _on_save(self) -> None:
|
||||
"""Keep the current sensor/calibration choice and dismiss the dialog."""
|
||||
self._persist_choice()
|
||||
self.close()
|
||||
|
||||
def _on_browse(self) -> None:
|
||||
chosen, _filter = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
@@ -554,7 +559,7 @@ class SensorDialog(QDialog):
|
||||
self.btn_export.setToolTip(t("cal.export_tip"))
|
||||
self.btn_apply.setText(t("cal.apply"))
|
||||
self.btn_apply.setToolTip(t("sensor.live_calibration_apply_tip"))
|
||||
self.btn_close.setText(t("dialog.cancel"))
|
||||
self.btn_save.setText(t("dialog.save"))
|
||||
self.picker.retranslate()
|
||||
self.refresh()
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ reason it cannot be used is one line, both in the language the catalog already s
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_app_shared.analysis.bare_video import (
|
||||
@@ -92,6 +93,10 @@ class VideoAnalysisDialog(QDialog):
|
||||
initial_device_id: str | None = None,
|
||||
lock_profile: bool = False,
|
||||
theme: str = "dark",
|
||||
remembered_calibration: Callable[
|
||||
[str], tuple[str, Path | None, str] | None
|
||||
]
|
||||
| None = None,
|
||||
) -> None:
|
||||
super().__init__(window)
|
||||
self._video = Path(video)
|
||||
@@ -110,6 +115,10 @@ class VideoAnalysisDialog(QDialog):
|
||||
1 if initial_reference_frame is None else int(initial_reference_frame)
|
||||
)
|
||||
self._restore_device = str(initial_device_id or "")
|
||||
#: ``(role, path, device_id)`` saved by the sensor dialog for a catalog ref,
|
||||
#: or ``None`` when that bundle has no saved choice. Missing means "keep
|
||||
#: this dialog's own default", which is not the same as an explicit raw-force choice.
|
||||
self._remembered_calibration = remembered_calibration
|
||||
self._device_source: Path | None = None
|
||||
self._lock_profile = bool(lock_profile)
|
||||
self.setObjectName("videoAnalysisDialog")
|
||||
@@ -300,7 +309,10 @@ class VideoAnalysisDialog(QDialog):
|
||||
)
|
||||
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not blocked)
|
||||
self._refresh_time_label()
|
||||
device_id = self._apply_remembered_calibration()
|
||||
self._sync_device_row()
|
||||
if device_id:
|
||||
self.device_edit.setText(device_id)
|
||||
|
||||
def _refresh_calibrations(self, *, custom: Path | None) -> None:
|
||||
"""The calibration field: the profile's default, raw force, or a chosen file."""
|
||||
@@ -328,6 +340,32 @@ class VideoAnalysisDialog(QDialog):
|
||||
self.calibration_combo.blockSignals(False)
|
||||
self._sync_device_row()
|
||||
|
||||
def _apply_remembered_calibration(self) -> str:
|
||||
"""Select the sensor dialog's saved choice for this profile.
|
||||
|
||||
Returns a device serial to put back after the row sync, or ``""`` when
|
||||
there is nothing saved (the combo keeps the profile default or raw force).
|
||||
"""
|
||||
entry = self.entry()
|
||||
memory = self._remembered_calibration
|
||||
if entry is None or not callable(memory):
|
||||
return ""
|
||||
found = memory(entry.ref)
|
||||
if found is None:
|
||||
return ""
|
||||
role, path, device_id = found
|
||||
if role == ROLE_FILE and path is not None and Path(path).is_file():
|
||||
self.select_calibration(Path(path))
|
||||
return str(device_id or "")
|
||||
if role == ROLE_NONE:
|
||||
self.select_calibration(None)
|
||||
return ""
|
||||
if role == ROLE_DEFAULT:
|
||||
index = self.calibration_combo.findData(ROLE_DEFAULT)
|
||||
if index >= 0:
|
||||
self.calibration_combo.setCurrentIndex(index)
|
||||
return ""
|
||||
|
||||
def _chosen_calibration(self) -> Path | None:
|
||||
role = str(self.calibration_combo.currentData() or "")
|
||||
entry = self.entry()
|
||||
|
||||
Reference in New Issue
Block a user