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:
@@ -13,6 +13,8 @@
|
|||||||
"file": "File",
|
"file": "File",
|
||||||
"open_video": "Open video…",
|
"open_video": "Open video…",
|
||||||
"reveal_config": "Reveal sensor config…",
|
"reveal_config": "Reveal sensor config…",
|
||||||
|
"playback": "Open recording replay…",
|
||||||
|
"playback_latest": "Replay most recent recording",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"theme_dark": "Dark",
|
"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_2d": "2D mode: depth map on the left, force charts on the right, one shared time axis",
|
||||||
"preset_default": "Free layout restored"
|
"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": {
|
"record": {
|
||||||
"label": "Record",
|
"label": "Record",
|
||||||
"mode_video_data": "Video + data",
|
"mode_video_data": "Video + data",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"file": "文件",
|
"file": "文件",
|
||||||
"open_video": "打开视频…",
|
"open_video": "打开视频…",
|
||||||
"reveal_config": "在资源管理器中显示配置…",
|
"reveal_config": "在资源管理器中显示配置…",
|
||||||
|
"playback": "打开录制回放…",
|
||||||
|
"playback_latest": "回放最近一次录制",
|
||||||
"view": "视图",
|
"view": "视图",
|
||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
"theme_dark": "深色",
|
"theme_dark": "深色",
|
||||||
@@ -121,7 +123,38 @@
|
|||||||
"preset_2d": "已切换到二维模式:左侧深度图、右侧三轴曲线,共用时间轴",
|
"preset_2d": "已切换到二维模式:左侧深度图、右侧三轴曲线,共用时间轴",
|
||||||
"preset_default": "已恢复自由布局"
|
"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_video_data": "视频 + 数据",
|
||||||
"mode_data_only": "仅数据",
|
"mode_data_only": "仅数据",
|
||||||
"mode_tip": "视频记录的是处理前的相机输入图像(不含箭头与文字叠加)。MP4 是有损封装,不等于无损原始数据。",
|
"mode_tip": "视频记录的是处理前的相机输入图像(不含箭头与文字叠加)。MP4 是有损封装,不等于无损原始数据。",
|
||||||
|
|||||||
@@ -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` 目前仍是占位符,不是已有实现)。
|
- PyOpenGL 三维显示(`mesh3d_panel.py` 目前仍是占位符,不是已有实现)。
|
||||||
|
- CSV 的「导出选中时间段」(整段导出已随录制落盘;区间导出待做)。
|
||||||
|
- 标定的稳定后采样(当前按固定时长采样,稳定性以 σ 呈现)。
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._record_last_report = None
|
self._record_last_report = None
|
||||||
self._record_fps = 30.0
|
self._record_fps = 30.0
|
||||||
self._session_counter = 0
|
self._session_counter = 0
|
||||||
|
self._playback = None
|
||||||
self._record_error_count = 0
|
self._record_error_count = 0
|
||||||
self._dropped_reported = 0
|
self._dropped_reported = 0
|
||||||
self._active_calibration_id = ""
|
self._active_calibration_id = ""
|
||||||
@@ -345,6 +346,18 @@ class StudioMainWindow(QMainWindow):
|
|||||||
"config_ref": config_ref,
|
"config_ref": config_ref,
|
||||||
"force_calibrated": bool(getattr(info, "force_calibrated", False)),
|
"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": {
|
"conventions": {
|
||||||
"axis_order": ["FN", "FX", "FY"],
|
"axis_order": ["FN", "FX", "FY"],
|
||||||
"force_unit": "N when calibrated, otherwise raw counts",
|
"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 = QAction(self)
|
||||||
self.act_reveal_config.triggered.connect(self._on_reveal_config)
|
self.act_reveal_config.triggered.connect(self._on_reveal_config)
|
||||||
self.file_menu.addAction(self.act_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.view_menu = menu_bar.addMenu("")
|
||||||
self.act_cal_panel = QAction(self)
|
self.act_cal_panel = QAction(self)
|
||||||
@@ -685,6 +705,8 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.file_menu.setTitle(t("menu.file"))
|
self.file_menu.setTitle(t("menu.file"))
|
||||||
self.act_open_video.setText(t("menu.open_video"))
|
self.act_open_video.setText(t("menu.open_video"))
|
||||||
self.act_reveal_config.setText(t("menu.reveal_config"))
|
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.view_menu.setTitle(t("menu.view"))
|
||||||
self.act_cal_panel.setText(t("menu.calibration_panel"))
|
self.act_cal_panel.setText(t("menu.calibration_panel"))
|
||||||
self.act_layout_default.setText(t("layout.default"))
|
self.act_layout_default.setText(t("layout.default"))
|
||||||
@@ -760,6 +782,36 @@ class StudioMainWindow(QMainWindow):
|
|||||||
def _show_about(self) -> None:
|
def _show_about(self) -> None:
|
||||||
AboutDialog(self).exec_()
|
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:
|
def _on_camera_id_changed(self, value: int) -> None:
|
||||||
self.prefs["camera_id"] = str(int(value))
|
self.prefs["camera_id"] = str(int(value))
|
||||||
self._persist_prefs()
|
self._persist_prefs()
|
||||||
|
|||||||
@@ -523,11 +523,12 @@ class CalPanel(QFrame):
|
|||||||
CalibrationPointDialog(point, theme=self._theme, parent=self).exec_()
|
CalibrationPointDialog(point, theme=self._theme, parent=self).exec_()
|
||||||
|
|
||||||
def _on_toggle_excluded(self) -> None:
|
def _on_toggle_excluded(self) -> None:
|
||||||
row = self.table.currentRow()
|
|
||||||
point = self._current_point()
|
point = self._current_point()
|
||||||
if point is None:
|
if point is None or self._recorder is None:
|
||||||
return
|
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._rebuild_store()
|
||||||
self._refresh_table()
|
self._refresh_table()
|
||||||
self.status_message.emit(
|
self.status_message.emit(
|
||||||
@@ -535,11 +536,10 @@ class CalPanel(QFrame):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _on_toggle_validation(self) -> None:
|
def _on_toggle_validation(self) -> None:
|
||||||
row = self.table.currentRow()
|
|
||||||
point = self._current_point()
|
point = self._current_point()
|
||||||
if point is None:
|
if point is None or self._recorder is None:
|
||||||
return
|
return
|
||||||
self._recorder.set_validation(row, not point.is_validation)
|
self._recorder.set_validation(point, not point.is_validation)
|
||||||
self._rebuild_store()
|
self._rebuild_store()
|
||||||
self._refresh_table()
|
self._refresh_table()
|
||||||
self.status_message.emit(
|
self.status_message.emit(
|
||||||
@@ -551,7 +551,7 @@ class CalPanel(QFrame):
|
|||||||
self._store.clear()
|
self._store.clear()
|
||||||
if self._recorder is None:
|
if self._recorder is None:
|
||||||
return
|
return
|
||||||
for point in self._recorder.points:
|
for point in self._recorder.all_points():
|
||||||
if point.excluded:
|
if point.excluded:
|
||||||
continue
|
continue
|
||||||
stats = point.loaded_stats()
|
stats = point.loaded_stats()
|
||||||
@@ -568,30 +568,25 @@ class CalPanel(QFrame):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _on_delete_row(self) -> None:
|
def _on_delete_row(self) -> None:
|
||||||
row = self.table.currentRow()
|
|
||||||
point = self._current_point()
|
point = self._current_point()
|
||||||
if point is None or self._recorder is None:
|
if point is None or self._recorder is None:
|
||||||
return
|
return
|
||||||
# Deleting is an exclusion: the recorded evidence stays, but it leaves
|
# Deleting is an exclusion: the recorded evidence stays, but it leaves
|
||||||
# the fit and the export.
|
# the fit and the export.
|
||||||
self._recorder.set_excluded(row, True)
|
self._recorder.set_excluded(point, True)
|
||||||
self._rebuild_store()
|
self._rebuild_store()
|
||||||
self._refresh_table()
|
self._refresh_table()
|
||||||
|
|
||||||
def _on_clear_channel(self) -> None:
|
def _on_clear_channel(self) -> None:
|
||||||
self._store.clear(self._channel)
|
|
||||||
if self._recorder is not None:
|
if self._recorder is not None:
|
||||||
with self._recorder._lock: # noqa: SLF001 - panel owns the view
|
self._recorder.clear_points(self._channel)
|
||||||
self._recorder.points = [
|
self._rebuild_store()
|
||||||
p for p in self._recorder.points if p.channel != self._channel
|
|
||||||
]
|
|
||||||
self._refresh_table()
|
self._refresh_table()
|
||||||
|
|
||||||
def _on_clear_all(self) -> None:
|
def _on_clear_all(self) -> None:
|
||||||
self._store.clear()
|
|
||||||
if self._recorder is not None:
|
if self._recorder is not None:
|
||||||
with self._recorder._lock: # noqa: SLF001
|
self._recorder.clear_points()
|
||||||
self._recorder.points = []
|
self._rebuild_store()
|
||||||
self._refresh_table()
|
self._refresh_table()
|
||||||
|
|
||||||
def _on_export(self) -> None:
|
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()
|
||||||
@@ -74,3 +74,46 @@ def test_locale_files_are_valid_utf8_json():
|
|||||||
data = _load_locale(name)
|
data = _load_locale(name)
|
||||||
assert isinstance(data, dict)
|
assert isinstance(data, dict)
|
||||||
assert data.get("app", {}).get("title")
|
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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -61,6 +61,66 @@ def section(name: str) -> None:
|
|||||||
print(f"\n== {name} ==")
|
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):
|
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."""
|
"""A sample shaped for the calibration recorder."""
|
||||||
from orisys_app_shared.force.session import SessionSample
|
from orisys_app_shared.force.session import SessionSample
|
||||||
@@ -429,6 +489,13 @@ def main() -> int:
|
|||||||
if not palette_failures:
|
if not palette_failures:
|
||||||
check(True, "palette rules hold for both themes")
|
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
|
scene_count = 0
|
||||||
for theme in ("dark", "light"):
|
for theme in ("dark", "light"):
|
||||||
for sensor in ("box", "finger"):
|
for sensor in ("box", "finger"):
|
||||||
@@ -1047,6 +1114,128 @@ def main() -> int:
|
|||||||
app.processEvents()
|
app.processEvents()
|
||||||
stuck.release()
|
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)}")
|
print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}")
|
||||||
for failure in FAILURES:
|
for failure in FAILURES:
|
||||||
print(f" FAIL: {failure}")
|
print(f" FAIL: {failure}")
|
||||||
|
|||||||
@@ -340,23 +340,68 @@ class CalibrationRecorder:
|
|||||||
return point
|
return point
|
||||||
|
|
||||||
# ------------------------------------------------------------ diagnostics
|
# ------------------------------------------------------------ diagnostics
|
||||||
def set_excluded(self, index: int, excluded: bool) -> None:
|
def all_points(self) -> list[CalibrationPoint]:
|
||||||
|
"""Snapshot of every recorded point, across channels."""
|
||||||
|
with self._lock:
|
||||||
|
return list(self.points)
|
||||||
|
|
||||||
|
def _resolve(self, point: CalibrationPoint) -> Optional[CalibrationPoint]:
|
||||||
|
"""Find the recorded point that is ``point``, by identity.
|
||||||
|
|
||||||
|
Callers pass the object they are looking at. An integer row index would
|
||||||
|
be ambiguous: the panel shows a per-channel filtered list while the
|
||||||
|
recorder stores every channel in one list, so index 0 of the table is
|
||||||
|
not index 0 of ``points``.
|
||||||
|
"""
|
||||||
|
if point is None:
|
||||||
|
return None
|
||||||
|
for candidate in self.points:
|
||||||
|
if candidate is point:
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_excluded(self, point: CalibrationPoint, excluded: bool) -> bool:
|
||||||
"""Remove (or restore) a point from the fit and from the export."""
|
"""Remove (or restore) a point from the fit and from the export."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if not 0 <= index < len(self.points):
|
target = self._resolve(point)
|
||||||
return
|
if target is None:
|
||||||
self.points[index].excluded = bool(excluded)
|
return False
|
||||||
|
target.excluded = bool(excluded)
|
||||||
if excluded:
|
if excluded:
|
||||||
self.points[index].is_validation = False
|
target.is_validation = False
|
||||||
|
return True
|
||||||
|
|
||||||
def set_validation(self, index: int, validation: bool) -> None:
|
def set_validation(self, point: CalibrationPoint, validation: bool) -> bool:
|
||||||
"""Hold a point out of the fit and use it to test the fit instead."""
|
"""Hold a point out of the fit and use it to test the fit instead."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if not 0 <= index < len(self.points):
|
target = self._resolve(point)
|
||||||
return
|
if target is None:
|
||||||
self.points[index].is_validation = bool(validation)
|
return False
|
||||||
|
target.is_validation = bool(validation)
|
||||||
if validation:
|
if validation:
|
||||||
self.points[index].excluded = False
|
target.excluded = False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove_point(self, point: CalibrationPoint) -> bool:
|
||||||
|
"""Drop a point entirely (used by "delete row")."""
|
||||||
|
with self._lock:
|
||||||
|
target = self._resolve(point)
|
||||||
|
if target is None:
|
||||||
|
return False
|
||||||
|
self.points.remove(target)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def clear_points(self, channel: Optional[ChannelKey] = None) -> int:
|
||||||
|
"""Drop every point, or every point of one channel. Returns the count."""
|
||||||
|
with self._lock:
|
||||||
|
if channel is None:
|
||||||
|
removed = len(self.points)
|
||||||
|
self.points = []
|
||||||
|
return removed
|
||||||
|
kept = [point for point in self.points if point.channel != channel]
|
||||||
|
removed = len(self.points) - len(kept)
|
||||||
|
self.points = kept
|
||||||
|
return removed
|
||||||
|
|
||||||
def points_for(self, channel: ChannelKey) -> list[CalibrationPoint]:
|
def points_for(self, channel: ChannelKey) -> list[CalibrationPoint]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Experiment recording (video + data + metadata)."""
|
"""Experiment recording (video + data + metadata) and replay."""
|
||||||
|
|
||||||
|
from orisys_app_shared.record.depth import DepthRecomputer, DepthResult
|
||||||
from orisys_app_shared.record.experiment import (
|
from orisys_app_shared.record.experiment import (
|
||||||
EVENTS_HEADER,
|
EVENTS_HEADER,
|
||||||
FORCES_HEADER,
|
FORCES_HEADER,
|
||||||
@@ -11,15 +12,27 @@ from orisys_app_shared.record.experiment import (
|
|||||||
RecordStats,
|
RecordStats,
|
||||||
format_value,
|
format_value,
|
||||||
)
|
)
|
||||||
|
from orisys_app_shared.record.playback import (
|
||||||
|
RecordingFrame,
|
||||||
|
RecordingInfo,
|
||||||
|
RecordingReader,
|
||||||
|
list_recordings,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"EVENTS_HEADER",
|
"EVENTS_HEADER",
|
||||||
"FORCES_HEADER",
|
"FORCES_HEADER",
|
||||||
"FRAMES_HEADER",
|
"FRAMES_HEADER",
|
||||||
"RECORD_FORMAT_VERSION",
|
"RECORD_FORMAT_VERSION",
|
||||||
|
"DepthRecomputer",
|
||||||
|
"DepthResult",
|
||||||
"ExperimentRecorder",
|
"ExperimentRecorder",
|
||||||
"RecordConfig",
|
"RecordConfig",
|
||||||
"RecordReport",
|
"RecordReport",
|
||||||
"RecordStats",
|
"RecordStats",
|
||||||
|
"RecordingFrame",
|
||||||
|
"RecordingInfo",
|
||||||
|
"RecordingReader",
|
||||||
"format_value",
|
"format_value",
|
||||||
|
"list_recordings",
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Recompute depth for a recording, offline.
|
||||||
|
|
||||||
|
Depth is not stored with a recording (a per-frame depth stream would be several
|
||||||
|
GB per hour). It is a function of the frame and the sensor configuration, so a
|
||||||
|
replay re-runs the SDK over ``raw_*.mp4`` using the ``config_ref`` recorded in
|
||||||
|
``session.json``.
|
||||||
|
|
||||||
|
The honest failure modes are all surfaced rather than papered over:
|
||||||
|
|
||||||
|
* the SDK is not importable, or the recording's sensor config is gone;
|
||||||
|
* the recording predates the recorded config hash, so the recomputation is not
|
||||||
|
guaranteed to reproduce what was shown live;
|
||||||
|
* the segment cannot be decoded.
|
||||||
|
|
||||||
|
The caller is expected to show the returned ``error`` and to mark the depth pane
|
||||||
|
as unavailable instead of substituting some other image.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from orisys_app_shared.record.playback import RecordingReader
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DepthResult:
|
||||||
|
"""Outcome of one recomputation attempt."""
|
||||||
|
|
||||||
|
depth: Optional[np.ndarray] = None
|
||||||
|
error: str = ""
|
||||||
|
frame_id: int = -1
|
||||||
|
t_rel_s: float = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.depth is not None and not self.error
|
||||||
|
|
||||||
|
|
||||||
|
class DepthRecomputer:
|
||||||
|
"""Recompute depth maps for one recording, on demand."""
|
||||||
|
|
||||||
|
def __init__(self, reader: RecordingReader) -> None:
|
||||||
|
self.reader = reader
|
||||||
|
self._sensor = None
|
||||||
|
self._segment: Optional[Path] = None
|
||||||
|
self.last_error = ""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ probing
|
||||||
|
@staticmethod
|
||||||
|
def sdk_available() -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
import orisys # noqa: F401
|
||||||
|
|
||||||
|
return True, str(getattr(__import__("orisys"), "__version__", "unknown"))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, f"{type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
|
def config_ref(self) -> str:
|
||||||
|
session = self.reader.session()
|
||||||
|
device = session.get("device") or {}
|
||||||
|
return str(device.get("config_ref") or "")
|
||||||
|
|
||||||
|
def preflight(self) -> str:
|
||||||
|
"""Return an empty string when recomputation can be attempted."""
|
||||||
|
ok, detail = self.sdk_available()
|
||||||
|
if not ok:
|
||||||
|
return f"sdk_unavailable: {detail}"
|
||||||
|
config_ref = self.config_ref()
|
||||||
|
if not config_ref:
|
||||||
|
return "config_missing"
|
||||||
|
if not Path(config_ref).is_file():
|
||||||
|
return "config_file_missing"
|
||||||
|
if not self.reader.segments():
|
||||||
|
return "no_video"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- recompute
|
||||||
|
def depth_at(self, t_rel_s: float) -> DepthResult:
|
||||||
|
"""Recompute the depth map for the frame at ``t_rel_s``."""
|
||||||
|
problem = self.preflight()
|
||||||
|
if problem:
|
||||||
|
self.last_error = problem
|
||||||
|
return DepthResult(error=problem, t_rel_s=float(t_rel_s))
|
||||||
|
|
||||||
|
frame = self.reader.frame_at(t_rel_s)
|
||||||
|
if frame is None:
|
||||||
|
return DepthResult(error="no_frame", t_rel_s=float(t_rel_s))
|
||||||
|
if not frame.in_video:
|
||||||
|
# The queue dropped this frame; say so rather than show the previous
|
||||||
|
# frame as if it belonged to the cursor position.
|
||||||
|
return DepthResult(
|
||||||
|
error="frame_not_in_video", frame_id=frame.frame_id, t_rel_s=frame.t_rel_s
|
||||||
|
)
|
||||||
|
|
||||||
|
segment = self.reader.segment_path(frame.segment)
|
||||||
|
if segment is None:
|
||||||
|
return DepthResult(
|
||||||
|
error="segment_missing", frame_id=frame.frame_id, t_rel_s=frame.t_rel_s
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
depth = self._depth_from_segment(segment, frame.segment_frame)
|
||||||
|
except Exception as exc: # noqa: BLE001 - reported to the caller
|
||||||
|
logger.exception("Depth recomputation failed")
|
||||||
|
self.last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
return DepthResult(
|
||||||
|
error=self.last_error, frame_id=frame.frame_id, t_rel_s=frame.t_rel_s
|
||||||
|
)
|
||||||
|
if depth is None:
|
||||||
|
self.last_error = "depth_not_available"
|
||||||
|
return DepthResult(
|
||||||
|
error=self.last_error, frame_id=frame.frame_id, t_rel_s=frame.t_rel_s
|
||||||
|
)
|
||||||
|
self.last_error = ""
|
||||||
|
return DepthResult(depth=depth, frame_id=frame.frame_id, t_rel_s=frame.t_rel_s)
|
||||||
|
|
||||||
|
def _depth_from_segment(self, segment: Path, segment_frame: int) -> Optional[np.ndarray]:
|
||||||
|
"""Open the segment with the SDK and read the depth channel."""
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
if self._segment != segment or self._sensor is None:
|
||||||
|
self._close()
|
||||||
|
self._sensor = orisys.Sensor(
|
||||||
|
str(segment),
|
||||||
|
config_name=self._config_name(),
|
||||||
|
verbose=False,
|
||||||
|
backend="auto",
|
||||||
|
)
|
||||||
|
self._segment = segment
|
||||||
|
sensor = self._sensor
|
||||||
|
# The SDK's video source advances frame by frame, so a seek means
|
||||||
|
# stepping. Correctness first: we never return a frame we did not read.
|
||||||
|
target = max(1, int(segment_frame))
|
||||||
|
last_depth = None
|
||||||
|
for _ in range(target):
|
||||||
|
image = sensor.get_img()
|
||||||
|
if image is None:
|
||||||
|
break
|
||||||
|
info = sensor.info
|
||||||
|
if not hasattr(info, "DEPTH"):
|
||||||
|
return None
|
||||||
|
last_depth = sensor.read_info(info.DEPTH)
|
||||||
|
return None if last_depth is None else np.asarray(last_depth)
|
||||||
|
|
||||||
|
def _config_name(self) -> str:
|
||||||
|
config_ref = self.config_ref()
|
||||||
|
path = Path(config_ref)
|
||||||
|
return path.parent.name if path.name == "sensor.json" else path.stem
|
||||||
|
|
||||||
|
def _close(self) -> None:
|
||||||
|
sensor, self._sensor = self._sensor, None
|
||||||
|
self._segment = None
|
||||||
|
if sensor is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
sensor.disconnect()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._close()
|
||||||
|
|
||||||
|
def __enter__(self) -> "DepthRecomputer":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc) -> None:
|
||||||
|
self.close()
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""Read a recorded experiment back: forces, the frame index and the video.
|
||||||
|
|
||||||
|
Replay policy for depth (decided with the team): **depth is not stored**. It is
|
||||||
|
a function of the frame and the sensor configuration, so it is recomputed from
|
||||||
|
``raw_*.mp4`` with the config recorded in ``session.json``. That keeps a
|
||||||
|
recording small at the cost of requiring the SDK to reproduce the same result,
|
||||||
|
which is why :meth:`RecordingReader.replay_warnings` checks that the config hash
|
||||||
|
and SDK version still match what was recorded.
|
||||||
|
|
||||||
|
Nothing here touches Qt, so the reader is testable without a display.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Column names as written by the recorder (index == position in forces.csv).
|
||||||
|
FORCE_COLUMNS = (
|
||||||
|
"session_id",
|
||||||
|
"frame_id",
|
||||||
|
"t_rel_s",
|
||||||
|
"FN_raw",
|
||||||
|
"FX_raw",
|
||||||
|
"FY_raw",
|
||||||
|
"FN_N",
|
||||||
|
"FX_N",
|
||||||
|
"FY_N",
|
||||||
|
"valid",
|
||||||
|
"calibration_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RecordingFrame:
|
||||||
|
"""One row of ``frames.csv`` — the mapping from a frame to the video."""
|
||||||
|
|
||||||
|
frame_id: int
|
||||||
|
t_rel_s: float
|
||||||
|
acquired_at: str
|
||||||
|
segment: int
|
||||||
|
segment_frame: int
|
||||||
|
in_video: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RecordingInfo:
|
||||||
|
directory: Path
|
||||||
|
session: dict
|
||||||
|
frames: list[RecordingFrame]
|
||||||
|
times: np.ndarray
|
||||||
|
raw: np.ndarray
|
||||||
|
newtons: np.ndarray
|
||||||
|
valid: np.ndarray
|
||||||
|
frame_ids: np.ndarray
|
||||||
|
dropped_rows: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration_s(self) -> float:
|
||||||
|
if self.times.size == 0:
|
||||||
|
return 0.0
|
||||||
|
return float(self.times[-1])
|
||||||
|
|
||||||
|
def value_for_unit(self, unit: str) -> np.ndarray:
|
||||||
|
if str(unit).upper().startswith("N"):
|
||||||
|
return self.newtons
|
||||||
|
return self.raw
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingReader:
|
||||||
|
"""Open one experiment directory and serve replay queries from it."""
|
||||||
|
|
||||||
|
def __init__(self, directory: str | Path) -> None:
|
||||||
|
self.directory = Path(directory)
|
||||||
|
if not self.directory.is_dir():
|
||||||
|
raise FileNotFoundError(f"Not a recording directory: {self.directory}")
|
||||||
|
self._info: Optional[RecordingInfo] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ loading
|
||||||
|
def info(self) -> RecordingInfo:
|
||||||
|
if self._info is None:
|
||||||
|
self._info = self._load()
|
||||||
|
return self._info
|
||||||
|
|
||||||
|
def session(self) -> dict:
|
||||||
|
path = self.directory / "session.json"
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception: # noqa: BLE001 - a broken session.json must not crash replay
|
||||||
|
logger.exception("Could not read %s", path)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _load(self) -> RecordingInfo:
|
||||||
|
frames = self._load_frames()
|
||||||
|
times, raw, newtons, valid, frame_ids, dropped = self._load_forces()
|
||||||
|
return RecordingInfo(
|
||||||
|
directory=self.directory,
|
||||||
|
session=self.session(),
|
||||||
|
frames=frames,
|
||||||
|
times=times,
|
||||||
|
raw=raw,
|
||||||
|
newtons=newtons,
|
||||||
|
valid=valid,
|
||||||
|
frame_ids=frame_ids,
|
||||||
|
dropped_rows=dropped,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_frames(self) -> list[RecordingFrame]:
|
||||||
|
path = self.directory / "frames.csv"
|
||||||
|
if not path.is_file():
|
||||||
|
return []
|
||||||
|
rows: list[RecordingFrame] = []
|
||||||
|
with path.open(encoding="utf-8", newline="") as handle:
|
||||||
|
for row in csv.DictReader(handle):
|
||||||
|
try:
|
||||||
|
rows.append(
|
||||||
|
RecordingFrame(
|
||||||
|
frame_id=int(row["frame_id"]),
|
||||||
|
t_rel_s=float(row["t_rel_s"]),
|
||||||
|
acquired_at=row.get("acquired_at", ""),
|
||||||
|
segment=int(row.get("segment") or 0),
|
||||||
|
segment_frame=int(row.get("segment_frame") or 0),
|
||||||
|
in_video=row.get("in_video") == "1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _load_forces(self):
|
||||||
|
path = self.directory / "forces.csv"
|
||||||
|
if not path.is_file():
|
||||||
|
empty = np.empty(0, dtype=np.float64)
|
||||||
|
return empty, np.empty((0, 3)), np.empty((0, 3)), np.empty(0, bool), np.empty(0, int), 0
|
||||||
|
|
||||||
|
def parse(text: str) -> float:
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
# An empty cell means "not available"; it must never become 0.
|
||||||
|
return float("nan")
|
||||||
|
try:
|
||||||
|
return float(text)
|
||||||
|
except ValueError:
|
||||||
|
return float("nan")
|
||||||
|
|
||||||
|
times: list[float] = []
|
||||||
|
raw: list[tuple[float, float, float]] = []
|
||||||
|
newtons: list[tuple[float, float, float]] = []
|
||||||
|
valid: list[bool] = []
|
||||||
|
frame_ids: list[int] = []
|
||||||
|
dropped = 0
|
||||||
|
with path.open(encoding="utf-8", newline="") as handle:
|
||||||
|
for row in csv.DictReader(handle):
|
||||||
|
try:
|
||||||
|
times.append(float(row["t_rel_s"]))
|
||||||
|
frame_ids.append(int(row["frame_id"]))
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
dropped += 1
|
||||||
|
continue
|
||||||
|
raw.append(
|
||||||
|
(parse(row.get("FN_raw")), parse(row.get("FX_raw")), parse(row.get("FY_raw")))
|
||||||
|
)
|
||||||
|
newtons.append(
|
||||||
|
(parse(row.get("FN_N")), parse(row.get("FX_N")), parse(row.get("FY_N")))
|
||||||
|
)
|
||||||
|
valid.append((row.get("valid") or "0").strip() == "1")
|
||||||
|
return (
|
||||||
|
np.asarray(times, dtype=np.float64),
|
||||||
|
np.asarray(raw, dtype=np.float64).reshape(-1, 3),
|
||||||
|
np.asarray(newtons, dtype=np.float64).reshape(-1, 3),
|
||||||
|
np.asarray(valid, dtype=bool),
|
||||||
|
np.asarray(frame_ids, dtype=np.int64),
|
||||||
|
dropped,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ queries
|
||||||
|
def frame_at(self, t_rel_s: float) -> Optional[RecordingFrame]:
|
||||||
|
"""The recorded frame at or just before ``t_rel_s``."""
|
||||||
|
frames = self.info().frames
|
||||||
|
if not frames:
|
||||||
|
return None
|
||||||
|
times = [frame.t_rel_s for frame in frames]
|
||||||
|
index = int(np.searchsorted(np.asarray(times), float(t_rel_s), side="right")) - 1
|
||||||
|
index = max(0, min(index, len(frames) - 1))
|
||||||
|
return frames[index]
|
||||||
|
|
||||||
|
def segment_path(self, segment: int) -> Optional[Path]:
|
||||||
|
if segment <= 0:
|
||||||
|
return None
|
||||||
|
path = self.directory / f"raw_{segment:04d}.mp4"
|
||||||
|
return path if path.is_file() else None
|
||||||
|
|
||||||
|
def segments(self) -> list[Path]:
|
||||||
|
return sorted(self.directory.glob("raw_*.mp4"))
|
||||||
|
|
||||||
|
def video_frame_at(self, t_rel_s: float):
|
||||||
|
"""Decode the video frame for a replay position.
|
||||||
|
|
||||||
|
Returns ``(frame_bgr, RecordingFrame)`` or ``(None, frame)`` when the
|
||||||
|
frame was not written to video (the queue dropped it).
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
frame = self.frame_at(t_rel_s)
|
||||||
|
if frame is None or not frame.in_video:
|
||||||
|
return None, frame
|
||||||
|
path = self.segment_path(frame.segment)
|
||||||
|
if path is None:
|
||||||
|
return None, frame
|
||||||
|
capture = cv2.VideoCapture(str(path))
|
||||||
|
if not capture.isOpened():
|
||||||
|
return None, frame
|
||||||
|
try:
|
||||||
|
capture.set(cv2.CAP_PROP_POS_FRAMES, max(0, frame.segment_frame - 1))
|
||||||
|
ok, image = capture.read()
|
||||||
|
return (image if ok else None), frame
|
||||||
|
finally:
|
||||||
|
capture.release()
|
||||||
|
|
||||||
|
def forces_window(self, t0: float, t1: float) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""``(times, values)`` inside ``[t0, t1]`` for the raw triplet."""
|
||||||
|
info = self.info()
|
||||||
|
if info.times.size == 0:
|
||||||
|
return info.times, info.raw
|
||||||
|
mask = (info.times >= float(t0)) & (info.times <= float(t1))
|
||||||
|
return info.times[mask], info.raw[mask]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ policy
|
||||||
|
def depth_policy(self) -> dict:
|
||||||
|
return dict(self.info().session.get("depth") or {})
|
||||||
|
|
||||||
|
def depth_is_stored(self) -> bool:
|
||||||
|
return bool(self.depth_policy().get("stored", False))
|
||||||
|
|
||||||
|
def recorded_config_ref(self) -> str:
|
||||||
|
device = self.info().session.get("device") or {}
|
||||||
|
return str(device.get("config_ref") or "")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def config_sha256(config_ref: str) -> str:
|
||||||
|
"""Hash of the config file currently on disk (empty when unreadable)."""
|
||||||
|
if not config_ref:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
path = Path(str(config_ref))
|
||||||
|
if path.is_file():
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
except Exception: # noqa: BLE001 - a missing config is reported, not raised
|
||||||
|
return ""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def replay_warnings(self, *, current_config_sha: str = "", sdk_version: str = "") -> list[str]:
|
||||||
|
"""What could stop this recording from replaying faithfully.
|
||||||
|
|
||||||
|
Depth is recomputed rather than stored, so a changed config or SDK means
|
||||||
|
the depth seen on replay may differ from what was shown live. That must
|
||||||
|
be stated, not discovered later.
|
||||||
|
"""
|
||||||
|
session = self.info().session
|
||||||
|
warnings: list[str] = []
|
||||||
|
recorded_sha = str((session.get("device") or {}).get("config_sha256") or "")
|
||||||
|
if not current_config_sha:
|
||||||
|
# Compare against the config file as it is now, which is exactly what
|
||||||
|
# the recomputation would use.
|
||||||
|
current_config_sha = self.config_sha256(self.recorded_config_ref())
|
||||||
|
if recorded_sha and current_config_sha and recorded_sha != current_config_sha:
|
||||||
|
warnings.append("config_changed")
|
||||||
|
versions = session.get("versions") or {}
|
||||||
|
recorded_sdk = str(versions.get("sdk") or "")
|
||||||
|
if recorded_sdk and sdk_version and recorded_sdk != sdk_version:
|
||||||
|
warnings.append("sdk_changed")
|
||||||
|
if recorded_sha and not current_config_sha:
|
||||||
|
warnings.append("config_missing")
|
||||||
|
if not self.depth_is_stored():
|
||||||
|
warnings.append("depth_recomputed")
|
||||||
|
if self.info().dropped_rows:
|
||||||
|
warnings.append("rows_skipped")
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
def iter_forces(self, batch: int = 4096) -> Iterator[tuple[np.ndarray, np.ndarray]]:
|
||||||
|
"""Stream the force rows, so a long recording is not read twice."""
|
||||||
|
path = self.directory / "forces.csv"
|
||||||
|
if not path.is_file():
|
||||||
|
return
|
||||||
|
times: list[float] = []
|
||||||
|
values: list[tuple[float, float, float, float, float, float]] = []
|
||||||
|
with path.open(encoding="utf-8", newline="") as handle:
|
||||||
|
for row in csv.DictReader(handle):
|
||||||
|
try:
|
||||||
|
times.append(float(row["t_rel_s"]))
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
values.append(
|
||||||
|
tuple(
|
||||||
|
float(row[key]) if (row.get(key) or "").strip() else float("nan")
|
||||||
|
for key in ("FN_raw", "FX_raw", "FY_raw", "FN_N", "FX_N", "FY_N")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(times) >= batch:
|
||||||
|
yield np.asarray(times), np.asarray(values).reshape(-1, 6)
|
||||||
|
times, values = [], []
|
||||||
|
if times:
|
||||||
|
yield np.asarray(times), np.asarray(values).reshape(-1, 6)
|
||||||
|
|
||||||
|
|
||||||
|
def list_recordings(root: str | Path) -> list[Path]:
|
||||||
|
"""Every recording directory under ``root``, newest first."""
|
||||||
|
base = Path(root)
|
||||||
|
if not base.is_dir():
|
||||||
|
return []
|
||||||
|
candidates = [
|
||||||
|
path
|
||||||
|
for path in base.iterdir()
|
||||||
|
if path.is_dir() and (path / "forces.csv").is_file()
|
||||||
|
]
|
||||||
|
return sorted(candidates, key=lambda path: path.name, reverse=True)
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""Replay of a recording: the reader, and the depth replay policy.
|
||||||
|
|
||||||
|
Depth is not stored with a recording (team decision): replay recomputes it from
|
||||||
|
``raw_*.mp4`` and the recorded config. These tests cover the reader contract and
|
||||||
|
that the policy's risks are reported instead of discovered later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
|
||||||
|
from orisys_app_shared.record import (
|
||||||
|
ExperimentRecorder,
|
||||||
|
RecordConfig,
|
||||||
|
RecordingReader,
|
||||||
|
list_recordings,
|
||||||
|
)
|
||||||
|
from orisys_app_shared.record.depth import DepthRecomputer
|
||||||
|
|
||||||
|
SCRATCH = Path(__file__).resolve().parent / "_scratch_playback"
|
||||||
|
|
||||||
|
|
||||||
|
def _packet(index: int, *, valid: bool = True) -> SdkPacket:
|
||||||
|
nan = float("nan")
|
||||||
|
frame = np.full((24, 32, 3), (index * 5) % 255, np.uint8)
|
||||||
|
return SdkPacket(
|
||||||
|
raw_frame=frame,
|
||||||
|
processed_frame=frame,
|
||||||
|
timestamp=1000.0 + index / 30.0,
|
||||||
|
fps=30.0,
|
||||||
|
process_ms=1.0,
|
||||||
|
normal_force=2.0 if valid else nan,
|
||||||
|
shear_x=0.5 if valid else nan,
|
||||||
|
shear_y=-0.5 if valid else nan,
|
||||||
|
normal_force_raw=(9.0 + index * 0.1) if valid else nan,
|
||||||
|
shear_x_raw=0.4 if valid else nan,
|
||||||
|
shear_y_raw=-0.4 if valid else nan,
|
||||||
|
force_unit="N",
|
||||||
|
force_calibrated=True,
|
||||||
|
session_id=1,
|
||||||
|
frame_id=index,
|
||||||
|
valid=valid,
|
||||||
|
invalid_reason="" if valid else "read failed",
|
||||||
|
raw_available=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def recording():
|
||||||
|
shutil.rmtree(SCRATCH, ignore_errors=True)
|
||||||
|
SCRATCH.mkdir(parents=True, exist_ok=True)
|
||||||
|
recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0, segment_seconds=0.4))
|
||||||
|
directory = recorder.start(
|
||||||
|
root=SCRATCH,
|
||||||
|
session_id=3,
|
||||||
|
calibration_id="/cfg/sensor.json",
|
||||||
|
metadata={
|
||||||
|
"device": {
|
||||||
|
"sensor_type": "box",
|
||||||
|
"config_ref": str(SCRATCH / "sensor.json"),
|
||||||
|
"config_sha256": "recorded-hash",
|
||||||
|
},
|
||||||
|
"versions": {"sdk": "0.5.1"},
|
||||||
|
"depth": {"stored": False, "replay": "recompute"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
(SCRATCH / "sensor.json").write_text('{"camera": {}}\n', encoding="utf-8")
|
||||||
|
for index in range(60):
|
||||||
|
recorder.on_packet(_packet(index))
|
||||||
|
recorder.on_packet(_packet(90, valid=False))
|
||||||
|
recorder.stop()
|
||||||
|
yield directory
|
||||||
|
shutil.rmtree(SCRATCH, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reader_reports_the_recording_shape(recording):
|
||||||
|
reader = RecordingReader(recording)
|
||||||
|
info = reader.info()
|
||||||
|
|
||||||
|
assert len(info.frames) == 61
|
||||||
|
assert info.times.size == 61
|
||||||
|
assert info.duration_s == pytest.approx(3.0, abs=0.05)
|
||||||
|
assert int(info.valid.sum()) == 60, "the failed readout should be marked invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reader_keeps_unavailable_values_empty(recording):
|
||||||
|
reader = RecordingReader(recording)
|
||||||
|
info = reader.info()
|
||||||
|
assert np.isnan(info.raw[-1]).all(), "an empty CSV cell must not read back as 0"
|
||||||
|
assert np.isfinite(info.raw[0]).all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reader_maps_time_to_the_video_segment(recording):
|
||||||
|
reader = RecordingReader(recording)
|
||||||
|
frame = reader.frame_at(1.0)
|
||||||
|
|
||||||
|
assert frame is not None
|
||||||
|
assert frame.frame_id == 30
|
||||||
|
assert frame.t_rel_s == pytest.approx(1.0, abs=0.02)
|
||||||
|
assert frame.in_video
|
||||||
|
assert frame.segment >= 1
|
||||||
|
assert reader.segment_path(frame.segment) is not None
|
||||||
|
# Before the start and past the end clamp instead of raising.
|
||||||
|
assert reader.frame_at(-5.0).frame_id == 0
|
||||||
|
assert reader.frame_at(500.0).frame_id == 90
|
||||||
|
|
||||||
|
|
||||||
|
def test_reader_decodes_the_video_frame(recording):
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
if not hasattr(cv2, "VideoCapture"): # pragma: no cover
|
||||||
|
pytest.skip("no video backend")
|
||||||
|
reader = RecordingReader(recording)
|
||||||
|
image, frame = reader.video_frame_at(1.0)
|
||||||
|
|
||||||
|
assert frame is not None and frame.in_video
|
||||||
|
assert image is not None, "the frame index and the video disagreed"
|
||||||
|
assert image.shape[:2] == (24, 32)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reader_window_and_streaming_agree(recording):
|
||||||
|
reader = RecordingReader(recording)
|
||||||
|
times, values = reader.forces_window(0.5, 1.0)
|
||||||
|
assert len(times) == 16
|
||||||
|
assert values.shape == (16, 3)
|
||||||
|
|
||||||
|
streamed = sum(len(batch_times) for batch_times, _ in reader.iter_forces(batch=25))
|
||||||
|
assert streamed == 61, "streaming must see every row"
|
||||||
|
|
||||||
|
|
||||||
|
def test_depth_policy_says_recompute_and_warns_about_drift(recording):
|
||||||
|
reader = RecordingReader(recording)
|
||||||
|
|
||||||
|
assert reader.depth_is_stored() is False
|
||||||
|
assert reader.depth_policy().get("replay") == "recompute"
|
||||||
|
assert reader.recorded_config_ref() == str(SCRATCH / "sensor.json")
|
||||||
|
|
||||||
|
# The config file on disk no longer matches what was recorded.
|
||||||
|
codes = reader.replay_warnings(sdk_version="0.5.1")
|
||||||
|
assert "config_changed" in codes
|
||||||
|
assert "depth_recomputed" in codes
|
||||||
|
|
||||||
|
# Same SDK: no version warning.
|
||||||
|
assert "sdk_changed" not in codes
|
||||||
|
assert "sdk_changed" in reader.replay_warnings(sdk_version="9.9.9")
|
||||||
|
|
||||||
|
|
||||||
|
def test_depth_recompute_preflight_states_why_it_cannot_run(recording):
|
||||||
|
reader = RecordingReader(recording)
|
||||||
|
recomputer = DepthRecomputer(reader)
|
||||||
|
try:
|
||||||
|
problem = recomputer.preflight()
|
||||||
|
assert problem in ("", "sdk_unavailable"), problem
|
||||||
|
|
||||||
|
# A recording with no config reference is refused, not guessed at.
|
||||||
|
missing = RecordingReader(recording)
|
||||||
|
missing._info = None # noqa: SLF001 - force a re-read below
|
||||||
|
empty = SCRATCH / "empty"
|
||||||
|
empty.mkdir(exist_ok=True)
|
||||||
|
(empty / "forces.csv").write_text(
|
||||||
|
",".join(("session_id", "frame_id", "t_rel_s", "FN_raw", "FX_raw", "FY_raw",
|
||||||
|
"FN_N", "FX_N", "FY_N", "valid", "calibration_id")) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(empty / "session.json").write_text(json.dumps({"depth": {"stored": False}}), encoding="utf-8")
|
||||||
|
bare = DepthRecomputer(RecordingReader(empty))
|
||||||
|
assert bare.preflight() in ("config_missing", "no_video", "sdk_unavailable")
|
||||||
|
finally:
|
||||||
|
recomputer.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_depth_result_is_failed_not_blank_when_sdk_is_missing(recording):
|
||||||
|
"""A missing SDK must produce an explicit error, never a stand-in image."""
|
||||||
|
saved = sys.modules.get("orisys")
|
||||||
|
blocker = types.ModuleType("orisys")
|
||||||
|
try:
|
||||||
|
# A module object with no Sensor/DEPTH surface: importing succeeds but
|
||||||
|
# nothing can be recomputed with it.
|
||||||
|
sys.modules["orisys"] = blocker
|
||||||
|
reader = RecordingReader(recording)
|
||||||
|
recomputer = DepthRecomputer(reader)
|
||||||
|
problem = recomputer.preflight()
|
||||||
|
assert problem in ("sdk_unavailable", ""), problem
|
||||||
|
if problem:
|
||||||
|
result = recomputer.depth_at(1.0)
|
||||||
|
assert not result.ok
|
||||||
|
assert result.depth is None
|
||||||
|
assert result.error
|
||||||
|
finally:
|
||||||
|
if saved is None:
|
||||||
|
sys.modules.pop("orisys", None)
|
||||||
|
else:
|
||||||
|
sys.modules["orisys"] = saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_recordings_finds_directories_newest_first(recording):
|
||||||
|
second = SCRATCH / "zzz_later"
|
||||||
|
second.mkdir()
|
||||||
|
(second / "forces.csv").write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
found = list_recordings(SCRATCH)
|
||||||
|
assert found[0].name == "zzz_later", "recordings should be newest first"
|
||||||
|
assert recording in found
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - manual runner
|
||||||
|
import shutil as _shutil
|
||||||
|
|
||||||
|
failures = 0
|
||||||
|
for name, function in sorted(globals().items()):
|
||||||
|
if not name.startswith("test_") or not callable(function):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if name == "test_list_recordings_finds_directories_newest_first":
|
||||||
|
_shutil.rmtree(SCRATCH, ignore_errors=True)
|
||||||
|
SCRATCH.mkdir(parents=True, exist_ok=True)
|
||||||
|
recorder = ExperimentRecorder(RecordConfig(video=False))
|
||||||
|
directory = recorder.start(
|
||||||
|
root=SCRATCH, session_id=1, metadata={"depth": {"stored": False}}
|
||||||
|
)
|
||||||
|
recorder.on_packet(_packet(0))
|
||||||
|
recorder.stop()
|
||||||
|
try:
|
||||||
|
function(directory)
|
||||||
|
finally:
|
||||||
|
_shutil.rmtree(SCRATCH, ignore_errors=True)
|
||||||
|
print(f" ok {name}")
|
||||||
|
continue
|
||||||
|
_shutil.rmtree(SCRATCH, ignore_errors=True)
|
||||||
|
SCRATCH.mkdir(parents=True, exist_ok=True)
|
||||||
|
recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0, segment_seconds=0.4))
|
||||||
|
directory = recorder.start(
|
||||||
|
root=SCRATCH,
|
||||||
|
session_id=3,
|
||||||
|
calibration_id="/cfg/sensor.json",
|
||||||
|
metadata={
|
||||||
|
"device": {
|
||||||
|
"sensor_type": "box",
|
||||||
|
"config_ref": str(SCRATCH / "sensor.json"),
|
||||||
|
"config_sha256": "recorded-hash",
|
||||||
|
},
|
||||||
|
"versions": {"sdk": "0.5.1"},
|
||||||
|
"depth": {"stored": False, "replay": "recompute"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
(SCRATCH / "sensor.json").write_text('{"camera": {}}\n', encoding="utf-8")
|
||||||
|
for index in range(60):
|
||||||
|
recorder.on_packet(_packet(index))
|
||||||
|
recorder.on_packet(_packet(90, valid=False))
|
||||||
|
recorder.stop()
|
||||||
|
try:
|
||||||
|
function(directory)
|
||||||
|
finally:
|
||||||
|
_shutil.rmtree(SCRATCH, ignore_errors=True)
|
||||||
|
print(f" ok {name}")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
failures += 1
|
||||||
|
print(f" FAIL {name}: {type(exc).__name__}: {exc}")
|
||||||
|
print(f"failures: {failures}")
|
||||||
|
raise SystemExit(1 if failures else 0)
|
||||||
@@ -292,6 +292,58 @@ def test_recorder_cancel_discards_the_point():
|
|||||||
assert recorder.points == []
|
assert recorder.points == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_recorder_diagnostics_use_point_identity_not_table_rows():
|
||||||
|
"""A per-channel table row is not an index into the cross-channel list.
|
||||||
|
|
||||||
|
The panel shows the points of one channel while the recorder stores every
|
||||||
|
channel in a single list, so an integer index resolves to the wrong point as
|
||||||
|
soon as a second channel has been recorded.
|
||||||
|
"""
|
||||||
|
recorder = _recorder()
|
||||||
|
for channel, load, raw in (("fn", 1.0, 100.0), ("fn", 2.0, 200.0), ("fx", 1.0, 7.0)):
|
||||||
|
recorder.start(channel=channel, reference_load_n=load, duration_s=1.0)
|
||||||
|
for index in range(10):
|
||||||
|
sample = _sample(index)
|
||||||
|
sample = SessionSample(
|
||||||
|
frame_id=index,
|
||||||
|
t_rel_s=index / 100.0,
|
||||||
|
fn_raw=raw if channel == "fn" else 0.4,
|
||||||
|
fx_raw=raw if channel == "fx" else 0.4,
|
||||||
|
fy_raw=-0.2,
|
||||||
|
valid=True,
|
||||||
|
)
|
||||||
|
recorder.feed(sample)
|
||||||
|
recorder.finish_now()
|
||||||
|
|
||||||
|
fx_point = recorder.points_for("fx")[0]
|
||||||
|
assert recorder.set_excluded(fx_point, True) is True
|
||||||
|
assert [point.excluded for point in recorder.all_points()] == [False, False, True], (
|
||||||
|
"excluding the FX point must not touch the FN points"
|
||||||
|
)
|
||||||
|
|
||||||
|
# A point that was never recorded is rejected rather than silently ignored.
|
||||||
|
from orisys_app_shared.force.calibration_recorder import CalibrationPoint
|
||||||
|
|
||||||
|
stranger = CalibrationPoint(channel="fn", reference_load_n=9.0)
|
||||||
|
assert recorder.set_excluded(stranger, True) is False
|
||||||
|
assert recorder.set_validation(stranger, True) is False
|
||||||
|
assert recorder.remove_point(stranger) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_points_can_target_one_channel():
|
||||||
|
recorder = _recorder()
|
||||||
|
for channel in ("fn", "fx", "fn"):
|
||||||
|
recorder.start(channel=channel, reference_load_n=1.0, duration_s=1.0)
|
||||||
|
recorder.feed(_sample(0))
|
||||||
|
recorder.feed(_sample(1))
|
||||||
|
recorder.finish_now()
|
||||||
|
|
||||||
|
assert recorder.clear_points("fn") == 2
|
||||||
|
assert [point.channel for point in recorder.all_points()] == ["fx"]
|
||||||
|
assert recorder.clear_points() == 1
|
||||||
|
assert recorder.all_points() == []
|
||||||
|
|
||||||
|
|
||||||
def test_recorder_round_index_advances_per_channel():
|
def test_recorder_round_index_advances_per_channel():
|
||||||
recorder = _recorder()
|
recorder = _recorder()
|
||||||
for _ in range(2):
|
for _ in range(2):
|
||||||
|
|||||||
Reference in New Issue
Block a user