record the full experiment: video, CSV and metadata

Phase 1 of the review asked for one directory per experiment, a force CSV,
video of the pre-processing image, and metadata that ties them together. The
record channel from the previous change already kept the whole session; this
adds the on-disk format.

What
- Add record/experiment.py: ExperimentRecorder writes one directory per
  session containing session.json, forces.csv, frames.csv, events.csv,
  raw_NNNN.mp4 (segmented) and an optional lossless PNG sequence.
- forces.csv uses the reviewed column order
  (session_id, frame_id, t_rel_s, FN_raw, FX_raw, FY_raw, FN_N, FX_N, FY_N,
  valid, calibration_id) and leaves a cell empty when a value is unavailable:
  an uncalibrated newton value or a failed readout is never written as 0.
- frames.csv is the frame-index mapping: frame_id, t_rel_s, acquisition wall
  clock, segment, segment_frame, in_video. forces.csv and frames.csv share the
  packet frame_id and the same t_rel_s origin, so video and data can be
  aligned after dropped or segmented frames.
- session.json carries format version, session id, start/end time in UTC,
  device and config (with a sha256 of the config file), app/shared/SDK
  versions, calibration reference, axis convention, filter parameters, video
  codec and the lossy-MP4 caveat, plus the event log and end-of-run statistics.
  It is written once at start so a crash still leaves identifiable metadata.
- Add a record control to the toolbar: mode (video+data / data only) and a
  start/stop toggle that stays enabled while recording.
- Log zeroing, calibration application and session end as timestamped events.

Why
- The recorder is fed from the worker sink list on the acquisition thread and
  only appends to a bounded queue, so disk I/O can never stall acquisition.
- On_packet copies the raw frame instead of holding a reference: the packet
  frame may alias a buffer the SDK reuses between frames.
- A queue overflow is counted and alarmed (rate limited), and a write or encode
  failure is counted, reported and after 5 consecutive errors stops the
  recording with failed=true rather than silently losing data.

Verification
- shared/tests/test_experiment_recorder.py: 12 cases covering the directory
  layout, the CSV contract, frame-id/time-base agreement, segment rolling,
  data-only and lossless modes, queue overflow accounting, encoder failure,
  metadata fields, crash-before-stop, and that on_packet never blocks.
- tools/ui_check.py gains an "experiment record" section (169 checks, 0
  failures overall), driving a recording through the UI and asserting the
  layout, the empty-cell rule and the event sequence.
- apps/studio/tests/test_ui_smoke.py mirrors those invariants.
- docs/record-channel.md documents the format and the remaining phase-2 work.

