add session browsing, calibration diagnostics and the 2D layout preset

Phase 2 of the review: browse the whole session instead of only the rolling
window, diagnose a calibration from the recorded points, and provide the
reviewed two-panel layout.

What
- SessionStore gains extent(), visible(t0, t1, max_points) and
  statistics(t0, t1, unit). visible() walks only the blocks that overlap the
  window and decimates by taking each bucket's minimum and maximum, so a spike
  survives decimation and browsing a long session does not copy all of it.
- ForcePlotsPanel gains follow/browse modes, a whole-session overview strip
  with the current window, pan and wheel zoom, a rubber-band zoom toggle,
  a shared time cursor across all three channels, and a selection readout
  (duration, valid samples, per-axis peak/mean/std).
- CalibrationPoint records a bounded per-axis trace, an excluded flag, a
  validation flag and its fit residual. CalibrationRecorder gains
  set_excluded/set_validation/fit; the fit is a least-squares line over the
  non-excluded, non-validation points with per-point residuals and an
  independent validation error.
- The calibration table becomes the reviewed diagnostic view
  (reference load | FN | FX | FY | stability | state) with the residual shown
  next to the loaded axis, plus exclude/restore and mark-validation actions and
  a fit summary line. Double clicking a row opens the point's three-axis trace.
- Add a layout preset: free layout or 2D mode (depth map on the left, force
  charts on the right, calibration folded away), persisted in preferences.

Why
- Browsing is a display pause, not a capture pause: the record channel keeps
  running while the charts show an older window.
- Excluding a point removes it from the fit and from the exported calibration
  but keeps it on record, so the evidence for the decision is not destroyed.
- The non-loaded axes are recorded but never given a reference load: their true
  load is unknown during a single-axis calibration, so it stays empty.

Verification
- shared/tests/test_record_channel.py grows to 18 cases, covering extent,
  peak-preserving decimation, windowed reads, inverted/empty windows, unit-aware
  statistics and the time origin.
- tools/ui_check.py gains "session browsing" and "layout preset" sections
  (223 checks, 0 failures overall).
- apps/studio/tests/test_ui_smoke.py covers the same invariants.

