改善 Studio 回放布局与传感器切换体验

缩短界面提示,保存上次视频目录,并防止旧结果绘入 Finger 3D。针对性测试及可见界面复测通过。
This commit is contained in:
ykcheungab
2026-10-06 20:18:50 +08:00
parent 425fa0d44d
commit 321e52f47a
11 changed files with 265 additions and 41 deletions
+7 -6
View File
@@ -209,13 +209,13 @@
"view_mode_tip": "Image display mode while running",
"replay_pipeline": "Recorded video (raw camera image)",
"replay_pipeline_tip": "Replay shows the recorded raw camera image with no live arrow overlay; to get arrows and depth from analysis, use View → Analyze video",
"replay_raw_note": "A recording stores only the raw camera image: there is no flow or depth to draw, because this recording has no analysis result yet. Opening a recording runs the pass automatically, or press “Analyze video”; once a result exists this pane can draw arrows and the depth map",
"replay_raw_note": "No analysis result yet. Opening a recording runs analysis, or choose Analyze video… above.",
"frame_missing_note": "This frame carries no {fields}: there is nothing for this view to draw",
"field_flow": "flow",
"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 recording open\nPress “Open recording…” below",
"replay_empty": "No video open\nChoose “Recorded video…” above",
"arrow_empty": "No image yet\nPick a video or camera, then press Run",
"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",
@@ -244,7 +244,7 @@
"mesh3d_reset_tip": "Reset view: back to the framing the scene itself uses",
"replay_analysis_running": "this recording is being analysed: there are no arrows or depth to draw until it finishes. Progress and Cancel are on the analysis panel.",
"replay_analysis_failed": "there is no analysis result to draw: {reason}. Only the raw camera image can be shown here; once that is dealt with, Analyze video runs the pass again by hand.",
"replay_analysis_unavailable": "this is not a recording that can be analysed (its folder has no session.json, or no raw_*.mp4 segment): no pass can run, so this panel shows the raw camera image only. For forces and deformation from this video use View > Analyze video."
"replay_analysis_unavailable": "This video has no analysis setup. Choose Analyze video… above to select one and generate results."
},
"chart": {
"fn": "FN",
@@ -295,7 +295,7 @@
"no_video": "This recording has no usable video segment",
"open_failed": "Could not open that recording: {error}",
"not_a_recording": "“{name}” is not a recording: its folder has no forces.csv (a recording is the video plus forces.csv and session.json). Replay needs a recording; to just watch this video, open it as a video or use View ▸ Analyze video.",
"video_only_opened": "“{name}” has no recording beside it (no forces.csv), so it is playing as a video: the picture and the timeline work, the force charts are empty. For forces and deformation use View ▸ Analyze video.",
"video_only_opened": "Opened “{name}”; no analysis result yet. Choose Analyze video… above to generate one.",
"video_only_notice": "This video has no recording beside it (no forces.csv): it plays, but the force charts and the recording details are empty.",
"video_only_panel": "This is not a recording, and this video cannot be decoded (no forces.csv beside it, and it does not read as playable video)\nYou can still try View ▸ Analyze video",
"empty_recording": "This recording has no playable picture (no raw_*.mp4 segment), so it cannot be replayed",
@@ -449,8 +449,9 @@
"phase_connecting": "Connecting",
"phase_running": "Acquiring",
"phase_error": "Error",
"rail_video_name": "Video file: {name}",
"rail_result_profile": "Result profile: {type}",
"rail_video_name": "Video: {name}",
"rail_recent_video": "Previous video: {name}",
"rail_result_profile": "Result: {type}",
"rail_result_reused": "Stored result",
"rail_result_complete": "Analysis complete",
"rail_result_running": "Analysing…",
+7 -6
View File
@@ -209,13 +209,13 @@
"view_mode_tip": "运行时的图像显示模式",
"replay_pipeline": "录制视频(原始相机图像)",
"replay_pipeline_tip": "回放显示的是录制下来的原始相机图像,不含实时箭头叠加;要看分析得到的箭头与深度请用「视图 → 分析视频」",
"replay_raw_note": "录像只保存原始相机图像:这里没有光流/深度可画,因为这个录像还没有分析结果。打开录像会自动跑一遍分析,也可以点「分析视频」手动生成;有结果后本面板就能画箭头和深度图",
"replay_raw_note": "尚无分析结果。打开录像会自动分析,或点顶部「分析视频…」。",
"frame_missing_note": "本帧没有{fields}:这个视图这一帧没有数据可画",
"field_flow": "光流",
"field_depth": "深度",
"recompute_pipeline": "由录像重算(非记录值)",
"recompute_pipeline_tip": "这一帧是当前算法版本对录像重新计算得到的,不是当初记录下来的测量值。",
"replay_empty": "尚未打开录制\n点击下方「打开录制…」",
"replay_empty": "尚未打开录像\n点击顶部「选择录制视频…」",
"arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」",
"arrow_empty_tip": "运行或预览后在此显示形变箭头图",
"arrow_render_failed": "本帧无法绘制形变箭头\n请换一种视图,或重新加载视频",
@@ -244,7 +244,7 @@
"mesh3d_reset_tip": "复位视角:回到场景自带的取景",
"replay_analysis_running": "正在分析这个录像:跑完之前这里没有箭头/深度可画,进度与取消在分析面板上。",
"replay_analysis_failed": "没有可画的分析结果:{reason}。这里只能显示原始相机图像;处理完之后可以点「分析视频」再跑一遍。",
"replay_analysis_unavailable": "这不是一条可分析的录像(同目录没有 session.json,或没有 raw_*.mp4 分段):没有能跑的分析,本面板只能显示原始相机图像。要从这段视频得到力值/形变,请用 视图 ▸ 分析视频。"
"replay_analysis_unavailable": "视频尚无分析配置。点顶部「分析视频…」选择配置并生成结果。"
},
"chart": {
"fn": "FN",
@@ -295,7 +295,7 @@
"no_video": "这段录制没有可用的视频分段",
"open_failed": "无法打开该录制: {error}",
"not_a_recording": "「{name}」不是一条记录:同目录没有 forces.csv(一条记录 = 录像 + forces.csv + session.json)。回放需要记录;只想看这段视频请直接打开视频,或用「分析视频」。",
"video_only_opened": "「{name}」没有配套记录(无 forces.csv),已按视频播放:可以看画面与拖时间轴,力曲线为空。要得到力值/形变,请使用 视图 ▸ 分析视频。",
"video_only_opened": "已打开「{name}」;尚无分析结果。点顶部「分析视频…」生成。",
"video_only_notice": "这段视频没有配套记录(同目录无 forces.csv):可以播放,但力曲线与记录信息为空。",
"video_only_panel": "这不是一条记录,而且无法解码这段视频(没有 forces.csv,也不像可播放的视频文件)\n可以试试 视图 ▸ 分析视频",
"empty_recording": "这条记录没有可播放的画面(没有 raw_*.mp4 视频分段),因此无法回放",
@@ -449,8 +449,9 @@
"phase_connecting": "连接中",
"phase_running": "采集中",
"phase_error": "出错",
"rail_video_name": "视频文件: {name}",
"rail_result_profile": "结果配置: {type}",
"rail_video_name": "视频:{name}",
"rail_recent_video": "上次视频:{name}",
"rail_result_profile": "结果:{type}",
"rail_result_reused": "已复用缓存结果",
"rail_result_complete": "分析完成",
"rail_result_running": "正在分析…",
+2 -1
View File
@@ -6,6 +6,7 @@
"camera_id": "0",
"source_mode": "camera",
"video_path": "",
"last_video_directory": "",
"show_cal_panel": false,
"show_finger_3d": true,
"show_fps": false,
@@ -35,5 +36,5 @@
"finger_2d_orientation": "rotate180",
"settings_expanded": false,
"view_preset": "custom",
"splitter_sizes": [340, 480, 400]
"splitter_sizes": [560, 400, 480, 0]
}
+28 -15
View File
@@ -84,14 +84,14 @@ 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)
DEFAULT_SPLITTER_SIZES = (560, 400, 480)
MIN_PANEL_WIDTHS = {
# The 3D pane used to be the one panel whose content was a stack of form fields, so
# its minimum was measured under the stylesheet in both languages (241 px in English)
# and could not go below 260. The controls now live in the settings dialog and what
# is left wraps, so the pane can be dragged narrow again — the picture is the point.
"mesh3d": 200,
"arrow": 300,
"arrow": 380,
"plots": 340,
"cal": 300,
}
@@ -101,13 +101,11 @@ VIEW_PRESETS = ("custom", "live", "replay", "calibration")
#: Splitter sizes per view, in panel order (3D, 2D, charts, calibration).
#: ``custom`` keeps whatever the user arranged (see ``_restore_splitter_sizes``).
#: Replay is the live layout on purpose: the two views differ in their source
#: (a camera, or a recorded video), not in their furniture.
#: The 3D pane is the widest of the three: it is the view's subject when it is on, and
#: the 2D panel is a reading of the same measurement rather than a second subject.
#: Live gives the 3D picture the most width; replay gives the history chart the
#: most width. Both keep the 2D picture large enough to inspect marker detail.
VIEW_SPLITTER_SIZES = {
"live": (620, 300, 520, 0),
"replay": (620, 300, 520, 0),
"live": (560, 400, 480, 0),
"replay": (480, 400, 560, 0),
"calibration": (0, 0, 560, 880),
}
#: The 3D pane belongs to the live view and to history browsing, and only for a
@@ -1488,7 +1486,12 @@ class StudioMainWindow(QMainWindow):
if self._view_preset == "replay":
path = self.toolbar.video_path()
if path:
detail = t("status.rail_video_name", name=Path(path).name)
key = (
"status.rail_video_name"
if self.replay.has_video()
else "status.rail_recent_video"
)
detail = t(key, name=Path(path).name)
# Name the *result's* profile and whether it was reused or just finished, so
# changing the sensor picker cannot look like the open figures came from the
# newly selected product.
@@ -1497,11 +1500,8 @@ class StudioMainWindow(QMainWindow):
provenance = getattr(getattr(session, "reader", None), "provenance", None)
result_type = str(getattr(provenance, "sensor_type", "") or "").strip()
if result_type:
detail = (
f"{detail} · {t('status.rail_result_profile', type=result_type)}"
if detail
else t("status.rail_result_profile", type=result_type)
)
result_label = t("status.rail_result_profile", type=result_type)
detail = f"{result_label} · {detail}" if detail else result_label
progress = getattr(self.replay_ctl, "_progress", None)
progress_state = (
str(progress.state()) if progress is not None and hasattr(progress, "state") else ""
@@ -1534,7 +1534,7 @@ class StudioMainWindow(QMainWindow):
# The full path and the replay's notes do not fit on a 30 px rail, so
# they live in the summary's tooltip (the bar that used to show them is
# gone: the two views share one layout).
tip_parts = [self.toolbar.video_path()]
tip_parts = [detail, self.toolbar.video_path()]
if self._view_preset == "replay" and self.replay.has_video():
# Includes the "this is a video, not a recording" note for a bare video.
tip_parts.append(self.replay.warnings_text())
@@ -1999,6 +1999,11 @@ class StudioMainWindow(QMainWindow):
def _on_sensor_type_changed(self, sensor_type: str) -> None:
self.prefs["sensor_type"] = str(sensor_type)
self.prefs["config_name"] = self.sensor_picker.config_name()
if sensor_type == "finger":
# Entering Finger opens its principal view even if an earlier session
# left the optional pane turned off. The user can still turn it off.
self._show_finger_3d = True
self.prefs["show_finger_3d"] = True
self.cal_panel.set_meta_context(
sensor_type=str(sensor_type),
config_ref=self._resolve_config_ref(),
@@ -2006,6 +2011,7 @@ class StudioMainWindow(QMainWindow):
# A different product is a different UV island; the previous latch must not
# paint the new canvas (a box result has no mask and would inherit a finger's).
self._domain_mask.reset()
self.mesh3d_panel.show_reference_only()
# The pane follows the sensor: a box sensor has no 3D scene, and a finger's
# missing package must not be reported while looking at a box.
self.layout_ctl.sync_3d_source()
@@ -2941,6 +2947,13 @@ class StudioMainWindow(QMainWindow):
policy is the open session's ``paint_policy``, so 3D arrows share the 2D canvas's
grid and threshold (including the box painter's 2 px floor).
"""
session = self.replay_ctl.available_result()
provenance = getattr(getattr(session, "reader", None), "provenance", None)
result_type = str(getattr(provenance, "sensor_type", "") or "").strip()
if result_type and result_type != self.sensor_picker.sensor_type():
# The old replay stays inspectable after a sensor switch, but its
# depth grid must never be submitted to the new sensor's 3D scene.
return
view = self.mesh3d_panel.view
if view is None:
return
@@ -238,7 +238,7 @@ class ReplayController(QObject):
*video* (what the operator recognises) and the charts pair it with the
``forces.csv`` written beside it.
"""
start = str(recordings_dir())
start = self._video_dialog_directory(recordings_dir())
chosen, _filter = QFileDialog.getOpenFileName(
self._dialog_parent(),
t("playback.open"),
@@ -247,6 +247,7 @@ class ReplayController(QObject):
)
if not chosen:
return
self._remember_video_directory(Path(chosen))
self.load_video(Path(chosen))
def on_browse_video(self) -> None:
@@ -258,7 +259,7 @@ class ReplayController(QObject):
"""
if self._host.running or self._host.preview_open:
return
start = self._toolbar.video_path() or str(Path.home())
start = self._video_dialog_directory(Path.home())
path, _ = QFileDialog.getOpenFileName(
self._dialog_parent(),
t("menu.open_video"),
@@ -267,8 +268,25 @@ class ReplayController(QObject):
)
if not path:
return
self._remember_video_directory(Path(path))
self.load_video(Path(path))
def _video_dialog_directory(self, fallback: Path) -> str:
"""Start both video pickers in the last directory the operator chose."""
saved = str(self._host.prefs.get("last_video_directory") or "")
if saved and Path(saved).is_dir():
return saved
current = self._toolbar.video_path()
if current and Path(current).parent.is_dir():
return str(Path(current).parent)
return str(fallback)
def _remember_video_directory(self, video: Path) -> None:
directory = video.parent
if directory.is_dir():
self._host.prefs["last_video_directory"] = str(directory)
self._host.persist_prefs()
def on_recompute_button(self) -> None:
"""The replay view's own entry for the processed picture."""
if self._actions is None:
@@ -1415,12 +1433,14 @@ class ReplayController(QObject):
if self._charts.is_recording_source():
self._charts.clear_recording_source()
self._charts.follow_latest()
self._image_panel.set_source_kind("live")
self._image_panel.set_source_kind("replay" if active else "live")
# The panel held a recorded frame: the next live packet must repaint it, so the
# "already rendered" marker is dropped.
self._host.drop_render_marker()
if not self._host.running and not self._host.preview_open:
self._image_panel.clear_preview(t("panels.arrow_empty"))
self._image_panel.clear_preview(
t("panels.replay_empty" if active else "panels.arrow_empty")
)
def _serve_curves(self, session: AnalysisSession | None = None) -> str:
"""Point the charts at the force trace this timeline really has, and label it.
+4 -6
View File
@@ -193,12 +193,10 @@ class ViewLayoutController(QObject):
self._cal_panel.setMinimumWidth(self._min_panel_widths["cal"])
self._set_check(self._actions.finger_3d if self._actions else None, show_3d, enabled=is_finger)
self._set_check(self._actions.cal_panel if self._actions else None, show_cal)
# Panel weights: the charts are the widest panel in a view without 3D, the 3D
# pane is the widest one when it is on (it is the subject, not an inset), and
# the 2D panel is deliberately the smaller one.
arrow_weight = 3
plots_weight = 4
mesh_weight = 5
# Keep each preset's panel priority when the window is resized.
arrow_weight = 4
plots_weight = 5 if preset == "replay" else 4
mesh_weight = 4 if preset == "replay" else 5
for i in range(self._splitter.count()):
widget = self._splitter.widget(i)
if widget is self._mesh_panel:
@@ -0,0 +1,54 @@
"""The two video pickers share the last directory chosen by the operator."""
from __future__ import annotations
import json
from pathlib import Path
from PySide6.QtWidgets import QApplication
import orisys_studio.replay_controller as replay_controller
def test_both_video_pickers_recall_the_last_chosen_directory(
make_window, monkeypatch, tmp_path
):
window = make_window(sensor_type="box")
window.show()
window.brand_bar.btn_playback.click()
QApplication.processEvents()
first = tmp_path / "first"
second = tmp_path / "second"
first.mkdir()
second.mkdir()
window.prefs["last_video_directory"] = str(first)
chosen = [second / "first.mp4", first / "second.mp4"]
starts: list[str] = []
def choose(_parent, _title, start, _filter):
starts.append(start)
return str(chosen[len(starts) - 1]), ""
monkeypatch.setattr(replay_controller.QFileDialog, "getOpenFileName", choose)
monkeypatch.setattr(window.replay_ctl, "load_video", lambda _video: True)
window.replay_ctl.on_open_recording()
assert starts == [str(first)]
assert window.prefs["last_video_directory"] == str(second)
window.replay_ctl.on_browse_video()
assert starts == [str(first), str(second)]
assert window.prefs["last_video_directory"] == str(first)
saved = json.loads((tmp_path / "preference.json").read_text(encoding="utf-8"))
assert saved["last_video_directory"] == str(first)
def test_missing_remembered_directory_falls_back_to_current_video(make_window, tmp_path):
window = make_window(sensor_type="box")
current = tmp_path / "clip.mp4"
current.write_bytes(b"fixture")
window.prefs["last_video_directory"] = str(tmp_path / "removed")
window.toolbar.set_video_path(str(current))
assert window.replay_ctl._video_dialog_directory(Path.home()) == str(tmp_path)
@@ -51,6 +51,45 @@ def _pick(window, sensor_type: str) -> None:
combo.setCurrentIndex(combo.findData(sensor_type))
def test_switching_to_finger_enables_3d_after_an_earlier_off_choice(qt_app, make_window):
window = make_window(sensor_type="box", show_finger_3d=False)
window.show()
qt_app.processEvents()
try:
assert not window._show_finger_3d
_pick(window, "finger")
qt_app.processEvents()
assert window._show_finger_3d
assert window.prefs["show_finger_3d"] is True
assert window.mesh3d_panel.isVisible()
assert window.act_finger_3d.isChecked()
window.layout_ctl.on_finger_3d_toggled(False)
assert not window._show_finger_3d
_pick(window, "box")
_pick(window, "finger")
qt_app.processEvents()
assert window.mesh3d_panel.isVisible()
assert window.act_finger_3d.isChecked()
finally:
window.close()
def test_replay_keeps_the_marker_image_readable_in_a_1197px_window(qt_app, make_window):
window = make_window(sensor_type="finger", show_finger_3d=True)
window.resize(1197, 820)
window.show()
qt_app.processEvents()
try:
window.brand_bar.btn_playback.click()
qt_app.processEvents()
assert window.mesh3d_panel.isVisible()
assert window.arrow_panel.width() >= 380
assert window.force_plots.width() > window.arrow_panel.width()
finally:
window.close()
def test_reserving_the_native_handle_costs_nothing_at_construction(qt_app, make_window):
"""The handle is reserved, and reserving it does not build the pane.
+82
View File
@@ -2830,6 +2830,88 @@ def test_the_rail_reads_the_rate_and_what_it_cost(qt_app, make_window):
window.close()
def test_replay_without_a_video_names_the_visible_picker(qt_app, make_window):
from orisys_app_shared.i18n import t
window = make_window(sensor_type="box")
window.brand_bar.btn_playback.click()
qt_app.processEvents()
assert window.arrow_panel.image_label.text() == t("panels.replay_empty")
assert window.toolbar.btn_replay_video.text() in t("panels.replay_empty")
window.close()
def test_a_remembered_video_is_not_described_as_open(make_window):
from orisys_app_shared.i18n import t
window = make_window(sensor_type="finger", video_path="D:/clips/old.mp4")
window.brand_bar.btn_playback.click()
window._refresh_status_rail()
assert not window.replay.has_video()
assert window.toolbar.source_summary.text() == t(
"status.rail_recent_video", name="old.mp4"
)
def test_the_rail_puts_result_identity_before_a_long_video_name(
qt_app, make_window, monkeypatch
):
from types import SimpleNamespace
from orisys_app_shared.i18n import t
window = make_window(sensor_type="finger")
window.resize(1197, 752)
window.show()
window.brand_bar.btn_playback.click()
window.toolbar.set_video_path("D:/very-long-recording-name-for-a-box-sensor.mp4")
session = SimpleNamespace(
reader=SimpleNamespace(provenance=SimpleNamespace(sensor_type="box"))
)
monkeypatch.setattr(window.replay_ctl, "available_result", lambda: session)
window._refresh_status_rail()
qt_app.processEvents()
prefix = t("status.rail_result_profile", type="box")
label = window.toolbar.source_summary
assert label.text().startswith(prefix), label.text()
assert label.fontMetrics().horizontalAdvance(prefix) <= label.width(), (
"the result's sensor disappears from the visible rail"
)
window.close()
def test_old_result_depth_is_not_sent_to_the_new_sensor_scene(make_window, monkeypatch):
from types import SimpleNamespace
window = make_window(sensor_type="finger")
session = SimpleNamespace(
reader=SimpleNamespace(provenance=SimpleNamespace(sensor_type="box")),
paint_policy=lambda: None,
)
monkeypatch.setattr(window.replay_ctl, "available_result", lambda: session)
submitted = []
fake_view = SimpleNamespace(
set_overlay_policy=lambda _policy: None,
submit_packet=submitted.append,
)
panel = window.mesh3d_panel
real_view = panel._view
panel._view = fake_view
monkeypatch.setattr(window, "_connect_analysis_display", lambda _panel: True)
monkeypatch.setattr(window, "_tick_analysis_3d", lambda: None)
try:
window._show_analysis_frame("old box packet")
assert submitted == []
session.reader.provenance.sensor_type = "finger"
window._show_analysis_frame("finger packet")
assert submitted == ["finger packet"]
finally:
panel._view = real_view
def test_the_camera_probe_is_usable_without_a_camera(capsys):
"""The probe has to be safe to run on a machine with no camera attached."""
import camera_probe
+16 -2
View File
@@ -304,9 +304,23 @@ def test_the_shipped_live_layout_makes_the_3d_pane_the_widest_panel():
assert mesh > charts, DEFAULT_SPLITTER_SIZES
live_mesh, live_image, live_charts, _cal_live = LIVE["live"]
assert live_mesh > live_image and live_mesh > live_charts, LIVE["live"]
assert LIVE["replay"] == LIVE["live"], (
"the replay view is the live layout with another source"
assert LIVE["replay"][2] > LIVE["replay"][0], (
"history browsing needs more chart width than 3D width"
)
assert LIVE["live"][1] >= 400 and LIVE["replay"][1] >= 400, (
"the 2D marker picture is too narrow to inspect"
)
def test_panel_stretch_keeps_each_views_priority_after_resizing():
controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="finger")
controller.show_finger_3d = True
controller.apply_panel_visibility()
assert dict(parts["splitter"].stretches) == {0: 5, 1: 4, 2: 4, 3: 0}
controller.apply_preset("replay")
assert dict(parts["splitter"].stretches) == {0: 4, 1: 4, 2: 5, 3: 0}
def test_the_calibration_panel_toggle_is_the_calibration_view_switch():