采集循环只认新帧,选区统计移到数值卡,状态栏改为会过期的通知行 - What: 1) 选区统计不再挤在曲线下方(中文长句会顶出面板右边界)——框选后三个数值卡的大号数字变成该窗口的峰值、下方小字为该窗口的均值·σ(chart.selection_detail),峰值语义放在卡片 tooltip;曲线下方只保留「选区 4.00s / 121 点」(完整一行留在它的 tooltip 里)。「全程总览」不算选区(那是默认适配),此时卡片回到"游标处样本";切换单位/换数据源/返回最新都会正确切回。2) 上方的 63.0 FPS 是采集循环自己的轮询速率,根因是同一帧被反复当成新样本:CaptureSource.read_latest 返回后台线程的最新帧,没有新帧时把同一帧再给一次,循环未做判断,于是 30 fps 的设备录出约 63 行/秒(frame_id 还各不相同)。新增 _capture_sequence():读 capture_source.status.last_seq,计数未变即"还没有新帧",睡 2 ms 重试、不进处理链、不发 packet;只有带后台读取线程的实时相机有该标记,视频文件是同步解码、每次都取下一帧,因此忽略(行为不变)。packet.fps 改为速率 (窗口帧数-1)/(首末到达差),窗口 1 s——按"1 秒内到达几帧"数数会系统性偏大。容器速率仍取传感器配置值,与此层无关。3) 底部状态栏只留一次性通知:删除右下角常驻「配置 sensor.json」(顶栏摘要按钮已写明),删除与顶栏重复的 status.run_started(运行中(0))与 status.connecting(正在连接…);QStatusBar 换成 _NoticeBar——Qt 的 showMessage(text) 默认 msecs=0 表示永不消失,这正是那句「运行中(0)」一直挂到底部的原因,子类统一补 6 秒超时;status.units_raw_fallback 之前每帧都发(等于永久占住通知行),改为每次运行只提示一次。附带修复:英文下表头模式名被截断(长句移入 tooltip,标签只留模式名 chart.mode_*_tip);数据源切换后曲线下方的选区文案会残留,改为在 _on_source_changed 里清零。 - Why: 用户评审 3 点——选区统计显示不全、顶部 fps 应受硬件限制最大 30、右下角配置文件名与左下角「运行中(0)」和顶栏信息重复。第 2 点查出根因是重复采样(数据缺陷,不只是显示问题),修掉后设备速率与记录行数才真正一致。 - Verification: ui_check.py 546 项 0 失败(新增:选区统计在卡片、卡片小字不裁切、状态栏无常驻标签且消息过期、顶栏 FPS 即 packet 交付速率、表头模式名中英不裁切);test_ui_smoke.py 71 项 0 失败(新增选区读数与通知行过期两例);drive_shared.py 148 项 0 失败(新增 shared/tests/test_worker_frame_rate.py:同一帧返回 3 次时 packet 数≈交付帧数,去掉判据为 11317:39,且 fps 与交付速率一致);locale_studio.py 267 键齐全、zh==en、prefs 对齐;reproduce.py 退出 0;ui_snapshot.py 新增 --select A,B,六张复核图(选区中英、实时、录制中、回放、标定 1280x720)。 - Not in this change: 录制文件格式与 CSV 列序不变;去重只影响"哪些帧算一帧",视频文件源行为不变;曲线下方不再显示三通道峰/均/σ(数值在卡片上,完整一行在 tooltip 里)。