Not in this change: synchronised playback of video, depth and curves. It needs
a decision on how depth is stored for replay (per-frame PNG, reduced-rate PNG,
or offline recomputation); the cursor plumbing (cursor_moved / set_cursor) is
already in place for it.
This commit is contained in:
ykcheungab
2026-09-13 13:34:26 +08:00
parent 817f830565
commit 92da51217c
13 changed files with 1576 additions and 75 deletions
+54 -5
View File
@@ -102,6 +102,7 @@ class StudioMainWindow(QMainWindow):
self._theme = str(self.prefs.get("theme") or "dark")
self._language = str(self.prefs.get("language") or "zh_CN")
self._show_cal = bool(self.prefs.get("show_cal_panel", False))
self._layout_preset = str(self.prefs.get("layout_preset") or "default")
self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True))
self._show_fps = bool(self.prefs.get("show_fps", False))
self._session_phase = "idle"
@@ -174,6 +175,8 @@ class StudioMainWindow(QMainWindow):
self.mesh3d_panel = Mesh3dPanel()
self.arrow_panel = ArrowPanel()
self.force_plots = ForcePlotsPanel(theme=self._theme)
# The charts browse the whole session, not just their rolling buffer.
self.force_plots.set_store(self.session_store)
self.cal_panel = CalPanel(theme=self._theme)
self.content_splitter.addWidget(self.mesh3d_panel)
self.content_splitter.addWidget(self.arrow_panel)
@@ -500,6 +503,23 @@ class StudioMainWindow(QMainWindow):
self.act_cal_panel.toggled.connect(self._on_cal_panel_toggled)
self.view_menu.addAction(self.act_cal_panel)
self.layout_group = QActionGroup(self)
self.layout_group.setExclusive(True)
self.act_layout_default = QAction(self)
self.act_layout_default.setCheckable(True)
self.act_layout_default.setChecked(self._layout_preset != "2d")
self.act_layout_default.triggered.connect(
lambda: self._on_layout_preset("default")
)
self.act_layout_2d = QAction(self)
self.act_layout_2d.setCheckable(True)
self.act_layout_2d.setChecked(self._layout_preset == "2d")
self.act_layout_2d.triggered.connect(lambda: self._on_layout_preset("2d"))
self.layout_group.addAction(self.act_layout_default)
self.layout_group.addAction(self.act_layout_2d)
self.view_menu.addAction(self.act_layout_default)
self.view_menu.addAction(self.act_layout_2d)
self.act_finger_3d = QAction(self)
self.act_finger_3d.setCheckable(True)
self.act_finger_3d.setChecked(self._show_finger_3d)
@@ -586,36 +606,62 @@ class StudioMainWindow(QMainWindow):
self.act_cal_panel.setChecked(self._show_cal)
self.act_cal_panel.blockSignals(False)
is_finger = self.sensor_picker.sensor_type() == "finger"
self.act_finger_3d.setEnabled(is_finger)
self.act_finger_3d.setEnabled(is_finger and self._layout_preset != "2d")
self.act_finger_3d.blockSignals(True)
self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False)
self.act_finger_3d.blockSignals(False)
self.act_layout_default.blockSignals(True)
self.act_layout_default.setChecked(self._layout_preset != "2d")
self.act_layout_default.blockSignals(False)
self.act_layout_2d.blockSignals(True)
self.act_layout_2d.setChecked(self._layout_preset == "2d")
self.act_layout_2d.blockSignals(False)
self._apply_arrow_rotation_for_sensor()
def _apply_panel_visibility(self) -> None:
is_finger = self.sensor_picker.sensor_type() == "finger"
show_3d = is_finger and bool(self._show_finger_3d)
show_3d = is_finger and bool(self._show_finger_3d) and self._layout_preset != "2d"
self.mesh3d_panel.setVisible(show_3d)
self.cal_panel.setVisible(self._show_cal)
self.mesh3d_panel.setMinimumWidth(MIN_PANEL_WIDTHS["mesh3d"])
self.arrow_panel.setMinimumWidth(MIN_PANEL_WIDTHS["arrow"])
self.force_plots.setMinimumWidth(MIN_PANEL_WIDTHS["plots"])
self.cal_panel.setMinimumWidth(MIN_PANEL_WIDTHS["cal"])
self.act_finger_3d.setEnabled(is_finger)
self.act_finger_3d.setEnabled(is_finger and self._layout_preset != "2d")
self.act_finger_3d.blockSignals(True)
self.act_finger_3d.setChecked(show_3d)
self.act_finger_3d.blockSignals(False)
# The 2D preset is the reviewed layout: depth map and force charts side
# by side, sharing one time axis, with calibration folded away.
arrow_weight = 3 if self._layout_preset != "2d" else 5
plots_weight = 4 if self._layout_preset != "2d" else 6
for i in range(self.content_splitter.count()):
widget = self.content_splitter.widget(i)
if widget is self.mesh3d_panel:
self.content_splitter.setStretchFactor(i, 2 if show_3d else 0)
elif widget is self.arrow_panel:
self.content_splitter.setStretchFactor(i, 3)
self.content_splitter.setStretchFactor(i, arrow_weight)
elif widget is self.force_plots:
self.content_splitter.setStretchFactor(i, 4)
self.content_splitter.setStretchFactor(i, plots_weight)
elif widget is self.cal_panel:
self.content_splitter.setStretchFactor(i, 3 if self._show_cal else 0)
def _on_layout_preset(self, preset: str) -> None:
"""Switch between the free layout and the reviewed 2D preset."""
self._layout_preset = "2d" if preset == "2d" else "default"
self.prefs["layout_preset"] = self._layout_preset
if self._layout_preset == "2d":
# Depth map on the left, force charts on the right, one timeline.
self.arrow_panel.set_view_mode("depth")
self._show_cal = False
self.prefs["show_cal_panel"] = False
self._apply_panel_visibility()
self._sync_menu_checks()
self._persist_prefs()
self.statusBar().showMessage(
t("layout.preset_2d" if self._layout_preset == "2d" else "layout.preset_default")
)
def _update_run_enabled(self) -> None:
sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES
source_ok = True
@@ -641,6 +687,8 @@ class StudioMainWindow(QMainWindow):
self.act_reveal_config.setText(t("menu.reveal_config"))
self.view_menu.setTitle(t("menu.view"))
self.act_cal_panel.setText(t("menu.calibration_panel"))
self.act_layout_default.setText(t("layout.default"))
self.act_layout_2d.setText(t("layout.preset_2d_name"))
self.act_finger_3d.setText(t("menu.finger_3d"))
self.act_show_fps.setText(t("menu.show_fps"))
self.act_plot_settings.setText(t("menu.plot_settings"))
@@ -1066,6 +1114,7 @@ class StudioMainWindow(QMainWindow):
self._dropped_reported = 0
self._depth_range_value = None
self._last_depth_frame = None
self.force_plots.set_store(self.session_store)
# A new session invalidates the previous recording.
self._stop_recording(wait=True)
self._session_counter += 1
+195 -29
View File
@@ -2,8 +2,9 @@
from __future__ import annotations
import numpy as np
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QFont, QFontDatabase
from PyQt5.QtGui import QColor, QFont, QFontDatabase
from PyQt5.QtWidgets import (
QAbstractItemView,
QButtonGroup,
@@ -35,8 +36,15 @@ from orisys_app_shared.i18n import t
from orisys_app_shared.paths import calibration_export_dir
from orisys_app_shared.theme import color
from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog
CHANNEL_LABELS = ("fn", "fx", "fy")
#: Diagnostic columns, in display order: reference load, the three axis means,
#: a stability measure and the point's state.
DIAGNOSTIC_COLUMNS = ("load", "fn", "fx", "fy", "stability", "state")
DIAGNOSTIC_COMPACT_COLUMNS = (0, 4, 5)
def _mono_font() -> str:
families = set(QFontDatabase().families())
@@ -143,7 +151,9 @@ class CalPanel(QFrame):
self.empty_hint.setWordWrap(True)
root.addWidget(self.empty_hint)
self.table = QTableWidget(0, 4)
# Diagnostic table: the loaded axis plus the two crosstalk axes, a
# stability measure and the point's state.
self.table = QTableWidget(0, len(DIAGNOSTIC_COLUMNS))
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
@@ -151,11 +161,19 @@ class CalPanel(QFrame):
self.table.setShowGrid(False)
header = self.table.horizontalHeader()
header.setSectionResizeMode(QHeaderView.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
for column in DIAGNOSTIC_COMPACT_COLUMNS:
header.setSectionResizeMode(column, QHeaderView.ResizeToContents)
self.table.verticalHeader().setVisible(False)
self.table.verticalHeader().setDefaultSectionSize(26)
self.table.itemSelectionChanged.connect(self._on_row_selected)
self.table.doubleClicked.connect(self._on_row_activated)
root.addWidget(self.table, 1)
self.fit_label = QLabel()
self.fit_label.setObjectName("hintText")
self.fit_label.setWordWrap(True)
root.addWidget(self.fit_label)
sn_row = QHBoxLayout()
self._sn_label = QLabel()
self._sn_label.setObjectName("fieldLabel")
@@ -170,16 +188,28 @@ class CalPanel(QFrame):
self.btn_delete = QPushButton()
self.btn_clear_channel = QPushButton()
self.btn_clear_all = QPushButton()
self.btn_toggle_excluded = QPushButton()
self.btn_toggle_validation = QPushButton()
self.btn_export = QPushButton()
self.btn_export.setObjectName("primaryButton")
for button in (self.btn_delete, self.btn_clear_channel, self.btn_clear_all):
for button in (
self.btn_delete,
self.btn_clear_channel,
self.btn_clear_all,
self.btn_toggle_excluded,
self.btn_toggle_validation,
):
# The action row must survive a narrow three-panel layout.
button.setProperty("compact", "true")
button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.btn_toggle_excluded.clicked.connect(self._on_toggle_excluded)
self.btn_toggle_validation.clicked.connect(self._on_toggle_validation)
self.btn_delete.clicked.connect(self._on_delete_row)
self.btn_clear_channel.clicked.connect(self._on_clear_channel)
self.btn_clear_all.clicked.connect(self._on_clear_all)
self.btn_export.clicked.connect(self._on_export)
actions.addWidget(self.btn_toggle_excluded)
actions.addWidget(self.btn_toggle_validation)
actions.addWidget(self.btn_delete)
actions.addWidget(self.btn_clear_channel)
actions.addWidget(self.btn_clear_all)
@@ -230,20 +260,14 @@ class CalPanel(QFrame):
def add_point(self, point) -> None:
"""Record a finished calibration point (three axes captured)."""
stats = point.loaded_stats()
sample = CalSample(
weight_n=float(point.reference_load_n),
raw_mean=float(stats.mean),
n=int(stats.n),
std=float(stats.std),
)
self._store.upsert(point.channel, sample)
self._rebuild_store()
self._refresh_table()
stats = point.loaded_stats()
if point.status == "few_samples":
self.status_message.emit(t("cal.record_failed_few"))
return
self.status_message.emit(
t("cal.record_ok", weight=f"{sample.weight_n:g}", raw=f"{sample.raw_mean:.3f}")
t("cal.record_ok", weight=f"{point.reference_load_n:g}", raw=f"{stats.mean:.3f}")
)
def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None:
@@ -261,14 +285,21 @@ class CalPanel(QFrame):
self.btn_clear_channel.setText(t("cal.clear_channel"))
self.btn_clear_all.setText(t("cal.clear_all"))
self.btn_export.setText(t("cal.export"))
self.btn_export.setText(t("cal.export"))
self.btn_toggle_excluded.setText(t("cal.state_exclude"))
self.btn_toggle_validation.setText(t("cal.mark_validation"))
self.btn_toggle_excluded.setToolTip(t("cal.state_exclude_tip"))
self.btn_toggle_validation.setToolTip(t("cal.mark_validation_tip"))
self._sn_label.setText(t("cal.sn"))
self.sn_edit.setPlaceholderText(t("cal.sn_placeholder"))
self.empty_hint.setText(t("cal.empty_hint"))
headers = (
t("cal.col_weight"),
t("cal.col_raw"),
t("cal.col_n"),
t("cal.col_std"),
t("cal.col_fn"),
t("cal.col_fx"),
t("cal.col_fy"),
t("cal.col_stability"),
t("cal.col_state"),
)
for i, text in enumerate(headers):
item = self.table.horizontalHeaderItem(i)
@@ -280,6 +311,7 @@ class CalPanel(QFrame):
self._refresh_channel_badge()
self._render_live_label()
self._refresh_enabled()
self._refresh_table()
# ------------------------------------------------------------- internals
def _refresh_channel_badge(self) -> None:
@@ -393,39 +425,173 @@ class CalPanel(QFrame):
self._recorder.requested_duration_s,
)
# ------------------------------------------------------------ diagnostics
def _diagnostic_points(self):
"""The recorder's points for the active channel, or the export store."""
if self._recorder is not None:
return self._recorder.points_for(self._channel)
return []
def _current_point(self):
row = self.table.currentRow()
points = self._diagnostic_points()
if 0 <= row < len(points):
return points[row]
return None
def _refresh_table(self) -> None:
rows = self._store.channel(self._channel)
self.table.setRowCount(len(rows))
for r, sample in enumerate(rows):
points = self._diagnostic_points()
self.table.setRowCount(len(points))
for row, point in enumerate(points):
values = (
f"{sample.weight_n:g}",
f"{sample.raw_mean:.4f}",
str(sample.n),
f"{sample.std:.4f}",
f"{point.reference_load_n:g}",
self._axis_cell(point, "fn"),
self._axis_cell(point, "fx"),
self._axis_cell(point, "fy"),
self._stability_cell(point),
t(point.state_key()),
)
for c, text in enumerate(values):
for column, text in enumerate(values):
item = QTableWidgetItem(text)
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
if c != 2:
if column in (0, 1, 2, 3):
item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter))
else:
item.setTextAlignment(int(Qt.AlignCenter))
self.table.setItem(r, c, item)
self.empty_hint.setVisible(not rows)
if point.excluded:
item.setForeground(QColor(color(self._theme, "text_muted")))
elif point.is_validation:
item.setForeground(QColor(color(self._theme, "info")))
self.table.setItem(row, column, item)
self.empty_hint.setVisible(not points)
self._refresh_fit_label()
@staticmethod
def _axis_cell(point, channel: str) -> str:
value = point.axis_mean(channel)
if not np.isfinite(value):
return "—"
if channel == point.channel and np.isfinite(point.residual_n):
# Residual of the fit on the same cell as the value it came from.
return f"{value:.4f} ({point.residual_n:+.4f})"
return f"{value:.4f}"
@staticmethod
def _stability_cell(point) -> str:
stats = point.loaded_stats()
if not np.isfinite(stats.std):
return "—"
return f"σ {stats.std:.4f} / n {stats.n}"
def _refresh_fit_label(self) -> None:
if self._recorder is None:
self.fit_label.setText("")
return
fit = self._recorder.fit(self._channel)
if not fit.usable:
self.fit_label.setText(t("cal.fit_insufficient", count=fit.n_points))
return
text = t(
"cal.fit_summary",
channel=self._channel.upper(),
gain=f"{fit.gain:g}",
offset=f"{fit.offset:g}",
count=fit.n_points,
residual=f"{fit.max_residual_n:.4f}",
)
if fit.validation_points:
text += " " + t(
"cal.fit_validation",
error=f"{fit.validation_error_n:.4f}",
count=fit.validation_points,
)
self.fit_label.setText(text)
def _on_row_selected(self) -> None:
point = self._current_point()
self.btn_toggle_excluded.setEnabled(point is not None)
self.btn_toggle_validation.setEnabled(point is not None)
if point is not None:
state = t("cal.state_restore") if point.excluded else t("cal.state_exclude")
self.btn_toggle_excluded.setText(state)
def _on_row_activated(self) -> None:
"""Double click a row to inspect that point's three-axis curve."""
point = self._current_point()
if point is None or not point.trace:
return
CalibrationPointDialog(point, theme=self._theme, parent=self).exec_()
def _on_toggle_excluded(self) -> None:
row = self.table.currentRow()
point = self._current_point()
if point is None:
return
self._recorder.set_excluded(row, not point.excluded)
self._rebuild_store()
self._refresh_table()
self.status_message.emit(
t("cal.excluded" if point.excluded else "cal.restored")
)
def _on_toggle_validation(self) -> None:
row = self.table.currentRow()
point = self._current_point()
if point is None:
return
self._recorder.set_validation(row, not point.is_validation)
self._rebuild_store()
self._refresh_table()
self.status_message.emit(
t("cal.marked_validation" if point.is_validation else "cal.unmarked_validation")
)
def _rebuild_store(self) -> None:
"""Rebuild the export store from the points that are still in the fit."""
self._store.clear()
if self._recorder is None:
return
for point in self._recorder.points:
if point.excluded:
continue
stats = point.loaded_stats()
if not np.isfinite(stats.mean):
continue
self._store.upsert(
point.channel,
CalSample(
weight_n=float(point.reference_load_n),
raw_mean=float(stats.mean),
n=int(stats.n),
std=float(stats.std),
),
)
def _on_delete_row(self) -> None:
row = self.table.currentRow()
if row < 0:
point = self._current_point()
if point is None or self._recorder is None:
return
self._store.remove_at(self._channel, row)
# Deleting is an exclusion: the recorded evidence stays, but it leaves
# the fit and the export.
self._recorder.set_excluded(row, True)
self._rebuild_store()
self._refresh_table()
def _on_clear_channel(self) -> None:
self._store.clear(self._channel)
if self._recorder is not None:
with self._recorder._lock: # noqa: SLF001 - panel owns the view
self._recorder.points = [
p for p in self._recorder.points if p.channel != self._channel
]
self._refresh_table()
def _on_clear_all(self) -> None:
self._store.clear()
if self._recorder is not None:
with self._recorder._lock: # noqa: SLF001
self._recorder.points = []
self._refresh_table()
def _on_export(self) -> None:
@@ -0,0 +1,147 @@
"""Inspect one calibration point: its three-axis trace and its diagnostics.
Kept in a dialog rather than inline so the calibration panel can stay narrow in
the three-panel layout, while a single point can still be examined in full.
"""
from __future__ import annotations
import numpy as np
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont, QFontDatabase
from PyQt5.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QLabel,
QVBoxLayout,
)
from orisys_app_shared.force import NA
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
try:
import pyqtgraph as pg
except ImportError as exc: # pragma: no cover
raise ImportError("pyqtgraph is required for the calibration point dialog") from exc
TRACE_TOKENS = ("plot_fn", "plot_fx", "plot_fy")
TRACE_KEYS = ("fn", "fx", "fy")
def _mono_font(point_size: int = 9) -> QFont:
families = set(QFontDatabase().families())
chosen = "monospace"
for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"):
if name in families:
chosen = name
break
return QFont(chosen, point_size)
class CalibrationPointDialog(QDialog):
"""One recorded point: all three raw traces plus its fit residual."""
def __init__(self, point, *, theme: str = "dark", parent=None) -> None:
super().__init__(parent)
self._point = point
self._theme = theme
self.setWindowTitle(
t(
"cal.point_title",
channel=point.channel.upper(),
load=f"{point.reference_load_n:g}",
)
)
self.resize(560, 420)
root = QVBoxLayout(self)
root.setSpacing(8)
form = QFormLayout()
form.setHorizontalSpacing(10)
form.setVerticalSpacing(4)
self._add_row(form, "cal.point_channel", point.channel.upper())
self._add_row(form, "cal.point_direction", point.direction)
self._add_row(form, "cal.point_round", str(point.round_index))
self._add_row(
form,
"cal.point_duration",
t(
"cal.point_duration_value",
actual=f"{point.duration_s:.2f}",
requested=f"{point.requested_duration_s:.2f}",
),
)
self._add_row(
form,
"cal.point_samples",
t("cal.point_samples_value", valid=point.n_valid, total=point.n_total),
)
stats = []
for key in TRACE_KEYS:
axis = point.raw.get(key)
if axis is None or not np.isfinite(axis.mean):
stats.append(f"{key.upper()} —")
continue
stats.append(f"{key.upper()} {axis.mean:.4f} ± {axis.std:.4f}")
value = QLabel(" ".join(stats))
value.setFont(_mono_font())
self._add_row(form, "cal.point_axes", "")
form.addRow(value)
residual = point.residual_n
self._add_row(
form,
"cal.point_residual",
"—" if not np.isfinite(residual) else f"{residual:+.4f}",
)
root.addLayout(form)
self.plot_widget = pg.GraphicsLayoutWidget()
self.plot_widget.setBackground(color(theme, "plot_bg"))
root.addWidget(self.plot_widget, 1)
self._build_traces()
buttons = QDialogButtonBox(QDialogButtonBox.Close)
buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept)
root.addWidget(buttons)
def _add_row(self, form: QFormLayout, key: str, value: str) -> None:
label = QLabel(t(key))
label.setObjectName("fieldLabel")
field = QLabel(str(value))
form.addRow(label, field)
def _build_traces(self) -> None:
"""One row per axis, stacked, sharing the sample axis."""
pen_axis = pg.mkPen(color(self._theme, "text_secondary"))
first = None
for row, key in enumerate(TRACE_KEYS):
plot = self.plot_widget.addPlot(row=row, col=0)
plot.showGrid(x=True, y=True, alpha=0.25)
plot.setMouseEnabled(x=True, y=False)
plot.getAxis("left").setPen(pen_axis)
plot.getAxis("left").setTextPen(pen_axis)
for axis_name in ("left", "bottom"):
plot.getAxis(axis_name).setPen(pen_axis)
plot.getAxis(axis_name).setTextPen(pen_axis)
plot.setLabel(
"left", key.upper(), color=color(self._theme, TRACE_TOKENS[row])
)
if first is None:
first = plot
else:
plot.setXLink(first)
self.plot_widget.ci.layout.setRowStretchFactor(row, 1)
values = self._point.trace.get(key)
if not values:
continue
step = self._point.trace_dt_s or 0.01
times = np.arange(len(values), dtype=float) * step
plot.plot(
times,
np.asarray(values, dtype=float),
pen=pg.mkPen(color(self._theme, TRACE_TOKENS[row]), width=2),
)
+363 -24
View File
@@ -2,10 +2,18 @@
from __future__ import annotations
import time
import numpy as np
from PyQt5.QtCore import Qt
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QColor, QFont, QFontDatabase
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout
from PyQt5.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
)
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
from orisys_app_shared.force import MetricHistory
@@ -23,6 +31,8 @@ CHANNEL_KEYS = ("fn", "fx", "fy")
CHANNEL_LABELS = ("FN", "FX", "FY")
# Theme token that colors each channel curve / label, per index.
CHANNEL_TOKENS = ("plot_fn", "plot_fx", "plot_fy")
#: How often the whole-session overview strip is rebuilt while following live.
OVERVIEW_REFRESH_S = 1.0
def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
@@ -56,7 +66,29 @@ def _mono_font(point_size: int = 9, *, bold: bool = False) -> QFont:
return font
class _Sync:
"""Marks a block of range changes as programmatic (not a user gesture)."""
def __init__(self, panel: "ForcePlotsPanel") -> None:
self._panel = panel
self._previous = panel._syncing
def __enter__(self) -> "_Sync":
self._panel._syncing = True
return self
def __exit__(self, *_exc) -> None:
self._panel._syncing = self._previous
class ForcePlotsPanel(QFrame):
#: The visible time window changed while browsing history.
window_changed = pyqtSignal(float, float)
#: The shared cursor moved; other views sync their playback position to it.
cursor_moved = pyqtSignal(float)
#: "follow" (live) or "browse" (user-controlled window).
mode_changed = pyqtSignal(str)
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
@@ -68,6 +100,15 @@ class ForcePlotsPanel(QFrame):
self._fixed_newton = True
self._unit = "raw"
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
# -- browsing state -------------------------------------------------
self._store = None
self._mode = "follow"
self._window: tuple[float, float] = (0.0, 0.0)
self._cursor_t = 0.0
self._syncing = False
self._overview_dirty = True
self._last_overview_at = 0.0
self._max_draw_points = 2000
self.history = MetricHistory(
channels=3,
window_sec=4.0,
@@ -86,7 +127,23 @@ class ForcePlotsPanel(QFrame):
self.title = QLabel()
self.title.setObjectName("panelTitle")
header.addWidget(self.title)
self.mode_label = QLabel()
self.mode_label.setObjectName("hintText")
header.addWidget(self.mode_label)
header.addStretch(1)
self.btn_latest = QPushButton()
self.btn_latest.setProperty("compact", "true")
self.btn_latest.clicked.connect(self.follow_latest)
header.addWidget(self.btn_latest)
self.btn_overview = QPushButton()
self.btn_overview.setProperty("compact", "true")
self.btn_overview.clicked.connect(self.show_all)
header.addWidget(self.btn_overview)
self.btn_box_zoom = QPushButton()
self.btn_box_zoom.setProperty("compact", "true")
self.btn_box_zoom.setCheckable(True)
self.btn_box_zoom.toggled.connect(self._on_box_zoom_toggled)
header.addWidget(self.btn_box_zoom)
self.unit_badge = QLabel()
self.unit_badge.setObjectName("unitBadge")
header.addWidget(self.unit_badge)
@@ -103,6 +160,7 @@ class ForcePlotsPanel(QFrame):
self.plots = []
self.curves = []
self.cursors = []
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)
@@ -111,8 +169,40 @@ class ForcePlotsPanel(QFrame):
if i > 0:
plot.setXLink(self.plots[0])
curve = plot.plot(pen=pen)
# One shared time cursor across all three channels.
cursor = pg.InfiniteLine(
pos=0.0, angle=90, movable=False, pen=pg.mkPen("#888888", width=1)
)
plot.addItem(cursor, ignoreBounds=True)
plot.getViewBox().sigXRangeChanged.connect(self._on_x_range_changed)
self.plots.append(plot)
self.curves.append(curve)
self.cursors.append(cursor)
# Whole-session overview: the full envelope plus the current window.
self.overview = self.plot_widget.addPlot(row=len(CHANNEL_LABELS), col=0)
self.overview.setMouseEnabled(x=False, y=False)
self.overview.showGrid(x=True, y=False, alpha=0.2)
self.overview.setMaximumHeight(46)
self.overview.getAxis("left").setStyle(showValues=False)
self.overview_curve = self.overview.plot(pen=pg.mkPen("#8A93A6", width=1))
self.overview_region = pg.LinearRegionItem(
values=(0.0, 1.0), movable=False, brush=(90, 110, 190, 60)
)
self.overview_region.setZValue(-10)
self.overview.addItem(self.overview_region)
self.overview_cursor = pg.InfiniteLine(
pos=0.0, angle=90, movable=False, pen=pg.mkPen("#888888", width=1)
)
self.overview.addItem(self.overview_cursor, ignoreBounds=True)
for row in range(len(CHANNEL_LABELS)):
self.plot_widget.ci.layout.setRowStretchFactor(row, 3)
self.plot_widget.ci.layout.setRowStretchFactor(len(CHANNEL_LABELS), 1)
self.stats_label = QLabel()
self.stats_label.setObjectName("panelMeta")
self.stats_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
root.addWidget(self.stats_label)
values_row = QHBoxLayout()
values_row.setSpacing(6)
@@ -130,6 +220,7 @@ class ForcePlotsPanel(QFrame):
root.addLayout(values_row)
self.set_theme(theme)
self._interactive(False)
self._apply_axis_mode()
self.retranslate()
@@ -223,8 +314,17 @@ class ForcePlotsPanel(QFrame):
self._clear_curves()
self._last_values = (0.0, 0.0, 0.0)
self._unit = "raw"
self._mode = "follow"
self._window = (0.0, 0.0)
self._cursor_t = 0.0
self._overview_dirty = True
self._interactive(False)
self.set_cursor(0.0)
self.overview_curve.setData([], [])
self.stats_label.setText("")
self._render_value_cards()
self._apply_axis_mode()
self._refresh_mode_label()
def push(
self,
@@ -241,26 +341,51 @@ class ForcePlotsPanel(QFrame):
# store; a bare change here would silently reinterpret old samples.
self._unit = unit
self._last_values = (float(fn), float(fx), float(fy))
self._render_value_cards()
if self._mode == "browse":
# Browsing is a display pause, not a capture pause: the record
# channel keeps running and the view simply stops following.
self._overview_dirty = True
return
use_fixed = self._uses_fixed_y()
# Store the true value. Clamping here (the old ``max(0.0, fn)``) erased
# negative FN from the buffer and hid zero drift; out-of-range values are
# now handled by the axis range, which only affects what is drawn.
self.history.push(timestamp, float(fn), float(fx), float(fy))
self._render_value_cards()
state = self.history.state()
if state is None:
return
values = state.values.copy()
for i, curve in enumerate(self.curves):
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
if use_fixed:
ymin, ymax = self._fixed_y_range(i)
self.plots[i].setYRange(ymin, ymax, padding=0.0)
self.plots[i].getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
else:
self.plots[i].getAxis("left").setTicks(None)
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
self._syncing = True
try:
values = state.values.copy()
for i, curve in enumerate(self.curves):
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
if use_fixed:
ymin, ymax = self._fixed_y_range(i)
self.plots[i].setYRange(ymin, ymax, padding=0.0)
self.plots[i].getAxis("left").setTicks(
[_nice_ticks(ymin, ymax, count=5)]
)
else:
self.plots[i].getAxis("left").setTicks(None)
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
finally:
self._syncing = False
self.set_cursor(float(timestamp) - (self._time_origin() or 0.0))
# The overview is a full-store read, so refresh it on a timer rather
# than on every frame.
now = time.perf_counter()
if self._overview_dirty or (now - self._last_overview_at) >= OVERVIEW_REFRESH_S:
self._overview_dirty = False
self._last_overview_at = now
self._sync_overview()
def _time_origin(self) -> float:
"""The store's time origin, so the cursor matches the overview axis."""
if self._store is None:
return 0.0
return self._store.origin_time()
def set_units(self, unit: str, store=None) -> None:
"""Switch display units without mixing units in the curve buffer.
@@ -275,11 +400,216 @@ class ForcePlotsPanel(QFrame):
self._unit = unit
self.history.clear()
self._clear_curves()
self._overview_dirty = True
if store is not None:
self._reload_from_store(store)
self._store = store
if self._mode == "browse":
self.refresh_from_store()
else:
self._reload_from_store(store)
self._render_value_cards()
self._apply_axis_mode()
# ---------------------------------------------------------------- browsing
def set_store(self, store) -> None:
"""Attach the whole-session store the browser reads from."""
self._store = store
self._overview_dirty = True
self._refresh_mode_label()
def mode(self) -> str:
return self._mode
def cursor_time(self) -> float:
return self._cursor_t
def window(self) -> tuple[float, float]:
if self._mode == "browse":
return self._window
if self._store is not None:
return self._store.extent()
return (0.0, 0.0)
def follow_latest(self) -> None:
"""Leave history browsing and resume the live rolling window."""
self._set_mode("follow")
self._apply_axis_mode()
def show_all(self) -> None:
"""Overview: fit the entire session, in browse mode."""
if self._store is None:
return
first, last = self._store.extent()
if last <= first:
return
self._set_mode("browse")
self.set_window(first, last)
def set_window(self, t0: float, t1: float) -> None:
"""Show ``[t0, t1]`` from the whole-session store."""
if self._store is None:
return
first, last = self._store.extent()
if t1 <= t0:
t1 = t0 + max(0.001, self.history.window_sec)
t0 = max(first, float(t0))
t1 = min(max(last, first + 0.001), float(t1))
self._set_mode("browse")
self._window = (t0, t1)
self.refresh_from_store()
def refresh_from_store(self) -> None:
"""Redraw the visible window. Called on pan, zoom and data changes."""
if self._store is None:
return
t0, t1 = self._window
slice_ = self._store.visible(t0, t1, max_points=self._max_draw_points)
self._syncing = True
try:
if len(slice_) == 0:
for curve in self.curves:
curve.setData([], [])
else:
values = slice_.value_for_unit(self._unit)
for index, curve in enumerate(self.curves):
column = values[:, index]
finite = np.isfinite(column)
curve.setData(slice_.times[finite], column[finite])
for plot in self.plots:
plot.setXRange(t0, t1, padding=0.0)
self._apply_axis_mode()
finally:
self._syncing = False
self._update_selection_readout()
self._sync_overview()
self.window_changed.emit(t0, t1)
def _set_mode(self, mode: str) -> None:
mode = "browse" if str(mode) == "browse" else "follow"
if mode == self._mode:
return
self._mode = mode
self._interactive(mode == "browse")
self._refresh_mode_label()
self._sync_overview()
self.mode_changed.emit(mode)
def _interactive(self, enabled: bool) -> None:
"""Pan/zoom is only meaningful over a stored window."""
for plot in self.plots:
plot.setMouseEnabled(x=bool(enabled), y=False)
if not enabled:
plot.getViewBox().setMouseMode(pg.ViewBox.PanMode)
def _on_box_zoom_toggled(self, checked: bool) -> None:
mode = pg.ViewBox.RectMode if checked else pg.ViewBox.PanMode
if not checked or self._mode != "browse":
self._set_mode("browse" if checked else self._mode)
for plot in self.plots:
plot.getViewBox().setMouseMode(mode)
def _on_x_range_changed(self, _viewbox, x_range) -> None:
"""User scrolled or dragged: leave live mode and read from the store.
Programmatic range changes are ignored via ``self._syncing`` — without
that, redrawing a window would feed back in as if the user had dragged,
and "back to latest" could never take effect.
"""
if self._syncing or self._store is None:
return
x0, x1 = float(x_range[0]), float(x_range[1])
if not (x1 > x0):
return
first, last = self._store.extent()
if last <= first:
return
# Keep the window inside the recorded session.
x0 = max(first, x0)
x1 = min(last, x1)
if not (x1 > x0):
return
if self._mode != "browse":
self._set_mode("browse")
self._window = (x0, x1)
self.refresh_from_store()
def _guard(self) -> "_Sync":
"""Context manager marking range changes as programmatic."""
return _Sync(self)
def set_cursor(self, t: float, *, emit: bool = False) -> None:
"""Move the shared time cursor without redrawing the data."""
self._cursor_t = float(t)
for cursor in self.cursors:
cursor.setValue(self._cursor_t)
self.overview_cursor.setValue(self._cursor_t)
if emit:
self.cursor_moved.emit(self._cursor_t)
def _update_selection_readout(self) -> None:
if self._store is None:
self.stats_label.setText("")
return
t0, t1 = self._window
stats = self._store.statistics(t0, t1, unit=self._unit)
if stats.samples == 0:
self.stats_label.setText(t("chart.selection_empty"))
return
unit = unit_display(self._unit)
parts = []
for index, label in enumerate(CHANNEL_LABELS):
peak = stats.peaks[index]
mean = stats.means[index]
std = stats.stds[index]
if not np.isfinite(peak):
continue
parts.append(
f"{label} 峰 {peak:.3g}{unit} · 均 {mean:.3g} · σ {std:.3g}"
)
self.stats_label.setText(
t(
"chart.selection",
duration=f"{stats.duration_s:.2f}",
samples=stats.samples,
detail=" ".join(parts),
)
)
def _sync_overview(self) -> None:
if self._store is None:
return
first, last = self._store.extent()
if last <= first:
self.overview_curve.setData([], [])
return
# The overview is a full-store read, so only rebuild it when it can have
# changed (window/unit/session) rather than on every pan.
slice_ = self._store.visible(first, last, max_points=self._overview_points())
values = slice_.value_for_unit(self._unit)
column = values[:, 0]
finite = np.isfinite(column)
self.overview_curve.setData(slice_.times[finite], column[finite])
self.overview.setXRange(first, last, padding=0.0)
if self._mode == "browse":
self.overview_region.setRegion(self._window)
self.overview_region.show()
else:
self.overview_region.hide()
self.overview.setYRange(
float(np.nanmin(column[finite])) if finite.any() else 0.0,
float(np.nanmax(column[finite])) if finite.any() else 1.0,
padding=0.05,
)
def _overview_points(self) -> int:
return 1200
def _refresh_mode_label(self) -> None:
key = "chart.mode_browse" if self._mode == "browse" else "chart.mode_follow"
self.mode_label.setText(t(key))
self.btn_latest.setEnabled(self._mode == "browse")
self.btn_overview.setEnabled(self._store is not None)
def _reload_from_store(self, store) -> None:
"""Re-populate the visible buffer from the whole-session store."""
window = self.history.window_sec
@@ -337,15 +667,17 @@ class ForcePlotsPanel(QFrame):
def _apply_axis_mode(self) -> None:
use_fixed = self._preview_fixed_y()
for i, plot in enumerate(self.plots):
if use_fixed:
ymin, ymax = self._fixed_y_range(i)
plot.setYRange(ymin, ymax, padding=0.0)
plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
else:
plot.getAxis("left").setTicks(None)
for plot in self.plots:
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
with self._guard():
for i, plot in enumerate(self.plots):
if use_fixed:
ymin, ymax = self._fixed_y_range(i)
plot.setYRange(ymin, ymax, padding=0.0)
plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
else:
plot.getAxis("left").setTicks(None)
if self._mode == "follow":
for plot in self.plots:
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
def _clear_curves(self) -> None:
for curve in self.curves:
@@ -364,4 +696,11 @@ class ForcePlotsPanel(QFrame):
self.title.setText(t("panels.plots"))
for badge, label in zip(self.channel_badges, CHANNEL_LABELS):
badge.setText(label)
self.btn_latest.setText(t("chart.return_latest"))
self.btn_latest.setToolTip(t("chart.return_latest_tip"))
self.btn_overview.setText(t("chart.overview"))
self.btn_overview.setToolTip(t("chart.overview_tip"))
self.btn_box_zoom.setText(t("chart.box_zoom"))
self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip"))
self._refresh_mode_label()
self._render_value_cards()