cd D:\orisys_ws\orisys-app

git add apps/studio/config/locales/zh_CN.json `
        apps/studio/config/locales/en_US.json `
        apps/studio/tests/test_locales.py `
        apps/studio/tools/ui_check.py `
        apps/studio/tests/test_ui_smoke.py `
        apps/studio/src/orisys_studio/app_window.py `
        apps/studio/src/orisys_studio/ui/cal_panel.py `
        apps/studio/src/orisys_studio/ui/playback_dialog.py `
        apps/studio/src/orisys_studio/ui/force_plots.py `
        apps/studio/docs/record-channel.md `
        shared/src/orisys_app_shared/force/calibration_recorder.py `
        shared/src/orisys_app_shared/record/__init__.py `
        shared/src/orisys_app_shared/record/playback.py `
        shared/src/orisys_app_shared/record/depth.py `
        shared/tests/test_record_channel.py `
        shared/tests/test_playback.py
This commit is contained in:
ykcheungab
2026-09-13 14:01:24 +08:00
parent 92da51217c
commit 9354d7be1a
14 changed files with 1689 additions and 35 deletions
+32
View File
@@ -13,6 +13,8 @@
"file": "File",
"open_video": "Open video…",
"reveal_config": "Reveal sensor config…",
"playback": "Open recording replay…",
"playback_latest": "Replay most recent recording",
"view": "View",
"theme": "Theme",
"theme_dark": "Dark",
@@ -121,6 +123,36 @@
"preset_2d": "2D mode: depth map on the left, force charts on the right, one shared time axis",
"preset_default": "Free layout restored"
},
"playback": {
"title": "Replay recording",
"open": "Open recording directory…",
"open_failed": "Could not open that recording: {error}",
"none_found": "No recording found under outputs/recordings",
"loaded": "{name} — {frames} frames / {duration} s",
"empty": "Pick a recording directory to start replay",
"position": "{position} / {duration} s",
"show_newton": "Show newtons",
"show_raw": "Show raw",
"recompute_depth": "Recompute depth",
"recompute_depth_tip": "Depth is not stored with a recording; recompute this frame offline from the video and the config",
"frame_dropped": "This frame never reached the video (the record queue overflowed)",
"frame_unreadable": "This frame could not be decoded from the video",
"depth_hint": "Depth is not stored: press Recompute depth to rebuild it from the video and the config",
"depth_running": "Recomputing depth… (on a worker thread)",
"depth_busy": "The previous recomputation is still running",
"depth_empty": "No depth data for this frame",
"depth_failed": "Depth recomputation failed: {detail}",
"depth_sdk_unavailable": "Cannot recompute: the SDK is not available",
"depth_config_missing": "Cannot recompute: the recording has no sensor config reference",
"depth_config_file_missing": "Cannot recompute: the config file no longer exists at that path",
"depth_no_video": "Cannot recompute: this recording has no video segment",
"warnings": "Replay notes: {detail}",
"warn_config_changed": "the config differs from recording time, so the recomputed depth may differ",
"warn_sdk_changed": "the SDK version differs from recording time, so the recomputation may differ",
"warn_config_missing": "no config hash was recorded, so recomputation cannot be verified",
"warn_depth_recomputed": "depth comes from offline recomputation, not from stored data",
"warn_rows_skipped": "some unparsable CSV rows were skipped"
},
"record": {
"label": "Record",
"mode_video_data": "Video + data",
+34 -1
View File
@@ -13,6 +13,8 @@
"file": "文件",
"open_video": "打开视频…",
"reveal_config": "在资源管理器中显示配置…",
"playback": "打开录制回放…",
"playback_latest": "回放最近一次录制",
"view": "视图",
"theme": "主题",
"theme_dark": "深色",
@@ -121,7 +123,38 @@
"preset_2d": "已切换到二维模式:左侧深度图、右侧三轴曲线,共用时间轴",
"preset_default": "已恢复自由布局"
},
"record": { "label": "记录",
"playback": {
"title": "录制回放",
"open": "打开录制目录…",
"open_failed": "无法打开该录制: {error}",
"none_found": "outputs/recordings 下没有找到录制",
"loaded": "{name} — {frames} 帧 / {duration} s",
"empty": "选择一个录制目录开始回放",
"position": "{position} / {duration} s",
"show_newton": "显示牛顿值",
"show_raw": "显示原始值",
"recompute_depth": "重算深度",
"recompute_depth_tip": "深度未随录制保存,按录像与配置离线重算当前帧",
"frame_dropped": "该帧未被写入视频(记录队列曾溢出)",
"frame_unreadable": "该帧无法从视频中解码",
"depth_hint": "深度未存储:点击「重算深度」按原视频与配置离线重算",
"depth_running": "正在重算深度…(在后台线程)",
"depth_busy": "上一次重算尚未完成",
"depth_empty": "该帧没有深度数据",
"depth_failed": "深度重算失败: {detail}",
"depth_sdk_unavailable": "无法重算:SDK 不可用",
"depth_config_missing": "无法重算:录制未记录传感器配置",
"depth_config_file_missing": "无法重算:配置文件的原始路径已不存在",
"depth_no_video": "无法重算:该录制没有视频分段",
"warnings": "回放提示: {detail}",
"warn_config_changed": "配置文件与录制时不一致,重算出的深度可能与当时不同",
"warn_sdk_changed": "SDK 版本与录制时不一致,重算结果可能不同",
"warn_config_missing": "找不到配置哈希,无法校验重算一致性",
"warn_depth_recomputed": "深度由离线重算得到,不是当时保存的数据",
"warn_rows_skipped": "有无法解析的 CSV 行被跳过"
},
"record": {
"label": "记录",
"mode_video_data": "视频 + 数据",
"mode_data_only": "仅数据",
"mode_tip": "视频记录的是处理前的相机输入图像(不含箭头与文字叠加)。MP4 是有损封装,不等于无损原始数据。",
+54 -5
View File
@@ -166,10 +166,59 @@ outputs/recordings/<UTC 时间戳>_session<NNNN>/
---
## 7. 尚未完成(第二阶段及以后)
## 7. 同步回放与深度重算
**决定:不保存深度。** 回放时按录像与配置**离线重算**。
理由是体积:30 Hz、320×240 的 float32 深度每帧约 300 KB,一小时就是 ~16 GB;
即使降频到 1 Hz 也要百兆级。所以 `session.json` 里明确写:
```json
"depth": {
"stored": false,
"replay": "recompute",
"quantity": "relative deformation / algorithm response, not a calibrated length",
"method": "re-run the SDK over raw_*.mp4 with this config_ref",
"config_sha256": "...",
"sdk_version": "..."
}
```
代价是重算要 SDK 能复现同样结果,因此 `RecordingReader.replay_warnings()` 会主动
比对**当前配置文件哈希**与录制时的哈希、以及 SDK 版本,回放窗口顶部把这些提示出来:
| 提示 | 含义 |
|---|---|
| `depth_recomputed` | 深度来自离线重算,不是当时保存的数据(始终提示) |
| `config_changed` | 配置文件已变,重算结果可能与当时不同 |
| `sdk_changed` | SDK 版本已变,重算结果可能不同 |
| `config_missing` | 缺少配置哈希,无法校验一致性 |
| `rows_skipped` | 有无法解析的 CSV 行被跳过 |
### 回放界面
`ui/playback_dialog.py`,入口在「文件 → 打开录制回放…/回放最近一次录制」。
- **一个游标驱动三处**:移动位置同时更新视频帧、深度面板与三张曲线的游标线;
并且通过 `cursor_moved` 信号同步到主界面的曲线面板。
- 单位可切换(raw/N),数据直接来自录制目录的 `forces.csv`。
- 帧未被写入视频时(记录队列曾溢出)明确提示,而不是显示上一帧冒充当前帧。
- **深度重算在守护线程上执行**,UI 线程立即返回。实测 `_on_recompute_depth` 返回耗时
0.001 s,而 SDK 调用仍在后台跑。
两个必须知晓的边界:
1. SDK 打开视频逐帧步进**可能长时间阻塞**,且无法取消。因此用守护线程:窗口能关、
进程能退,但那个线程会一直挂到进程结束。用线程池(非守护)会让**整个进程无法退出**,
这是实测踩到的。
2. 重算失败一律显示原因(SDK 不可用/未记录配置/配置路径不存在/无视频分段/
帧不在视频中),**绝不用其它图像顶替**。
---
## 8. 尚未完成(第三阶段及以后)
- CSV 的「导出选中时间段」(整段导出已随录制落盘;区间导出待做)。
- 全程曲线浏览(跟随/历史、框选缩放、降采样保峰值)。
- 标定诊断扩展:稳定后采样、异常点排除/恢复、拟合残差、独立验证点。当前
`CalibrationPoint` 已记录三轴均值/标准差/有效样本数、方向、轮次与实际采样时长。
- PyOpenGL 三维显示(`mesh3d_panel.py` 目前仍是占位符,不是已有实现)。
- CSV 的「导出选中时间段」(整段导出已随录制落盘;区间导出待做)。
- 标定的稳定后采样(当前按固定时长采样,稳定性以 σ 呈现)。
@@ -138,6 +138,7 @@ class StudioMainWindow(QMainWindow):
self._record_last_report = None
self._record_fps = 30.0
self._session_counter = 0
self._playback = None
self._record_error_count = 0
self._dropped_reported = 0
self._active_calibration_id = ""
@@ -345,6 +346,18 @@ class StudioMainWindow(QMainWindow):
"config_ref": config_ref,
"force_calibrated": bool(getattr(info, "force_calibrated", False)),
},
# Depth is deliberately NOT stored (decided with the team): it is a
# function of the frame and the sensor configuration, so replay
# recomputes it from raw_*.mp4. The hashes below are what a replay
# checks before trusting that recomputation.
"depth": {
"stored": False,
"replay": "recompute",
"quantity": "relative deformation / algorithm response, not a calibrated length",
"method": "re-run the SDK over raw_*.mp4 with this config_ref",
"config_sha256": self._config_hash(config_ref),
"sdk_version": self._sdk_version(),
},
"conventions": {
"axis_order": ["FN", "FX", "FY"],
"force_unit": "N when calibrated, otherwise raw counts",
@@ -495,6 +508,13 @@ class StudioMainWindow(QMainWindow):
self.act_reveal_config = QAction(self)
self.act_reveal_config.triggered.connect(self._on_reveal_config)
self.file_menu.addAction(self.act_reveal_config)
self.file_menu.addSeparator()
self.act_playback = QAction(self)
self.act_playback.triggered.connect(self._open_playback)
self.file_menu.addAction(self.act_playback)
self.act_playback_latest = QAction(self)
self.act_playback_latest.triggered.connect(self._open_latest_recording)
self.file_menu.addAction(self.act_playback_latest)
self.view_menu = menu_bar.addMenu("")
self.act_cal_panel = QAction(self)
@@ -685,6 +705,8 @@ class StudioMainWindow(QMainWindow):
self.file_menu.setTitle(t("menu.file"))
self.act_open_video.setText(t("menu.open_video"))
self.act_reveal_config.setText(t("menu.reveal_config"))
self.act_playback.setText(t("menu.playback"))
self.act_playback_latest.setText(t("menu.playback_latest"))
self.view_menu.setTitle(t("menu.view"))
self.act_cal_panel.setText(t("menu.calibration_panel"))
self.act_layout_default.setText(t("layout.default"))
@@ -760,6 +782,36 @@ class StudioMainWindow(QMainWindow):
def _show_about(self) -> None:
AboutDialog(self).exec_()
def _open_playback(self) -> None:
"""Replay a recording: video, depth (recomputed) and curves, one cursor."""
from orisys_studio.ui.playback_dialog import PlaybackDialog
if self._playback is not None:
self._playback.close()
self._playback = None
dialog = PlaybackDialog(theme=self._theme, parent=self)
# The replay cursor and the live charts share one time position.
dialog.cursor_moved.connect(self._on_playback_cursor)
self._playback = dialog
dialog.show()
def _on_playback_cursor(self, t_rel_s: float) -> None:
self.force_plots.set_cursor(float(t_rel_s))
def _open_latest_recording(self) -> None:
from orisys_app_shared.record import list_recordings
found = list_recordings(recordings_dir())
if not found:
self.statusBar().showMessage(t("playback.none_found"))
return
from orisys_studio.ui.playback_dialog import PlaybackDialog
dialog = PlaybackDialog(theme=self._theme, directory=found[0], parent=self)
dialog.cursor_moved.connect(self._on_playback_cursor)
self._playback = dialog
dialog.show()
def _on_camera_id_changed(self, value: int) -> None:
self.prefs["camera_id"] = str(int(value))
self._persist_prefs()
+12 -17
View File
@@ -523,11 +523,12 @@ class CalPanel(QFrame):
CalibrationPointDialog(point, theme=self._theme, parent=self).exec_()
def _on_toggle_excluded(self) -> None:
row = self.table.currentRow()
point = self._current_point()
if point is None:
if point is None or self._recorder is None:
return
self._recorder.set_excluded(row, not point.excluded)
# Pass the point itself: a table row index would resolve against the
# recorder's cross-channel list and could hit the wrong point.
self._recorder.set_excluded(point, not point.excluded)
self._rebuild_store()
self._refresh_table()
self.status_message.emit(
@@ -535,11 +536,10 @@ class CalPanel(QFrame):
)
def _on_toggle_validation(self) -> None:
row = self.table.currentRow()
point = self._current_point()
if point is None:
if point is None or self._recorder is None:
return
self._recorder.set_validation(row, not point.is_validation)
self._recorder.set_validation(point, not point.is_validation)
self._rebuild_store()
self._refresh_table()
self.status_message.emit(
@@ -551,7 +551,7 @@ class CalPanel(QFrame):
self._store.clear()
if self._recorder is None:
return
for point in self._recorder.points:
for point in self._recorder.all_points():
if point.excluded:
continue
stats = point.loaded_stats()
@@ -568,30 +568,25 @@ class CalPanel(QFrame):
)
def _on_delete_row(self) -> None:
row = self.table.currentRow()
point = self._current_point()
if point is None or self._recorder is None:
return
# Deleting is an exclusion: the recorded evidence stays, but it leaves
# the fit and the export.
self._recorder.set_excluded(row, True)
self._recorder.set_excluded(point, True)
self._rebuild_store()
self._refresh_table()
def _on_clear_channel(self) -> None:
self._store.clear(self._channel)
if self._recorder is not None:
with self._recorder._lock: # noqa: SLF001 - panel owns the view
self._recorder.points = [
p for p in self._recorder.points if p.channel != self._channel
]
self._recorder.clear_points(self._channel)
self._rebuild_store()
self._refresh_table()
def _on_clear_all(self) -> None:
self._store.clear()
if self._recorder is not None:
with self._recorder._lock: # noqa: SLF001
self._recorder.points = []
self._recorder.clear_points()
self._rebuild_store()
self._refresh_table()
def _on_export(self) -> None:
@@ -0,0 +1,380 @@
"""Synchronised replay of a recording: video, depth and force curves, one cursor.
Depth is recomputed on demand rather than read from disk (see
:mod:`orisys_app_shared.record.depth`), so the depth pane states plainly when it
is unavailable instead of showing a stand-in image.
The cursor is the single source of truth for the position: moving it updates the
video frame, the depth pane and the charts' cursor line together.
"""
from __future__ import annotations
import threading
from pathlib import Path
import numpy as np
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
from PyQt5.QtGui import QImage, QPixmap
from PyQt5.QtWidgets import (
QDialog,
QFileDialog,
QHBoxLayout,
QLabel,
QPushButton,
QSlider,
QSplitter,
QVBoxLayout,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import recordings_dir
from orisys_app_shared.record import DepthRecomputer, RecordingReader, list_recordings
from orisys_app_shared.record.playback import FORCE_COLUMNS
from orisys_app_shared.theme import color
try:
import pyqtgraph as pg
except ImportError as exc: # pragma: no cover
raise ImportError("pyqtgraph is required for replay") from exc
CHANNEL_TOKENS = ("plot_fn", "plot_fx", "plot_fy")
SLIDER_STEPS = 2000
class PlaybackDialog(QDialog):
"""Replay one experiment directory."""
#: Emitted when the replay cursor moves, so the main charts can follow it.
cursor_moved = pyqtSignal(float)
def __init__(self, *, theme: str = "dark", directory: Path | None = None, parent=None) -> None:
super().__init__(parent)
self._theme = theme
self._reader: RecordingReader | None = None
self._recomputer: DepthRecomputer | None = None
self._unit = "raw"
self._position = 0.0
self._depth_ok = False
# A **daemon** thread, not a ThreadPoolExecutor: the SDK call can block
# indefinitely and an executor's non-daemon worker would then keep the
# whole process alive at exit. A blocked daemon thread cannot be
# cancelled either, so it is left to die with the process.
self._depth_thread: threading.Thread | None = None
self._depth_result = None
self._depth_error: BaseException | None = None
self._depth_poll = QTimer(self)
self._depth_poll.setInterval(120)
self._depth_poll.timeout.connect(self._poll_depth)
self.setWindowTitle(t("playback.title"))
self.resize(1080, 640)
root = QVBoxLayout(self)
root.setSpacing(8)
controls = QHBoxLayout()
self.open_button = QPushButton()
self.open_button.clicked.connect(self._on_open)
controls.addWidget(self.open_button)
self.path_label = QLabel()
self.path_label.setObjectName("hintText")
controls.addWidget(self.path_label, 1)
self.unit_button = QPushButton()
self.unit_button.setProperty("compact", "true")
self.unit_button.clicked.connect(self._on_toggle_unit)
controls.addWidget(self.unit_button)
self.depth_button = QPushButton()
self.depth_button.setProperty("compact", "true")
self.depth_button.clicked.connect(self._on_recompute_depth)
controls.addWidget(self.depth_button)
root.addLayout(controls)
self.warning_label = QLabel()
self.warning_label.setObjectName("hintText")
self.warning_label.setWordWrap(True)
root.addWidget(self.warning_label)
splitter = QSplitter(Qt.Horizontal)
self.video_label = QLabel()
self.video_label.setAlignment(Qt.AlignCenter)
self.video_label.setMinimumSize(280, 200)
self.video_label.setWordWrap(True)
splitter.addWidget(self.video_label)
self.depth_label = QLabel()
self.depth_label.setAlignment(Qt.AlignCenter)
self.depth_label.setMinimumSize(200, 160)
self.depth_label.setWordWrap(True)
splitter.addWidget(self.depth_label)
splitter.setSizes([420, 320])
root.addWidget(splitter, 1)
self.plot_widget = pg.GraphicsLayoutWidget()
self.plot_widget.setBackground(color(theme, "plot_bg"))
self.plot_widget.setMinimumHeight(180)
root.addWidget(self.plot_widget, 1)
self.curves = []
self.cursor_lines = []
for row, token in enumerate(CHANNEL_TOKENS):
plot = self.plot_widget.addPlot(row=row, col=0)
plot.showGrid(x=True, y=True, alpha=0.25)
plot.setMouseEnabled(x=False, y=False)
pen = pg.mkPen(color(theme, "text_secondary"))
for axis_name in ("left", "bottom"):
plot.getAxis(axis_name).setPen(pen)
plot.getAxis(axis_name).setTextPen(pen)
plot.setLabel("left", ("FN", "FX", "FY")[row], color=color(theme, token))
self.curves.append(plot.plot(pen=pg.mkPen(color(theme, token), width=2)))
line = pg.InfiniteLine(pos=0.0, angle=90, movable=False, pen=pg.mkPen("#888888"))
plot.addItem(line, ignoreBounds=True)
self.cursor_lines.append(line)
position_row = QHBoxLayout()
self.slider = QSlider(Qt.Horizontal)
self.slider.setRange(0, SLIDER_STEPS)
self.slider.setEnabled(False)
self.slider.valueChanged.connect(self._on_slider)
position_row.addWidget(self.slider, 1)
self.position_label = QLabel()
self.position_label.setObjectName("panelMeta")
position_row.addWidget(self.position_label)
root.addLayout(position_row)
if directory is not None:
self.open_recording(Path(directory))
# ------------------------------------------------------------------ opening
def _on_open(self) -> None:
start = str(recordings_dir())
chosen = QFileDialog.getExistingDirectory(self, t("playback.open"), start)
if not chosen:
return
try:
self.open_recording(Path(chosen))
except Exception as exc: # noqa: BLE001
self.warning_label.setText(t("playback.open_failed", error=str(exc)))
def open_recording(self, directory: Path) -> None:
if self._recomputer is not None:
self._recomputer.close()
self._recomputer = None
self._reader = RecordingReader(directory)
self._recomputer = DepthRecomputer(self._reader)
info = self._reader.info()
self.path_label.setText(
t(
"playback.loaded",
name=directory.name,
frames=len(info.frames),
duration=f"{info.duration_s:.1f}",
)
)
self.slider.setEnabled(info.duration_s > 0)
self._render_curves()
self._refresh_warnings()
self._depth_ok = False
self.set_position(0.0)
def _refresh_warnings(self) -> None:
if self._reader is None:
return
_ok, sdk_version = DepthRecomputer.sdk_available()
# The reader compares the recorded config hash against the config file as
# it is now, which is what the depth recomputation would actually use.
codes = self._reader.replay_warnings(sdk_version=sdk_version)
if not codes:
self.warning_label.setText("")
return
self.warning_label.setText(
t("playback.warnings", detail=", ".join(t(f"playback.warn_{c}") for c in codes))
)
# ------------------------------------------------------------------ drawing
def _render_curves(self) -> None:
if self._reader is None:
return
info = self._reader.info()
if info.times.size == 0:
for curve in self.curves:
curve.setData([], [])
return
values = info.value_for_unit(self._unit)
for index, curve in enumerate(self.curves):
column = values[:, index]
finite = np.isfinite(column)
curve.setData(info.times[finite], column[finite])
self.plot_widget.ci.layout.setRowStretchFactor(index, 1)
self.plot_widget.getItem(index, 0).setXRange(
float(info.times[0]), float(info.times[-1]), padding=0.02
)
def _on_toggle_unit(self) -> None:
self._unit = "N" if self._unit == "raw" else "raw"
self._render_curves()
self.set_position(self._position)
# ------------------------------------------------------------------ cursor
def position(self) -> float:
return self._position
def _on_slider(self, value: int) -> None:
if self._reader is None:
return
duration = self._reader.info().duration_s
self.set_position(duration * (value / SLIDER_STEPS))
def set_position(self, t_rel_s: float) -> None:
"""Move the single cursor: video, depth and charts follow it."""
if self._reader is None:
return
duration = self._reader.info().duration_s
self._position = max(0.0, min(float(t_rel_s), duration if duration > 0 else 0.0))
self.slider.blockSignals(True)
if duration > 0:
self.slider.setValue(int(SLIDER_STEPS * self._position / duration))
self.slider.blockSignals(False)
self.position_label.setText(
t("playback.position", position=f"{self._position:.2f}", duration=f"{duration:.2f}")
)
for line in self.cursor_lines:
line.setValue(self._position)
self._show_video_frame()
if self._depth_ok:
self._show_depth_frame()
self.cursor_moved.emit(self._position)
def _show_video_frame(self) -> None:
if self._reader is None:
return
image, frame = self._reader.video_frame_at(self._position)
if image is None:
reason = (
t("playback.frame_dropped")
if frame is not None and not frame.in_video
else t("playback.frame_unreadable")
)
self.video_label.setPixmap(QPixmap())
self.video_label.setText(reason)
return
rgb = image[:, :, ::-1].copy()
height, width, channels = rgb.shape
qimage = QImage(
rgb.data, width, height, channels * width, QImage.Format_RGB888
).copy()
pixmap = QPixmap.fromImage(qimage).scaled(
self.video_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
)
self.video_label.setPixmap(pixmap)
self.video_label.setText("")
# -------------------------------------------------------------------- depth
def _on_recompute_depth(self) -> None:
"""Kick off a recomputation on a worker thread.
Opening a video with the SDK and stepping to a frame can block, so it
must never run on the UI thread — that is exactly how the replay window
would freeze.
"""
if self._recomputer is None:
return
problem = self._recomputer.preflight()
if problem:
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(t(f"playback.depth_{problem}"))
return
if self._depth_thread is not None and self._depth_thread.is_alive():
self.depth_label.setText(t("playback.depth_busy"))
return
self._depth_ok = False
self._depth_result = None
self._depth_error = None
self.depth_label.setText(t("playback.depth_running"))
position = self._position
def work() -> None:
# Runs off the UI thread and touches no Qt object.
try:
self._depth_result = self._recomputer.depth_at(position)
except BaseException as exc: # noqa: BLE001 - reported via the poll
self._depth_error = exc
self._depth_thread = threading.Thread(
target=work, name="depth-replay", daemon=True
)
self._depth_thread.start()
self._depth_poll.start()
def _poll_depth(self) -> None:
"""UI-thread poll for the worker result."""
thread = self._depth_thread
if thread is None or thread.is_alive():
return
self._depth_poll.stop()
if self._depth_error is not None:
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(
t("playback.depth_failed", detail=str(self._depth_error))
)
return
result = self._depth_result
if result is None:
return
self._render_depth_result(result)
def _render_depth_result(self, result) -> None:
if not result.ok:
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(t("playback.depth_failed", detail=result.error))
return
from orisys_app_shared.acquisition.arrows import depth_map_to_bgr
bgr = depth_map_to_bgr(np.asarray(result.depth))
if bgr is None:
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(t("playback.depth_empty"))
return
self._depth_ok = True
rgb = bgr[:, :, ::-1].copy()
height, width, channels = rgb.shape
qimage = QImage(
rgb.data, width, height, channels * width, QImage.Format_RGB888
).copy()
pixmap = QPixmap.fromImage(qimage).scaled(
self.depth_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
)
self.depth_label.setPixmap(pixmap)
self.depth_label.setText("")
def _show_depth_frame(self) -> None:
"""Re-render the last result at the new position (no SDK work)."""
if self._depth_result is not None:
self._render_depth_result(self._depth_result)
# ------------------------------------------------------------------- labels
def retranslate(self) -> None:
self.setWindowTitle(t("playback.title"))
self.open_button.setText(t("playback.open"))
self.unit_button.setText(
t("playback.show_raw") if self._unit == "N" else t("playback.show_newton")
)
self.depth_button.setText(t("playback.recompute_depth"))
self.depth_button.setToolTip(t("playback.recompute_depth_tip"))
if self._reader is None:
self.video_label.setText(t("playback.empty"))
self.depth_label.setText(t("playback.depth_hint"))
self.path_label.setText("")
def closeEvent(self, event) -> None: # noqa: N802
self._depth_poll.stop()
# A depth recomputation that is blocked inside the SDK cannot be
# cancelled; it is a daemon thread, so it does not keep the process
# alive and the window always closes.
if self._recomputer is not None and not self._depth_busy():
self._recomputer.close()
super().closeEvent(event)
def _depth_busy(self) -> bool:
return self._depth_thread is not None and self._depth_thread.is_alive()
+43
View File
@@ -74,3 +74,46 @@ def test_locale_files_are_valid_utf8_json():
data = _load_locale(name)
assert isinstance(data, dict)
assert data.get("app", {}).get("title")
#: Sequences that only ever appear when UTF-8 text was decoded as a single-byte
#: codepage and then re-encoded. A ``Get-Content -Raw | Set-Content`` round trip
#: in Windows PowerShell does exactly that and silently mangles every non-ASCII
#: string, which is how the Chinese UI once ended up unreadable.
#:
#: Written as escapes, with no literal examples in the comments, so that this
#: guard cannot itself be corrupted by the same mistake.
_MOJIBAKE = (
"\u00c3",
"\u00c2",
"\u00e2\u20ac",
"\u00e3\u20ac",
"\u00ef\u00bf\u00bd",
)
def test_locale_files_are_utf8_without_bom_and_not_mojibake():
for name in ("zh_CN", "en_US"):
path = LOCALES_DIR / f"{name}.json"
raw = path.read_bytes()
assert not raw.startswith(b"\xef\xbb\xbf"), f"{name}.json starts with a BOM"
text = raw.decode("utf-8") # raises if it is not valid UTF-8
for sequence in _MOJIBAKE:
assert sequence not in text, (
f"{name}.json contains {sequence!r}: the file was decoded with the "
"wrong encoding. Rewrite it with an explicit UTF-8 writer instead "
"of a PowerShell Get-Content/Set-Content round trip."
)
def test_chinese_locale_actually_contains_chinese():
"""A corrupted zh_CN.json still parses as JSON, so check the content."""
data = _load_locale("zh_CN")
for key in ("menu/theme_dark", "app/about_title", "chart/mode_follow"):
node = data
for part in key.split("/"):
node = node[part]
assert any("\u4e00" <= char <= "\u9fff" for char in node), (
f"zh_CN.{key} has no Chinese characters: {node!r}"
)
+189
View File
@@ -61,6 +61,66 @@ def section(name: str) -> None:
print(f"\n== {name} ==")
#: Sequences that only appear when UTF-8 text was decoded with a single-byte
#: codepage and re-encoded. A PowerShell ``Get-Content -Raw | Set-Content``
#: round trip does that and silently mangles every non-ASCII string, which is
#: how the Chinese UI once ended up unreadable.
#:
#: Written as escapes, with no literal examples in the comments, so that this
#: guard cannot itself be corrupted by the same mistake and so that a scan of
#: the tree for these sequences stays clean.
MOJIBAKE_MARKERS = (
"\u00c3",
"\u00c2",
"\u00e2\u20ac",
"\u00e3\u20ac",
"\u00ef\u00bf\u00bd",
)
def audit_locale_encoding() -> list[str]:
"""Locale files must be BOM-free UTF-8 and not mojibake."""
import json
problems: list[str] = []
locales = APP_ROOT / "config" / "locales"
for name in ("zh_CN", "en_US"):
path = locales / f"{name}.json"
if not path.is_file():
problems.append(f"{name}.json is missing")
continue
raw = path.read_bytes()
if raw.startswith(b"\xef\xbb\xbf"):
problems.append(f"{name}.json starts with a UTF-8 BOM")
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
problems.append(f"{name}.json is not valid UTF-8: {exc}")
continue
for marker in MOJIBAKE_MARKERS:
if marker in text:
problems.append(
f"{name}.json contains {marker!r} — written by a tool that "
"decoded UTF-8 with the wrong codepage"
)
break
try:
json.loads(text)
except json.JSONDecodeError as exc:
problems.append(f"{name}.json is not valid JSON: {exc}")
# A corrupted zh_CN.json still parses, so check that it holds Chinese text.
try:
import json as _json
zh = _json.loads((locales / "zh_CN.json").read_text(encoding="utf-8"))
sample = zh["menu"]["theme_dark"]
if not any("\u4e00" <= char <= "\u9fff" for char in sample):
problems.append(f"zh_CN.menu.theme_dark has no Chinese text: {sample!r}")
except Exception as exc: # noqa: BLE001
problems.append(f"could not verify the Chinese locale: {exc}")
return problems
def _calibration_sample(index: int, *, fn_raw: float, fx_raw: float = 0.4, fy_raw: float = -0.2):
"""A sample shaped for the calibration recorder."""
from orisys_app_shared.force.session import SessionSample
@@ -429,6 +489,13 @@ def main() -> int:
if not palette_failures:
check(True, "palette rules hold for both themes")
section("locale encoding")
encoding_failures = audit_locale_encoding()
for failure in encoding_failures:
check(False, failure)
if not encoding_failures:
check(True, "locale files are BOM-free UTF-8 and not mojibake")
scene_count = 0
for theme in ("dark", "light"):
for sensor in ("box", "finger"):
@@ -1047,6 +1114,128 @@ def main() -> int:
app.processEvents()
stuck.release()
section("replay")
replay_root = APP_ROOT / "outputs" / "_replay_check"
_shutil.rmtree(replay_root, ignore_errors=True)
replay_root.mkdir(parents=True, exist_ok=True)
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
recorder = ExperimentRecorder(
RecordConfig(video=True, fps=30.0, segment_seconds=30.0)
)
recording = recorder.start(
root=replay_root,
session_id=1,
metadata={
"device": {
"sensor_type": "box",
"config_ref": str(replay_root / "sensor.json"),
"config_sha256": "recorded",
},
"versions": {"sdk": "0.5.1"},
"depth": {"stored": False, "replay": "recompute"},
},
)
(replay_root / "sensor.json").write_text('{"camera": {}}\n', encoding="utf-8")
for index in range(40):
recorder.on_packet(_fake_packet(index))
recorder.stop()
from orisys_studio.ui.playback_dialog import PlaybackDialog
playback = PlaybackDialog(theme="dark", directory=recording)
playback.resize(1000, 620)
playback.show()
app.processEvents()
check(
playback.position() == 0.0,
f"replay did not start at the beginning: {playback.position()}",
)
check(
all(int(curve.getData()[0].size) == 40 for curve in playback.curves),
"replay did not draw every force sample",
)
check(playback.slider.isEnabled(), "the replay position slider is disabled")
check(
t("playback.warn_depth_recomputed").split("{")[0].strip()[:4]
in playback.warning_label.text(),
f"the replay notes are missing: {playback.warning_label.text()!r}",
)
moved: list[float] = []
playback.cursor_moved.connect(moved.append)
playback.set_position(1.0)
app.processEvents()
check(
[round(line.value(), 2) for line in playback.cursor_lines] == [1.0, 1.0, 1.0],
"the replay cursor is not shared across the three charts",
)
check(moved == [1.0], f"the cursor signal did not fire once: {moved}")
check(
playback.video_label.pixmap() is not None
and not playback.video_label.pixmap().isNull(),
"no video frame was rendered at the cursor position",
)
# 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
playback._recomputer = _Blocking() # noqa: SLF001 - exercising the contract
started = _time.perf_counter()
playback._on_recompute_depth()
elapsed = _time.perf_counter() - started
check(elapsed < 0.5, f"the depth recompute blocked the UI for {elapsed:.2f}s")
check(
playback._depth_thread is not None and playback._depth_thread.is_alive(),
"the depth recompute did not run on a worker thread",
)
check(
playback._depth_thread.daemon,
"the depth worker is not a daemon, so a blocked SDK call would keep the "
"process alive at exit",
)
playback._on_recompute_depth() # a second request is refused, not queued
check(
playback.depth_label.text() == t("playback.depth_busy"),
f"a concurrent recompute was not refused: {playback.depth_label.text()!r}",
)
_Blocking.released.set()
for _ in range(40):
app.processEvents()
_time.sleep(0.05)
if not playback._depth_busy(): # noqa: SLF001
break
check(not playback._depth_busy(), "the depth worker never finished")
# Deliver the worker result deterministically instead of waiting for the
# 120 ms poll timer.
playback._poll_depth()
app.processEvents()
check(
t("playback.depth_failed").split("{")[0].strip()[:4]
in playback.depth_label.text(),
f"a failed recomputation was not reported: {playback.depth_label.text()!r}",
)
playback.close()
app.processEvents()
_shutil.rmtree(replay_root, ignore_errors=True)
print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}")
for failure in FAILURES:
print(f" FAIL: {failure}")