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:
@@ -68,6 +68,13 @@ def calibration_export_dir() -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def recordings_dir() -> Path:
|
||||
"""Root for one directory per recorded experiment."""
|
||||
path = outputs_dir() / "recordings"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def preference_path() -> Path:
|
||||
return app_root() / "preference.json"
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Experiment recording (video + data + metadata)."""
|
||||
|
||||
from orisys_app_shared.record.experiment import (
|
||||
EVENTS_HEADER,
|
||||
FORCES_HEADER,
|
||||
FRAMES_HEADER,
|
||||
RECORD_FORMAT_VERSION,
|
||||
ExperimentRecorder,
|
||||
RecordConfig,
|
||||
RecordReport,
|
||||
RecordStats,
|
||||
format_value,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EVENTS_HEADER",
|
||||
"FORCES_HEADER",
|
||||
"FRAMES_HEADER",
|
||||
"RECORD_FORMAT_VERSION",
|
||||
"ExperimentRecorder",
|
||||
"RecordConfig",
|
||||
"RecordReport",
|
||||
"RecordStats",
|
||||
"format_value",
|
||||
]
|
||||
@@ -0,0 +1,653 @@
|
||||
"""Experiment recording: one directory per session.
|
||||
|
||||
Phase-1 record format, deliberately independent of the display path:
|
||||
|
||||
outputs/recordings/<session>/
|
||||
session.json experiment metadata, events and end-of-run statistics
|
||||
forces.csv one row per acquired frame
|
||||
frames.csv frame-index mapping (frame id <-> video segment)
|
||||
events.csv timestamped operator/system events
|
||||
raw_0001.mp4 processed-input video, segmented (optional)
|
||||
frames/ lossless PNG sequence (optional)
|
||||
|
||||
``on_packet`` is called from the acquisition thread, so it only copies the
|
||||
scalars it needs (plus the raw frame when video is enabled) into a bounded queue
|
||||
and returns. All file I/O happens on a writer thread.
|
||||
|
||||
Two rules from the architecture review are enforced here:
|
||||
|
||||
* the queue is bounded and **never blocks acquisition**; an overflow is counted
|
||||
and surfaced rather than silently discarded;
|
||||
* a write or encode failure is counted, reported, and after
|
||||
:data:`MAX_CONSECUTIVE_ERRORS` stops the writer instead of pretending the
|
||||
experiment was recorded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECORD_FORMAT_VERSION = "1.0"
|
||||
|
||||
#: Column order required by the review; do not reorder without a format bump.
|
||||
FORCES_HEADER = (
|
||||
"session_id",
|
||||
"frame_id",
|
||||
"t_rel_s",
|
||||
"FN_raw",
|
||||
"FX_raw",
|
||||
"FY_raw",
|
||||
"FN_N",
|
||||
"FX_N",
|
||||
"FY_N",
|
||||
"valid",
|
||||
"calibration_id",
|
||||
)
|
||||
FRAMES_HEADER = (
|
||||
"frame_id",
|
||||
"t_rel_s",
|
||||
"acquired_at",
|
||||
"segment",
|
||||
"segment_frame",
|
||||
"in_video",
|
||||
"dropped",
|
||||
)
|
||||
EVENTS_HEADER = ("at", "t_rel_s", "kind", "detail")
|
||||
|
||||
MAX_CONSECUTIVE_ERRORS = 5
|
||||
#: A sustained queue overflow reports at most every N drops.
|
||||
ALARM_EVERY_N_DROPS = 1000
|
||||
|
||||
|
||||
def format_value(value: float) -> str:
|
||||
"""Full-precision CSV cell. ``NaN`` means "not available" and is left empty.
|
||||
|
||||
An uncalibrated newton value must be empty, never ``0``; a failed readout
|
||||
must be empty too, otherwise it is indistinguishable from a real zero.
|
||||
"""
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
if not np.isfinite(number):
|
||||
return ""
|
||||
return repr(number)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordConfig:
|
||||
"""What to capture, and how."""
|
||||
|
||||
video: bool = True
|
||||
lossless_frames: bool = False
|
||||
fps: float = 30.0
|
||||
codec: str = "mp4v"
|
||||
segment_seconds: float = 300.0
|
||||
max_queue: int = 2048
|
||||
flush_interval_s: float = 1.0
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"video": bool(self.video),
|
||||
"lossless_frames": bool(self.lossless_frames),
|
||||
"fps": float(self.fps),
|
||||
"codec": self.codec,
|
||||
"segment_seconds": float(self.segment_seconds),
|
||||
"max_queue": int(self.max_queue),
|
||||
"lossy_note": (
|
||||
"MP4 is a lossy container: this is the pre-processing camera "
|
||||
"image, not a lossless record of the raw sensor data."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordStats:
|
||||
frames: int = 0
|
||||
video_frames: int = 0
|
||||
segments: int = 0
|
||||
dropped_data: int = 0
|
||||
dropped_video: int = 0
|
||||
write_errors: int = 0
|
||||
failed: bool = False
|
||||
last_error: str = ""
|
||||
duration_s: float = 0.0
|
||||
bytes_written: int = 0
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"frames": self.frames,
|
||||
"video_frames": self.video_frames,
|
||||
"segments": self.segments,
|
||||
"dropped_data": self.dropped_data,
|
||||
"dropped_video": self.dropped_video,
|
||||
"write_errors": self.write_errors,
|
||||
"failed": self.failed,
|
||||
"last_error": self.last_error,
|
||||
"duration_s": self.duration_s,
|
||||
"bytes_written": self.bytes_written,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordReport:
|
||||
"""Returned by :meth:`ExperimentRecorder.stop`."""
|
||||
|
||||
directory: Optional[Path]
|
||||
stats: RecordStats
|
||||
problems: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return bool(self.directory) and not self.stats.failed and not self.problems
|
||||
|
||||
|
||||
class ExperimentRecorder:
|
||||
"""Writes one experiment directory. Sink-compatible with the workers."""
|
||||
|
||||
def __init__(self, config: Optional[RecordConfig] = None) -> None:
|
||||
self.config = config or RecordConfig()
|
||||
self._queue: queue.Queue = queue.Queue(maxsize=max(16, self.config.max_queue))
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
self._writing = False
|
||||
self._directory: Optional[Path] = None
|
||||
self._stats = RecordStats()
|
||||
self._session_id = 0
|
||||
self._calibration_id = ""
|
||||
self._metadata: dict[str, Any] = {}
|
||||
self._events: list[dict] = []
|
||||
self._t0: Optional[float] = None
|
||||
self._last_t: Optional[float] = None
|
||||
self._started_at = ""
|
||||
self._consecutive_errors = 0
|
||||
self._on_alarm = None
|
||||
|
||||
# ------------------------------------------------------------------ state
|
||||
@property
|
||||
def is_recording(self) -> bool:
|
||||
with self._lock:
|
||||
return self._writing
|
||||
|
||||
@property
|
||||
def directory(self) -> Optional[Path]:
|
||||
return self._directory
|
||||
|
||||
def stats(self) -> RecordStats:
|
||||
with self._lock:
|
||||
return RecordStats(**self._stats.as_dict())
|
||||
|
||||
def set_alarm_handler(self, handler) -> None:
|
||||
"""``handler(message)`` is called from the writer thread on a problem."""
|
||||
self._on_alarm = handler
|
||||
|
||||
# -------------------------------------------------------------- lifecycle
|
||||
def start(
|
||||
self,
|
||||
*,
|
||||
root: Path,
|
||||
session_id: int,
|
||||
metadata: dict[str, Any],
|
||||
calibration_id: str = "",
|
||||
) -> Path:
|
||||
"""Create the experiment directory and start the writer thread."""
|
||||
if self.is_recording:
|
||||
raise RuntimeError("experiment recording is already running")
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
directory = Path(root) / f"{stamp}_session{session_id:04d}"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
with self._lock:
|
||||
self._directory = directory
|
||||
self._stats = RecordStats()
|
||||
self._session_id = int(session_id)
|
||||
self._calibration_id = str(calibration_id or "")
|
||||
self._metadata = dict(metadata)
|
||||
self._events = []
|
||||
self._t0 = None
|
||||
self._last_t = None
|
||||
self._consecutive_errors = 0
|
||||
self._started_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
self._writing = True
|
||||
self._stop.clear()
|
||||
self._drain_queue()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name=f"ExperimentRecorder-{session_id}",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
self._write_session_json(final=False)
|
||||
logger.info("Recording experiment to %s", directory)
|
||||
return directory
|
||||
|
||||
def stop(self, *, wait: bool = True, timeout: float = 20.0) -> RecordReport:
|
||||
"""Flush and close. Returns what actually happened."""
|
||||
with self._lock:
|
||||
was_writing = self._writing
|
||||
self._writing = False
|
||||
if not was_writing:
|
||||
return RecordReport(self._directory, self.stats())
|
||||
self._stop.set()
|
||||
try:
|
||||
self._queue.put_nowait(("stop", None))
|
||||
except queue.Full: # pragma: no cover - writer will drain and see the flag
|
||||
pass
|
||||
thread = self._thread
|
||||
if wait and thread is not None and thread.is_alive():
|
||||
thread.join(timeout=timeout)
|
||||
self._thread = None
|
||||
report = self._finalise()
|
||||
return report
|
||||
|
||||
def log_event(self, kind: str, detail: str = "") -> None:
|
||||
"""Record a timestamped event (zeroing, calibration applied, disconnect…)."""
|
||||
if not self.is_recording:
|
||||
return
|
||||
event = {
|
||||
"at": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
|
||||
"t_rel_s": None,
|
||||
"kind": str(kind),
|
||||
"detail": str(detail or ""),
|
||||
}
|
||||
try:
|
||||
self._queue.put_nowait(("event", event))
|
||||
except queue.Full:
|
||||
self._note_drop("event")
|
||||
|
||||
# ----------------------------------------------------------- sink interface
|
||||
def on_packet(self, packet) -> None:
|
||||
"""Called on the acquisition thread: enqueue, never do I/O here."""
|
||||
if not self.is_recording:
|
||||
return
|
||||
timestamp = float(packet.timestamp)
|
||||
if self._t0 is None:
|
||||
self._t0 = timestamp
|
||||
with self._lock:
|
||||
self._last_t = timestamp
|
||||
row = (
|
||||
int(getattr(packet, "frame_id", 0)),
|
||||
timestamp,
|
||||
float(packet.normal_force_raw),
|
||||
float(packet.shear_x_raw),
|
||||
float(packet.shear_y_raw),
|
||||
float(packet.normal_force),
|
||||
float(packet.shear_x),
|
||||
float(packet.shear_y),
|
||||
1 if bool(getattr(packet, "valid", True)) else 0,
|
||||
)
|
||||
frame = None
|
||||
if self.config.video or self.config.lossless_frames:
|
||||
frame = self._capture_frame(packet)
|
||||
try:
|
||||
self._queue.put_nowait(("frame", (row, frame)))
|
||||
except queue.Full:
|
||||
# Never block acquisition. The loss is counted, not hidden.
|
||||
self._note_drop("video" if frame is not None else "data")
|
||||
|
||||
@staticmethod
|
||||
def _capture_frame(packet):
|
||||
"""Copy the pre-processing frame.
|
||||
|
||||
The packet's ``raw_frame`` may alias a buffer the sensor reuses, so a
|
||||
recording must take its own copy here rather than hold the reference.
|
||||
"""
|
||||
frame = getattr(packet, "raw_frame", None)
|
||||
if frame is None:
|
||||
return None
|
||||
block = np.asarray(frame)
|
||||
if block.size == 0:
|
||||
return None
|
||||
return block.copy()
|
||||
|
||||
# ----------------------------------------------------------------- internals
|
||||
def _drain_queue(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
|
||||
def _note_drop(self, kind: str) -> None:
|
||||
with self._lock:
|
||||
if kind == "video":
|
||||
self._stats.dropped_video += 1
|
||||
else:
|
||||
# Data rows and events are both record-channel content.
|
||||
self._stats.dropped_data += 1
|
||||
total = self._stats.dropped_video + self._stats.dropped_data
|
||||
# Rate-limited: a sustained overflow must not flood the log or the UI.
|
||||
if total == 1 or total % ALARM_EVERY_N_DROPS == 0:
|
||||
self._alarm(f"record queue overflow ({kind}); {total} item(s) not written")
|
||||
|
||||
def _alarm(self, message: str) -> None:
|
||||
logger.warning("Recording problem: %s", message)
|
||||
handler = self._on_alarm
|
||||
if handler is not None:
|
||||
try:
|
||||
handler(message)
|
||||
except Exception: # noqa: BLE001 - reporting must not break recording
|
||||
logger.exception("Recording alarm handler failed")
|
||||
|
||||
def _run(self) -> None:
|
||||
writer = _SessionWriter(
|
||||
directory=self._directory,
|
||||
config=self.config,
|
||||
session_id=self._session_id,
|
||||
calibration_id=self._calibration_id,
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
if self._stop.is_set() and self._queue.empty():
|
||||
break
|
||||
try:
|
||||
kind, payload = self._queue.get(timeout=0.2)
|
||||
except queue.Empty:
|
||||
writer.maybe_flush()
|
||||
continue
|
||||
if kind == "stop":
|
||||
break
|
||||
if kind == "event":
|
||||
self._handle_event(writer, payload)
|
||||
continue
|
||||
row, frame = payload
|
||||
self._handle_frame(writer, row, frame)
|
||||
writer.maybe_flush()
|
||||
except Exception as exc: # noqa: BLE001 - reported through stats
|
||||
logger.exception("Experiment writer stopped")
|
||||
with self._lock:
|
||||
self._stats.failed = True
|
||||
self._stats.last_error = f"{type(exc).__name__}: {exc}"
|
||||
self._alarm(f"recording stopped: {exc}")
|
||||
finally:
|
||||
problems = writer.close()
|
||||
with self._lock:
|
||||
self._stats.write_errors += writer.write_errors
|
||||
self._stats.segments = writer.segments
|
||||
self._stats.video_frames = writer.video_frames
|
||||
self._stats.bytes_written = writer.bytes_written
|
||||
if problems:
|
||||
self._stats.failed = True
|
||||
if not self._stats.last_error:
|
||||
self._stats.last_error = problems[0]
|
||||
for problem in problems:
|
||||
self._alarm(problem)
|
||||
|
||||
def _handle_event(self, writer, event: dict) -> None:
|
||||
if self._t0 is not None:
|
||||
event["t_rel_s"] = round(float(time.time()) - self._t0, 6)
|
||||
with self._lock:
|
||||
self._events.append(event)
|
||||
try:
|
||||
writer.write_event(event)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._record_error(exc)
|
||||
|
||||
def _handle_frame(self, writer, row, frame) -> None:
|
||||
try:
|
||||
writer.write_sample(row, frame)
|
||||
with self._lock:
|
||||
self._stats.frames += 1
|
||||
self._stats.video_frames = writer.video_frames
|
||||
self._stats.segments = writer.segments
|
||||
self._consecutive_errors = 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._record_error(exc)
|
||||
|
||||
def _record_error(self, exc: Exception) -> None:
|
||||
with self._lock:
|
||||
self._stats.write_errors += 1
|
||||
self._stats.last_error = f"{type(exc).__name__}: {exc}"
|
||||
self._consecutive_errors += 1
|
||||
fatal = self._consecutive_errors >= MAX_CONSECUTIVE_ERRORS
|
||||
if fatal:
|
||||
self._stats.failed = True
|
||||
self._alarm(f"recording write failed: {exc}")
|
||||
if fatal:
|
||||
# Stop pretending we are recording rather than losing data quietly.
|
||||
self._stop.set()
|
||||
|
||||
def _finalise(self) -> RecordReport:
|
||||
with self._lock:
|
||||
if self._t0 is not None and self._last_t is not None:
|
||||
self._stats.duration_s = round(float(self._last_t - self._t0), 6)
|
||||
stats = self.stats()
|
||||
problems: list[str] = []
|
||||
if stats.dropped_data or stats.dropped_video:
|
||||
problems.append(
|
||||
f"{stats.dropped_data} data and {stats.dropped_video} video frame(s) "
|
||||
"were dropped by the bounded queue"
|
||||
)
|
||||
if stats.write_errors:
|
||||
problems.append(f"{stats.write_errors} write error(s); last: {stats.last_error}")
|
||||
self._write_session_json(final=True)
|
||||
return RecordReport(directory=self._directory, stats=stats, problems=problems)
|
||||
|
||||
def _write_session_json(self, *, final: bool) -> None:
|
||||
directory = self._directory
|
||||
if directory is None:
|
||||
return
|
||||
payload = self._build_metadata(final=final)
|
||||
target = directory / "session.json"
|
||||
try:
|
||||
# Written at start too, so a crash still leaves identifiable metadata.
|
||||
temporary = target.with_suffix(".json.tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(target)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
with self._lock:
|
||||
self._stats.write_errors += 1
|
||||
self._stats.last_error = f"session.json: {type(exc).__name__}: {exc}"
|
||||
self._alarm(f"could not write session.json: {exc}")
|
||||
|
||||
def _build_metadata(self, *, final: bool) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
stats = self._stats.as_dict()
|
||||
events = list(self._events)
|
||||
payload: dict[str, Any] = {
|
||||
"format_version": RECORD_FORMAT_VERSION,
|
||||
"session_id": self._session_id,
|
||||
"calibration_id": self._calibration_id,
|
||||
"started_at": self._started_at,
|
||||
"ended_at": (
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds") if final else None
|
||||
),
|
||||
"video": self.config.as_dict(),
|
||||
"events": events,
|
||||
"stats": stats,
|
||||
"files": {
|
||||
"forces": "forces.csv",
|
||||
"frames": "frames.csv",
|
||||
"events": "events.csv",
|
||||
"video": "raw_%04d.mp4" if self.config.video else None,
|
||||
"lossless_frames": "frames/" if self.config.lossless_frames else None,
|
||||
},
|
||||
}
|
||||
payload.update(self._metadata)
|
||||
return payload
|
||||
|
||||
|
||||
class _SessionWriter:
|
||||
"""Owns the file handles. Only ever touched by the writer thread."""
|
||||
|
||||
def __init__(self, *, directory, config, session_id, calibration_id) -> None:
|
||||
self.directory = Path(directory)
|
||||
self.config = config
|
||||
self.session_id = session_id
|
||||
self.calibration_id = calibration_id
|
||||
self.write_errors = 0
|
||||
self.segments = 0
|
||||
self.video_frames = 0
|
||||
self.png_frames = 0
|
||||
self.bytes_written = 0
|
||||
self._last_flush = time.perf_counter()
|
||||
self._t0: Optional[float] = None
|
||||
self._segment: Optional[cv2.VideoWriter] = None
|
||||
self._segment_index = 0
|
||||
self._segment_frames = 0
|
||||
self._segment_started = 0.0
|
||||
self._frame_shape: Optional[tuple[int, int]] = None
|
||||
self._problems: list[str] = []
|
||||
|
||||
self._forces = self._open_csv("forces.csv", FORCES_HEADER)
|
||||
self._frames = self._open_csv("frames.csv", FRAMES_HEADER)
|
||||
self._events = self._open_csv("events.csv", EVENTS_HEADER)
|
||||
self._png_dir = None
|
||||
if self.config.lossless_frames:
|
||||
self._png_dir = self.directory / "frames"
|
||||
self._png_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _open_csv(self, name: str, header):
|
||||
handle = (self.directory / name).open("w", newline="", encoding="utf-8")
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(header)
|
||||
return (handle, writer)
|
||||
|
||||
# ------------------------------------------------------------------ writing
|
||||
def write_sample(self, row, frame) -> None:
|
||||
"""Write one acquired frame: a forces row, the frame index, and video.
|
||||
|
||||
``forces.csv`` and ``frames.csv`` share the packet's ``frame_id`` and the
|
||||
same ``t_rel_s`` origin, which is what lets video and data be aligned
|
||||
afterwards even when frames were dropped or split into segments.
|
||||
"""
|
||||
frame_id, timestamp, fn_r, fx_r, fy_r, fn_n, fx_n, fy_n, valid = row
|
||||
if self._t0 is None:
|
||||
self._t0 = timestamp
|
||||
t_rel = round(timestamp - self._t0, 6)
|
||||
|
||||
self._forces[1].writerow(
|
||||
(
|
||||
self.session_id,
|
||||
frame_id,
|
||||
repr(t_rel),
|
||||
format_value(fn_r),
|
||||
format_value(fx_r),
|
||||
format_value(fy_r),
|
||||
format_value(fn_n),
|
||||
format_value(fx_n),
|
||||
format_value(fy_n),
|
||||
valid,
|
||||
self.calibration_id,
|
||||
)
|
||||
)
|
||||
|
||||
segment = 0
|
||||
segment_frame = 0
|
||||
if frame is not None:
|
||||
if self.config.video:
|
||||
segment, segment_frame = self._write_video_frame(frame)
|
||||
if self._png_dir is not None:
|
||||
# The lossless sequence is independent of the MP4 setting.
|
||||
self._write_lossless(frame)
|
||||
|
||||
self._frames[1].writerow(
|
||||
(
|
||||
frame_id,
|
||||
repr(t_rel),
|
||||
datetime.fromtimestamp(timestamp, timezone.utc).isoformat(
|
||||
timespec="milliseconds"
|
||||
),
|
||||
segment,
|
||||
segment_frame,
|
||||
1 if segment else 0,
|
||||
0,
|
||||
)
|
||||
)
|
||||
|
||||
def _write_video_frame(self, frame) -> tuple[int, int]:
|
||||
if self._frame_shape is None:
|
||||
self._frame_shape = (int(frame.shape[1]), int(frame.shape[0]))
|
||||
if self._should_roll():
|
||||
self._close_segment()
|
||||
if self._segment is None:
|
||||
self._open_segment()
|
||||
self._segment.write(frame)
|
||||
self._segment_frames += 1
|
||||
self.video_frames += 1
|
||||
return (self._segment_index, self._segment_frames)
|
||||
|
||||
def _write_lossless(self, frame) -> None:
|
||||
self.png_frames += 1
|
||||
target = self._png_dir / f"{self.png_frames:06d}.png"
|
||||
if not cv2.imwrite(str(target), frame):
|
||||
raise OSError(f"could not write {target.name}")
|
||||
|
||||
def write_event(self, event: dict) -> None:
|
||||
self._events[1].writerow(
|
||||
(event.get("at", ""), event.get("t_rel_s", ""), event["kind"], event["detail"])
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ segments
|
||||
def _should_roll(self) -> bool:
|
||||
if self._segment is None:
|
||||
return False
|
||||
if self.config.segment_seconds <= 0:
|
||||
return False
|
||||
return (time.perf_counter() - self._segment_started) >= self.config.segment_seconds
|
||||
|
||||
def _open_segment(self) -> None:
|
||||
self._segment_index += 1
|
||||
path = self.directory / f"raw_{self._segment_index:04d}.mp4"
|
||||
fourcc = cv2.VideoWriter_fourcc(*self.config.codec)
|
||||
fps = max(1.0, float(self.config.fps))
|
||||
writer = cv2.VideoWriter(str(path), fourcc, fps, self._frame_shape)
|
||||
if not writer.isOpened():
|
||||
raise OSError(f"could not open video writer for {path.name}")
|
||||
self._segment = writer
|
||||
self._segment_frames = 0
|
||||
self._segment_started = time.perf_counter()
|
||||
self.segments = self._segment_index
|
||||
|
||||
def _close_segment(self) -> None:
|
||||
if self._segment is None:
|
||||
return
|
||||
self._segment.release()
|
||||
self._segment = None
|
||||
|
||||
def maybe_flush(self) -> None:
|
||||
if (time.perf_counter() - self._last_flush) < self.config.flush_interval_s:
|
||||
return
|
||||
self._last_flush = time.perf_counter()
|
||||
for handle, _writer in (self._forces, self._frames, self._events):
|
||||
try:
|
||||
handle.flush()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.write_errors += 1
|
||||
if len(self._problems) < 5:
|
||||
self._problems.append(f"flush failed: {exc}")
|
||||
|
||||
def close(self) -> list[str]:
|
||||
self._close_segment()
|
||||
for handle, _writer in (self._forces, self._frames, self._events):
|
||||
try:
|
||||
handle.flush()
|
||||
handle.close()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.write_errors += 1
|
||||
if len(self._problems) < 5:
|
||||
self._problems.append(f"close failed: {exc}")
|
||||
try:
|
||||
self.bytes_written = sum(
|
||||
path.stat().st_size for path in self.directory.glob("*") if path.is_file()
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
self.bytes_written = 0
|
||||
return self._problems
|
||||
Reference in New Issue
Block a user