Not in this change: CSV export of a selected time range, full-session curve
browsing, calibration diagnostics (outlier exclusion, fit residuals) and
PyOpenGL 3D.
This commit is contained in:
ykcheungab
2026-09-13 13:14:10 +08:00
parent f501fa93f3
commit 817f830565
12 changed files with 1531 additions and 16 deletions
+16
View File
@@ -104,6 +104,22 @@
"fy": "FY",
"no_data": "—"
},
"record": {
"label": "Record",
"mode_video_data": "Video + data",
"mode_data_only": "Data only",
"mode_tip": "Video records the pre-processing camera input (no arrows or text overlays). MP4 is a lossy container, not a lossless copy of the raw data.",
"start_label": "● Start recording",
"stop_label": "■ Stop recording",
"start_tip": "Record this experiment: video, force data and metadata into one directory",
"stop_tip": "Stop recording and write session.json (always available)",
"requires_run": "Press Run to start acquisition before recording",
"started": "Recording started ({mode})",
"start_failed": "Could not start recording: {error}",
"finished": "Recording finished — {frames} frames / {segments} segment(s) in {path}",
"finished_with_problems": "Recording finished with problems: {detail}",
"alarm": "Recording problem: {detail}"
},
"cal": {
"weight": "Weight (N)",
"duration": "Record duration",
+16
View File
@@ -104,6 +104,22 @@
"fy": "FY",
"no_data": "—"
},
"record": {
"label": "记录",
"mode_video_data": "视频 + 数据",
"mode_data_only": "仅数据",
"mode_tip": "视频记录的是处理前的相机输入图像(不含箭头与文字叠加)。MP4 是有损封装,不等于无损原始数据。",
"start_label": "● 开始记录",
"stop_label": "■ 停止记录",
"start_tip": "开始记录本实验:视频、力数据与元数据写入同一目录",
"stop_tip": "停止记录并写入 session.json(随时可用)",
"requires_run": "请先点击「运行」开始采集,再开始记录",
"started": "已开始记录({mode})",
"start_failed": "无法开始记录: {error}",
"finished": "记录完成 — {frames} 帧 / {segments} 段,已写入 {path}",
"finished_with_problems": "记录完成,但存在问题: {detail}",
"alarm": "记录异常: {detail}"
},
"cal": {
"weight": "砝码重量 (N)",
"duration": "采集时长",
+51 -7
View File
@@ -104,11 +104,14 @@ cd D:\orisys_ws\orisys-app\apps\studio
- `shared/tests/test_record_channel.py`:11 项,覆盖内存上界、双单位、无效隔离、
跨线程 feed、截止时间结算、取消语义。
- `shared/tests/test_experiment_recorder.py`:12 项,覆盖目录结构、CSV 列序与空值规则、
帧号/时间基准一致、分段滚动与帧索引、仅数据模式、无损序列、队列溢出计数与报警、
编码失败上报并停止录制、元数据字段、崩溃后仍留有元数据、`on_packet` 不阻塞。
- `shared/tests/test_worker_cancellation.py`:4 项,覆盖非阻塞 stop、线程所有权、
会话交接。
- `tools/ui_check.py`:新增 `record channel` 段落,断言记录通道在**完全没有 UI 定时器
触发**的情况下仍收满样本、隐藏标定面板仍能采样、录制按钮是可用取消入口、
深度缺失不回退、色标锁帧。
- `tools/ui_check.py`:新增 `record channel` 与 `experiment record` 两段,断言记录通道
在**完全没有 UI 定时器触发**的情况下仍收满样本、隐藏标定面板仍能采样、录制按钮是可用
取消入口、深度缺失不回退、色标锁帧,以及通过界面录制的目录结构与事件序列。
- `apps/studio/tests/test_ui_smoke.py`:上述不变量各有对应 pytest 用例,复用
`ui_check._StuckWorker` 与 `ui_check._fake_packet`,保持单一来源。
@@ -120,11 +123,52 @@ cd D:\orisys_ws\orisys-app\apps\studio
---
## 6. 尚未完成(第二阶段及以后)
## 6. 实验录制(第一阶段剩余部分)
- MP4 分段录制、`frames.csv` 帧索引、`session.json` 元数据(评审第一阶段剩余部分)。
- CSV 导出(`session_id/frame_id/t_rel_s/FN_raw/…/valid/calibration_id`)——数据模型
`SessionSample` 已按这些字段设计,`SessionStore.iter_valid()` 已可直接供导出使用。
`shared/src/orisys_app_shared/record/experiment.py` 的 `ExperimentRecorder` 落盘格式:
```
outputs/recordings/<UTC 时间戳>_session<NNNN>/
session.json 元数据 + 事件 + 结束统计
forces.csv 每帧一行
frames.csv 帧索引映射(帧号 ↔ 视频分段)
events.csv 带时间戳的操作事件
raw_0001.mp4 处理前相机图像,按需分段
frames/ 可选无损 PNG 序列
```
`forces.csv` 列序固定为
`session_id, frame_id, t_rel_s, FN_raw, FX_raw, FY_raw, FN_N, FX_N, FY_N, valid, calibration_id`。
几条硬性约束:
- **一次实验一个目录**,`session.json` 在**开始时先写一次**,因此即使进程崩溃也留有可识别
的元数据;停止时再写一次并补上 `ended_at`、事件与统计。
- **不静默丢数据**:`on_packet` 只把标量(以及开启视频时的一帧拷贝)放进**有界队列**,
队列满则计数并报警,绝不阻塞采集;连续写失败达到 `MAX_CONSECUTIVE_ERRORS` 就停止录制
并把 `failed` 置位,而不是假装录完了。
- **帧号与时间统一**:`forces.csv` 与 `frames.csv` 共用 packet 的 `frame_id`,`t_rel_s`
同源于首帧;`frames.csv` 额外记录采集墙钟时间、落在哪个分段、以及是否进过视频,
这就是丢帧/分段场景下的重建依据。
- **未标定或读取失败的单元格留空**,不写 0:`FN_N` 等列在无标定时为空,`valid=0` 的行
所有数值列都为空。精度用 `repr(float)`(最短往返表示),界面小数位不影响落盘。
- **MP4 是有损封装**:`session.json` 的 `video.lossy_note` 明确记录这一点,需要算法复现时
可开启 `lossless_frames` 输出 PNG 序列。
- `raw_frame` 在入队时**必须拷贝**:packet 里的帧可能复用 SDK 的采集缓冲,直接持有引用
会让录制内容随后续帧改变。
### 已知环境限制
本机未安装 pytest,且沙箱拒绝写入工作区之外的路径,`conftest.py` 的 `tmp_path` 因此
写 prefs 失败。上述 pytest 用例是用等价驱动脚本执行的(参数化用例除外);`ui_check.py`
覆盖同样的不变量且可完整运行。录制相关的测试把临时目录放在
`shared/tests/_scratch/`(已加入 `.gitignore`)以避开该限制。
---
## 7. 尚未完成(第二阶段及以后)
- CSV 的「导出选中时间段」(整段导出已随录制落盘;区间导出待做)。
- 全程曲线浏览(跟随/历史、框选缩放、降采样保峰值)。
- 标定诊断扩展:稳定后采样、异常点排除/恢复、拟合残差、独立验证点。当前
`CalibrationPoint` 已记录三轴均值/标准差/有效样本数、方向、轮次与实际采样时长。
+184 -6
View File
@@ -28,9 +28,15 @@ from PyQt5.QtWidgets import (
QWidget,
)
from orisys_app_shared.acquisition.constants import (
FORCE_SAMPLE_RATE_HZ,
LOWPASS_CUTOFF_HZ,
)
from orisys_app_shared.force import select_display_forces
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
from orisys_app_shared.force.session import SessionSample, SessionStore
from orisys_app_shared.paths import recordings_dir
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.logging import report_error
from orisys_app_shared.paths import logo_path, sensor_json_path
@@ -125,6 +131,12 @@ class StudioMainWindow(QMainWindow):
on_finished=self._on_calibration_point,
on_cancelled=self._on_calibration_cancelled,
)
self.recorder = ExperimentRecorder()
self.recorder.set_alarm_handler(self._on_record_alarm)
self._record_alarms: list[str] = []
self._record_last_report = None
self._record_fps = 30.0
self._session_counter = 0
self._record_error_count = 0
self._dropped_reported = 0
self._active_calibration_id = ""
@@ -224,6 +236,148 @@ class StudioMainWindow(QMainWindow):
for worker in (self._box_worker, self._finger_worker):
worker.add_sink(self._on_packet_recorded)
# ------------------------------------------------------- experiment record
def _on_record_alarm(self, message: str) -> None:
"""Called from the writer thread. Queue it; never touch Qt here."""
logger.error("Recording alarm: %s", message)
self._record_alarms.append(str(message))
def _drain_record_alarms(self) -> None:
while self._record_alarms:
message = self._record_alarms.pop(0)
self._set_phase("error")
self.statusBar().showMessage(t("record.alarm", detail=message))
def _on_record_toggled(self, checked: bool) -> None:
if checked:
self._start_recording()
else:
self._stop_recording()
def _start_recording(self) -> None:
if self.recorder.is_recording:
return
if not self._running:
self.toolbar.set_record_checked(False)
self.statusBar().showMessage(t("record.requires_run"))
return
mode = self.toolbar.record_mode()
config = RecordConfig(
video=(mode == "video_data"),
lossless_frames=bool(self.prefs.get("record_lossless_frames", False)),
fps=float(self._record_fps),
segment_seconds=float(self.prefs.get("record_segment_seconds", 300.0)),
)
try:
directory = self.recorder.start(
root=recordings_dir(),
session_id=int(self.session_store.session_id),
metadata=self._build_session_metadata(config),
calibration_id=self._active_calibration_id,
)
except Exception as exc:
self.toolbar.set_record_checked(False)
self.statusBar().showMessage(t("record.start_failed", error=str(exc)))
logger.exception("Could not start recording")
return
self._record_last_report = None
self.recorder.log_event("record_started", f"mode={mode}")
self.toolbar.set_record_status(Path(directory).name)
self.statusBar().showMessage(t("record.started", mode=t(f"record.mode_{mode}")))
logger.info("Recording started: %s (mode=%s)", directory, mode)
def _stop_recording(self, *, wait: bool = True) -> None:
if not self.recorder.is_recording:
self.toolbar.set_record_status("")
return
self.recorder.log_event("record_stopped", "")
report = self.recorder.stop(wait=wait)
self._record_last_report = report
self.toolbar.set_record_status("")
self.toolbar.set_record_checked(False)
if report.problems:
self.statusBar().showMessage(
t("record.finished_with_problems", detail="; ".join(report.problems))
)
else:
self.statusBar().showMessage(
t(
"record.finished",
frames=report.stats.frames,
segments=report.stats.segments,
path=str(report.directory),
)
)
def _build_session_metadata(self, config: RecordConfig) -> dict:
from orisys_app_shared import API_VERSION, __version__ as shared_version
from orisys_studio import __version__ as app_version
sensor_type = self.sensor_picker.sensor_type()
config_ref = self._resolve_config_ref() or ""
info = self._active_worker.latest() if self._active_worker is not None else None
return {
"device": {
"sensor_type": sensor_type,
"config_ref": config_ref,
"config_sha256": self._config_hash(config_ref),
"frame": {
"width": int(getattr(info, "raw_frame", None).shape[1])
if getattr(info, "raw_frame", None) is not None
else None,
"height": int(getattr(info, "raw_frame", None).shape[0])
if getattr(info, "raw_frame", None) is not None
else None,
"fps": float(self._record_fps),
},
},
"versions": {
"app": app_version,
"shared": shared_version,
"shared_api": API_VERSION,
"sdk": self._sdk_version(),
},
"calibration": {
"config_ref": config_ref,
"force_calibrated": bool(getattr(info, "force_calibrated", False)),
},
"conventions": {
"axis_order": ["FN", "FX", "FY"],
"force_unit": "N when calibrated, otherwise raw counts",
"time_base": "t_rel_s is relative to the first recorded frame",
"frame_id": "monotonic per acquisition session",
},
"filter": {
"lowpass_cutoff_hz": float(LOWPASS_CUTOFF_HZ),
"sample_rate_hz": float(FORCE_SAMPLE_RATE_HZ),
},
"recording_mode": self.toolbar.record_mode(),
"video_config": config.as_dict(),
}
@staticmethod
def _config_hash(config_ref: str) -> str:
try:
import hashlib
from pathlib import Path as _Path
path = _Path(str(config_ref))
if path.is_file():
return hashlib.sha256(path.read_bytes()).hexdigest()
except Exception: # noqa: BLE001 - metadata must never block recording
pass
return ""
@staticmethod
def _sdk_version() -> str:
try:
import orisys
return str(getattr(orisys, "__version__", "unknown"))
except Exception: # noqa: BLE001
return "unavailable"
# ------------------------------------------------------- record channel
def _on_packet_recorded(self, packet) -> None:
"""Runs on the **acquisition thread**. Must stay cheap and Qt-free.
@@ -247,6 +401,11 @@ class StudioMainWindow(QMainWindow):
)
self.session_store.append(sample)
self.cal_recorder.feed(sample)
# Video + CSV + metadata share this one record path.
self.recorder.on_packet(packet)
fps = float(getattr(packet, "fps", 0.0) or 0.0)
if fps > 0.0:
self._record_fps = fps
if not sample.valid:
self._record_error_count += 1
@@ -262,6 +421,7 @@ class StudioMainWindow(QMainWindow):
self.toolbar.source_mode_changed.connect(self._on_source_mode_changed)
self.toolbar.browse_video_clicked.connect(self._on_browse_video)
self.toolbar.run_toggled.connect(self._on_run_toggled)
self.toolbar.record_toggled.connect(self._on_record_toggled)
self.toolbar.reset_clicked.connect(self._on_reset)
self.toolbar.units_changed.connect(self._on_units_changed)
@@ -467,6 +627,9 @@ class StudioMainWindow(QMainWindow):
reason = t("toolbar.run_disabled_video")
self.toolbar.set_run_tooltip_reason(reason)
self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok))
# Recording needs a live session; while recording the button stays
# enabled so it can always be stopped.
self.toolbar.set_record_enabled(self._running or self.recorder.is_recording)
if not self._running:
self.toolbar.refresh_labels()
self._sync_shortcut_actions()
@@ -646,6 +809,7 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(t("cal.apply_no_target"))
return
worker.set_pending_calibration(path)
self.recorder.log_event("calibration_applied", f"{sensor_type}: {path}")
self.statusBar().showMessage(
t("cal.apply_done", sensor_type=sensor_type)
)
@@ -902,8 +1066,12 @@ class StudioMainWindow(QMainWindow):
self._dropped_reported = 0
self._depth_range_value = None
self._last_depth_frame = None
self.session_store.reset(calibration_id=str(config_ref))
self.cal_recorder.reset_session()
# A new session invalidates the previous recording.
self._stop_recording(wait=True)
self._session_counter += 1
self.session_store.reset(
session_id=self._session_counter, calibration_id=str(config_ref)
)
self._ensure_workers()
self._active_worker = (
self._finger_worker if sensor_type == "finger" else self._box_worker
@@ -942,6 +1110,9 @@ class StudioMainWindow(QMainWindow):
def _stop_run(self, *, reason: str = "user_stop", wait: bool = False) -> None:
self._run_timer.stop()
# Close the experiment first so the reason lands in its event log.
self.recorder.log_event("session_stopped", f"reason={reason}")
self._stop_recording(wait=wait)
if self._active_worker is not None:
# Never wait here: a device open that hangs must not freeze the GUI.
# ``wait=True`` is reserved for shutdown paths.
@@ -982,6 +1153,7 @@ class StudioMainWindow(QMainWindow):
self._active_worker.reset()
self.force_plots.reset()
self._last_plot_packet_key = None
self.recorder.log_event("zeroing", "tracker reset")
self.statusBar().showMessage(t("status.reset_ok"))
except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
@@ -1086,6 +1258,7 @@ class StudioMainWindow(QMainWindow):
# Finalise calibration sampling on the UI thread once its deadline passes.
self.cal_recorder.tick()
self.cal_panel.poll_recorder()
self._drain_record_alarms()
self._report_dropped_samples()
mode = self.arrow_panel.view_mode()
@@ -1095,9 +1268,12 @@ class StudioMainWindow(QMainWindow):
self.arrow_panel.set_fps(packet.fps)
def _report_invalid_sample(self, reason: str) -> None:
"""Surface failed readouts instead of letting them pass as data."""
if self._session_phase == "running":
self._set_phase("error")
"""Surface failed readouts instead of letting them pass as data.
The session phase is deliberately left alone: a single dropped sample
must not lock the session into ``error`` (that would also disable
reset). The count makes a persistent failure visible.
"""
self.statusBar().showMessage(
t("status.sample_invalid", count=self._record_error_count, reason=reason)
)
@@ -1233,8 +1409,10 @@ class StudioMainWindow(QMainWindow):
for worker in (self._box_worker, self._finger_worker):
if worker is not None and hasattr(worker, "remove_sink"):
worker.remove_sink(self._on_packet_recorded)
# Blocking: on the way out we must not leave a device handle behind.
# Blocking: on the way out we must not leave a device handle or a
# half-written experiment behind.
self._stop_run(reason="user_stop", wait=True)
self._stop_recording(wait=True)
self._persist_prefs()
logger.info("Studio shell closed; preferences saved")
super().closeEvent(event)
+86 -1
View File
@@ -1,4 +1,4 @@
"""Top toolbar: source, camera/video, run/reset, units."""
"""Top toolbar: source, camera/video, run/reset, record, units."""
from __future__ import annotations
@@ -25,6 +25,9 @@ from orisys_app_shared.i18n import t
CONTROL_HEIGHT = 30
BUTTON_HEIGHT = 32
#: Recording modes, in combo order. ``value`` is what the app consumes.
RECORD_MODES = (("video_data", "record.mode_video_data"), ("data_only", "record.mode_data_only"))
def _separator() -> QFrame:
line = QFrame()
@@ -54,6 +57,7 @@ class ToolbarWidget(QWidget):
run_toggled = pyqtSignal(bool)
reset_clicked = pyqtSignal()
units_changed = pyqtSignal(str)
record_toggled = pyqtSignal(bool)
def __init__(self, parent=None) -> None:
super().__init__(parent)
@@ -148,6 +152,23 @@ class ToolbarWidget(QWidget):
self.units_combo.setMinimumHeight(CONTROL_HEIGHT)
self.units_combo.currentIndexChanged.connect(self._on_units_index_changed)
# --- record group -------------------------------------------------
self.record_label = QLabel()
self.record_label.setObjectName("fieldLabel")
self.record_mode_combo = QComboBox()
self.record_mode_combo.setMinimumWidth(120)
self.record_mode_combo.setMinimumHeight(CONTROL_HEIGHT)
self.btn_record = QPushButton()
self.btn_record.setObjectName("ghostButton")
self.btn_record.setMinimumHeight(BUTTON_HEIGHT)
self.btn_record.setMinimumWidth(96)
self.btn_record.setCheckable(True)
self.btn_record.setEnabled(False)
self.btn_record.toggled.connect(self._on_record_toggled)
self.record_status = QLabel()
self.record_status.setObjectName("panelMeta")
self.record_status.setVisible(False)
layout.addWidget(
_group(self.source_label, self.source_combo, self.source_stack)
)
@@ -157,10 +178,16 @@ class ToolbarWidget(QWidget):
)
layout.addWidget(_separator())
layout.addWidget(_group(self.units_label, self.units_combo))
layout.addWidget(_separator())
layout.addWidget(
_group(self.record_label, self.record_mode_combo, self.btn_record,
self.record_status)
)
layout.addStretch(1)
self._populate_source_combo()
self._populate_units_combo()
self._populate_record_modes()
self.retranslate()
self._apply_source_mode_ui()
@@ -280,6 +307,58 @@ class ToolbarWidget(QWidget):
self.units_combo.setCurrentIndex(max(0, idx))
self.units_combo.blockSignals(False)
def _populate_record_modes(self) -> None:
current = self.record_mode_combo.currentData()
self.record_mode_combo.blockSignals(True)
self.record_mode_combo.clear()
for value, _key in RECORD_MODES:
self.record_mode_combo.addItem("", value)
if current:
idx = self.record_mode_combo.findData(current)
self.record_mode_combo.setCurrentIndex(max(0, idx))
self.record_mode_combo.blockSignals(False)
def record_mode(self) -> str:
return str(self.record_mode_combo.currentData() or "video_data")
def set_record_enabled(self, enabled: bool) -> None:
"""Enable the record control.
While a recording is running this must stay enabled — it is the only
way to stop it.
"""
self.btn_record.setEnabled(bool(enabled))
self.record_mode_combo.setEnabled(bool(enabled) and not self.btn_record.isChecked())
def set_record_checked(self, checked: bool) -> None:
self.btn_record.blockSignals(True)
self.btn_record.setChecked(bool(checked))
self.btn_record.blockSignals(False)
self._refresh_record_label()
def set_record_status(self, text: str) -> None:
"""Show elapsed time / output location next to the record control."""
self.record_status.setText(str(text or ""))
self.record_status.setVisible(bool(text))
def _on_record_toggled(self, checked: bool) -> None:
self._refresh_record_label()
self.record_mode_combo.setEnabled(not checked)
self.record_toggled.emit(bool(checked))
def _refresh_record_label(self) -> None:
if self.btn_record.isChecked():
self.btn_record.setText(t("record.stop_label"))
self.btn_record.setObjectName("dangerButton")
else:
self.btn_record.setText(t("record.start_label"))
self.btn_record.setObjectName("ghostButton")
self.btn_record.style().unpolish(self.btn_record)
self.btn_record.style().polish(self.btn_record)
self.btn_record.setToolTip(
t("record.stop_tip") if self.btn_record.isChecked() else t("record.start_tip")
)
def _apply_source_mode_ui(self) -> None:
video = self.source_mode() == "video"
self.source_stack.setCurrentIndex(1 if video else 0)
@@ -360,3 +439,9 @@ class ToolbarWidget(QWidget):
self.units_label.setText(t("toolbar.units"))
self.units_combo.setItemText(0, t("toolbar.units_newton"))
self.units_combo.setItemText(1, t("toolbar.units_raw"))
self.record_label.setText(t("record.label"))
for index, (value, key) in enumerate(RECORD_MODES):
if index < self.record_mode_combo.count():
self.record_mode_combo.setItemText(index, t(key))
self.record_mode_combo.setToolTip(t("record.mode_tip"))
self._refresh_record_label()
+88 -1
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from pathlib import Path
import pytest
import ui_check
@@ -197,7 +199,7 @@ def test_toolbar_run_button_is_labelled(make_window):
window = _show(make_window(), (1400, 880))
label = window.toolbar.btn_run.text()
assert label.strip()
assert label != "â–"
assert label != "â–Â"
window.toolbar.set_run_checked(True)
assert window.toolbar.btn_run.text() != label
@@ -429,3 +431,88 @@ def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window):
)
_finish(window, stuck)
# ------------------------------------------------------------- experiment record
#: Recording scratch goes under the app's gitignored ``outputs`` tree rather than
#: the system temp directory, so the tests also run under a file sandbox that
#: only permits writes inside the workspace.
RECORD_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_record"
@pytest.fixture
def record_root():
import shutil
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
RECORD_SCRATCH.mkdir(parents=True, exist_ok=True)
yield RECORD_SCRATCH
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
def test_recording_writes_the_documented_experiment_layout(make_window, record_root):
import csv
import json
import orisys_studio.app_window as app_module
from orisys_app_shared.i18n import t
window, stuck = _running_window(make_window)
app_module.recordings_dir = lambda: record_root
assert window.toolbar.btn_record.isEnabled()
assert window.toolbar.btn_record.text() == t("record.start_label")
window.toolbar.btn_record.setChecked(True)
assert window.recorder.is_recording, "the recording did not start"
assert window.toolbar.btn_record.text() == t("record.stop_label")
assert not window.toolbar.record_mode_combo.isEnabled()
for index in range(20):
stuck.emit(ui_check._fake_packet(index))
window._on_run_tick()
window.toolbar.btn_record.setChecked(False)
report = window._record_last_report
assert report is not None and report.ok, getattr(report, "problems", None)
names = sorted(path.name for path in report.directory.iterdir())
assert names == ["events.csv", "forces.csv", "frames.csv", "raw_0001.mp4", "session.json"]
with (report.directory / "forces.csv").open(encoding="utf-8") as handle:
forces = list(csv.DictReader(handle))
with (report.directory / "frames.csv").open(encoding="utf-8") as handle:
frames = list(csv.DictReader(handle))
assert len(forces) == len(frames) == 20
assert [f["frame_id"] for f in frames] == [r["frame_id"] for r in forces]
assert [f["t_rel_s"] for f in frames] == [r["t_rel_s"] for r in forces]
meta = json.loads((report.directory / "session.json").read_text(encoding="utf-8"))
assert meta["ended_at"]
assert meta["format_version"]
assert meta["stats"]["frames"] == 20
_finish(window, stuck)
def test_recording_leaves_unavailable_values_empty(make_window, record_root):
import csv
import orisys_studio.app_window as app_module
window, stuck = _running_window(make_window)
app_module.recordings_dir = lambda: record_root
window.toolbar.btn_record.setChecked(True)
stuck.emit(ui_check._fake_packet(0))
window._on_run_tick()
stuck.emit(ui_check._fake_packet(1, valid=False, reason="read failed"))
window._on_run_tick()
window.toolbar.btn_record.setChecked(False)
with (window._record_last_report.directory / "forces.csv").open(encoding="utf-8") as handle:
rows = list(csv.DictReader(handle))
failed = rows[-1]
assert failed["valid"] == "0"
assert failed["FN_raw"] == "" and failed["FN_N"] == "", (
"a failed readout was persisted as a number"
)
_finish(window, stuck)
+126 -1
View File
@@ -32,6 +32,7 @@ if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
os.chdir(APP_ROOT)
from orisys_app_shared.acquisition.sinks import RecordSinkMixin # noqa: E402
from orisys_app_shared.record import FORCES_HEADER # noqa: E402
FAILURES: list[str] = []
CHECKS = 0
@@ -44,6 +45,18 @@ def check(condition: bool, message: str) -> None:
FAILURES.append(message)
def _inside_scroll_area(widget) -> bool:
"""True when ``widget`` lives inside a QScrollArea's content."""
from PyQt5.QtWidgets import QScrollArea
node = widget
while node is not None:
if isinstance(node, QScrollArea):
return True
node = node.parentWidget() if hasattr(node, "parentWidget") else None
return False
def section(name: str) -> None:
print(f"\n== {name} ==")
@@ -539,9 +552,17 @@ def main() -> int:
parent = child.parentWidget()
if parent is None or not parent.isVisible():
continue
if _inside_scroll_area(child):
# A scroll area's content is *meant* to exceed its viewport (the
# toolbar scrolls horizontally on narrow windows).
continue
geometry = child.geometry()
if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1:
oversize.append(type(child).__name__)
oversize.append(
f"{type(child).__name__}({getattr(child, 'objectName', lambda: '')()})"
f" {geometry.width()}x{geometry.height()}"
f" in {type(parent).__name__} {parent.width()}x{parent.height()}"
)
check(not oversize, f"widgets overflow their parent: {oversize}")
window.close()
app.processEvents()
@@ -718,6 +739,110 @@ def main() -> int:
app.processEvents()
stuck.release()
section("experiment record")
import shutil as _shutil
scratch = APP_ROOT / "outputs" / "_record_check"
_shutil.rmtree(scratch, ignore_errors=True)
scratch.mkdir(parents=True, exist_ok=True)
import orisys_studio.app_window as _app_module
_app_module.recordings_dir = lambda: scratch
stuck = _StuckWorker()
window = StudioMainWindow(
prefs={
"language": "zh_CN",
"theme": "dark",
"sensor_type": "box",
"source_mode": "camera",
}
)
window.resize(1440, 880)
window.show()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window._start_run()
window._run_timer.stop()
window._set_phase("running")
app.processEvents()
check(
window.toolbar.btn_record.isEnabled(),
"the record control is disabled while a session is running",
)
check(
window.toolbar.btn_record.text() == t("record.start_label"),
f"record button label is wrong: {window.toolbar.btn_record.text()!r}",
)
check(
window.toolbar.record_mode() == "video_data",
"the default recording mode is not video+data",
)
window.toolbar.btn_record.setChecked(True)
app.processEvents()
check(window.recorder.is_recording, "the recording did not start")
check(
window.toolbar.btn_record.isEnabled() and window.toolbar.btn_record.text() == t("record.stop_label"),
"the record button is not a usable stop control",
)
check(
not window.toolbar.record_mode_combo.isEnabled(),
"the recording mode can be changed mid-recording",
)
for index in range(30):
stuck.emit(_fake_packet(index))
window._on_run_tick()
stuck.emit(_fake_packet(90, valid=False, reason="read failed"))
window._on_run_tick()
window._on_reset() # event: zeroing
window.toolbar.btn_record.setChecked(False)
app.processEvents()
report = window._record_last_report
check(not window.recorder.is_recording, "the recording did not stop")
check(report is not None and report.ok, f"recording reported problems: {getattr(report, 'problems', None)}")
import csv as _csv
import json as _json
directory = report.directory
names = sorted(path.name for path in directory.iterdir())
check(
names == ["events.csv", "forces.csv", "frames.csv", "raw_0001.mp4", "session.json"],
f"unexpected experiment layout: {names}",
)
with (directory / "forces.csv").open(encoding="utf-8") as handle:
rows = list(_csv.DictReader(handle))
check(tuple(rows[0].keys()) == FORCES_HEADER, "forces.csv header drifted from the spec")
check(
rows[-1]["valid"] == "0" and rows[-1]["FN_raw"] == "",
"a failed readout was written as a number instead of being left empty",
)
with (directory / "frames.csv").open(encoding="utf-8") as handle:
frames = list(_csv.DictReader(handle))
check(len(frames) == len(rows), "forces.csv and frames.csv disagree on the frame count")
check(
all(f["frame_id"] == r["frame_id"] for f, r in zip(frames, rows)),
"the frame index does not share frame ids with the data",
)
meta = _json.loads((directory / "session.json").read_text(encoding="utf-8"))
check(meta.get("ended_at"), "session.json was not finalised")
check(meta["format_version"], "session.json has no format version")
check(
[event["kind"] for event in meta["events"]] == ["record_started", "zeroing", "record_stopped"],
f"events not recorded: {[e['kind'] for e in meta['events']]}",
)
check(meta["stats"]["frames"] == 31, f"unexpected frame count: {meta['stats']['frames']}")
window.close()
app.processEvents()
stuck.release()
_shutil.rmtree(scratch, ignore_errors=True)
print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}")
for failure in FAILURES:
print(f" FAIL: {failure}")