Phase 11
This commit is contained in:
commit
c5bcb95d74
172 files changed
+9487
No files matched your search
@@ -0,0 +1,132 @@
|
||||
"""UTF-8 BOM CSV logging for Phase 4 turn analysis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
|
||||
from .config_loader import TurnLoggingSettings
|
||||
from .exceptions import TurnLogError
|
||||
from .models import TurnEvent, TurnFrameResult, TurnTrackState
|
||||
|
||||
|
||||
class TurnLogger:
|
||||
"""Write turn frame, event, and deduplicated summary streams."""
|
||||
|
||||
FRAME_FIELDS = ["experiment_id", "frame_number", "timestamp", "elapsed_time_sec", "track_id", "face_detected",
|
||||
"pose_estimated", "baseline_available", "baseline_yaw", "current_yaw", "yaw_delta_raw",
|
||||
"yaw_delta_toward_signage", "signage_yaw_direction", "turn_candidate", "turn_confirmed",
|
||||
"turn_level", "continuous_duration_sec", "failure_reason"]
|
||||
EVENT_FIELDS = ["experiment_id", "event_type", "track_id", "frame_number", "timestamp", "elapsed_time_sec",
|
||||
"yaw", "yaw_delta_toward_signage", "turn_level", "metadata_json"]
|
||||
SUMMARY_FIELDS = ["experiment_id", "track_id", "total_frames", "face_detected_frames", "pose_frames",
|
||||
"face_detection_rate", "pose_estimation_rate", "baseline_acquired", "baseline_yaw",
|
||||
"baseline_frame", "baseline_time", "baseline_sample_count", "face_appeared_after_missing",
|
||||
"first_face_detected_frame", "first_face_detected_time", "max_yaw_delta_toward_signage",
|
||||
"max_yaw_delta_frame", "max_yaw_delta_time", "turn_started", "turn_start_frame",
|
||||
"turn_start_time", "turn_confirmed", "turn_confirmed_frame", "turn_confirmed_time",
|
||||
"turn_level", "turn_duration_sec", "evaluable", "valid_for_turn_analysis", "exclusion_reason"]
|
||||
|
||||
def __init__(self, config: TurnLoggingSettings, experiment_id: str, run_stamp: str | None = None) -> None:
|
||||
self.config, self.experiment_id = config, experiment_id
|
||||
self.run_stamp = run_stamp or datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
self.directory = Path(config.directory)
|
||||
self._files: dict[str, TextIO] = {}
|
||||
self._writers: dict[str, csv.DictWriter[str]] = {}
|
||||
self._summary_ids: set[int] = set()
|
||||
self._frames_since_flush = 0
|
||||
|
||||
def open(self) -> None:
|
||||
if not self.config.enabled:
|
||||
return
|
||||
self.directory.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
self._open("frames", "turn_frames", self.FRAME_FIELDS, self.config.log_frame_turns)
|
||||
self._open("events", "turn_events", self.EVENT_FIELDS, self.config.log_events)
|
||||
self._open("summary", "turn_summary", self.SUMMARY_FIELDS, self.config.log_summaries)
|
||||
except OSError as exc:
|
||||
self.close()
|
||||
raise TurnLogError(f"Failed to open turn logs: {exc}") from exc
|
||||
|
||||
def _open(self, key: str, suffix: str, fields: list[str], enabled: bool) -> None:
|
||||
if not enabled:
|
||||
return
|
||||
stream = (self.directory / f"{self.run_stamp}_{self.experiment_id}_{suffix}.csv").open(
|
||||
"w", encoding="utf-8-sig", newline="",
|
||||
)
|
||||
writer = csv.DictWriter(stream, fieldnames=fields)
|
||||
writer.writeheader()
|
||||
self._files[key], self._writers[key] = stream, writer
|
||||
|
||||
def write_frames(self, results: list[TurnFrameResult]) -> None:
|
||||
writer = self._writers.get("frames")
|
||||
if writer is None:
|
||||
return
|
||||
for item in results:
|
||||
writer.writerow({
|
||||
"experiment_id": self.experiment_id, "frame_number": item.frame_number,
|
||||
"timestamp": item.timestamp_iso, "elapsed_time_sec": self._number(item.elapsed_time_sec),
|
||||
"track_id": item.track_id, "face_detected": item.face_detected, "pose_estimated": item.pose_estimated,
|
||||
"baseline_available": item.baseline_available, "baseline_yaw": self._number(item.baseline_yaw),
|
||||
"current_yaw": self._number(item.current_yaw), "yaw_delta_raw": self._number(item.yaw_delta_raw),
|
||||
"yaw_delta_toward_signage": self._number(item.yaw_delta_toward_signage),
|
||||
"signage_yaw_direction": item.signage_direction, "turn_candidate": item.turn_candidate,
|
||||
"turn_confirmed": item.turn_confirmed, "turn_level": item.turn_level,
|
||||
"continuous_duration_sec": self._number(item.continuous_duration_sec),
|
||||
"failure_reason": item.failure_reason,
|
||||
})
|
||||
self._frames_since_flush += 1
|
||||
if self._frames_since_flush >= self.config.flush_interval_frames:
|
||||
self.flush()
|
||||
|
||||
def write_events(self, events: list[TurnEvent]) -> None:
|
||||
writer = self._writers.get("events")
|
||||
if writer is None:
|
||||
return
|
||||
for item in events:
|
||||
writer.writerow({
|
||||
"experiment_id": self.experiment_id, "event_type": item.event_type, "track_id": item.track_id,
|
||||
"frame_number": item.frame_number, "timestamp": item.timestamp_iso,
|
||||
"elapsed_time_sec": self._number(item.elapsed_time_sec), "yaw": self._number(item.yaw),
|
||||
"yaw_delta_toward_signage": self._number(item.yaw_delta_toward_signage),
|
||||
"turn_level": item.turn_level,
|
||||
"metadata_json": json.dumps(item.metadata, ensure_ascii=False),
|
||||
})
|
||||
|
||||
def write_summary(self, state: TurnTrackState) -> None:
|
||||
writer = self._writers.get("summary")
|
||||
if writer is None or state.track_id in self._summary_ids:
|
||||
return
|
||||
row = {field: getattr(state, field, "") for field in self.SUMMARY_FIELDS if field != "experiment_id"}
|
||||
row["experiment_id"] = self.experiment_id
|
||||
for field in ("face_detection_rate", "pose_estimation_rate"):
|
||||
row[field] = self._number(getattr(state, field))
|
||||
for field in ("baseline_yaw", "max_yaw_delta_toward_signage", "turn_duration_sec"):
|
||||
row[field] = self._number(getattr(state, field))
|
||||
writer.writerow(row)
|
||||
self._summary_ids.add(state.track_id)
|
||||
|
||||
@staticmethod
|
||||
def _number(value: float | None) -> str:
|
||||
return "" if value is None else f"{value:.6f}"
|
||||
|
||||
def flush(self) -> None:
|
||||
for stream in self._files.values():
|
||||
stream.flush()
|
||||
self._frames_since_flush = 0
|
||||
|
||||
def close(self) -> None:
|
||||
for stream in self._files.values():
|
||||
stream.close()
|
||||
self._files.clear()
|
||||
self._writers.clear()
|
||||
|
||||
def __enter__(self) -> "TurnLogger":
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.close()
|
||||
Reference in new issue
Block a user