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:
@@ -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