删除深度重算引擎;修好回放视频每帧重开文件导致的无法播放,运输控制改到状态轨主按钮 - What: 1) 彻底删除深度重算引擎:shared/.../record/depth.py(DepthRecomputer/DepthResult)与 depth_backends.py(BoxReplay/FingerReplay)、其专属测试 test_depth_replay.py,record/__init__ 不再导出,playback.py::replay_warnings 不再报 depth_recomputed;apps/studio/replay.py 由 515 行瘦身为只做读盘+游标+视频帧+提示,回放视图的 2D 面板只提供「原始图像」;session.json 的 depth.replay 由 recompute 改为 none;删除 31 个只剩深度使用的文案键(depth_*/warn_state_*/warn_reset_*),warn_config_changed 与 warn_sdk_changed 改为描述"由视频重算";ui_check 深度段、studio 两个深度用例、test_playback 三例、drive_shared 的 test_depth_replay 与 reproduce.py 的深度段一并清理(shared 套件 150→134 项)。2) 无法播放的根因是 RecordingReader.video_frame_at 每帧都 cv2.VideoCapture 重开并 seek:小片段已 ~10 ms/帧,1080p H.264 每次 seek 需从关键帧解码(上百 ms),而播放每 33 ms 要一帧且全在 UI 线程上,视频因此看着不动;改为按分段缓存解码器、顺序帧用 read() 步进(实测 10 ms → 0.5 ms/帧)、跳转才 seek,新增 RecordingReader.close() 释放。运输控制按用户建议上移到状态轨主按钮:toolbar.set_run_role("playback") 使回放视图的主按钮变成「▶ 回放」/「⏸ 暂停」(实时视图仍是「▶ 运行」),点按以 1× 推进游标(墙钟计时,漏拍不变慢)、到末尾自动停,拖动游标/框选/滚轮/拖总览条经 ForcePlotsPanel.timeline_taken_over 自动暂停,空格键同效;曲线表头不再放第二个播放按钮;按钮样式在回放角色下不再套用"停止"的红色语义。 - Why: 用户要求先清掉深度重算引擎、并反馈"现在无法播放视频",建议把上方播放按钮在历史回放形式下改为回放。 - Verification: ui_check.py 554 项 0 失败(新增 seek<250ms/步进<60ms 的解码预算、回放按钮文字随播放/暂停切换、手动拖游标即暂停、切回实时视图按钮恢复运行、按深度视图已移除);test_ui_smoke.py 71 项 0 失败;drive_shared.py 134 项 0 失败(深度引擎用例随代码删除);locale_studio.py 262 键齐全、zh==en;reproduce.py 退出 0;.repro/probe_play.py 实测按「回放」后 0.75 s 内游标推进 0.689 s、面板 12/12 帧都在更新;两张复核图(回放视图的「▶ 回放」、实时视图的「▶ 运行」)。 - Not in this change: 录制文件格式与 CSV 列序不变(raw_*.mp4 与 forces.csv 继续一起写);「由视频重算」复核模式保留(它复用实时管线,是有状态、只能顺序前进的一遍)。