This commit is contained in:
ykcheungab
2026-09-13 21:19:23 +08:00
parent 824544fc7a
commit e6a1e77a5b
13 changed files with 783 additions and 66 deletions
+48 -23
View File
@@ -112,6 +112,22 @@ def _reveal_in_explorer(path: Path) -> None:
subprocess.run(["xdg-open", str(folder)], check=False)
class _NoticeBar(QStatusBar):
"""A status bar whose messages expire.
Everything that is a *state* — the phase, the source, the calibration, whether
the data is valid — is read from the toolbar's rail. This line is for one-off
notices, so a message with no timeout is a defect: it is how an hour-old
"运行中(0)" was still sitting under a stopped window. Any caller that asks for
a permanent message gets ``DEFAULT_TIMEOUT_MS`` instead.
"""
DEFAULT_TIMEOUT_MS = 6000
def showMessage(self, text: str, timeout: int = 0) -> None: # noqa: N802 - Qt name
super().showMessage(str(text or ""), int(timeout) or self.DEFAULT_TIMEOUT_MS)
class StudioMainWindow(QMainWindow):
def __init__(self, prefs: dict[str, Any] | None = None) -> None:
super().__init__()
@@ -127,6 +143,8 @@ class StudioMainWindow(QMainWindow):
self._running = False
self._connect_started_at = 0.0
self._connect_slow_notified = False
#: Whether this run has already said "showing raw, nothing is calibrated".
self._raw_fallback_notified = False
self._preview = None
self._preview_timer = QTimer(self)
self._preview_timer.setInterval(33)
@@ -226,13 +244,12 @@ class StudioMainWindow(QMainWindow):
self.replay_bar.setVisible(False)
root.addWidget(self.replay_bar)
self.setStatusBar(QStatusBar())
# The phase/record state lives in the toolbar's rail; the status bar is
# for transient messages plus one permanent line of device identity.
self.device_label = QLabel()
self.device_label.setObjectName("panelMeta")
self.statusBar().addPermanentWidget(self.device_label)
self.cal_panel.status_message.connect(self.statusBar().showMessage)
self.setStatusBar(_NoticeBar())
# The status bar carries **one-off notices** only (a recording finished, a
# calibration was applied, a failure). The phase, the source and the
# validity of the data are already on the toolbar's rail, so repeating
# them here only put stale text under the window for the whole session.
self.cal_panel.status_message.connect(self._notify)
self.cal_panel.calibration_exported.connect(self._on_calibration_exported)
self.cal_panel.apply_requested.connect(self._on_calibration_apply)
self._set_phase("idle")
@@ -1058,10 +1075,18 @@ class StudioMainWindow(QMainWindow):
self._refresh_status_chip()
self._refresh_status_rail()
self._refresh_record_state()
if not self._preview_open and not self._running:
self.statusBar().showMessage(t("status.ready"))
self._update_run_enabled()
#: How long a one-off notice stays on the status bar (see ``_NoticeBar``).
NOTICE_MS = _NoticeBar.DEFAULT_TIMEOUT_MS
def _notify(self, text: str, ms: int = NOTICE_MS) -> None:
"""Show a one-off notice on the status bar and let it expire."""
message = str(text or "")
if not message:
return
self.statusBar().showMessage(message, int(ms))
def _set_phase(self, phase: str) -> None:
"""Track the acquisition phase and reflect it in the status chip."""
self._session_phase = phase if phase in SESSION_PHASES else "idle"
@@ -1117,7 +1142,6 @@ class StudioMainWindow(QMainWindow):
self.toolbar.set_data_state(
t("status.data_invalid", count=self._record_error_count), "warning"
)
self._refresh_device_label()
def _calibration_summary(self) -> str:
"""What the calibration state means for the three axes.
@@ -1143,14 +1167,6 @@ class StudioMainWindow(QMainWindow):
return t("status.uncalibrated")
return t("status.calibrated_axes", axes="+".join(names))
def _refresh_device_label(self) -> None:
label = getattr(self, "device_label", None)
if label is None:
return
config = self.sensor_picker.config_name()
config_name = Path(config).name if config else t("status.config_none")
label.setText(t("status.device_label", config=config_name))
# ---------------------------------------------------------- record state
def _record_stats(self):
"""``(state, stats, directory)`` — the single read of the record channel.
@@ -1844,6 +1860,7 @@ class StudioMainWindow(QMainWindow):
self._set_phase("connecting")
self._connect_started_at = time.perf_counter()
self._connect_slow_notified = False
self._raw_fallback_notified = False
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)
@@ -1854,7 +1871,8 @@ class StudioMainWindow(QMainWindow):
self._update_run_enabled()
self._refresh_status_rail()
self._refresh_record_state()
self.statusBar().showMessage(t("status.connecting"))
# The phase chip on the rail already reads 连接中, so there is no status
# bar copy of it here (see ``_NoticeBar``).
logger.info(
"Connecting type=%s source=%s config=%s",
sensor_type,
@@ -1988,9 +2006,9 @@ class StudioMainWindow(QMainWindow):
self._connect_slow_notified = True
self.toolbar.set_reset_enabled(True)
self._sync_shortcut_actions()
self.statusBar().showMessage(
t("status.run_started", source=self._run_source_label)
)
# No status-bar line here: the rail's phase chip says 采集中 and the
# 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:
self._last_plot_packet_key = packet_key
@@ -2005,7 +2023,14 @@ class StudioMainWindow(QMainWindow):
sample=record,
)
if display.using_raw_fallback:
self.statusBar().showMessage(t("status.units_raw_fallback"))
# Announced once per run, not once per frame: a notice that is
# re-issued every 16 ms never expires, and the rail already
# carries the calibration state.
if not self._raw_fallback_notified:
self._raw_fallback_notified = True
self._notify(t("status.units_raw_fallback"))
else:
self._raw_fallback_notified = False
self._last_sample_valid = True
self._last_display_fallback = bool(display.using_raw_fallback)
self._last_display_available = tuple(bool(flag) for flag in display.available)
+152 -20
View File
@@ -50,6 +50,10 @@ VALUE_FONT_PT = 16
#: timeline. One second is wide enough that a sample is always inside it and small
#: enough that reading a recording from disk stays free while scrubbing.
CURSOR_READOUT_S = 0.5
#: How much of the session an edge must be inset by before the visible window
#: counts as a *selection* rather than the default "whole session" fit. 1% of the
#: session absorbs the float noise of a fit without hiding a real zoom.
SELECTION_EPSILON = 0.01
def _window_brush(theme: str):
@@ -140,6 +144,9 @@ class ForcePlotsPanel(QFrame):
self._mode = "follow"
self._window: tuple[float, float] = (0.0, 0.0)
self._cursor_t = 0.0
#: The current selection's statistics, or None when the cards describe the
#: cursor instead (following live, a fitted session, a replay).
self._selection = None
self._syncing = False
self._overview_dirty = True
self._last_overview_at = 0.0
@@ -198,17 +205,21 @@ class ForcePlotsPanel(QFrame):
values_row.setSpacing(14)
self.value_cards: list[QFrame] = []
self.value_labels: list[QLabel] = []
self.value_details: list[QLabel] = []
self.channel_badges: list[QLabel] = []
self.channel_names: list[QLabel] = []
self.value_units: list[QLabel] = []
for label in CHANNEL_LABELS:
card, badge, name_label, value_label, unit_label = self._build_value_card(label)
card, badge, name_label, value_label, unit_label, detail_label = (
self._build_value_card(label)
)
values_row.addWidget(card, 1)
self.value_cards.append(card)
self.channel_badges.append(badge)
self.channel_names.append(name_label)
self.value_labels.append(value_label)
self.value_units.append(unit_label)
self.value_details.append(detail_label)
root.addWidget(self.value_group)
self.plot_frame = QFrame()
@@ -316,11 +327,15 @@ class ForcePlotsPanel(QFrame):
def _build_value_card(
self, channel: str
) -> tuple[QFrame, QLabel, QLabel, QLabel, QLabel]:
) -> tuple[QFrame, QLabel, QLabel, QLabel, QLabel, QLabel]:
"""One axis readout: channel badge + name over a large monospace value.
The value is deliberately borderless and large — three boxed cards side
by side were part of what made the panel look fragmented.
The third line carries the selection's mean and σ. It exists on every card
and is kept at a fixed height even while empty, so entering a selection
adds a number instead of moving the charts.
"""
card = QFrame()
card.setObjectName("valueGroup")
@@ -354,7 +369,13 @@ class ForcePlotsPanel(QFrame):
value_row.addWidget(unit_label)
value_row.addStretch(1)
layout.addLayout(value_row)
return card, badge, name_label, value_label, unit_label
detail_label = QLabel()
detail_label.setObjectName("hintText")
detail_label.setFont(_mono_font(9))
detail_label.setFixedHeight(detail_label.fontMetrics().height())
layout.addWidget(detail_label)
return card, badge, name_label, value_label, unit_label, detail_label
def set_theme(self, theme: str) -> None:
self._theme = theme
@@ -446,11 +467,13 @@ class ForcePlotsPanel(QFrame):
self._mode = "follow"
self._window = (0.0, 0.0)
self._cursor_t = 0.0
self._selection = None
self._overview_dirty = True
self._interactive(False)
self.set_cursor(0.0)
self.overview_curve.setData([], [])
self.stats_label.setText("")
self.stats_label.setToolTip("")
self._render_value_cards()
self._apply_axis_mode()
self._refresh_mode_label()
@@ -633,13 +656,17 @@ class ForcePlotsPanel(QFrame):
self._clear_curves()
self._mode = "follow"
self._window = (0.0, 0.0)
self._selection = None
# The strip described the *previous* source's window: a summary left
# behind here would claim a selection that no longer exists.
self.stats_label.setText("")
self.stats_label.setToolTip("")
self._overview_dirty = True
self._refresh_mode_label()
self._apply_axis_mode()
if not self._source_ready:
self.overview_curve.setData([], [])
self.window_label.setText("")
self.stats_label.setText("")
return
self._sync_overview()
self._reload_from_store()
@@ -728,6 +755,9 @@ class ForcePlotsPanel(QFrame):
self._interactive(mode == "browse")
self._refresh_mode_label()
self._sync_overview()
if mode == "follow":
# Leaving the selection: the cards go back to describing the cursor.
self._update_selection_readout()
self._readout_at_cursor()
self.mode_changed.emit(mode)
@@ -818,6 +848,10 @@ class ForcePlotsPanel(QFrame):
return
if not self._source_ready:
return
if self._selection_active():
# The cards are stating the selection's peak / mean / σ; the cursor is
# inside that window and must not overwrite them.
return
first, last = self._source.extent()
t0 = max(first, self._cursor_t - CURSOR_READOUT_S)
t1 = min(last, self._cursor_t + CURSOR_READOUT_S)
@@ -837,34 +871,83 @@ class ForcePlotsPanel(QFrame):
self._render_value_cards()
def _update_selection_readout(self) -> None:
"""Refresh what the selection is worth: the cards, and the strip's size.
The per-channel numbers live on the cards (peak, then mean · σ); the strip
under the charts keeps only how long the selection is and how many samples
it holds, because that is all that fits there. The full line stays in the
strip's tooltip.
"""
if not self._source_ready:
self._selection = None
self.stats_label.setText("")
self.stats_label.setToolTip("")
self._render_value_cards()
return
if not self._selection_active():
# Following live, or fitted to the whole session: the cards are the
# readout of the cursor, not of a window.
self._selection = None
self.stats_label.setText("")
self.stats_label.setToolTip("")
self._render_value_cards()
return
t0, t1 = self._window
stats = self._source.statistics(t0, t1, unit=self._unit)
if stats.samples == 0:
self._selection = None
self.stats_label.setText(t("chart.selection_empty"))
self.stats_label.setToolTip("")
self._render_value_cards()
return
self._selection = stats
self.stats_label.setText(
t(
"chart.selection_summary",
duration=f"{stats.duration_s:.2f}",
samples=stats.samples,
)
)
self.stats_label.setToolTip(self._selection_tooltip(stats))
self._render_value_cards()
def _selection_tooltip(self, stats) -> str:
"""The full selection readout, for the strip's tooltip."""
unit = unit_display(self._unit)
parts = []
for index, label in enumerate(CHANNEL_LABELS):
peak = stats.peaks[index]
mean = stats.means[index]
std = stats.stds[index]
peak = float(stats.peaks[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),
f"{label} 峰 {peak:.3g}{unit} · 均 {float(stats.means[index]):.3g} "
f"· σ {float(stats.stds[index]):.3g}"
)
return t(
"chart.selection",
duration=f"{stats.duration_s:.2f}",
samples=stats.samples,
detail=" ".join(parts),
)
def _selection_active(self) -> bool:
"""Whether the visible window is a *selection* and not the whole session.
Following live is not a selection, and neither is "whole session" — that is
the default fit, where the number the operator wants is the one under the
cursor. A window that covers the session (the fit, or a zoom back out to
it) therefore keeps the cursor readout.
"""
if self._mode != "browse" or not self._source_ready:
return False
first, last = self._source.extent()
span = last - first
if span <= 0.0:
return False
start, end = self._window
edge = span * SELECTION_EPSILON
return (start - first) > edge or (last - end) > edge
def _sync_overview(self) -> None:
if not self._source_ready:
return
@@ -938,11 +1021,19 @@ class ForcePlotsPanel(QFrame):
return 1200
def _refresh_mode_label(self) -> None:
"""The mode name on the header, with its meaning in the tooltip.
The long form ("Browsing history (display paused; capture and recording
continue)") did not fit next to the header's icon buttons at 1280–1440 px
in English and was simply cut mid-word, so the parenthetical lives in the
tooltip and the label stays a name.
"""
if self.is_recording_source():
key = "chart.mode_replay"
else:
key = "chart.mode_browse" if self._mode == "browse" else "chart.mode_follow"
self.mode_label.setText(t(key))
self.mode_label.setToolTip(t(f"{key}_tip"))
# "Back to latest" only means something while reading a live session; a
# recording has no latest sample to return to.
self.btn_latest.setEnabled(
@@ -1042,25 +1133,64 @@ class ForcePlotsPanel(QFrame):
curve.setData([], [])
def _render_value_cards(self) -> None:
"""One card per channel: what it reads *now*, in the active unit.
While a selection is being inspected the big number is that window's peak
and the line under it its mean and σ — the selection statistics used to be
a single long line under the charts, which could not fit and pushed the
panel wider.
"""
fmt = "{value:.3f}" if self._unit == "N" else "{value:.1f}"
unavailable = t("chart.value_unavailable")
for index, (value_label, unit_label, value) in enumerate(
zip(self.value_labels, self.value_units, self._last_values)
selecting = self._selection
for index, (value_label, unit_label, detail_label, value) in enumerate(
zip(
self.value_labels,
self.value_units,
self.value_details,
self._last_values,
)
):
if not self._available[index]:
available = self._available[index]
if selecting is not None:
peak = float(selecting.peaks[index])
available = bool(np.isfinite(peak))
value = peak
if not available:
# An axis with no curve in this unit: a dash, never a raw count
# presented as a newton value.
value_label.setText(unavailable)
unit_label.setText("")
detail_label.setText("")
value_label.setToolTip(
t("chart.value_unavailable_tip", channel=CHANNEL_LABELS[index])
)
continue
value_label.setText(fmt.format(value=value))
value_label.setToolTip("")
value_label.setToolTip(
t("chart.selection_peak_tip") if selecting is not None else ""
)
unit_label.setText(unit_display(self._unit))
detail_label.setText(
self._selection_detail(index) if selecting is not None else ""
)
self.unit_badge.setText(unit_display(self._unit))
def _selection_detail(self, index: int) -> str:
"""``mean · σ`` for one channel of the current selection."""
stats = self._selection
if stats is None:
return ""
mean = float(stats.means[index])
std = float(stats.stds[index])
if not np.isfinite(mean):
return ""
return t(
"chart.selection_detail",
mean=f"{mean:.4g}",
std=f"{std:.4g}" if np.isfinite(std) else "-",
)
def retranslate(self) -> None:
self.title.setText(t("panels.plots"))
for badge, label in zip(self.channel_badges, CHANNEL_LABELS):
@@ -1079,4 +1209,6 @@ class ForcePlotsPanel(QFrame):
self.window_caption.setToolTip(t("chart.window_tip"))
self._refresh_mode_label()
self._refresh_window_label()
self._render_value_cards()
# Which readout a card carries depends on the mode, so the language switch
# goes through the same path as a data refresh.
self._update_selection_readout()