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

分析对话框默认使用传感器对话框保存的标定,关闭按钮改为保存。框选曲线时数值卡片仍显示光标读数。相关单测通过。
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
+26 -9
View File
@@ -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
+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.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:
+19 -85
View File
@@ -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()