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:
@@ -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",
|
||||
|
||||
@@ -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": "采集时长",
|
||||
|
||||
@@ -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` 已记录三轴均值/标准差/有效样本数、方向、轮次与实际采样时长。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user