修正 Studio 力曲线原点、运行不可用提示与禁用按钮样式
FN 轴视图留 8% 余量使 0 刻度与静止曲线离开边框;实时视图在轨道上直接写出运行不可用的原因;禁用按钮改为虚线无填充,复选框加勾,单选框选中不再变方,并放大勾选框、微调框箭头、滚动条与菜单行以便触屏操作。另修 ui_check 桩 worker 签名与英文回放提示。在 Linux 离屏环境对比未改动的 HEAD 验证:studio 失败集合为 HEAD 的子集,ui_check 结果一致。
This commit is contained in:
@@ -128,8 +128,7 @@
|
||||
"cancel_label": "■ Cancel",
|
||||
"cancel_tip": "Abort the connection (always available while connecting, shortcut Esc)",
|
||||
"run_unavailable": "Not available yet",
|
||||
"run_disabled_video": "Select a video file first",
|
||||
"run_video_in_replay": "Play a video from the History replay view (Play and the chart cursor)",
|
||||
"run_video_in_replay": "Live uses a camera: press Use camera",
|
||||
"run_disabled_sensor": "Run is not available for this sensor type",
|
||||
"playback": "▶ Play",
|
||||
"playback_pause": "⏸ Pause",
|
||||
@@ -227,8 +226,8 @@
|
||||
"field_depth": "depth",
|
||||
"recompute_pipeline": "Recomputed from the recording (not the recorded value)",
|
||||
"recompute_pipeline_tip": "This frame is what the current algorithm version computes for that footage — not the measurement that was recorded at the time.",
|
||||
"replay_empty": "No video open\nChoose “Recorded video…” above",
|
||||
"arrow_empty": "No image yet\nPick a video or camera, then press Run",
|
||||
"replay_empty": "No video open\nPress “Choose a recorded video…” above",
|
||||
"arrow_empty": "No image yet\nConnect a camera, then press Run above\nOpen recorded videos in Replay",
|
||||
"arrow_empty_tip": "The deformation arrow image appears here while running or previewing",
|
||||
"arrow_render_failed": "Flow arrows could not be drawn for this frame\nTry another view, or reload the video",
|
||||
"arrow_capture_unavailable": "The captured frame is not available for this overlay\nThe raw camera view is still one selection away",
|
||||
|
||||
@@ -128,8 +128,7 @@
|
||||
"cancel_label": "■ 断开",
|
||||
"cancel_tip": "中断连接(连接卡住时随时可用,快捷键 Esc)",
|
||||
"run_unavailable": "当前不可运行",
|
||||
"run_disabled_video": "请先选择一个视频文件",
|
||||
"run_video_in_replay": "请在「历史回放」视图里播放视频(用播放与曲线游标)",
|
||||
"run_video_in_replay": "实时视图只用摄像机:请点「使用摄像机」",
|
||||
"run_disabled_sensor": "此传感器类型暂不支持运行",
|
||||
"playback": "▶ 播放",
|
||||
"playback_pause": "⏸ 暂停",
|
||||
@@ -228,7 +227,7 @@
|
||||
"recompute_pipeline": "由录像重算(非记录值)",
|
||||
"recompute_pipeline_tip": "这一帧是当前算法版本对录像重新计算得到的,不是当初记录下来的测量值。",
|
||||
"replay_empty": "尚未打开录像\n点击顶部「选择录制视频…」",
|
||||
"arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」",
|
||||
"arrow_empty": "尚未采集图像\n连接摄像机后点击上方「运行」\n录好的视频请到「回放」中打开",
|
||||
"arrow_empty_tip": "运行或预览后在此显示形变箭头图",
|
||||
"arrow_render_failed": "本帧无法绘制形变箭头\n请换一种视图,或重新加载视频",
|
||||
"arrow_capture_unavailable": "当前无法取得采集图像用于叠加\n原始相机画面仍可在同一选择器中查看",
|
||||
|
||||
@@ -1344,12 +1344,9 @@ class StudioMainWindow(QMainWindow):
|
||||
elif self.toolbar.source_mode() == "video":
|
||||
# Live acquires from a camera. A video file is played from the History
|
||||
# replay view (Play and the chart cursor), not by starting a chain here.
|
||||
# Choosing a file would not help here, so the reason never asks for one.
|
||||
source_ok = False
|
||||
reason = (
|
||||
t("toolbar.run_video_in_replay")
|
||||
if self.toolbar.video_path()
|
||||
else t("toolbar.run_disabled_video")
|
||||
)
|
||||
reason = t("toolbar.run_video_in_replay")
|
||||
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.
|
||||
|
||||
@@ -60,6 +60,28 @@ CURSOR_READOUT_S = 0.5
|
||||
#: 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
|
||||
#: Share of a fixed axis span left free beyond each configured bound. Without it
|
||||
#: the bound labels (FN's "0") sit on the plot frame and are not drawn, and a
|
||||
#: resting FN curve is painted on the border it shares with the FX subplot. 8 %
|
||||
#: is about half a label's height on the smallest subplot ui_check measures.
|
||||
AXIS_MARGIN_FRACTION = 0.08
|
||||
|
||||
|
||||
def _display_range(lower: float, upper: float) -> tuple[float, float]:
|
||||
"""The view range that shows ``lower…upper`` with a margin on both sides.
|
||||
|
||||
The configured range — and the ticks — stay exactly what the operator set;
|
||||
only the drawn view is wider. A margin never brings zero into view when the
|
||||
configured range excludes it, so the zero line keeps meaning "zero is here".
|
||||
"""
|
||||
pad = (float(upper) - float(lower)) * AXIS_MARGIN_FRACTION
|
||||
view_lower = lower - pad
|
||||
if lower > 0.0 >= view_lower:
|
||||
view_lower = lower
|
||||
view_upper = upper + pad
|
||||
if upper < 0.0 <= view_upper:
|
||||
view_upper = upper
|
||||
return view_lower, view_upper
|
||||
|
||||
|
||||
def _window_brush(theme: str):
|
||||
@@ -316,6 +338,9 @@ class ForcePlotsPanel(QFrame):
|
||||
for i, _name in enumerate(CHANNEL_LABELS):
|
||||
plot = self.plot_widget.addPlot(row=i, col=0)
|
||||
plot.showGrid(x=True, y=True, alpha=GRID_ALPHA)
|
||||
# A bound label (FN's 0) may reach past the axis end by a few pixels on
|
||||
# a short subplot; drawing it is better than losing the origin.
|
||||
plot.getAxis("left").setStyle(hideOverlappingLabels=False)
|
||||
# Panning and zooming are always available: a chart that ignores the
|
||||
# mouse until it is already browsing could never *enter* browsing by
|
||||
# being dragged. Y stays locked to the configured limits.
|
||||
@@ -719,7 +744,7 @@ class ForcePlotsPanel(QFrame):
|
||||
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].setYRange(*_display_range(ymin, ymax), padding=0.0)
|
||||
self.plots[i].getAxis("left").setTicks(
|
||||
[_nice_ticks(ymin, ymax, count=5)]
|
||||
)
|
||||
@@ -1363,7 +1388,7 @@ class ForcePlotsPanel(QFrame):
|
||||
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.setYRange(*_display_range(ymin, ymax), padding=0.0)
|
||||
plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
|
||||
else:
|
||||
plot.getAxis("left").setTicks(None)
|
||||
|
||||
@@ -181,6 +181,16 @@ class ToolbarWidget(QWidget):
|
||||
self.data_state = ShrinkableLabel()
|
||||
self.data_state.setObjectName("panelMeta")
|
||||
self.data_state.setVisible(False)
|
||||
# Why the transport cannot start, as text on the rail. A tooltip alone was
|
||||
# the only place this was said, and a touch screen has no hover to show it.
|
||||
# Ignored horizontally, so it only ever gets the rail's spare width and
|
||||
# never squeezes a button; hidden when empty, so it costs no spacing.
|
||||
self.run_hint = ShrinkableLabel()
|
||||
self.run_hint.setObjectName("panelMeta")
|
||||
self.run_hint.setSizePolicy(
|
||||
QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred
|
||||
)
|
||||
self.run_hint.setVisible(False)
|
||||
self.record_chip = RecordStatusChip(theme=self._theme)
|
||||
|
||||
# --- capture source (one of the two groups is visible) -------------
|
||||
@@ -288,7 +298,9 @@ class ToolbarWidget(QWidget):
|
||||
rail.addWidget(self.source_summary)
|
||||
rail.addWidget(self.calibration_label)
|
||||
rail.addWidget(self.data_state)
|
||||
rail.addStretch(1)
|
||||
# Shown, the hint takes the spare width (stretch 1); hidden, the spacer does.
|
||||
rail.addWidget(self.run_hint, 1)
|
||||
rail.addStretch(0)
|
||||
rail.addWidget(self.record_chip)
|
||||
rail.addWidget(_rail(self.btn_record))
|
||||
rail.addWidget(_rail(self.btn_run))
|
||||
@@ -358,6 +370,7 @@ class ToolbarWidget(QWidget):
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = str(theme)
|
||||
self.record_chip.set_theme(self._theme)
|
||||
self._apply_run_hint()
|
||||
|
||||
def set_sensor_summary(self, text: str) -> None:
|
||||
"""The rail's sensor button text (``BOX · box/sensor.json``)."""
|
||||
@@ -432,6 +445,7 @@ class ToolbarWidget(QWidget):
|
||||
# The reset is the one rail control whose *target* moves with the view (see
|
||||
# ``reset_target``), so its label is refreshed with the other two.
|
||||
self._refresh_reset_label()
|
||||
self._apply_run_hint()
|
||||
|
||||
def run_role(self) -> str:
|
||||
return self._run_role
|
||||
@@ -557,11 +571,33 @@ class ToolbarWidget(QWidget):
|
||||
self._run_available = bool(enabled)
|
||||
self.btn_run.setEnabled(bool(enabled))
|
||||
self._apply_run_tooltip()
|
||||
self._apply_run_hint()
|
||||
|
||||
def set_run_tooltip_reason(self, reason: str) -> None:
|
||||
"""Explain *why* Run is unavailable (shown in the button tooltip)."""
|
||||
self._run_tooltip_reason = str(reason or "")
|
||||
self._apply_run_tooltip()
|
||||
self._apply_run_hint()
|
||||
|
||||
def _apply_run_hint(self) -> None:
|
||||
"""Show the reason on the rail while acquisition cannot start.
|
||||
|
||||
Only the *blocking* reason is shown: an advisory one (a bare video plays
|
||||
with empty charts) stays in the tooltip, so the rail is not a second
|
||||
status bar. The replay view is left out on purpose: its empty 2D pane
|
||||
already says which button opens a recording, and the extra rail item
|
||||
would cost the width the replay rail does not have at 1280 px.
|
||||
"""
|
||||
blocked = not self._run_available and self._run_role != "playback"
|
||||
text = self._run_tooltip_reason if blocked else ""
|
||||
self.run_hint.setText(text)
|
||||
self.run_hint.setToolTip(text)
|
||||
self.run_hint.setVisible(bool(text))
|
||||
if text:
|
||||
self.run_hint.setStyleSheet(
|
||||
f"QLabel#panelMeta {{ color: {color(self._theme, 'warning')};"
|
||||
" background: transparent; }"
|
||||
)
|
||||
|
||||
def set_run_phase(self, phase: str) -> None:
|
||||
"""Track the session phase.
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_studio.ui.force_plots import ForcePlotsPanel, _nice_ticks
|
||||
from orisys_studio.ui.force_plots import ForcePlotsPanel, _display_range, _nice_ticks
|
||||
|
||||
|
||||
def _panel(unit: str = "raw") -> ForcePlotsPanel:
|
||||
@@ -23,6 +23,12 @@ def _ticks(panel: ForcePlotsPanel, channel: int) -> list[float]:
|
||||
return [float(value) for value, _text in levels[0]]
|
||||
|
||||
|
||||
def _view(panel: ForcePlotsPanel, channel: int) -> tuple[float, float]:
|
||||
"""The Y range the subplot actually draws."""
|
||||
low, high = panel.plots[channel].getViewBox().viewRange()[1]
|
||||
return float(low), float(high)
|
||||
|
||||
|
||||
def _curve(panel: ForcePlotsPanel, channel: int) -> np.ndarray:
|
||||
_times, values = panel.curves[channel].getData()
|
||||
return np.asarray(values, dtype=float)
|
||||
@@ -82,14 +88,15 @@ def test_the_configured_ranges_reach_every_axis(qt_app):
|
||||
)
|
||||
panel.set_fixed_newton(True)
|
||||
|
||||
# The panel starts in raw, so the raw limits are the ones on the axes.
|
||||
assert panel.plots[0].getViewBox().viewRange()[1] == [-100000.0, 1000000.0]
|
||||
assert panel.plots[1].getViewBox().viewRange()[1] == [-500000.0, 500000.0]
|
||||
# The panel starts in raw, so the raw limits are the ones on the axes (drawn
|
||||
# with a small margin beyond each bound; see ``_display_range``).
|
||||
assert _view(panel, 0) == _display_range(-100000.0, 1000000.0)
|
||||
assert _view(panel, 1) == _display_range(-500000.0, 500000.0)
|
||||
|
||||
panel.set_units("N")
|
||||
assert panel.plots[0].getViewBox().viewRange()[1] == [-5.0, 15.0]
|
||||
assert panel.plots[1].getViewBox().viewRange()[1] == [-3.0, 3.0]
|
||||
assert panel.plots[2].getViewBox().viewRange()[1] == [-3.0, 3.0]
|
||||
assert _view(panel, 0) == _display_range(-5.0, 15.0)
|
||||
assert _view(panel, 1) == _display_range(-3.0, 3.0)
|
||||
assert _view(panel, 2) == _display_range(-3.0, 3.0)
|
||||
assert panel._fixed_y_range(0) == (-5.0, 15.0)
|
||||
assert panel._fixed_y_range(1) == (-3.0, 3.0)
|
||||
# A range that starts below zero carries an explicit 0 tick: that is the
|
||||
@@ -164,3 +171,32 @@ def test_the_default_ranges_are_the_ones_they_always_were(qt_app):
|
||||
(15.0, "15"),
|
||||
(20.0, "20"),
|
||||
]
|
||||
|
||||
|
||||
def test_the_default_fn_origin_is_drawn_off_the_frame(qt_app):
|
||||
"""FN starts at 0, yet its 0 label and resting curve stay off the plot border.
|
||||
|
||||
With the view ending exactly at 0 the label was not drawn and an idle FN
|
||||
curve lay on the frame shared with FX — the customer's "缺少原点" report.
|
||||
"""
|
||||
for unit in ("raw", "N"):
|
||||
panel = ForcePlotsPanel(theme="dark")
|
||||
panel.set_units(unit)
|
||||
panel.set_fixed_newton(True)
|
||||
ymin, ymax = panel._fixed_y_range(0)
|
||||
assert ymin == 0.0, "the configured FN floor stays 0"
|
||||
view_min, view_max = _view(panel, 0)
|
||||
assert view_min < 0.0 < view_max, unit
|
||||
assert view_max > ymax, unit
|
||||
assert 0.0 in _ticks(panel, 0), unit
|
||||
assert panel.zero_lines[0].isVisibleTo(panel.plots[0].getViewBox()), unit
|
||||
|
||||
|
||||
def test_a_margin_never_brings_an_excluded_zero_into_view():
|
||||
# A margin that stays on its own side of zero is kept...
|
||||
assert _display_range(100.0, 1100.0) == (20.0, 1180.0)
|
||||
# ...one that would cross zero stops at the configured bound.
|
||||
assert _display_range(10.0, 1010.0) == (10.0, 1090.0)
|
||||
assert _display_range(-1010.0, -10.0) == (-1090.0, -10.0)
|
||||
# A range that already holds zero gets the full margin on both sides.
|
||||
assert _display_range(0.0, 20.0) == (-1.6, 21.6)
|
||||
|
||||
@@ -1576,6 +1576,8 @@ def test_live_run_refuses_a_video_source(make_window, monkeypatch):
|
||||
window._set_source_mode("video")
|
||||
QApplication.processEvents()
|
||||
assert not window.toolbar.btn_run.isEnabled(), "Run is offered for a video file in live"
|
||||
# The reason is on the rail as text, not only in a tooltip (no hover on touch).
|
||||
assert window.toolbar.run_hint.text() == t("toolbar.run_video_in_replay")
|
||||
called: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"orisys_app_shared.acquisition.sdk_worker.orisys_importable",
|
||||
@@ -3138,6 +3140,19 @@ def test_force_threshold_is_on_for_every_sensor_by_default(make_window):
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
def test_the_run_hint_clears_once_run_is_available(make_window):
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
|
||||
|
||||
window = make_window(sensor_type="finger", source_mode="camera")
|
||||
try:
|
||||
window.brand_bar.btn_live.click()
|
||||
QApplication.processEvents()
|
||||
window._set_source_mode("video")
|
||||
QApplication.processEvents()
|
||||
assert window.toolbar.run_hint.text()
|
||||
window._set_source_mode("camera")
|
||||
QApplication.processEvents()
|
||||
assert window.toolbar.btn_run.isEnabled()
|
||||
assert window.toolbar.run_hint.text() == ""
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
@@ -298,7 +298,10 @@ class _StuckWorker(RecordSinkMixin):
|
||||
mono_t = float(getattr(packet, "mono_t", 0.0) or 0.0)
|
||||
self.on_reset_applied(frame_id, mono_t, 1)
|
||||
|
||||
def start(self, _source, *, config_ref=None) -> None:
|
||||
def start(
|
||||
self, _source, *, config_ref=None, cal_path=None, device_id=None
|
||||
) -> None:
|
||||
# Same keywords as the real workers' ``start``; the stub ignores them.
|
||||
self._release.clear()
|
||||
self._thread = threading.Thread(target=self._hang, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
Reference in New Issue
Block a user