删掉回放的五个转发、重复的空格快捷键与重复的等待循环。

回放调用点直接走 replay_ctl,场景工具的路径引导收敛到 _paths,panel_snapshot
复用 panel_check 的 _wait_for,report_from 收成模块私有的 _report_from。
This commit is contained in:
ykcheungab
2026-10-07 12:25:48 +08:00
parent 86996694b4
commit b1cd37148c
14 changed files with 72 additions and 68 deletions
+6 -12
View File
@@ -15,6 +15,12 @@ import os
import sys
from pathlib import Path
# The wait loop lives with the checker; this tool used to carry its own copy.
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from panel_check import _wait_for # noqa: E402
APP_ROOT = Path(__file__).resolve().parents[1]
for path in (APP_ROOT / "src", APP_ROOT.parents[1] / "shared" / "src"):
if str(path) not in sys.path:
@@ -151,17 +157,5 @@ def _render_every_tab(window, app, args, width: int, height: int) -> int:
return 1 if failures else 0
def _wait_for(app, predicate, timeout: float) -> bool:
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
app.processEvents()
if predicate():
return True
time.sleep(0.02)
return False
if __name__ == "__main__":
raise SystemExit(main())
+2 -2
View File
@@ -69,7 +69,7 @@
重算是否在跑)与 2D 渲染属于窗口级的 `ReplayController`(见
`docs/architecture/studio-architecture.md` 建议 1)。
- **选视频即选录制**:状态轨的「选择录制视频…」(`toolbar.btn_replay_video`)与
「文件 → 打开视频…」都走 `_load_replay_video(path)`——视频所在目录就是录制目录,
「文件 → 打开视频…」都走 `ReplayController.load_video(path)`——视频所在目录就是录制目录,
于是曲线自动配对同目录的 `forces.csv`。视频与 CSV 仍然同时记录(录制格式不变)。
- 曲线:`ForcePlotsPanel.set_recording_source(reader)` 用 `RecordingCurveSource` 直接读
录制目录(区间按需读盘 + 缓存包络),所以全程总览、拖动、缩放、框选都在同一套曲线上;
@@ -92,7 +92,7 @@
- **数值卡读游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`)。浏览历史或回放时,
三个大数字是播放头所指的样本;跟随实时时"最新样本就是游标",不额外读盘。旧写法在回放
时显示实时采集的最后一个样本,于是一张 7.5e5 原始计数的曲线旁边写着实时的 `0.0`。
- **2D 面板归当前视图的来源所有**(`_replay_owns_image()`):回放视图打开录制后,采集线程
- **2D 面板归当前视图的来源所有**(`ReplayController.owns_image()`):回放视图打开录制后,采集线程
仍然在跑,但 `_on_run_tick` / `_on_preview_tick` 不再把实时帧画进 2D 面板,
`_render_run_image` 直接返回,否则回放画面会被摄像头帧一帧一帧盖掉。切回实时视图才把
面板交还给实时帧。
@@ -65,7 +65,7 @@ class AnalysisReport:
)
def report_from(outcome: AnalysisOutcome | None, *, cancelled: bool) -> AnalysisReport:
def _report_from(outcome: AnalysisOutcome | None, *, cancelled: bool) -> AnalysisReport:
"""Turn a service outcome into the surface's report."""
if outcome is None:
return AnalysisReport(
@@ -178,7 +178,7 @@ class AnalysisWorker(QObject):
with self._lock:
self._service = None
cancelled = self._cancel.is_set()
report = report_from(outcome, cancelled=cancelled)
report = _report_from(outcome, cancelled=cancelled)
if outcome is None and not cancelled:
report = AnalysisReport(
outcome=None,
+2 -2
View File
@@ -65,7 +65,7 @@ from orisys_app_shared.scene3d.visual_policy import (
policy_for_settings,
select_flow_samples,
)
from orisys_studio.analysis_worker import AnalysisWorker, report_from
from orisys_studio.analysis_worker import AnalysisWorker, _report_from
from orisys_studio.replay import MODE_ANALYSIS, MODE_RECORDING
from orisys_studio.replay_analysis import (
AnalysisSession,
@@ -1342,7 +1342,7 @@ def test_the_worker_reports_progress_and_a_cancelled_outcome():
report = reports[-1][1]
assert report.cancelled is True and report.complete is False
assert report.frames_written == 2
assert report_from(None, cancelled=True).cancelled is True
assert _report_from(None, cancelled=True).cancelled is True
# ------------------------------------------------------------------ the depth view
+1 -1
View File
@@ -1109,7 +1109,7 @@ def test_the_replay_view_shows_the_pane_and_says_where_the_field_comes_from(
"""
window = _show(make_window(sensor_type="finger"), (1440, 880))
try:
assert window._load_recording(Path(replay_recording))
assert window.replay_ctl.load_recording(Path(replay_recording))
assert window._view_preset == "replay"
pane = window.mesh3d_panel
assert pane.isVisible(), "the 3D pane is hidden in the replay view"
+6 -6
View File
@@ -2084,9 +2084,9 @@ def main() -> int:
app.processEvents()
pane_window.brand_bar.btn_playback.click()
app.processEvents()
check(pane_window._load_recording(recording), "the replay could not open the recording")
check(pane_window.replay_ctl.load_recording(recording), "the replay could not open the recording")
app.processEvents()
check(pane_window._replay_owns_image(), "the replay does not own the 2D pane")
check(pane_window.replay_ctl.owns_image(), "the replay does not own the 2D pane")
# The recording has no calibrated axis, so newton would be an empty chart.
check(
pane_window.force_plots.unit() == "raw",
@@ -2139,7 +2139,7 @@ def main() -> int:
pane_window.brand_bar.btn_live.click()
app.processEvents()
check(
not pane_window._replay_owns_image(),
not pane_window.replay_ctl.owns_image(),
"the live view still hands the 2D pane to the replay",
)
pane_window._on_run_tick()
@@ -3187,7 +3187,7 @@ def main() -> int:
"the replay view still offers the camera picker",
)
check(
replay_window._load_recording(recording),
replay_window.replay_ctl.load_recording(recording),
"the replay view could not open the recording",
)
app.processEvents()
@@ -3238,7 +3238,7 @@ def main() -> int:
_prompt_module.VideoAnalysisDialog.exec = _answer_prompt
try:
check(
other_window._load_replay_video(bare_video) is True,
other_window.replay_ctl.load_video(bare_video) is True,
"a playable video with no recording beside it was refused",
)
app.processEvents()
@@ -3652,7 +3652,7 @@ def main() -> int:
# Leaving the replay view returns the charts to the live session and hides
# the bar: the replay stays available, it just stops being on screen.
check(
replay_window._load_recording(recording),
replay_window.replay_ctl.load_recording(recording),
"the replay view could not be re-opened",
)
app.processEvents()
+1 -1
View File
@@ -91,7 +91,7 @@ def _run_scene(window, app, scene: str):
for _ in range(4):
app.processEvents()
recording = _replay_data(window, app)
window._load_recording(recording)
window.replay_ctl.load_recording(recording)
# Park the playhead where the bar and the charts show a real position.
window.replay.set_position(window.replay.duration_s() * 0.4)
window.arrow_panel.set_view_mode("recorded")
+5 -5
View File
@@ -518,7 +518,7 @@ shared 全部 **262 项**。`ui_check.py` **566 项检查 0 失败**(新增 "3
`act_recompute` 同一个开关),2D 面板的 note 直接指路。
- **实时界面禁止回放入口**:新增 `_sync_replay_actions()`——`act_open_video`/`act_playback`/
`act_playback_latest` 只在历史回放视图可用,否则禁用 + tooltip 说明原因;
`_load_replay_video()`/`_open_latest_recording()` 在非 replay 预设**拒绝并提示**,不再把采集源偷偷
`load_video()`/`open_latest_recording()` 在非 replay 预设**拒绝并提示**,不再把采集源偷偷
切成文件。实时视图用视频**当采集源**("回到摄像机")是另一项既有能力,保留不变。
- locale:`panels.replay_raw_note`/`frame_missing_note`/`field_flow`/`field_depth`/`recompute_pipeline(_tip)`、
`toolbar.recompute(_tip)`/`replay_view_only`(中英各一套)。
@@ -529,7 +529,7 @@ shared 全部 **262 项**。`ui_check.py` **566 项检查 0 失败**(新增 "3
|---|---|
| 后端单一 | `shared/tests/test_image_backend.py` 15 项:三类来源的 mode 计划、`missing`/note、模式回退、**与旧渲染逐像素一致**(arrows 对 `draw_arrow_display`、depth 对 `depth_map_to_bgr` 带主题色带)、原始帧不缩放不替图、缺 depth → None、缺 flow → 底图 |
| 路径一致 | `test_ui_smoke.py`:同一段视频在**回放**路径只出 `recorded` 且有说明行、在**重算/实时**路径出三种派生视图且都经过同一个 `render_image`(spy 断言来源与模式);"全应用只有一处 BGR→QImage"的结构断言 |
| 回放入口收口 | 实时预设下三个文件菜单动作禁用且 tooltip 带原因、rail 无回放/重算按钮、`_load_replay_video()` 返回 False 且 `source_mode()`/播放定时器不变、空格不启动回放;切到历史回放后三者可用、裸视频仍能打开、rail 的重算按钮可点并同步 `act_recompute` |
| 回放入口收口 | 实时预设下三个文件菜单动作禁用且 tooltip 带原因、rail 无回放/重算按钮、`ReplayController.load_video()` 返回 False 且 `source_mode()`/播放定时器不变、空格不启动回放;切到历史回放后三者可用、裸视频仍能打开、rail 的重算按钮可点并同步 `act_recompute` |
| 不回归 | 缺深度仍报"本帧没有深度数据"(不回退显示普通形变图);只导入视频仍可播放(既有用例) |
---
@@ -666,9 +666,9 @@ shared 全部 **262 项**。`ui_check.py` **566 项检查 0 失败**(新增 "3
`bench_scene.py --package …`、`soak_scene.py --no-window --minutes 10` 报告。板卡不在手边时按"未跑"
记录,**不得**写成通过。
3. **真机待验证一项**(阶段 6 遗留):算法链失败时 SDK 的 `VRAW`(光流)是否也过期。当前取保守解释。
4. **阶段 10 的过渡转发**:窗口里那约 40 个与旧名同名的成员(`_apply_view_preset`、`_set_display_unit`、
`_play_timer`、`_recompute_running`、`_load_recording` …)是给 tests/`ui_check` 的桥,不是最终形态。
下一次改动落在哪个控制器,就把该控制器的转发与测试调用点改成 `self.<controller>.*`。
4. **阶段 10 的过渡转发**:窗口里三十余个与旧名同名的成员(`_apply_view_preset`、`_set_display_unit`、
`_play_timer`、`_recompute_running` …)是给 tests/`ui_check` 的桥,非最终形态。下一次改动落在哪个
控制器,就把它的转发与测试调用点改成 `self.<controller>.*`。
5. **仍留在窗口的胶水**:预览(`_start_preview`/`_stop_preview`/`_on_preview_tick`)与 2D 渲染
(`_show_image_frame`/`_render_run_image`/`_depth_range`)。它们同时属于面板与相机,暂时没有第二个
变更驱动;硬拆只会把耦合搬家。
+20
View File
@@ -0,0 +1,20 @@
"""Put the studio and shared source trees on ``sys.path`` for the scene tools.
These tools are run as scripts out of this directory, but the tests load them by
file location (``spec_from_file_location`` + ``exec_module``), which leaves this
directory off ``sys.path``. Each tool therefore adds it before importing here.
"""
from __future__ import annotations
import sys
from pathlib import Path
def bootstrap_paths() -> None:
"""Make ``orisys_studio`` and ``orisys_app_shared`` importable from the repo."""
here = Path(__file__).resolve()
repo = here.parents[2]
for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
+5 -7
View File
@@ -37,13 +37,11 @@ import sys
import time
from pathlib import Path
# Tests load this tool by file location, so add our own directory to the path.
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
def _bootstrap_paths() -> None:
here = Path(__file__).resolve()
repo = here.parents[2]
for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
from _paths import bootstrap_paths # noqa: E402
def _percentiles(values: list[float]) -> dict[str, float]:
@@ -604,7 +602,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
_bootstrap_paths()
bootstrap_paths()
width, _, height = args.size.partition("x")
if args.cpu_only:
report, code = measure_cpu(
+5 -7
View File
@@ -43,13 +43,11 @@ import json
import sys
from pathlib import Path
# Tests load this tool by file location, so add our own directory to the path.
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
def _bootstrap_paths() -> None:
here = Path(__file__).resolve()
repo = here.parents[2]
for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
from _paths import bootstrap_paths # noqa: E402
def _platform_lock_from_args(args):
@@ -189,7 +187,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
_bootstrap_paths()
bootstrap_paths()
import numpy as np
from orisys_app_shared.scene3d.contracts import ThreeDError
from orisys_app_shared.scene3d.legacy import LegacyFingerImporter
+7 -9
View File
@@ -26,20 +26,18 @@ import sys
import time
from pathlib import Path
# Tests load this tool by file location, so add our own directory to the path.
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _paths import bootstrap_paths # noqa: E402
#: Exit codes: distinct values so a CI script can tell the two failures apart.
EXIT_OK = 0
EXIT_SOFTWARE = 3
EXIT_NO_CONTEXT = 4
def _bootstrap_paths() -> None:
here = Path(__file__).resolve()
repo = here.parents[2]
for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
def collect(profile: str, *, keep_open_s: float) -> dict:
"""Create a real window, let Qt initialise it, and report what it got."""
from orisys_studio.scene3d.gl_backend import (
@@ -189,7 +187,7 @@ def main(argv: list[str] | None = None) -> int:
)
args = parser.parse_args(argv)
_bootstrap_paths()
bootstrap_paths()
report = collect(args.profile, keep_open_s=0.6 if args.windowed else 0.3)
context = report.get("context")
if not context or not context.get("valid"):
+5 -7
View File
@@ -37,13 +37,11 @@ import threading
import time
from pathlib import Path
# Tests load this tool by file location, so add our own directory to the path.
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
def _bootstrap_paths() -> None:
here = Path(__file__).resolve()
repo = here.parents[2]
for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
from _paths import bootstrap_paths # noqa: E402
def resident_bytes() -> int:
@@ -519,7 +517,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
_bootstrap_paths()
bootstrap_paths()
report, code = run(
minutes=args.minutes,
fps=args.fps,
+5 -7
View File
@@ -25,13 +25,11 @@ import json
import sys
from pathlib import Path
# Tests load this tool by file location, so add our own directory to the path.
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
def _bootstrap_paths() -> None:
here = Path(__file__).resolve()
repo = here.parents[2]
for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
from _paths import bootstrap_paths # noqa: E402
def _legacy_report(args) -> tuple[int, dict]:
@@ -308,7 +306,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--json", action="store_true", help="machine-readable output")
args = parser.parse_args(argv)
_bootstrap_paths()
bootstrap_paths()
if args.legacy_dir is not None:
if args.map is None:
print("--legacy-dir also needs --map", file=sys.stderr)