删除深度重算引擎;修好回放视频每帧重开文件导致的无法播放,运输控制改到状态轨主按钮 - 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:
+55
-152
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user