This commit is contained in:
ykcheungab
2026-09-14 09:31:21 +08:00
parent 9b3c5fe46c
commit da8165cf03
19 changed files with 354 additions and 2056 deletions
+55 -152
View File
@@ -2864,15 +2864,31 @@ def main() -> int:
)
# The chart cursor is the playhead, and the transport moves it at 1x.
check(replay_plots.btn_play.isEnabled(), "the replay has no transport")
check(not replay_plots.btn_play.isChecked(), "the replay starts while playing")
replay_plots.btn_play.setChecked(True)
check(
replay_window.toolbar.run_role() == "playback",
"the rail's primary button is not the replay's transport",
)
check(replay_window.toolbar.btn_run.isEnabled(), "the replay has no transport")
check(
not replay_window.toolbar.btn_run.isChecked(),
"the replay starts while playing",
)
check(
replay_window.toolbar.btn_run.text() == t("toolbar.playback"),
f"the button does not say it plays: {replay_window.toolbar.btn_run.text()!r}",
)
replay_window.toolbar.btn_run.setChecked(True)
app.processEvents()
check(
replay_window.toolbar.btn_run.text() == t("toolbar.playback_pause"),
f"the button does not offer pause: {replay_window.toolbar.btn_run.text()!r}",
)
started = time.perf_counter()
for _ in range(12):
app.processEvents()
time.sleep(0.03)
elapsed = time.perf_counter() - started
replay_plots.btn_play.setChecked(False)
replay_window.toolbar.btn_run.setChecked(False)
app.processEvents()
check(
replay.position() > 0.05,
@@ -2886,14 +2902,17 @@ def main() -> int:
abs(float(replay_plots.cursors[0].value()) - replay.position()) < 0.05,
"the chart cursor did not follow the transport",
)
check(not replay_plots.btn_play.isChecked(), "the transport stayed checked after pause")
check(
not replay_window.toolbar.btn_run.isChecked(),
"the transport stayed checked after pause",
)
# A manual gesture on the timeline takes over: playback pauses.
replay_plots.btn_play.setChecked(True)
replay_window.toolbar.btn_run.setChecked(True)
app.processEvents()
replay_plots.cursors[0].setValue(0.25)
app.processEvents()
check(
not replay_plots.btn_play.isChecked(),
not replay_window.toolbar.btn_run.isChecked(),
"dragging the cursor did not pause the playback",
)
check(
@@ -2934,12 +2953,12 @@ def main() -> int:
f"the recording reports {reader.info().total_rows} rows",
)
check(
t("playback.warn_depth_recomputed").split("{")[0].strip()[:4]
t("playback.warn_config_changed").split("{")[0].strip()[:4]
in replay.warnings_text(),
f"the replay notes are missing: {replay.warnings_text()!r}",
)
check(
t("playback.warn_depth_recomputed").split("{")[0].strip()[:4]
t("playback.warn_config_changed").split("{")[0].strip()[:4]
in replay_window.toolbar.source_summary.toolTip(),
f"the replay notes are not reachable from the rail: "
f"{replay_window.toolbar.source_summary.toolTip()!r}",
@@ -2968,157 +2987,41 @@ def main() -> int:
f"the unit chip did not reach the replay: {replay.unit()}",
)
# The depth recomputation must never run on the UI thread. A stub that blocks
# proves the call returns immediately instead of freezing the window.
import threading as _threading
import time as _time
class _Blocking:
released = _threading.Event()
def preflight(self) -> str:
return ""
def depth_at(self, _t):
_Blocking.released.wait(timeout=5.0)
return type("R", (), {"ok": False, "error": "stub", "depth": None})()
def close(self) -> None:
return None
replay._recomputer = _Blocking() # noqa: SLF001 - exercising the contract
started = _time.perf_counter()
replay.request_depth()
elapsed = _time.perf_counter() - started
check(elapsed < 0.5, f"the depth recompute blocked the UI for {elapsed:.2f}s")
# Playing means decoding a frame per tick. Reopening and seeking the file for
# every frame (10 ms for a small clip, hundreds for 1080p H.264) is what made
# the video stand still; the reader keeps one decoder open and steps it.
span = max(0.1, replay.duration_s() - 0.05)
seeks: list[float] = []
for fraction in (0.0, 0.25, 0.5, 0.75, 1.0):
moment = fraction * span
started_at = time.perf_counter()
frame_image, _frame = replay.reader().video_frame_at(moment)
seeks.append((time.perf_counter() - started_at) * 1000.0)
check(frame_image is not None, f"no frame at {moment:.2f}s")
check(
replay._depth_thread is not None and replay._depth_thread.is_alive(),
"the depth recompute did not run on a worker thread",
max(seeks) < 250.0,
f"a seek costs {max(seeks):.0f} ms, so playback would stall",
)
steps: list[float] = []
for index in range(1, 8):
started_at = time.perf_counter()
replay.reader().video_frame_at(index / 30.0)
steps.append((time.perf_counter() - started_at) * 1000.0)
check(
replay._depth_thread.daemon,
"the depth worker is not a daemon, so a blocked SDK call would keep the "
"process alive at exit",
max(steps) < 60.0,
f"stepping one frame costs {max(steps):.0f} ms, so 30 fps cannot hold",
)
replay.request_depth() # a second request is refused, not queued
check(
replay.depth_message() == t("playback.depth_busy"),
f"a concurrent recompute was not refused: {replay.depth_message()!r}",
)
_Blocking.released.set()
for _ in range(40):
app.processEvents()
_time.sleep(0.05)
if not replay._depth_busy(): # noqa: SLF001
break
check(not replay._depth_busy(), "the depth worker never finished")
# Deliver the worker result deterministically instead of waiting for the
# 120 ms poll timer.
replay._poll_depth()
app.processEvents()
check(
t("playback.depth_failed").split("{")[0].strip()[:4] in replay.depth_message(),
f"a failed recomputation was not reported: {replay.depth_message()!r}",
)
check(
replay.depth_image() is None,
"a failed recomputation left an image in the depth pane",
)
# The replay view no longer offers a depth pane: depth is not stored with a
# recording, and re-running the pipeline over the video is the recompute
# mode's job (see the `recompute` section).
# There is no depth recomputation any more: the engine was deleted with
# the replay bar (depth is not stored with a recording, and the replay
# view shows the recorded frames). The arrow panel in the replay view
# therefore offers the recorded image and nothing else.
check(
replay_window.arrow_panel.view_modes() == ("recorded",),
f"the replay still offers a depth pane: "
f"{replay_window.arrow_panel.view_modes()}",
)
# The plan must state where to start, which reset it is anchored to and
# whether the state is really recoverable; and a request must belong to the
# recording it started in.
from orisys_app_shared.record.depth import (
STATE_APPROXIMATE,
STATE_PREROLL,
DepthRecomputer,
)
from orisys_app_shared.record.playback import RecordingReader
plan_reader = RecordingReader(recording)
planner = DepthRecomputer(plan_reader)
frames = plan_reader.info().frames
check(bool(frames), "the replay recording has no frame index")
plan = planner.plan_for(frames[10])
check(
plan.state == STATE_APPROXIMATE and plan.anchor == "segment_start",
f"a recording with no reset/pre-roll is not reported as approximate: {plan.state}",
)
check(
plan.steps and plan.steps[0].source == plan_reader.segment_path(frames[10].segment),
"the plan does not name the video to read",
)
check(
"state_approximate" in plan.warnings,
f"the approximate replay is not reported: {plan.warnings}",
)
# A pre-roll makes the start of a recording reproducible.
session_path = recording / "session.json"
session_doc = _json.loads(session_path.read_text(encoding="utf-8"))
prerecord = recording / "preroll_0001.mp4"
prerecord.write_bytes(b"")
session_doc["preroll_frames"] = 30
session_doc["preroll_segment"] = prerecord.name
session_path.write_text(_json.dumps(session_doc), encoding="utf-8")
planner.close()
planner = DepthRecomputer(RecordingReader(recording))
plan = planner.plan_for(planner.reader.info().frames[5])
check(
plan.state == STATE_PREROLL and plan.preroll == prerecord,
f"the pre-roll was not used for the start of the recording: {plan.state}",
)
check(plan.frames_to_read == 30 + 6, f"unexpected replay cost: {plan.frames_to_read}")
planner.close()
# A reset boundary is re-applied, and a request is bound to its recording.
_Blocking.released.clear()
second_controller = ReplayController()
second_controller.open_recording(recording)
serial_before = second_controller._record_serial
blocking = _Blocking()
second_controller._recomputer = blocking
second_controller.request_depth()
check(
second_controller._depth_pending_record == serial_before,
"the request has no recording id",
)
check(
second_controller._depth_pending_seq is not None,
"the request carries no sequence number",
)
# Opening another recording invalidates the running request...
second_controller.open_recording(recording)
check(
second_controller._record_serial == serial_before + 1,
"opening a recording did not invalidate the running request",
)
check(
second_controller._recomputer is not blocking,
"the invalidated processor was not replaced",
)
_Blocking.released.set()
for _ in range(40):
app.processEvents()
_time.sleep(0.05)
if not second_controller._depth_busy():
break
second_controller._poll_depth()
app.processEvents()
check(
second_controller.depth_image() is None,
"an answer for a previous recording was drawn into the new one",
)
second_controller.close()
app.processEvents()
# The recompute mode: off (default) the charts read the recorded CSV; on, the
# recorded video is run through the pipeline instead. The run itself is not
# started here (that needs the SDK), only the wiring around it.
@@ -3169,8 +3072,8 @@ def main() -> int:
"leaving the replay view did not restore the live charts",
)
check(
not replay_plots.btn_play.isEnabled(),
"the live view still offers the replay's transport",
replay_window.toolbar.run_role() != "playback",
"the rail kept the replay's transport in the live view",
)
check(
not replay_window.toolbar.source_summary.text().endswith(".mp4")