fixed commetn from songxiao

This commit is contained in:
ykcheungab
2026-09-25 17:46:37 +08:00
parent 6effe74172
commit 12781854ca
56 changed files with 4935 additions and 419 deletions
+231 -44
View File
@@ -76,8 +76,12 @@ from orisys_studio.ui.toolbar import ToolbarWidget
logger = logging.getLogger(__name__)
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
ARROW_ROTATE_TYPES = frozenset({"box", "mini"})
#: Types the Run button accepts. ``finger-mems`` is a studio-side product (deprecated in the
#: SDK) that reuses the box/mini pipeline, so it is runnable exactly like box.
RUNNABLE_TYPES = frozenset({"box", "mini", "finger", "finger-mems"})
#: Types whose 2D arrow view is rotated 90° (the sensor is mounted that way). finger-mems is
#: a box-style stitched camera, so it rotates like box until the device says otherwise.
ARROW_ROTATE_TYPES = frozenset({"box", "mini", "finger-mems"})
DEFAULT_SPLITTER_SIZES = (620, 320, 500)
MIN_PANEL_WIDTHS = {
# The 3D pane used to be the one panel whose content was a stack of form fields, so
@@ -204,6 +208,10 @@ class StudioMainWindow(QMainWindow):
self.session.tick.connect(self._on_run_tick)
# The phase is the session's, not the window's; the chip is one view of it.
self.session.phaseChanged.connect(self._on_session_phase_changed)
#: True while the operator has paused the live session. The acquisition thread
#: stops computing; this flag stops the *display* half of the run tick, so a
#: frame that was already in flight cannot repaint the panels they paused on.
self._display_paused = False
self._active_calibration_id = ""
#: Last display-unit decision (``None`` before the first sample): the
#: status rail's calibration readout is derived from it, because an axis
@@ -738,6 +746,7 @@ class StudioMainWindow(QMainWindow):
self.toolbar.replay_video_clicked.connect(self.replay_ctl.on_open_recording)
self.toolbar.recompute_clicked.connect(self.replay_ctl.on_recompute_button)
self.toolbar.run_toggled.connect(self._on_run_toggled)
self.toolbar.stop_clicked.connect(self._on_stop_clicked)
self.toolbar.record_toggled.connect(self._on_record_toggled)
self.toolbar.reset_clicked.connect(self._on_reset)
self.toolbar.units_changed.connect(self.display_ctl.on_units_changed)
@@ -974,6 +983,22 @@ class StudioMainWindow(QMainWindow):
self.act_reset.setShortcut("Ctrl+R")
self.act_reset.triggered.connect(self._on_reset)
self.view_menu.addAction(self.act_reset)
# The graphics half of the old 重置: curves, window, cursor and the 3D camera.
# Separate because wanting a fresh device baseline is not the same intention as
# wanting the views back, and only the first one belongs to the device.
self.act_reset_view = QAction(self)
self.act_reset_view.setShortcut("F8")
self.act_reset_view.triggered.connect(self._on_reset_view)
self.view_menu.addAction(self.act_reset_view)
# Pause has a shortcut as well as a rail button: an operator who is holding a
# sample still wants to stop the numbers moving without reaching for the mouse.
self.act_pause = QAction(self)
self.act_pause.setShortcut("F7")
self.act_pause.setCheckable(True)
self.act_pause.toggled.connect(self._on_pause_action_toggled)
self.view_menu.addAction(self.act_pause)
self.view_menu.addSeparator()
self.theme_menu = self.view_menu.addMenu("")
@@ -1111,6 +1136,12 @@ class StudioMainWindow(QMainWindow):
return self.layout_ctl.view_preset
def _update_run_enabled(self) -> None:
"""What the rail's transport, stop and reset can do in the current view.
One place decides it for both views: the rail's buttons mean the same
things there (the transport is a play/pause toggle, stop ends what is
running), so a second copy of the rule is a second thing to keep in step.
"""
if self.toolbar.run_role() == "playback":
# In the replay view the button is a transport: it needs something to
# play — a recording, or a bare video — not a runnable sensor. While it
@@ -1131,14 +1162,16 @@ class StudioMainWindow(QMainWindow):
reason = t("toolbar.playback_disabled")
self.toolbar.set_run_tooltip_reason(reason)
self.toolbar.set_run_enabled(loaded or self.replay_ctl.is_playing())
# The stop button is offered whenever there is something to stop; the
# operator must never have to guess whether that is the case.
if self.toolbar.reset_role() == "stop":
self.toolbar.set_reset_enabled(
self.replay_ctl.is_playing()
or self._recompute_running
or self._session_active
)
# Stop leaves the replay, whatever it is doing: plain playback, a
# recompute pass, or a session the pass started. The operator must never
# have to work out which of those is stuck before they can stop it.
self.toolbar.set_stop_enabled(
self.replay_ctl.is_playing()
or self._recompute_running
or self._session_active
)
# The tracker reset belongs to a device session; this view has none.
self.toolbar.set_reset_enabled(False)
self.toolbar.set_record_enabled(
self._running or self.recorder.is_recording
)
@@ -1155,10 +1188,16 @@ class StudioMainWindow(QMainWindow):
source_ok = False
reason = t("toolbar.run_disabled_video")
self.toolbar.set_run_tooltip_reason(reason)
# While a session exists the transport stays enabled — pausing and resuming
# are the whole point of it being checked, not an exception to an idle rule.
self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok))
# Recording needs a live session; while recording the button stays
# enabled so it can always be stopped.
self.toolbar.set_record_enabled(self._running or self.recorder.is_recording)
# Stop releases the device and ends the session. It covers a device open
# that hangs as well, so it is offered for as long as a session exists at
# all — including the moment the worker is still winding down.
self.toolbar.set_stop_enabled(self._session_active)
if not self._running:
self.toolbar.refresh_labels()
self._sync_shortcut_actions()
@@ -1189,6 +1228,10 @@ class StudioMainWindow(QMainWindow):
self.act_stop.setText(t("menu.stop"))
self.act_preview.setText(t("menu.preview"))
self.act_reset.setText(t("menu.reset"))
self.act_reset_view.setText(t("menu.reset_view"))
self.act_reset_view.setToolTip(t("menu.reset_view_tip"))
self.act_pause.setText(t("menu.pause"))
self._sync_pause_action()
self.theme_menu.setTitle(t("menu.theme"))
self.act_theme_dark.setText(t("menu.theme_dark"))
self.act_theme_light.setText(t("menu.theme_light"))
@@ -1494,10 +1537,17 @@ class StudioMainWindow(QMainWindow):
dialog = PlotSettingsDialog(
self,
window_sec=settings["window_sec"],
# FN's lower bound may be negative: that is the "show me below zero" the
# operator asked for, and it is what puts the 0 tick back on the axis.
fn_min=settings["fn_min"],
fn_max=settings["fn_max"],
shear_max=settings["shear_max"],
fn_raw_min=settings["fn_raw_min"],
fn_raw_max=settings["fn_raw_max"],
shear_raw_max=settings["shear_raw_max"],
# Display-only zeroing floors; 0 means "use the estimated band".
deadband_n=settings["deadband_n"],
deadband_raw=settings["deadband_raw"],
units=settings["units"],
filters=self._filter_settings(),
# Asked *now*: the answer is a property of this moment, and the
@@ -1507,16 +1557,9 @@ class StudioMainWindow(QMainWindow):
)
if dialog.exec() != PlotSettingsDialog.Accepted:
return
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
self.display_ctl.apply_plot_settings(
{
"window_sec": window_sec,
"fn_max": fn_max,
"shear_max": shear_max,
"fn_raw_max": fn_raw_max,
"shear_raw_max": shear_raw_max,
}
)
# One call for the whole picture: the dialog owns the schema, the controller
# writes what it was given and re-reads the rest.
self.display_ctl.apply_plot_settings(dialog.settings())
filters = dialog.filter_values()
# The same keys ``_filter_settings()`` reads back, so what the dialog
# shows next time is what the session will actually be given.
@@ -1718,20 +1761,43 @@ class StudioMainWindow(QMainWindow):
if getattr(self, "act_run", None) is None:
return
self.act_run.setEnabled(self.toolbar.btn_run.isEnabled())
# The menu's Stop mirrors the toolbar's stop affordance, not the acquisition
# The menu's Stop mirrors the rail's Stop button, not the acquisition
# session alone: a replay or a recompute pass is something to stop.
#
# This is not cosmetic. Qt6 honours ``enabled`` in ``QAction::trigger()`` (Qt5
# did not) and never delivers the shortcut of a disabled action, so a Stop that
# is disabled while a replay is playing is a keyboard escape hatch that does not
# work — the one thing ``_on_stop_shortcut`` exists to guarantee.
self.act_stop.setEnabled(
self._session_active
or self.replay_ctl.is_playing()
or self._recompute_running
)
self.act_stop.setEnabled(self.toolbar.btn_stop.isEnabled())
self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled())
# A live pause is only meaningful while the pipeline is running; in the replay
# view its own transport owns play/pause.
self.act_pause.setEnabled(
self.toolbar.run_role() != "playback" and self._running
)
self.act_pause.setToolTip(
t("toolbar.resume_tip") if self.act_pause.isChecked() else t("toolbar.pause_tip")
)
def _sync_pause_action(self) -> None:
"""The View-menu action follows the session; it never leads it.
The rail's transport owns the pause state (checked = acquiring and not
paused, unchecked with a session = paused), so the action is a mirror and a
shortcut into it — two controls that cannot disagree.
"""
action = getattr(self, "act_pause", None)
if action is None:
return
paused = bool(self._running and self._display_paused)
if action.isChecked() != paused:
blocked = action.blockSignals(True)
action.setChecked(paused)
action.blockSignals(blocked)
action.setToolTip(
t("toolbar.resume_tip") if paused else t("toolbar.pause_tip")
)
def _on_play_shortcut(self) -> None:
"""Space plays or pauses the recording, if there is one to move through."""
@@ -1753,6 +1819,14 @@ class StudioMainWindow(QMainWindow):
``_sync_shortcut_actions`` enables the action for, so the action can never be
enabled with nothing behind it.
"""
self._stop_activity()
def _stop_activity(self) -> None:
"""End whatever this view is doing. The rail's Stop and Esc share this body.
One implementation, because the button and the shortcut are the same act:
two copies would be two answers to "what does Stop stop".
"""
if self.toolbar.run_role() == "playback":
if (
self.replay_ctl.is_playing()
@@ -1765,6 +1839,10 @@ class StudioMainWindow(QMainWindow):
return
self._stop_run(reason="user_stop")
def _on_stop_clicked(self) -> None:
"""The rail's Stop button — the same escape hatch Esc reaches."""
self._stop_activity()
def _on_preview_shortcut(self) -> None:
if not self.toolbar.btn_preview.isEnabled():
return
@@ -1876,14 +1954,28 @@ class StudioMainWindow(QMainWindow):
self._show_image_frame(ImageFrame(frame, source=SOURCE_PREVIEW))
def _on_run_toggled(self, checked: bool) -> None:
"""The rail's transport. One button, three states, routed by the session.
``checked`` means "acquiring and not paused" in the live view and "playing"
in the replay view. Unchecked is ambiguous on its own — it is *idle* when no
session exists and *paused* when one does — so whether a session is already
running is what decides between starting and resuming, and between pausing
and doing nothing. Stopping is never this button's job: it has its own.
"""
if self.toolbar.run_role() == "playback":
# The primary button of the replay view is its transport.
self.replay_ctl.on_transport_toggled(bool(checked))
return
if checked:
self._start_run()
else:
self._stop_run(reason="user_stop")
if self._running:
# A live session already exists and the button was unchecked: that
# is the paused state, so checking it again means resume.
self._on_pause_toggled(False)
else:
self._start_run()
return
if self._running:
self._on_pause_toggled(True)
def _start_run(self) -> None:
from orisys_app_shared.acquisition.sdk_worker import orisys_importable
@@ -1919,11 +2011,11 @@ class StudioMainWindow(QMainWindow):
self.force_plots.configure_presentation(
sensor_type=sensor_type,
unit=units,
enabled=(
sensor_type == "finger"
and units == "raw"
and bool(self.prefs.get("raw_auto_zero", True))
),
# The operator's own switch, and only that: the aid covers every sensor type and
# both units, which is the point of it (an offset reads as an offset in newtons
# too). This used to be a ``finger and raw`` conjunction, which silently disabled
# it for every other product.
enabled=bool(self.prefs.get("raw_auto_zero", True)),
)
self._last_plot_packet_key = None
self._last_rendered_view_mode = None
@@ -1975,6 +2067,12 @@ class StudioMainWindow(QMainWindow):
# The session is now connecting: it owns the phase, the connect clock and the
# run timer's cadence (the timer *is* the session, so it lives with it).
self.session.mark_running()
# A session exists and is not paused, which is exactly what the transport's
# checked state means: stated here as well as by the click, because starting
# is not only ever reached through the button (a recompute pass starts the
# same session), and an unchecked transport beside a live session would read
# as "paused".
self.toolbar.set_run_checked(True)
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
self.cal_panel.set_running(True)
self.toolbar.set_source_controls_enabled(False)
@@ -2013,6 +2111,12 @@ class StudioMainWindow(QMainWindow):
self.toolbar.refresh_labels()
self.cal_panel.set_running(False)
self.toolbar.set_run_checked(False)
# The session is over, so the transport must not stay latched in "paused" —
# the next run starts computing. The worker clears its own flag on start as
# well, so both sides agree even if this path is skipped (a stop from the
# acquisition thread).
self._display_paused = False
self._sync_pause_action()
self.toolbar.set_reset_enabled(False)
self.toolbar.set_source_controls_enabled(True)
self.sensor_picker.set_picker_enabled(True)
@@ -2064,29 +2168,111 @@ class StudioMainWindow(QMainWindow):
self._notify(t("toolbar.nothing_to_stop"))
def _on_reset(self) -> None:
if self.toolbar.reset_role() == "stop":
# In the replay view that button is the operator's way out of a replay or
# a recompute pass, whatever state the transport is in.
self.replay_ctl.stop_activity()
return
"""Zero the device tracker. Graphics are reset separately (``_on_reset_view``).
This used to call ``force_plots.reset()`` as well, so wanting a fresh baseline
also threw away the history, the window and the cursor on screen — two different
operator intentions on one button (and only one of them recorded, see
``note_reset_applied``). The display baseline is re-estimated instead of wiped:
a new device zero means the old idle estimate is stale, not that the operator
wants to lose what they were looking at.
It is only ever this: ending a replay or a recompute pass is the rail's Stop
button (the same act as Esc), so this button is disabled wherever there is no
device session to zero.
"""
if self._session_phase != "running" or self._active_worker is None:
return
try:
# A request, not the zeroing itself: the acquisition thread applies it
# between two frames and reports the frame and time it took effect.
self._active_worker.reset()
self.force_plots.reset()
self.force_plots.request_presentation_reestimate()
self._last_plot_packet_key = None
self.statusBar().showMessage(t("status.reset_pending"))
except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
def _on_pause_toggled(self, paused: bool) -> None:
"""Pause the pipeline and the display; the device stays open.
Not a display freeze: the acquisition thread stops *computing* as well, so nothing
is spent on flow, depth or contact while an operator reads a result. What it
deliberately does not do is release the device — that is 停止 — so resuming is
immediate and the camera is not re-enumerated.
Consequence, stated rather than hidden: with no packets there is nothing to write,
so a recording that spans a pause has a **gap**. The store's own time base shows it
(sample times jump), and 停止 still ends the session normally.
Reached from the rail's transport (which owns the state) and from the View-menu
action / F7 (which mirrors it); never from two controls of its own.
"""
paused = bool(paused)
self._display_paused = paused
self._sync_pause_action()
worker = self._active_worker
if worker is not None:
try:
worker.pause() if paused else worker.resume()
except Exception as exc: # noqa: BLE001 - report, never raise from a click
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
return
if paused:
self.statusBar().showMessage(t("status.paused"))
else:
# The next packet is a new sample again: without this the first one after a
# resume can be swallowed as a repeat of the last key the tick saw.
self._last_plot_packet_key = None
self.statusBar().showMessage(t("status.resumed"))
def _on_pause_action_toggled(self, paused: bool) -> None:
"""The menu/shortcut half of pause: drive the rail's transport, which owns it.
The action is checked exactly while the session is paused, so pressing it
toggles the transport button — and that click is what routes to pause or
resume. Keeping one control authoritative means the button and the shortcut
can never disagree about whether the session is paused.
"""
paused = bool(paused)
if self.toolbar.run_role() == "playback" or not self._running:
# Nothing here to pause: put the action back rather than letting it
# advertise a state the session is not in.
self._sync_pause_action()
return
wanted = not paused
if self.toolbar.btn_run.isChecked() != wanted:
# ``setChecked`` emits, so the click takes the window's own transport
# path (start/resume/pause) instead of a second copy of that decision.
self.toolbar.btn_run.setChecked(wanted)
def _on_reset_view(self) -> None:
"""Put the views back to their defaults: curves, window, cursor and 3D camera.
Pressures the same state ``force_plots.reset()`` always cleared, now on its own
action, plus the 3D pane's camera. It touches nothing the device owns, so it is
always available — including while a replay is on screen.
"""
self.force_plots.reset()
panel = getattr(self, "mesh3d_panel", None)
if panel is not None:
try:
panel.reset_view()
except Exception: # noqa: BLE001 - a pane with no scene has no view to reset
logger.debug("3D view reset skipped", exc_info=True)
self.statusBar().showMessage(t("status.view_reset"))
def _on_run_tick(self) -> None:
# The 3D pane advances on this timer rather than one of its own: the window's
# refresh rate is then the only one that exists, and hiding or disabling the
# pane cannot leave a timer behind. It starts at most one compose on the
# pane's own worker and takes at most one finished frame.
self.mesh3d_panel.tick()
if not self._display_paused:
# While paused the pane holds the frame it had. The acquisition thread has
# already stopped computing, so this only matters for a frame that was in
# flight when the operator pressed pause — which must not be the one frame
# they cannot see.
self.mesh3d_panel.tick()
if self._active_worker is None:
return
# Record bookkeeping happens before anything that can return early: a
@@ -2141,7 +2327,7 @@ class StudioMainWindow(QMainWindow):
# source slot names the camera, so "运行中(0)" was a second copy of
# the same fact that then stayed on screen for the rest of the session.
if is_new:
if is_new and not self._display_paused:
self._last_plot_packet_key = packet_key
# The live view consumes the *same* standardised measurement as the
# CSV and the session store: one conversion decides what a value
@@ -2227,8 +2413,9 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(dropped_message)
mode = self.arrow_panel.view_mode()
if (is_new or mode != self._last_rendered_view_mode) and not (
self._replay_owns_image()
if not self._display_paused and (
(is_new or mode != self._last_rendered_view_mode)
and not (self._replay_owns_image())
):
self._render_run_image(packet)
if is_new:
+105 -43
View File
@@ -23,8 +23,38 @@ from typing import Any, Optional, Protocol
from PySide6.QtCore import QObject
from orisys_studio.ui.plot_settings_dialog import (
DEFAULT_PLOT_DEADBAND_N,
DEFAULT_PLOT_DEADBAND_RAW,
DEFAULT_PLOT_FN_MIN,
DEFAULT_PLOT_FN_MAX,
DEFAULT_PLOT_FN_RAW_MIN,
DEFAULT_PLOT_FN_RAW_MAX,
DEFAULT_PLOT_SHEAR_MAX,
DEFAULT_PLOT_SHEAR_RAW_MAX,
DEFAULT_PLOT_WINDOW_SEC,
)
logger = logging.getLogger(__name__)
#: The chart range, per channel and per unit. ``fn_min`` is the one that may be
#: negative (FN is asymmetric); FX/FY take one absolute magnitude each.
PLOT_RANGE_DEFAULTS = {
"window_sec": DEFAULT_PLOT_WINDOW_SEC,
"fn_min": DEFAULT_PLOT_FN_MIN,
"fn_max": DEFAULT_PLOT_FN_MAX,
"shear_max": DEFAULT_PLOT_SHEAR_MAX,
"fn_raw_min": DEFAULT_PLOT_FN_RAW_MIN,
"fn_raw_max": DEFAULT_PLOT_FN_RAW_MAX,
"shear_raw_max": DEFAULT_PLOT_SHEAR_RAW_MAX,
}
#: Display deadband floors, per unit. 0 = auto (the estimate from the quiet
#: startup window, with the presentation layer's own floor under it).
PLOT_DEADBAND_DEFAULTS = {
"deadband_n": DEFAULT_PLOT_DEADBAND_N,
"deadband_raw": DEFAULT_PLOT_DEADBAND_RAW,
}
class DisplayHost(Protocol):
"""What the display controller needs from the window.
@@ -84,7 +114,9 @@ class DisplayPrefsController(QObject):
#: Whether the panels explain the pipeline around the picture (see
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
self._show_diagnostics = bool(host.prefs.get("show_diagnostics", False))
#: Finger-raw value cards: startup auto baseline / deadband (display only).
#: The display baseline / deadband (``raw_auto_zero``): a display aid
#: that holds an idle channel at zero, for every sensor type and both
#: units. It never reaches the session store or forces.csv.
self._raw_auto_zero = bool(host.prefs.get("raw_auto_zero", True))
self._apply_diagnostics()
self.apply_presentation_prefs()
@@ -143,50 +175,69 @@ class DisplayPrefsController(QObject):
# --------------------------------------------------------------------- plot
def plot_settings(self) -> dict:
"""The chart range and window as the settings dialog should show them."""
"""The chart range, deadband and window as the settings dialog shows them."""
prefs = self._host.prefs
return {
"window_sec": float(
prefs.get("plot_window_sec") or self._plot_defaults["window_sec"]
),
"fn_max": float(prefs.get("plot_fn_max") or self._plot_defaults["fn_max"]),
"shear_max": float(
prefs.get("plot_shear_max") or self._plot_defaults["shear_max"]
),
"fn_raw_max": float(
prefs.get("plot_fn_raw_max") or self._plot_defaults["fn_raw_max"]
),
"shear_raw_max": float(
prefs.get("plot_shear_raw_max") or self._plot_defaults["shear_raw_max"]
),
"units": self._toolbar.units(),
settings = {
key: self._number(
f"plot_{key}", self._plot_defaults.get(key, fallback)
)
for key, fallback in {**PLOT_RANGE_DEFAULTS, **PLOT_DEADBAND_DEFAULTS}.items()
}
settings["units"] = self._toolbar.units()
return settings
def _number(self, key: str, fallback: float) -> float:
"""A stored number, or the fallback when the preference says nothing.
0 is a value here, not "unset": the raw bounds and the deadband floors
all give 0 a meaning of its own.
"""
value = self._host.prefs.get(key)
if value is None or value == "":
return float(fallback)
try:
return float(value)
except (TypeError, ValueError):
return float(fallback)
def apply_plot_settings(self, values: dict) -> None:
"""Remember the chart range the operator chose, and show it at once."""
for key in ("window_sec", "fn_max", "shear_max", "fn_raw_max", "shear_raw_max"):
self._host.prefs[f"plot_{key}"] = values[key]
self._charts.set_window_sec(float(values["window_sec"]))
self._charts.set_axis_limits(
float(values["fn_max"]),
float(values["shear_max"]),
float(values["fn_raw_max"]),
float(values["shear_raw_max"]),
)
"""Remember the chart range the operator chose, and show it at once.
A caller that knows only some of the keys leaves the rest as stored, so
this can never silently reset a range it was not told about.
"""
for key in (*PLOT_RANGE_DEFAULTS, *PLOT_DEADBAND_DEFAULTS):
if values.get(key) is not None:
self._host.prefs[f"plot_{key}"] = values[key]
settings = self.plot_settings()
self._charts.set_window_sec(float(settings["window_sec"]))
self._apply_ranges(settings)
self._host._persist_prefs()
def apply_plot_prefs_to_panel(self) -> None:
"""Show the stored chart range without asking anything (startup path)."""
values = self.plot_settings()
self._charts.set_window_sec(float(values["window_sec"]))
self._charts.set_axis_limits(
float(values["fn_max"]),
float(values["shear_max"]),
float(values["fn_raw_max"]),
float(values["shear_raw_max"]),
)
settings = self.plot_settings()
self._charts.set_window_sec(float(settings["window_sec"]))
self._apply_ranges(settings)
self._charts.set_fixed_newton(self._toolbar.units() == "newton")
def _apply_ranges(self, settings: dict) -> None:
"""Push one complete range + deadband picture onto the charts."""
self._charts.set_axis_limits(
fn_min=float(settings["fn_min"]),
fn_max=float(settings["fn_max"]),
shear_max=float(settings["shear_max"]),
fn_raw_min=float(settings["fn_raw_min"]),
fn_raw_max=float(settings["fn_raw_max"]),
shear_raw_max=float(settings["shear_raw_max"]),
)
self._push_deadband_floors(settings["deadband_n"], settings["deadband_raw"])
def _push_deadband_floors(self, newton: float, raw: float) -> None:
floors = getattr(self._charts, "set_deadband_floors", None)
if callable(floors):
floors(newton=float(newton), raw=float(raw))
def device_sample_rate(self) -> float:
"""The frame rate the low-pass coefficients would be designed for."""
from orisys_studio import session_metadata
@@ -271,7 +322,11 @@ class DisplayPrefsController(QObject):
return bool(self._raw_auto_zero)
def set_raw_auto_zero(self, enabled: bool) -> None:
"""Persist whether Finger raw value cards auto-baseline at session start."""
"""Persist whether the display baseline / deadband is on at all.
The preference keeps its name; it is no longer Finger-raw-only (see
:meth:`apply_presentation_prefs`).
"""
self._raw_auto_zero = bool(enabled)
self._host.prefs["raw_auto_zero"] = self._raw_auto_zero
self._host._persist_prefs()
@@ -281,20 +336,27 @@ class DisplayPrefsController(QObject):
self.set_raw_auto_zero(enabled)
def apply_presentation_prefs(self) -> None:
"""Push the current baseline policy onto the charts (Finger raw only)."""
"""Push the display baseline policy onto the charts.
Every sensor type and both units. The aid holds an idle channel at zero:
a newton offset left in the numbers is the same display problem as a raw
count, so which device produced the reading is not what decides it.
"""
configure = getattr(self._charts, "configure_presentation", None)
if not callable(configure):
return
sensor = (
self._picker.sensor_type()
if hasattr(self._picker, "sensor_type")
else "box"
)
unit = "N" if self._toolbar.units() == "newton" else "raw"
configure(
sensor_type=sensor,
unit=unit,
enabled=bool(self._raw_auto_zero and sensor == "finger" and unit == "raw"),
if callable(configure):
configure(sensor_type=sensor, unit=unit)
enable = getattr(self._charts, "set_presentation_enabled", None)
if callable(enable):
enable(bool(self._raw_auto_zero))
self._push_deadband_floors(
self._number("plot_deadband_n", PLOT_DEADBAND_DEFAULTS["deadband_n"]),
self._number("plot_deadband_raw", PLOT_DEADBAND_DEFAULTS["deadband_raw"]),
)
def request_raw_reestimate(self) -> None:
@@ -58,7 +58,7 @@ PACKAGE_SUFFIX = ".oryscene"
COMPILE_HINT = (
"python tools/scene_assets/compile_scene.py "
"--legacy-dir config/sensors/finger/assets/30fps-0703 "
"--map config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz "
"--map config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.42.npz "
"--meters-per-source-unit <stated> --output "
"config/sensors/finger/scene/finger-display-1.0.0.oryscene"
)
@@ -180,8 +180,9 @@ class ThreeDPanel(QFrame):
self.housing_check = QCheckBox()
# Checked to match ``DisplaySettings.housing_visible``'s default: the panel's
# starting state and the settings it reports must be the same thing.
self.housing_check.setChecked(True)
# starting state and the settings it reports must be the same thing. That default is
# now unchecked — the housing is a mechanical check, not the measurement.
self.housing_check.setChecked(False)
self.housing_check.toggled.connect(self._emit_settings)
form.addRow(self.housing_check)
@@ -158,7 +158,7 @@ def settings_from_prefs(
display_gain=gain,
deform_enabled=_flag(raw.get("deform_enabled"), False),
color_scale=scale,
housing_visible=_flag(raw.get("housing_visible"), True),
housing_visible=_flag(raw.get("housing_visible"), False),
max_fps=max_fps,
field=field,
flow_arrows_enabled=_flag(raw.get("flow_arrows_enabled"), True),
+240 -30
View File
@@ -18,9 +18,14 @@ from PySide6.QtWidgets import (
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force import MetricHistory
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
from orisys_app_shared.force.presentation import ForcePresentation
from orisys_app_shared.force.presentation import (
DEFAULT_MIN_DEADBAND_N,
DEFAULT_MIN_DEADBAND_RAW,
ForcePresentation,
)
from orisys_app_shared.force.source import StoreCurveSource
from orisys_app_shared.i18n import t
from orisys_app_shared.record.sample import plottable_axis_mask
from orisys_app_shared.theme import color
from orisys_studio.ui import icons
@@ -65,10 +70,33 @@ def _window_brush(theme: str):
def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
"""Evenly spaced Y ticks that always carry a zero label when zero is inside.
The zero label is what makes "not pressing" readable: on an axis whose range
starts below zero the resting curve has no reference point without it.
"""
if hi <= lo:
return [(lo, f"{lo:g}")]
step = (hi - lo) / max(1, count - 1)
return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
ticks = [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
if lo <= 0.0 <= hi and all(abs(value) > abs(step) * 1e-6 for value, _ in ticks):
ticks.append((0.0, "0"))
ticks.sort(key=lambda tick: tick[0])
return ticks
def _ordered_range(lower: float, upper: float) -> tuple[float, float]:
"""A usable ``(ymin, ymax)``: an empty or inverted range is widened upward.
Nothing stops an operator from asking for FN 5…2 N in the dialog, and an
inverted range collapses the axis in pyqtgraph. Widening keeps the chart
readable while the dialog keeps showing what was asked for.
"""
lower = float(lower)
upper = float(upper)
if upper <= lower:
upper = lower + max(0.1, abs(lower) * 0.05)
return lower, upper
def format_force(
@@ -133,8 +161,12 @@ class ForcePlotsPanel(QFrame):
super().__init__(parent)
self.setObjectName("contentPanel")
self._theme = theme
# One range per channel, per unit. FN is asymmetric and its lower bound
# may be negative; FX/FY take one absolute magnitude each.
self._fn_min = 0.0
self._fn_max = 20.0
self._shear_max = 20.0
self._fn_raw_min = 0.0
self._fn_raw_max = 1000000.0
self._shear_raw_max = 500000.0
self._fixed_newton = True
@@ -142,8 +174,13 @@ class ForcePlotsPanel(QFrame):
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
#: Per axis: does ``_last_values`` hold a real value in the active unit?
self._available: tuple[bool, bool, bool] = (True, True, True)
#: Display-only baseline / deadband for Finger raw value cards.
#: Display-only baseline / deadband for the value cards *and* the curves.
self.presentation = ForcePresentation(enabled=False)
#: The operator's master switch for that baseline. It is owned by
#: ``display_prefs`` (the ``raw_auto_zero`` preference) and kept here so a
#: caller that does not know it cannot switch the aid off (see
#: ``configure_presentation``).
self._presentation_enabled = False
self._sensor_type = "box"
self._display_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
self._display_suppressed: tuple[bool, bool, bool] = (False, False, False)
@@ -261,6 +298,10 @@ class ForcePlotsPanel(QFrame):
self.plots = []
self.curves = []
self.cursors = []
#: Horizontal line at y = 0, one per channel: the origin the axis labels
#: alone could not give, and what keeps the idle FN curve off the subplot
#: border it shares with FX.
self.zero_lines = []
pen = pg.mkPen(color(theme, CHANNEL_TOKENS[0]), width=2)
for i, _name in enumerate(CHANNEL_LABELS):
plot = self.plot_widget.addPlot(row=i, col=0)
@@ -283,6 +324,14 @@ class ForcePlotsPanel(QFrame):
)
cursor.sigPositionChanged.connect(self._on_cursor_dragged)
plot.addItem(cursor, ignoreBounds=True)
zero_line = pg.InfiniteLine(
pos=0.0,
angle=0,
movable=False,
pen=pg.mkPen(color(theme, "text_muted"), width=1),
)
zero_line.setZValue(-10)
plot.addItem(zero_line, ignoreBounds=True)
# ``sigRangeChangedManually`` — not ``sigXRangeChanged`` — is what
# pyqtgraph emits for a wheel/drag gesture. The plain range signal
# also fires when a resize propagates through the X links, which is
@@ -291,6 +340,7 @@ class ForcePlotsPanel(QFrame):
self.plots.append(plot)
self.curves.append(curve)
self.cursors.append(cursor)
self.zero_lines.append(zero_line)
# Whole-session overview: the full envelope plus the current window.
self.overview = self.plot_widget.addPlot(row=len(CHANNEL_LABELS), col=0)
@@ -441,6 +491,8 @@ class ForcePlotsPanel(QFrame):
self.overview_region.setBrush(_window_brush(theme))
for cursor in (*self.cursors, self.overview_cursor):
cursor.setPen(pg.mkPen(muted, width=1))
for zero_line in self.zero_lines:
zero_line.setPen(pg.mkPen(muted, width=1))
pens = tuple(color(theme, token) for token in CHANNEL_TOKENS)
for curve, pen in zip(self.curves, pens):
curve.setPen(pg.mkPen(pen, width=2))
@@ -473,31 +525,77 @@ class ForcePlotsPanel(QFrame):
def set_axis_limits(
self,
*,
fn_min: float,
fn_max: float,
shear_max: float,
fn_raw_max: float | None = None,
shear_raw_max: float | None = None,
fn_raw_min: float,
fn_raw_max: float,
shear_raw_max: float,
) -> None:
"""The Y range of every channel, per display unit.
FN is asymmetric — ``fn_min`` may be negative, which is what gives the
axis a visible origin below the resting curve — while FX/FY each take one
absolute magnitude and are drawn symmetrically around zero.
"""
self._fn_min = float(fn_min)
self._fn_max = max(0.1, float(fn_max))
self._shear_max = max(0.1, float(shear_max))
if fn_raw_max is not None:
self._fn_raw_max = max(0.0, float(fn_raw_max))
if shear_raw_max is not None:
self._shear_raw_max = max(0.0, float(shear_raw_max))
self._fn_raw_min = float(fn_raw_min)
self._fn_raw_max = max(0.0, float(fn_raw_max))
self._shear_raw_max = max(0.0, float(shear_raw_max))
self._apply_axis_mode()
def set_deadband_floors(self, *, newton: float | None = None, raw: float | None = None) -> None:
"""The operator's display deadband floors; 0 means "auto (estimated)".
A floor, not a replacement: the estimate from the quiet window still
applies underneath, so this is how an operator widens the zero zone (or
pins one when the calibration data is suspect) without re-fitting.
"""
if newton is not None:
value = max(0.0, float(newton))
self.presentation.set_min_deadband(
"N", value if value > 0.0 else DEFAULT_MIN_DEADBAND_N
)
if raw is not None:
value = max(0.0, float(raw))
self.presentation.set_min_deadband(
"raw", value if value > 0.0 else DEFAULT_MIN_DEADBAND_RAW
)
self._render_value_cards()
def set_presentation_enabled(self, enabled: bool) -> None:
"""The operator's master switch for the display baseline and deadband."""
self._presentation_enabled = bool(enabled)
self.presentation.set_enabled(self._presentation_enabled, unit=self._unit)
# Switching the aid off must show the measurement again, and switching it
# on must show the policy — from the same values the curve is drawn with,
# or a card and its own curve would disagree for a frame.
self._refresh_display_values()
self._render_value_cards()
def set_fixed_newton(self, enabled: bool) -> None:
self._fixed_newton = bool(enabled)
self._apply_axis_mode()
def reset(self) -> None:
"""Back to the view defaults: empty curves, follow mode, unit ``raw``.
The display baseline is deliberately *kept*: a view reset is not a
statement about the device, and the operator's zero estimate (or its
absence) survives it. A new session restarts the estimate through
``configure_presentation``.
"""
self.history.reset()
self._clear_curves()
self._last_values = (0.0, 0.0, 0.0)
self._display_values = (0.0, 0.0, 0.0)
self._display_suppressed = (False, False, False)
self._available = (True, True, True)
self.presentation.reset_session(unit=self._unit)
self._unit = "raw"
self.presentation.unit = "raw"
self._mode = "follow"
self._window = (0.0, 0.0)
self._cursor_t = 0.0
@@ -519,20 +617,29 @@ class ForcePlotsPanel(QFrame):
unit: str,
enabled: bool | None = None,
) -> None:
"""Enable Finger-raw auto baseline for value cards only."""
"""Point the display baseline at the sensor and unit now being shown.
The aid is for every sensor type and both units — an idle channel that
reads a raw count or an offset in newtons is a display problem whichever
device produced it — so the sensor type only names what is drawn.
``enabled`` sets the master switch directly when given; the operator's
``raw_auto_zero`` preference is what the run-start call site passes.
Omitting it leaves the current switch alone.
"""
self._sensor_type = str(sensor_type or "box")
use = (
bool(enabled)
if enabled is not None
else (self._sensor_type == "finger" and unit != "N")
)
self.presentation.enabled = use
if enabled is not None:
self._presentation_enabled = bool(enabled)
self.presentation.enabled = bool(self._presentation_enabled)
self.presentation.reset_session(unit=unit)
def request_presentation_reestimate(self) -> None:
"""Restart the idle baseline window (display cards only)."""
"""Restart the idle baseline window (display only: cards and curves)."""
self.presentation.request_reestimate()
self._display_values = self._last_values
# A restarted window starts from nothing, so the cards go back to the
# measurement for the couple of samples it takes to re-learn the offset
# — derived by the same call the curve uses, never left stale.
self._refresh_display_values()
self._render_value_cards()
def observe_for_baseline(
@@ -543,8 +650,13 @@ class ForcePlotsPanel(QFrame):
valid: bool = True,
flow_p95: float | None = None,
contact_found: bool = False,
unit: str | None = None,
) -> None:
"""Feed the startup estimator; curves keep the raw measurement."""
"""Feed the startup estimator in the unit these values are expressed in.
The curve and the record keep the raw measurement; the estimate is only
ever a display copy.
"""
if not self.presentation.enabled:
return
self.presentation.observe(
@@ -553,6 +665,7 @@ class ForcePlotsPanel(QFrame):
valid=valid,
flow_p95=flow_p95,
contact_found=contact_found,
unit=unit,
)
def push(
@@ -596,6 +709,9 @@ class ForcePlotsPanel(QFrame):
valid=bool(forces_valid),
flow_p95=flow_p95,
contact_found=contact_found,
# The values are in ``unit``, and the estimate is kept per unit, so
# the sample must be filed under the unit it was measured in.
unit=unit,
)
displayed = self.presentation.accept(
self._last_values, unit=unit, available=self._available
@@ -617,8 +733,9 @@ class ForcePlotsPanel(QFrame):
if state is not None:
self._syncing = True
try:
values = self._curve_values(state.values)
for i, curve in enumerate(self.curves):
curve.setData(state.times, state.values[:, i])
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
finally:
self._syncing = False
@@ -648,7 +765,10 @@ class ForcePlotsPanel(QFrame):
return
self._syncing = True
try:
values = state.values.copy()
# ...and draw the *display* copy of it, so a resting channel is a
# flat line on zero. ``history`` — and the session store and
# forces.csv behind it — keep the measurement.
values = self._curve_values(state.values)
for i, curve in enumerate(self.curves):
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
@@ -661,6 +781,7 @@ class ForcePlotsPanel(QFrame):
else:
self.plots[i].getAxis("left").setTicks(None)
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
self._sync_zero_lines()
finally:
self._syncing = False
# The buffer already carries session-relative time, so the cursor is the
@@ -686,6 +807,9 @@ class ForcePlotsPanel(QFrame):
if unit == self._unit:
return
self._unit = unit
# The display policy keeps one estimate per unit, so it has to know which
# one is on screen: a stale "raw" is ready does not mean newtons are.
self.presentation.unit = unit
# ``clear`` keeps the buffer's time origin on purpose: the rebuilt history
# is in the same session-relative base, so the X axis must not restart.
self.history.clear()
@@ -696,6 +820,17 @@ class ForcePlotsPanel(QFrame):
self.refresh_from_store()
else:
self._reload_from_store()
# ``_last_values`` were measured in the unit being left. The
# rebuilt buffer holds the same samples in the unit now being
# shown, so its last row is what the cards describe — otherwise
# the card would carry the old unit's numbers while its own curve
# is drawn from the new one.
state = self.history.state()
if state is not None and len(state.values):
self._last_values = tuple(
float(value) for value in state.values[-1]
)
self._refresh_display_values()
self._render_value_cards()
self._apply_axis_mode()
# Re-read the point under the cursor in the new unit: while browsing, or
@@ -735,6 +870,7 @@ class ForcePlotsPanel(QFrame):
scale.
"""
self._unit = unit
self.presentation.unit = unit
self.history.clear()
self._clear_curves()
self._overview_dirty = True
@@ -826,9 +962,14 @@ class ForcePlotsPanel(QFrame):
for curve in self.curves:
curve.setData([], [])
else:
# The read is of the *measured* values (the session store or the
# recording); presenting them here is what keeps a browsed window
# looking like the live one — the record itself is untouched.
presented = self._curve_values(window.values)
mask = plottable_axis_mask(presented, window.valid)
for index, curve in enumerate(self.curves):
times, values = window.plottable_axis(index)
curve.setData(times, values)
column = mask[:, index]
curve.setData(window.times[column], presented[column, index])
self._availability_from_window(window)
for plot in self.plots:
plot.setXRange(t0, t1, padding=0.0)
@@ -962,6 +1103,14 @@ class ForcePlotsPanel(QFrame):
self._available = tuple(
bool(valid and np.isfinite(value)) for value in values
)
# The cards describe this point, so they carry the display copy of it —
# including while browsing or replaying, where the values did not come
# from the live sample the last ``push`` presented.
presented, suppressed = self.presentation.present(
np.asarray([values], dtype=float), unit=self._unit
)
self._display_values = tuple(float(value) for value in presented[0])
self._display_suppressed = tuple(bool(flag) for flag in suppressed[0])
self._render_value_cards()
def _update_selection_readout(self) -> None:
@@ -1056,6 +1205,10 @@ class ForcePlotsPanel(QFrame):
buckets=self._overview_points(), unit=self._unit
)
values = np.asarray(window.values, dtype=float)
# The envelope is drawn from the same store measurement as the window
# above, so it goes through the same presentation: the overview thumb
# must not show a jittering baseline the main chart has flattened.
values = self._curve_values(values)
column = values[:, 0]
finite = np.isfinite(column)
self.overview_curve.setData(window.times[finite], column[finite])
@@ -1164,8 +1317,9 @@ class ForcePlotsPanel(QFrame):
state = self.history.state()
if state is None:
return
values = self._curve_values(state.values)
for i, curve in enumerate(self.curves):
curve.setData(state.times, state.values[:, i])
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
def _availability_from_window(self, window) -> None:
@@ -1194,12 +1348,20 @@ class ForcePlotsPanel(QFrame):
return self._fn_raw_max > 0.0 and self._shear_raw_max > 0.0
def _fixed_y_range(self, channel_index: int) -> tuple[float, float]:
"""The configured Y range of one channel, in the active unit.
FN's lower bound may be negative — that is the "below zero threshold"
the operator asked for, and it is what keeps the resting curve off the
frame it shares with FX.
"""
if self._unit == "N":
fn_max, shear_max = self._fn_max, self._shear_max
fn_min, fn_max, shear_max = self._fn_min, self._fn_max, self._shear_max
else:
fn_max, shear_max = self._fn_raw_max, self._shear_raw_max
fn_min = self._fn_raw_min
fn_max = self._fn_raw_max
shear_max = self._shear_raw_max
if channel_index == 0:
return 0.0, fn_max
return _ordered_range(fn_min, fn_max)
return -shear_max, shear_max
def _preview_fixed_y(self) -> bool:
@@ -1223,6 +1385,49 @@ class ForcePlotsPanel(QFrame):
if self._mode == "follow":
for plot in self.plots:
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
self._sync_zero_lines()
def _sync_zero_lines(self) -> None:
"""Show the zero line only where zero is inside the visible range.
pyqtgraph does not clip a layout item to its own view box, so a line at
0 painted while the axis sits entirely above it would be drawn across the
neighbouring subplot.
"""
for zero_line, plot in zip(self.zero_lines, self.plots):
try:
ymin, ymax = plot.getViewBox().viewRange()[1]
except Exception: # noqa: BLE001 - a view box mid-construction
continue
zero_line.setVisible(bool(ymin <= 0.0 <= ymax))
def _curve_values(self, values) -> np.ndarray:
"""The values the curves draw: the measurement through the display policy.
``history``, the session store and ``forces.csv`` keep the measurement;
the baseline and deadband are applied here, on the way to the plot only.
That is what makes a resting channel a flat line at exactly zero without
the record being edited. Browsing and a replay read the store, so they go
through this same presentation — the curves show the operator's display
policy, the store shows what was measured.
"""
displayed, _suppressed = self.presentation.present(values, unit=self._unit)
return displayed
def _refresh_display_values(self) -> None:
"""Re-derive the card values from the policy the curves are drawn with.
``push`` fills these from :meth:`ForcePresentation.accept`, whose
hysteresis is what keeps a *live* reading steady. Every other path — a
re-estimate, the master switch, a unit switch — goes through the same
stateless :meth:`ForcePresentation.present` the curve uses, so a card can
never state a value its own curve does not carry.
"""
displayed, suppressed = self.presentation.present(
np.asarray([self._last_values], dtype=float), unit=self._unit
)
self._display_values = tuple(float(value) for value in displayed[0])
self._display_suppressed = tuple(bool(flag) for flag in suppressed[0])
def _clear_curves(self) -> None:
for curve in self.curves:
@@ -1252,7 +1457,13 @@ class ForcePlotsPanel(QFrame):
peak = float(selecting.peaks[index])
available = bool(np.isfinite(peak))
value = peak
elif self.presentation.enabled and self.presentation.ready:
elif self.presentation.enabled:
# The policy is *live from the first samples*: there is no
# separate "the estimate is ready" gate here. Such a gate is what
# let a card read FN 1118 raw while its own curve was already flat
# on zero, because the card fell back to the raw measurement and
# the curve did not. Both are ``_display_values`` / ``present()``
# of the same applied estimate.
value = self._display_values[index]
if not available:
# An axis with no curve in this unit: a dash, never a raw count
@@ -1273,7 +1484,6 @@ class ForcePlotsPanel(QFrame):
zero_zone = (
selecting is None
and self.presentation.enabled
and self.presentation.ready
and abs(float(value)) == 0.0
and index < len(self._display_suppressed)
and self._display_suppressed[index]
@@ -1,7 +1,8 @@
"""Modal dialog for the force-plot window, Y-axis limits and the filters.
"""Modal dialog for the force-plot window, Y-axis limits, deadband and filters.
Two things live here because they are answered together, at the chart: how much
time and range the curves show, and what the acquisition is allowed to filter.
time and range the curves show (per channel, and the display deadband that holds
a resting channel at zero), and what the acquisition is allowed to filter.
The filter half is a *request*: it is handed to the SDK through
``FilterSettings.apply_to`` at the start of the next session, and what the device
reports as done is what the record will say (see ``force/filters.py``).
@@ -33,12 +34,23 @@ from orisys_app_shared.force.filters import (
from orisys_app_shared.i18n import t
DEFAULT_PLOT_WINDOW_SEC = 4.0
#: Lower bounds. FN is allowed below zero: a resting normal force has no reason
#: to sit exactly on the frame, and with the lower bound at 0 the idle curve is
#: drawn along the subplot border that abuts the FX frame (see force_plots).
DEFAULT_PLOT_FN_MIN = 0.0
DEFAULT_PLOT_FN_MAX = 20.0
DEFAULT_PLOT_SHEAR_MAX = 20.0
# Raw (uncalibrated) HHD units: sized from measured peaks so the idle jitter
# stays sub-pixel while a full contact still fits (see prefs store).
DEFAULT_PLOT_FN_RAW_MIN = 0.0
DEFAULT_PLOT_FN_RAW_MAX = 1000000.0
DEFAULT_PLOT_SHEAR_RAW_MAX = 500000.0
#: Display deadband floors ("归零阈值") in the unit each applies to. 0 means
#: "auto": the percentile the presentation layer estimates from the quiet
#: startup window. Non-zero forces a floor under that estimate, so an operator
#: can widen the zero zone (or pin one when calibration data is suspect).
DEFAULT_PLOT_DEADBAND_N = 0.0
DEFAULT_PLOT_DEADBAND_RAW = 0.0
class PlotSettingsDialog(QDialog):
@@ -47,10 +59,14 @@ class PlotSettingsDialog(QDialog):
parent=None,
*,
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
fn_min: float = DEFAULT_PLOT_FN_MIN,
fn_max: float = DEFAULT_PLOT_FN_MAX,
shear_max: float = DEFAULT_PLOT_SHEAR_MAX,
fn_raw_min: float = DEFAULT_PLOT_FN_RAW_MIN,
fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX,
shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX,
deadband_n: float = DEFAULT_PLOT_DEADBAND_N,
deadband_raw: float = DEFAULT_PLOT_DEADBAND_RAW,
units: str = "newton",
filters: FilterSettings | None = None,
filters_locked: bool = False,
@@ -58,7 +74,7 @@ class PlotSettingsDialog(QDialog):
) -> None:
super().__init__(parent)
self.setModal(True)
self.resize(420, 520)
self.resize(460, 640)
self._sample_rate_hz = float(sample_rate_hz)
#: True while the widgets are being filled in from a preset, so the
#: "edited -> custom" handler does not fire on our own writes.
@@ -75,6 +91,15 @@ class PlotSettingsDialog(QDialog):
self.window_spin.setSuffix(" s")
self.window_spin.setValue(float(window_sec))
# FN is asymmetric: a lower bound that may be negative, and an upper one.
self.fn_min_label = QLabel()
self.fn_min_spin = QDoubleSpinBox()
self.fn_min_spin.setRange(-1000.0, 1000.0)
self.fn_min_spin.setSingleStep(1.0)
self.fn_min_spin.setDecimals(1)
self.fn_min_spin.setSuffix(" N")
self.fn_min_spin.setValue(float(fn_min))
self.fn_max_label = QLabel()
self.fn_max_spin = QDoubleSpinBox()
self.fn_max_spin.setRange(1.0, 100.0)
@@ -91,6 +116,13 @@ class PlotSettingsDialog(QDialog):
self.shear_max_spin.setSuffix(" N")
self.shear_max_spin.setValue(float(shear_max))
self.fn_raw_min_label = QLabel()
self.fn_raw_min_spin = QDoubleSpinBox()
self.fn_raw_min_spin.setRange(-1e9, 1e9)
self.fn_raw_min_spin.setSingleStep(50000.0)
self.fn_raw_min_spin.setDecimals(0)
self.fn_raw_min_spin.setValue(float(fn_raw_min))
# Raw limits: 0 means "auto range on the data".
self.fn_raw_max_label = QLabel()
self.fn_raw_max_spin = QDoubleSpinBox()
@@ -106,22 +138,48 @@ class PlotSettingsDialog(QDialog):
self.shear_raw_max_spin.setDecimals(0)
self.shear_raw_max_spin.setValue(float(shear_raw_max))
# The display deadband: a *display* aid, per unit. It never reaches the
# store or forces.csv (see force/presentation.py).
self.deadband_n_label = QLabel()
self.deadband_n_spin = QDoubleSpinBox()
self.deadband_n_spin.setRange(0.0, 100.0)
self.deadband_n_spin.setSingleStep(0.1)
self.deadband_n_spin.setDecimals(2)
self.deadband_n_spin.setSuffix(" N")
self.deadband_n_spin.setValue(float(deadband_n))
self.deadband_raw_label = QLabel()
self.deadband_raw_spin = QDoubleSpinBox()
self.deadband_raw_spin.setRange(0.0, 1e9)
self.deadband_raw_spin.setSingleStep(5000.0)
self.deadband_raw_spin.setDecimals(0)
self.deadband_raw_spin.setValue(float(deadband_raw))
form.addRow(self.window_label, self.window_spin)
form.addRow(self.fn_min_label, self.fn_min_spin)
form.addRow(self.fn_max_label, self.fn_max_spin)
form.addRow(self.shear_max_label, self.shear_max_spin)
form.addRow(self.fn_raw_min_label, self.fn_raw_min_spin)
form.addRow(self.fn_raw_max_label, self.fn_raw_max_spin)
form.addRow(self.shear_raw_max_label, self.shear_raw_max_spin)
form.addRow(self.deadband_n_label, self.deadband_n_spin)
form.addRow(self.deadband_raw_label, self.deadband_raw_spin)
root.addLayout(form)
newton = str(units or "newton").lower().startswith("n")
raw_widgets = (
self.fn_raw_min_spin,
self.fn_raw_max_spin,
self.shear_raw_max_spin,
self.deadband_raw_spin,
self.fn_raw_min_label,
self.fn_raw_max_label,
self.shear_raw_max_label,
self.deadband_raw_label,
)
for widget in (self.fn_max_spin, self.shear_max_spin, self.fn_max_label,
self.shear_max_label):
for widget in (self.fn_min_spin, self.fn_max_spin, self.shear_max_spin,
self.deadband_n_spin, self.fn_min_label, self.fn_max_label,
self.shear_max_label, self.deadband_n_label):
widget.setEnabled(newton)
for widget in raw_widgets:
widget.setEnabled(not newton)
@@ -330,6 +388,7 @@ class PlotSettingsDialog(QDialog):
self.filter_note.setText("")
def values(self) -> tuple[float, float, float, float, float]:
"""The original five-values shape, kept for the existing caller."""
return (
float(self.window_spin.value()),
float(self.fn_max_spin.value()),
@@ -338,12 +397,41 @@ class PlotSettingsDialog(QDialog):
float(self.shear_raw_max_spin.value()),
)
def ranges(self) -> dict[str, float]:
"""Every axis limit, keyed the way the preference names it."""
return {
"window_sec": float(self.window_spin.value()),
"fn_min": float(self.fn_min_spin.value()),
"fn_max": float(self.fn_max_spin.value()),
"shear_max": float(self.shear_max_spin.value()),
"fn_raw_min": float(self.fn_raw_min_spin.value()),
"fn_raw_max": float(self.fn_raw_max_spin.value()),
"shear_raw_max": float(self.shear_raw_max_spin.value()),
}
def deadbands(self) -> dict[str, float]:
"""The per-unit display deadband floors. 0 means "auto" (estimated)."""
return {
"deadband_n": float(self.deadband_n_spin.value()),
"deadband_raw": float(self.deadband_raw_spin.value()),
}
def settings(self) -> dict[str, float]:
"""Everything the plot half of the dialog decides, in one mapping."""
return {**self.ranges(), **self.deadbands()}
def _reset_defaults(self) -> None:
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
self.fn_min_spin.setValue(DEFAULT_PLOT_FN_MIN)
self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX)
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
self.fn_raw_min_spin.setValue(DEFAULT_PLOT_FN_RAW_MIN)
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_RAW_MAX)
# "Auto" is the shipped answer for the deadband: the estimate from the
# quiet startup window, with the layer's own floor under it.
self.deadband_n_spin.setValue(DEFAULT_PLOT_DEADBAND_N)
self.deadband_raw_spin.setValue(DEFAULT_PLOT_DEADBAND_RAW)
# "Reset" means the shipped defaults, and those filter nothing — the same
# answer the dialog gives for a preference file that says nothing.
self.flow_ema_spin.setValue(1.0)
@@ -358,14 +446,22 @@ class PlotSettingsDialog(QDialog):
self.setWindowTitle(t("dialog.plot_settings_title"))
self.window_label.setText(t("dialog.plot_window"))
self.window_spin.setToolTip(t("dialog.plot_window_tip"))
self.fn_min_label.setText(t("dialog.plot_fn_min"))
self.fn_min_spin.setToolTip(t("dialog.plot_fn_min_tip"))
self.fn_max_label.setText(t("dialog.plot_fn_max"))
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
self.shear_max_label.setText(t("dialog.plot_shear_max"))
self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip"))
self.fn_raw_min_label.setText(t("dialog.plot_fn_raw_min"))
self.fn_raw_min_spin.setToolTip(t("dialog.plot_fn_raw_min_tip"))
self.fn_raw_max_label.setText(t("dialog.plot_fn_raw_max"))
self.fn_raw_max_spin.setToolTip(t("dialog.plot_fn_raw_max_tip"))
self.shear_raw_max_label.setText(t("dialog.plot_shear_raw_max"))
self.shear_raw_max_spin.setToolTip(t("dialog.plot_shear_raw_max_tip"))
self.deadband_n_label.setText(t("dialog.plot_deadband_n"))
self.deadband_n_spin.setToolTip(t("dialog.plot_deadband_n_tip"))
self.deadband_raw_label.setText(t("dialog.plot_deadband_raw"))
self.deadband_raw_spin.setToolTip(t("dialog.plot_deadband_raw_tip"))
self.btn_reset.setText(t("dialog.reset_defaults"))
self.filters_group.setTitle(t("dialog.plot_filters"))
self.filter_preset_label.setText(t("dialog.filter_preset"))
+132 -70
View File
@@ -1,8 +1,15 @@
"""Top of the studio shell: status rail and a collapsible settings area.
The rail is what an operator watches while a session runs — phase, device,
calibration, data validity and the record channel — plus the three actions that
must never be more than one click away (record, run/stop, reset).
calibration, data validity and the record channel — plus the actions that must
never be more than one click away (record, run/pause, stop, reset).
The transport is the *same* control in both views (``btn_run``): a play/pause
toggle whose checked state means "acquiring / playing and not paused". A session
that is stopped and a session that is paused both leave the button unchecked, so
the session phase — not the button — is what tells the two apart (see
``_refresh_run_label``). Stopping is its own button (``btn_stop``), because
releasing the device is a different act from pausing the computation.
Everything that is *configuration* rather than state — source, camera number,
video file, units and the sensor/config pickers — lives in the settings area,
@@ -81,7 +88,10 @@ class ToolbarWidget(QWidget):
replay_video_clicked = Signal()
#: Replay view: re-run the pipeline over that video (the only processed picture).
recompute_clicked = Signal()
#: The transport: True when a live session should acquire, or a replay play.
run_toggled = Signal(bool)
#: Stop: end the live session (releasing the device), or leave the replay.
stop_clicked = Signal()
reset_clicked = Signal()
units_changed = Signal(str)
record_toggled = Signal(bool)
@@ -109,7 +119,6 @@ class ToolbarWidget(QWidget):
self._video_path = ""
#: ``acquire`` while measuring, ``playback`` while replaying a recording.
self._run_role = "acquire"
self._reset_role = "reset"
outer = QVBoxLayout(self)
outer.setContentsMargins(2, 2, 2, 2)
@@ -242,6 +251,16 @@ class ToolbarWidget(QWidget):
self.btn_run.setCheckable(True)
self.btn_run.toggled.connect(self._on_run_toggled)
# Stop is a text button beside the transport, like Run — the icon-only pause
# it replaces cost the same rail width while saying nothing about what it
# stopped. It carries no minimum width, so the rail can shrink it at 1280
# (``tools/ui_check.py`` checks the rail for elision in both locales).
self.btn_stop = QPushButton()
self.btn_stop.setObjectName("dangerOutline")
self.btn_stop.setMinimumHeight(BUTTON_HEIGHT)
self.btn_stop.setEnabled(False)
self.btn_stop.clicked.connect(self.stop_clicked.emit)
self.btn_reset = QPushButton()
self.btn_reset.setMinimumHeight(BUTTON_HEIGHT)
self.btn_reset.setEnabled(False)
@@ -266,6 +285,7 @@ class ToolbarWidget(QWidget):
rail.addWidget(self.record_chip)
rail.addWidget(_rail(self.btn_record))
rail.addWidget(_rail(self.btn_run))
rail.addWidget(_rail(self.btn_stop))
rail.addWidget(_rail(self.btn_reset))
rail.addWidget(_rail(self.btn_settings))
return rail
@@ -386,7 +406,11 @@ class ToolbarWidget(QWidget):
self._apply_source_mode_ui()
def set_run_role(self, role: str) -> None:
"""What the primary button does in this view: acquire, or play back."""
"""What the primary button does in this view: acquire, or play back.
The same button is the transport of both views, so the *labels* follow the
role; only the words change (运行/暂停/继续 against 播放/暂停/继续).
"""
wanted = "playback" if str(role) == "playback" else "acquire"
if wanted == self._run_role:
return
@@ -397,38 +421,11 @@ class ToolbarWidget(QWidget):
self.btn_run.setChecked(False)
self.btn_run.blockSignals(False)
self._refresh_run_label()
self._refresh_stop_label()
def run_role(self) -> str:
return self._run_role
def set_reset_role(self, role: str) -> None:
"""What the second rail button does: zero the tracker, or stop the replay.
The replay view needs a *stop* the operator can always reach. The transport
button is a play/pause toggle, which is ambiguous precisely when something is
wrong (is it playing? is a recompute pass running?), so the second button —
disabled in that view otherwise — becomes an unconditional stop instead of a
second thing to reason about.
"""
wanted = "stop" if str(role) == "stop" else "reset"
if wanted == self._reset_role:
return
self._reset_role = wanted
if wanted == "reset":
self.btn_reset.setEnabled(False)
self._refresh_reset_label()
def reset_role(self) -> str:
return self._reset_role
def _refresh_reset_label(self) -> None:
if self._reset_role == "stop":
self.btn_reset.setText(t("toolbar.stop_playback"))
self.btn_reset.setToolTip(t("toolbar.stop_playback_tip"))
return
self.btn_reset.setText(t("toolbar.reset"))
self.btn_reset.setToolTip("")
def source_view(self) -> str:
return self._source_view
@@ -467,6 +464,12 @@ class ToolbarWidget(QWidget):
self.refresh_labels()
def set_run_checked(self, checked: bool) -> None:
"""Follow the session's transport state without re-emitting the signal.
The window calls this to *state* what the session is doing (a replay began,
a session ended, a recompute pass stopped). An operator's own click goes
through the button instead, and is routed by ``_on_run_toggled``.
"""
self.btn_run.blockSignals(True)
self.btn_run.setChecked(bool(checked))
self.btn_run.blockSignals(False)
@@ -474,6 +477,14 @@ class ToolbarWidget(QWidget):
if not checked:
self.btn_reset.setEnabled(False)
def set_stop_enabled(self, enabled: bool) -> None:
"""Stop is offered for as long as there is something to end.
That includes a device open that has not returned yet: the stop button is
where the cancel-a-hung-connect escape hatch lives now.
"""
self.btn_stop.setEnabled(bool(enabled))
def set_reset_enabled(self, enabled: bool) -> None:
self.btn_reset.setEnabled(bool(enabled))
@@ -517,7 +528,13 @@ class ToolbarWidget(QWidget):
self._apply_run_tooltip()
def set_run_phase(self, phase: str) -> None:
"""Track the session phase so Stop can advertise itself while connecting."""
"""Track the session phase.
The phase is half of what the transport label means: unchecked *with* a
live session is paused, unchecked without one is idle (see
``_refresh_run_label``), and ``connecting`` is what the stop button
advertises itself as cancel for.
"""
self._run_phase = str(phase or "idle")
self.refresh_labels()
@@ -531,6 +548,7 @@ class ToolbarWidget(QWidget):
"""Public refresh hook (kept separate from ``retranslate`` for reuse)."""
self._refresh_preview_label()
self._refresh_run_label()
self._refresh_stop_label()
# ------------------------------------------------------------- internals
def _populate_units_combo(self) -> None:
@@ -636,9 +654,15 @@ class ToolbarWidget(QWidget):
self.preview_toggled.emit(bool(checked))
def _on_run_toggled(self, checked: bool) -> None:
"""The transport: the label follows the button, the *state* does not.
Nothing about a session is decided here — the window routes the click (§
``_on_run_toggled`` there) and states the result back through
``set_run_checked``. Reset is deliberately left alone: unchecking now means
pause as often as it means stop, and pausing must not disable a control the
still-running session owns.
"""
self._refresh_run_label()
if not checked:
self.btn_reset.setEnabled(False)
self.run_toggled.emit(bool(checked))
def _on_units_index_changed(self, _index: int) -> None:
@@ -650,28 +674,52 @@ class ToolbarWidget(QWidget):
else:
self.btn_preview.setText(t("toolbar.preview"))
def _session_running(self) -> bool:
"""Whether a *live* session exists in this view (connecting or acquiring).
This is the third transport state the two-state button cannot carry: with
the button unchecked it is what separates "paused" from "not started".
"""
return self._run_role == "acquire" and self._run_phase in ("connecting", "running")
def _refresh_run_label(self) -> None:
if self._run_role == "playback":
# The primary action of the replay view is playing the recording —
# the same button, doing what this view is for.
# the same button, doing what this view is for. Unchecked with nothing
# loaded is idle, and the button is disabled with the reason in its tip.
self.btn_run.setText(
t("toolbar.playback_pause") if self.btn_run.isChecked() else t("toolbar.playback")
)
self._refresh_emphasis()
self._apply_run_tooltip()
return
if self.btn_run.isChecked():
# While connecting, cancel is the action the user actually needs —
# a device open can hang, and this is the way out of it.
if self._run_phase == "connecting":
self.btn_run.setText(t("toolbar.cancel_label"))
else:
self.btn_run.setText(t("toolbar.stop_label"))
elif self.btn_run.isChecked():
# checked = acquiring and not paused, so the action is Pause.
self.btn_run.setText(t("toolbar.pause"))
elif self._session_running():
# unchecked *with* a live session is the paused state: the device is
# open and the computation is stopped, so the action is Resume.
self.btn_run.setText(t("toolbar.resume_label"))
else:
self.btn_run.setText(t("toolbar.run_label"))
self._refresh_emphasis()
self._apply_run_tooltip()
def _refresh_stop_label(self) -> None:
"""Stop is one button for two situations, and its label says which.
A device open can hang, and the way out of it is this button: while the
session is still connecting it advertises itself as cancel, because that
is the act the operator needs. In the replay view it is the way out of the
replay (or a recompute pass), whatever state the transport is in.
"""
if self._run_role == "playback":
self.btn_stop.setText(t("toolbar.stop_label"))
self.btn_stop.setToolTip(t("toolbar.stop_playback_tip"))
elif self._run_phase == "connecting":
self.btn_stop.setText(t("toolbar.cancel_label"))
self.btn_stop.setToolTip(t("toolbar.cancel_tip"))
else:
self.btn_stop.setText(t("toolbar.stop_label"))
self.btn_stop.setToolTip(t("toolbar.stop"))
def _refresh_emphasis(self) -> None:
"""Exactly one *filled* button at a time.
@@ -681,31 +729,36 @@ class ToolbarWidget(QWidget):
* not recording yet → the record action is the filled primary;
* recording → stopping the recording is the filled danger;
* connecting → cancel is the filled danger (it is the way out of a hang).
* connecting → cancel (on the stop button) is the filled danger, since it
is the way out of a hang;
* a live session acquiring or paused → the transport is an outline: pause,
resume and stop all change the session's state rather than being the one
action the rail is pointing at.
The replay's transport is the exception: it *is* what that view is for, so
it is filled while it plays and an outline while it is idle.
"""
recording = self.btn_record.isChecked()
# The phase counts as running even if the button has not been repainted
# yet: the rail must describe the session, not the widget's lag.
running = self.btn_run.isChecked() or self._run_phase in ("connecting", "running")
playing = self.btn_run.isChecked()
if self._run_role == "playback":
# The transport is the replay view's own action, not a session stop:
# filled while it plays, an outline when idle, and never red.
self.btn_record.setObjectName(
"dangerButton" if recording else "primaryButton"
)
self.btn_run.setObjectName("primaryButton" if running else "ghostButton")
elif recording:
self.btn_record.setObjectName("dangerButton")
self.btn_run.setObjectName("dangerOutline")
# Never red: the replay's stop is an outline beside a filled transport.
self.btn_record.setObjectName("dangerButton" if recording else "primaryButton")
self.btn_run.setObjectName("primaryButton" if playing else "ghostButton")
self.btn_stop.setObjectName("dangerOutline")
else:
self.btn_record.setObjectName("primaryButton")
if running and self._run_phase != "connecting":
self.btn_run.setObjectName("dangerOutline")
elif running:
self.btn_run.setObjectName("dangerButton")
# The phase counts as running even if the button has not been repainted
# yet: the rail must describe the session, not the widget's lag.
connecting = self._run_phase == "connecting"
self.btn_stop.setObjectName("dangerButton" if connecting else "dangerOutline")
if recording:
self.btn_record.setObjectName("dangerButton")
self.btn_run.setObjectName("ghostButton")
else:
self.btn_run.setObjectName("primaryButton")
for button in (self.btn_record, self.btn_run):
self.btn_record.setObjectName("primaryButton")
self.btn_run.setObjectName(
"ghostButton" if playing or self._session_running() else "primaryButton"
)
for button in (self.btn_record, self.btn_run, self.btn_stop):
button.style().unpolish(button)
button.style().polish(button)
@@ -723,10 +776,12 @@ class ToolbarWidget(QWidget):
self.btn_run.setToolTip(t(key))
return
if self.btn_run.isChecked():
if self._run_phase == "connecting":
self.btn_run.setToolTip(t("toolbar.cancel_tip"))
else:
self.btn_run.setToolTip(t("toolbar.stop"))
# The tip is where the pause semantics are stated: computing stops, the
# picture holds, the device stays open — and a recording that spans the
# pause has a gap.
self.btn_run.setToolTip(t("toolbar.pause_tip"))
elif self._session_running():
self.btn_run.setToolTip(t("toolbar.resume_label_tip"))
elif not self._run_available and self._run_tooltip_reason:
self.btn_run.setToolTip(self._run_tooltip_reason)
elif not self._run_available:
@@ -737,6 +792,13 @@ class ToolbarWidget(QWidget):
tip = f"{tip} — {self._run_tooltip_reason}"
self.btn_run.setToolTip(tip)
def _refresh_reset_label(self) -> None:
self.btn_reset.setText(t("toolbar.reset"))
# The button zeroes the *device* tracker; it no longer clears the curves.
# That is now 视图 → 复位视图, because one button doing both meant an
# operator who wanted a fresh baseline also lost the history on screen.
self.btn_reset.setToolTip(t("toolbar.reset_tip"))
def retranslate(self) -> None:
self.cam_id_label.setText(t("toolbar.cam_id"))
self.btn_search.setText(t("toolbar.search_cameras"))
+3 -3
View File
@@ -279,10 +279,10 @@ class ViewLayoutController(QObject):
self._replay.pause_playback()
self._toolbar.set_source_view("replay" if preset == "replay" else "live")
# The rail's primary button is this view's main action: acquire while
# measuring, play back while replaying; and the second button is that view's
# own reset — or, in the replay view, an unconditional stop.
# measuring, play back while replaying. The button beside it is the same in
# both views — Stop, which ends a session or leaves a replay — and the
# tracker reset is simply disabled wherever there is no device session.
self._toolbar.set_run_role("playback" if preset == "replay" else "acquire")
self._toolbar.set_reset_role("stop" if preset == "replay" else "reset")
self.apply_panel_visibility()
if apply_sizes and preset in self._splitter_sizes:
self._splitter.setSizes(self.splitter_sizes_for(preset))