Phase 11
This commit is contained in:
commit
c5bcb95d74
172 files changed
+9487
No files matched your search
@@ -0,0 +1,3 @@
|
||||
"""Phase 1-5 pedestrian detection, tracking, pose, turn, and voice system."""
|
||||
|
||||
__version__ = "0.11.0"
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Offline Phase 8 analysis command that never imports camera/application code."""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from .analysis_loader import AnalysisLoader
|
||||
from .analysis_plots import AnalysisPlots
|
||||
from .analysis_reporter import AnalysisReporter
|
||||
from .config_loader import AnalysisSettings
|
||||
from .statistical_analyzer import StatisticalAnalyzer
|
||||
LOGGER=logging.getLogger(__name__)
|
||||
|
||||
def run_analysis_cli(config:AnalysisSettings,input_path:Path,output_path:Path)->list[Path]:
|
||||
settings=AnalysisSettings(config.enabled,str(input_path),str(output_path),config.recursive,config.include_prompt_condition,config.include_control_condition,config.valid_voice_analysis_only,config.response_level_threshold,config.response_rate_test,config.numeric_test,config.yaw_metric_denominator,config.generate_plots,config.overwrite_outputs,config.alpha)
|
||||
loader=AnalysisLoader(settings);paths=loader.find_person_analysis_tables(input_path,settings.recursive) if input_path.is_dir() else [input_path]
|
||||
df=loader.load_person_analysis_tables(paths);result=StatisticalAnalyzer(settings).run_analysis(df,len(paths));outputs=AnalysisReporter(settings).write(result);outputs+=AnalysisPlots(settings).generate(df,result)
|
||||
for path in outputs:LOGGER.info("Analysis output: %s",path)
|
||||
return outputs
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Validated multi-session Phase 7 CSV loading."""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from .config_loader import AnalysisSettings
|
||||
from .exceptions import ConfigurationError
|
||||
|
||||
class AnalysisLoader:
|
||||
REQUIRED={"experiment_id","session_id","track_id","prompt_condition","response_detected",
|
||||
"valid_for_voice_analysis","valid_for_turn_analysis","turn_confirmed","turn_level",
|
||||
"reaction_time_sec","max_yaw_delta_toward_signage","excluded","exclusion_reason"}
|
||||
BOOLS={"prompt_played","pseudo_prompt","response_window_started","response_detected","turn_confirmed","face_detected_ever","pose_estimated_ever","valid_for_turn_analysis","valid_for_voice_analysis","completed","excluded","trigger_crossed"}
|
||||
NUMBERS={"track_id","prompt_elapsed_time_sec","reaction_time_sec","max_yaw_delta_toward_signage","face_detection_rate","pose_estimation_rate","track_duration_sec"}
|
||||
def __init__(self,config:AnalysisSettings)->None:self.config=config
|
||||
def find_person_analysis_tables(self,directory:Path,recursive:bool=True)->list[Path]:
|
||||
return sorted(directory.rglob("*_person_analysis_table.csv") if recursive else directory.glob("*_person_analysis_table.csv"))
|
||||
def load_person_analysis_tables(self,paths:list[Path]):
|
||||
pd=self._pandas()
|
||||
if not paths:raise ConfigurationError("No person_analysis_table.csv files found")
|
||||
frames=[]
|
||||
for path in paths:
|
||||
try: frames.append(pd.read_csv(path,encoding="utf-8-sig"))
|
||||
except Exception as exc:raise ConfigurationError(f"Failed to read {path}: {exc}") from exc
|
||||
df=pd.concat(frames,ignore_index=True);self.validate_person_analysis_columns(df)
|
||||
for col in self.BOOLS & set(df.columns):df[col]=df[col].map(self._bool_value).astype("boolean")
|
||||
for col in self.NUMBERS & set(df.columns):df[col]=pd.to_numeric(df[col],errors="coerce")
|
||||
conditions=set(df["prompt_condition"].dropna().astype(str))
|
||||
if not conditions<={"prompt","control","disabled","unknown"}:raise ConfigurationError(f"Invalid prompt_condition values: {sorted(conditions)}")
|
||||
return df
|
||||
def load_session_summaries(self,paths:list[Path]):
|
||||
pd=self._pandas();return pd.concat([pd.read_csv(p,encoding="utf-8-sig") for p in paths],ignore_index=True) if paths else pd.DataFrame()
|
||||
def validate_person_analysis_columns(self,df:object)->None:
|
||||
missing=self.REQUIRED-set(df.columns)
|
||||
if missing:raise ConfigurationError(f"Missing person analysis columns: {sorted(missing)}")
|
||||
@staticmethod
|
||||
def _bool_value(value:object):
|
||||
if value is None:return None
|
||||
text=str(value).strip().lower()
|
||||
if text in {"true","1","yes"}:return True
|
||||
if text in {"false","0","no"}:return False
|
||||
return None
|
||||
@staticmethod
|
||||
def _pandas():
|
||||
try:import pandas as pd
|
||||
except ImportError as exc:raise ConfigurationError("Phase 8 requires pandas; run pip install -r requirements.txt") from exc
|
||||
return pd
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Minimal matplotlib plots for exploratory Phase 8 review."""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from .config_loader import AnalysisSettings
|
||||
LOGGER=logging.getLogger(__name__)
|
||||
|
||||
class AnalysisPlots:
|
||||
"""Generate five independent PNG files with an optional Agg backend."""
|
||||
def __init__(self,config:AnalysisSettings)->None:self.config=config;self.directory=Path(config.output_directory)/"plots"
|
||||
def generate(self,df,result)->list[Path]:
|
||||
if not self.config.generate_plots:return []
|
||||
try:
|
||||
import matplotlib;matplotlib.use("Agg",force=True);import matplotlib.pyplot as plt
|
||||
except ImportError as exc:raise RuntimeError("Phase 8 plots require matplotlib") from exc
|
||||
self.directory.mkdir(parents=True,exist_ok=True);paths=[]
|
||||
if df.empty: LOGGER.warning("Analysis plot input is empty; placeholder plots will be generated")
|
||||
def save(name,title,x,y,ylabel):
|
||||
fig,ax=plt.subplots();ax.bar(x,y);ax.set_title(title);ax.set_ylabel(ylabel);fig.tight_layout();path=self.directory/name;fig.savefig(path);plt.close(fig);paths.append(path)
|
||||
summaries=result.condition_summaries;save("response_rate_by_condition.png","Response Rate",[x.condition for x in summaries],[x.response_rate or 0 for x in summaries],"Rate")
|
||||
for metric,name,title in (("reaction_time_sec","reaction_time_by_condition.png","Reaction Time"),("max_yaw_delta_toward_signage","max_yaw_delta_by_condition.png","Max Yaw Delta")):
|
||||
groups=[df[df.prompt_condition==c][metric].dropna().tolist() for c in ("prompt","control")];fig,ax=plt.subplots()
|
||||
# Matplotlib 3.9 renamed ``labels`` to ``tick_labels`` and newer
|
||||
# releases no longer accept the old keyword. Use the new name
|
||||
# first while retaining compatibility with older supported builds.
|
||||
try:
|
||||
ax.boxplot(groups,tick_labels=["prompt","control"])
|
||||
except TypeError:
|
||||
ax.boxplot(groups,labels=["prompt","control"])
|
||||
ax.set_title(title);fig.tight_layout();path=self.directory/name;fig.savefig(path);plt.close(fig);paths.append(path)
|
||||
levels=["none","subtle","weak","medium","strong","not_evaluable"];p=df[df.prompt_condition=="prompt"].turn_level.fillna("not_evaluable");c=df[df.prompt_condition=="control"].turn_level.fillna("not_evaluable");fig,ax=plt.subplots();x=range(len(levels));ax.bar([i-.2 for i in x],[(p==v).sum() for v in levels],.4,label="prompt");ax.bar([i+.2 for i in x],[(c==v).sum() for v in levels],.4,label="control");ax.set_xticks(list(x),levels,rotation=30);ax.legend();fig.tight_layout();path=self.directory/"turn_level_distribution.png";fig.savefig(path);plt.close(fig);paths.append(path)
|
||||
exclusions=df[df.excluded==True].exclusion_reason.fillna("unknown").value_counts();save("exclusion_reason_counts.png","Exclusion Reasons",list(exclusions.index) or ["none"],list(exclusions.values) or [0],"Count")
|
||||
return paths
|
||||
@@ -0,0 +1,23 @@
|
||||
"""UTF-8 BOM Phase 8 result CSV reporting."""
|
||||
from __future__ import annotations
|
||||
import csv
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from .config_loader import AnalysisSettings
|
||||
from .models import AnalysisResult
|
||||
|
||||
class AnalysisReporter:
|
||||
"""Write six fixed-schema result tables, including empty tables."""
|
||||
FILES={"condition_summary":("condition_summary.csv",["condition","total_records","valid_voice_analysis_count","response_detected_count","no_response_count","response_rate","excluded_count","not_evaluable_count","mean_reaction_time_sec","median_reaction_time_sec","std_reaction_time_sec","mean_max_yaw_delta","median_max_yaw_delta","std_max_yaw_delta","subtle_count","weak_count","medium_count","strong_count","weak_or_higher_count","medium_or_higher_count"]),"response_rate_test":("response_rate_test.csv",["test_name","prompt_success","prompt_total","control_success","control_total","prompt_rate","control_rate","rate_difference","statistic","p_value","effect_size_name","effect_size","odds_ratio","method_note"]),"numeric_comparisons":("numeric_comparisons.csv",["metric_name","test_name","prompt_count","control_count","prompt_mean","control_mean","prompt_median","control_median","mean_difference","statistic","p_value","effect_size_name","effect_size","method_note"]),"turn_levels":("turn_level_distribution.csv",["condition","none_count","face_appeared_count","subtle_count","weak_count","medium_count","strong_count","not_evaluable_count","total_count"]),"exclusions":("exclusion_summary.csv",["condition","exclusion_reason","count","ratio"]),"run":("analysis_run_summary.csv",["analysis_id","input_files_count","total_records","prompt_records","control_records","valid_prompt_records","valid_control_records","generated_at","output_directory","notes"])}
|
||||
def __init__(self,config:AnalysisSettings)->None:self.config=config;self.output=Path(config.output_directory)
|
||||
def write(self,result:AnalysisResult)->list[Path]:
|
||||
self.output.mkdir(parents=True,exist_ok=True);mapping={"condition_summary":result.condition_summaries,"response_rate_test":[result.response_rate_test],"numeric_comparisons":result.numeric_comparisons,"turn_levels":result.turn_level_distributions,"exclusions":result.exclusion_summaries,"run":[result.run_summary]};paths=[]
|
||||
for key,items in mapping.items():
|
||||
name,fields=self.FILES[key];path=self.output/name
|
||||
if path.exists() and not self.config.overwrite_outputs:raise FileExistsError(path)
|
||||
with path.open("w",encoding="utf-8-sig",newline="") as f:
|
||||
writer=csv.DictWriter(f,fieldnames=fields);writer.writeheader()
|
||||
for item in items:writer.writerow(asdict(item))
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Build Phase 8-ready rows from integrated person summaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .config_loader import UnifiedLogSettings
|
||||
from .models import (PersonAnalysisRecord, PersonStateSummary, TurnTrackState,
|
||||
VoicePromptTrackState)
|
||||
|
||||
|
||||
class AnalysisTableBuilder:
|
||||
"""Convert integrated person summaries to one Phase 8-ready row each."""
|
||||
|
||||
def __init__(self, config: UnifiedLogSettings, session_id: str) -> None:
|
||||
self.config, self.session_id = config, session_id
|
||||
|
||||
def build_record(
|
||||
self, person_summary: PersonStateSummary, turn_state: TurnTrackState | None = None,
|
||||
voice_state: VoicePromptTrackState | None = None, face_state: object | None = None,
|
||||
track_summary: object | None = None,
|
||||
) -> PersonAnalysisRecord:
|
||||
del turn_state, voice_state, face_state, track_summary
|
||||
mode = person_summary.voice_mode or "disabled"
|
||||
condition = mode if mode in {"prompt", "control", "disabled"} else "unknown"
|
||||
return PersonAnalysisRecord(person_summary.experiment_id, self.session_id, person_summary.track_id,
|
||||
mode, condition, person_summary.prompt_played, person_summary.pseudo_prompt,
|
||||
person_summary.prompt_time, person_summary.prompt_elapsed_time_sec,
|
||||
person_summary.response_window_started, person_summary.response_detected,
|
||||
person_summary.reaction_time_sec, person_summary.response_turn_level,
|
||||
person_summary.turn_confirmed, person_summary.turn_level,
|
||||
person_summary.max_yaw_delta_toward_signage, person_summary.face_detected_ever,
|
||||
person_summary.pose_estimated_ever, person_summary.face_detection_rate,
|
||||
person_summary.pose_estimation_rate, person_summary.valid_for_turn_analysis,
|
||||
person_summary.valid_for_voice_analysis, person_summary.final_status, person_summary.completed,
|
||||
person_summary.excluded, person_summary.exclusion_reason, person_summary.duration_sec,
|
||||
person_summary.trigger_crossed, person_summary.trigger_crossing_direction,
|
||||
person_summary.movement_direction, self.config.camera_position_note or None, person_summary.notes)
|
||||
|
||||
def build_records(self, person_summaries: list[PersonStateSummary], **_states: object) -> list[PersonAnalysisRecord]:
|
||||
return [self.build_record(item) for item in person_summaries]
|
||||
@@ -0,0 +1,569 @@
|
||||
"""Top-level Phase 1-4 computer-vision research orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import logging.config
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import yaml
|
||||
|
||||
from .camera import Camera
|
||||
from .analysis_table_builder import AnalysisTableBuilder
|
||||
from .audio_player import AudioPlayer
|
||||
from .config_loader import AppConfig
|
||||
from .detection_logger import DetectionLogger
|
||||
from .detector import PersonDetector
|
||||
from .exceptions import VideoWriterError
|
||||
from .fps_counter import FPSCounter
|
||||
from .face_logger import FaceLogger
|
||||
from .face_processor import FaceProcessor, FaceTrackManager
|
||||
from .models import Detection, FrameInfo, RuntimeMetrics, TrackedPerson
|
||||
from .person_state_logger import PersonStateLogger
|
||||
from .person_state_manager import PersonStateManager
|
||||
from .log_consistency_checker import LogConsistencyChecker
|
||||
from .session_summary_builder import SessionSummaryBuilder
|
||||
from .track_manager import TrackManager
|
||||
from .tracker import PersonTracker
|
||||
from .tracking_logger import TrackingLogger
|
||||
from .turn_detector import TurnDetector
|
||||
from .turn_logger import TurnLogger
|
||||
from .turn_track_manager import TurnTrackManager
|
||||
from .visualizer import Visualizer
|
||||
from .unified_event_builder import UnifiedEventBuilder
|
||||
from .unified_event_logger import UnifiedEventLogger
|
||||
from .voice_logger import VoiceLogger
|
||||
from .voice_prompt_controller import VoicePromptController
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Application:
|
||||
"""Own application resources and execute the frame-processing loop."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AppConfig,
|
||||
logging_config_path: Path = Path("config/logging.yaml"),
|
||||
on_frame_rendered: Callable[[np.ndarray, RuntimeMetrics], None] | None = None,
|
||||
on_status_updated: Callable[[RuntimeMetrics], None] | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.logging_config_path = logging_config_path
|
||||
self._run_stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
self._writer: cv2.VideoWriter | None = None
|
||||
self._detection_logger: DetectionLogger | None = None
|
||||
self._tracking_logger: TrackingLogger | None = None
|
||||
self._face_logger: FaceLogger | None = None
|
||||
self._turn_logger: TurnLogger | None = None
|
||||
self._voice_logger: VoiceLogger | None = None
|
||||
self._person_state_logger: PersonStateLogger | None = None
|
||||
self._unified_logger: UnifiedEventLogger | None = None
|
||||
self._stop_requested = threading.Event()
|
||||
self._pause_requested = threading.Event()
|
||||
self._frame_callback = on_frame_rendered
|
||||
self._status_callback = on_status_updated
|
||||
|
||||
def request_stop(self) -> None:
|
||||
"""Request a cooperative stop; resource finalization remains in ``run``."""
|
||||
self._stop_requested.set()
|
||||
|
||||
def request_pause(self) -> None:
|
||||
"""Pause processing without advancing frames or loggers."""
|
||||
self._pause_requested.set()
|
||||
|
||||
def request_resume(self) -> None:
|
||||
"""Resume processing after a GUI-requested pause."""
|
||||
self._pause_requested.clear()
|
||||
|
||||
def set_frame_callback(self, callback: Callable[[np.ndarray, RuntimeMetrics], None] | None) -> None:
|
||||
self._frame_callback = callback
|
||||
|
||||
def set_status_callback(self, callback: Callable[[RuntimeMetrics], None] | None) -> None:
|
||||
self._status_callback = callback
|
||||
|
||||
def _prepare(self) -> None:
|
||||
for directory in (
|
||||
self.config.logging.runtime_log_directory, self.config.logging.detection_log_directory,
|
||||
self.config.output.video_directory, self.config.output.screenshot_directory,
|
||||
self.config.tracking_logging.directory,
|
||||
self.config.face_logging.directory,
|
||||
self.config.turn_logging.directory,
|
||||
self.config.voice_logging.directory,
|
||||
self.config.person_state_logging.directory,
|
||||
self.config.unified_logging.directory,
|
||||
):
|
||||
Path(directory).mkdir(parents=True, exist_ok=True)
|
||||
with self.logging_config_path.open("r", encoding="utf-8") as stream:
|
||||
logging_settings = yaml.safe_load(stream)
|
||||
if self.config.logging.runtime_log_enabled:
|
||||
runtime_file = Path(self.config.logging.runtime_log_directory) / f"{self._run_stamp}_{self.config.experiment.experiment_id}.log"
|
||||
logging_settings["handlers"]["file"]["filename"] = str(runtime_file)
|
||||
else:
|
||||
logging_settings["root"]["handlers"] = ["console"]
|
||||
logging.config.dictConfig(logging_settings)
|
||||
merged_path = Path(self.config.logging.runtime_log_directory) / f"{self._run_stamp}_{self.config.experiment.experiment_id}_config.yaml"
|
||||
with merged_path.open("w", encoding="utf-8") as stream:
|
||||
yaml.safe_dump(self.config.to_dict(), stream, allow_unicode=True, sort_keys=False)
|
||||
LOGGER.info("Final configuration:\n%s", yaml.safe_dump(self.config.to_dict(), allow_unicode=True, sort_keys=False))
|
||||
|
||||
def _open_writer(self, width: int, height: int, source_fps: float) -> None:
|
||||
if not self.config.output.save_video:
|
||||
return
|
||||
path = Path(self.config.output.video_directory) / f"{self._run_stamp}_{self.config.experiment.experiment_id}.mp4"
|
||||
fps = source_fps if source_fps > 0 else (self.config.input.fps if self.config.input.fps > 0 else 30.0)
|
||||
codec = self.config.output.video_codec
|
||||
if len(codec) != 4:
|
||||
raise VideoWriterError("output.video_codec must contain exactly four characters")
|
||||
self._writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*codec), fps, (width, height))
|
||||
if not self._writer.isOpened():
|
||||
self._writer.release()
|
||||
self._writer = None
|
||||
raise VideoWriterError(f"Could not open output video: {path}")
|
||||
LOGGER.info("Saving annotated video to %s", path)
|
||||
|
||||
def run(self) -> None:
|
||||
self._prepare()
|
||||
camera = Camera(self.config.input)
|
||||
detector = PersonDetector(self.config.detector)
|
||||
tracker: PersonTracker | None = None
|
||||
track_manager: TrackManager | None = None
|
||||
face_processor: FaceProcessor | None = None
|
||||
face_manager: FaceTrackManager | None = None
|
||||
turn_detector: TurnDetector | None = None
|
||||
turn_manager: TurnTrackManager | None = None
|
||||
audio_player: AudioPlayer | None = None
|
||||
voice_controller: VoicePromptController | None = None
|
||||
person_state_manager: PersonStateManager | None = None
|
||||
unified_builder: UnifiedEventBuilder | None = None
|
||||
analysis_builder: AnalysisTableBuilder | None = None
|
||||
fps = FPSCounter(self.config.performance.fps_average_window)
|
||||
visualizer = Visualizer(self.config.display, self.config.performance.warn_fps_threshold, self.config.performance.fps_average_window)
|
||||
frame_number, processed_frames = 0, 0
|
||||
start = time.perf_counter()
|
||||
session_started_at = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
paused = False
|
||||
pause_started: float | None = None
|
||||
paused_total = 0.0
|
||||
debug = self.config.debug.enabled
|
||||
display_frame = None
|
||||
last_info: FrameInfo | None = None
|
||||
session_id = f"{self._run_stamp}_{self.config.experiment.experiment_id}"
|
||||
analysis_person_summaries = []
|
||||
session_started_written = False
|
||||
try:
|
||||
camera.open()
|
||||
detector.load_model()
|
||||
tracker = PersonTracker(self.config.tracker, camera.get_actual_fps())
|
||||
face_effective = self.config.face.enabled and self.config.tracker.enabled
|
||||
if self.config.face.enabled and not self.config.tracker.enabled:
|
||||
LOGGER.warning("Face processing is disabled because tracker.enabled is false")
|
||||
if face_effective:
|
||||
face_processor = FaceProcessor(self.config.face, self.config.head_pose)
|
||||
face_manager = FaceTrackManager(self.config.track_management.trajectory_max_points)
|
||||
turn_effective = (self.config.turn_detection.enabled and self.config.tracker.enabled
|
||||
and self.config.face.enabled and self.config.head_pose.enabled)
|
||||
if self.config.turn_detection.enabled and not turn_effective:
|
||||
LOGGER.warning("Turn detection is disabled because tracker, face, or head_pose is disabled")
|
||||
if turn_effective:
|
||||
turn_detector = TurnDetector(self.config.turn_detection, self.config.track_management.trajectory_max_points)
|
||||
turn_manager = TurnTrackManager(turn_detector)
|
||||
voice_effective = (self.config.voice_prompt.enabled and self.config.voice_prompt.mode != "disabled"
|
||||
and self.config.tracker.enabled)
|
||||
if self.config.voice_prompt.enabled and not self.config.tracker.enabled:
|
||||
LOGGER.warning("Voice prompting is disabled because tracker.enabled is false")
|
||||
if voice_effective:
|
||||
audio_player = AudioPlayer(self.config.audio)
|
||||
if self.config.voice_prompt.mode == "prompt":
|
||||
audio_player.load(Path(self.config.audio.file_path))
|
||||
source_fps = camera.get_actual_fps() or self.config.input.fps
|
||||
voice_controller = VoicePromptController(self.config.voice_prompt, audio_player, turn_effective, source_fps)
|
||||
person_state_effective = self.config.person_state.enabled and self.config.tracker.enabled
|
||||
if self.config.person_state.enabled and not self.config.tracker.enabled:
|
||||
LOGGER.warning("Person state management is disabled because tracker.enabled is false")
|
||||
if person_state_effective:
|
||||
person_state_manager = PersonStateManager(
|
||||
self.config.person_state, self.config.experiment.experiment_id, turn_effective, voice_effective,
|
||||
)
|
||||
if self.config.unified_logging.enabled:
|
||||
unified_builder = UnifiedEventBuilder(self.config.unified_logging, self.config.experiment.experiment_id, session_id)
|
||||
analysis_builder = AnalysisTableBuilder(self.config.unified_logging, session_id)
|
||||
detection_path = Path(self.config.logging.detection_log_directory) / f"{self._run_stamp}_{self.config.experiment.experiment_id}_detections.csv"
|
||||
if self.config.logging.detection_log_enabled:
|
||||
self._detection_logger = DetectionLogger(self.config.logging, self.config.experiment.experiment_id, detection_path)
|
||||
self._detection_logger.open()
|
||||
if self.config.tracking_logging.enabled:
|
||||
self._tracking_logger = TrackingLogger(
|
||||
self.config.tracking_logging, self.config.experiment.experiment_id, self._run_stamp,
|
||||
)
|
||||
self._tracking_logger.open()
|
||||
if face_effective and self.config.face_logging.enabled:
|
||||
self._face_logger = FaceLogger(
|
||||
self.config.face_logging, self.config.experiment.experiment_id, self._run_stamp,
|
||||
)
|
||||
self._face_logger.open()
|
||||
if turn_effective and self.config.turn_logging.enabled:
|
||||
self._turn_logger = TurnLogger(
|
||||
self.config.turn_logging, self.config.experiment.experiment_id, self._run_stamp,
|
||||
)
|
||||
self._turn_logger.open()
|
||||
if voice_effective and self.config.voice_logging.enabled:
|
||||
self._voice_logger = VoiceLogger(
|
||||
self.config.voice_logging, self.config.experiment.experiment_id, self._run_stamp,
|
||||
)
|
||||
self._voice_logger.open()
|
||||
if person_state_effective and self.config.person_state_logging.enabled:
|
||||
self._person_state_logger = PersonStateLogger(
|
||||
self.config.person_state_logging, self.config.experiment.experiment_id, self._run_stamp,
|
||||
)
|
||||
self._person_state_logger.open()
|
||||
if self.config.unified_logging.enabled:
|
||||
self._unified_logger = UnifiedEventLogger(
|
||||
self.config.unified_logging, self.config.experiment.experiment_id, session_id, self._run_stamp,
|
||||
)
|
||||
self._unified_logger.open()
|
||||
self._open_writer(camera.get_actual_width(), camera.get_actual_height(), camera.get_actual_fps())
|
||||
while True:
|
||||
if self._stop_requested.is_set():
|
||||
LOGGER.info("Stop requested")
|
||||
break
|
||||
if self._pause_requested.is_set() and not paused:
|
||||
paused = True
|
||||
pause_started = time.perf_counter()
|
||||
if paused:
|
||||
if not self._pause_requested.is_set() and not self.config.display.enabled:
|
||||
paused = False
|
||||
if pause_started is not None:
|
||||
paused_total += time.perf_counter() - pause_started
|
||||
pause_started = None
|
||||
fps.reset()
|
||||
continue
|
||||
key = cv2.waitKey(30) & 0xFF if self.config.display.enabled else -1
|
||||
if key in (ord(self.config.application.exit_key), 27):
|
||||
break
|
||||
if key == ord(self.config.application.pause_key):
|
||||
paused = False
|
||||
self._pause_requested.clear()
|
||||
if pause_started is not None:
|
||||
paused_total += time.perf_counter() - pause_started
|
||||
pause_started = None
|
||||
fps.reset()
|
||||
elif key == ord("d"):
|
||||
debug = not debug
|
||||
continue
|
||||
frame = camera.read()
|
||||
if frame is None:
|
||||
if self.config.input.type == "video":
|
||||
LOGGER.info("Reached end of video")
|
||||
break
|
||||
continue
|
||||
frame_number += 1
|
||||
if self.config.input.horizontal_flip:
|
||||
frame = cv2.flip(frame, 1)
|
||||
height, width = frame.shape[:2]
|
||||
region = self.config.evaluation_region.to_pixels(width, height)
|
||||
trigger_line = self.config.trigger_line.to_pixels(width, height)
|
||||
if track_manager is None:
|
||||
track_manager = TrackManager(
|
||||
self.config.track_management, region, trigger_line, self.config.crossing,
|
||||
self.config.tracker.max_missed_frames,
|
||||
)
|
||||
should_infer = (frame_number - 1) % (self.config.performance.skip_frames + 1) == 0
|
||||
detections: list[Detection] = detector.detect(frame, region) if should_infer else []
|
||||
pre_info = FrameInfo(frame_number, datetime.now().astimezone().isoformat(timespec="milliseconds"),
|
||||
time.perf_counter() - start - paused_total, width, height, fps.average_fps)
|
||||
tracked = tracker.update(detections, frame, pre_info) if tracker is not None else []
|
||||
fps.tick()
|
||||
measured = fps.average_fps
|
||||
now = datetime.now().astimezone()
|
||||
info = FrameInfo(frame_number, now.isoformat(timespec="milliseconds"), time.perf_counter() - start - paused_total, width, height, measured)
|
||||
events = track_manager.update(tracked, info) if track_manager is not None else []
|
||||
if track_manager is not None:
|
||||
events.extend(track_manager.finalize_stale_tracks(info))
|
||||
states = {state.track_id: state for state in track_manager.get_active_states()} if track_manager else {}
|
||||
face_results = face_processor.process(frame, tracked, info) if face_processor is not None else []
|
||||
if face_manager is not None:
|
||||
face_manager.update(face_results)
|
||||
if turn_detector is not None:
|
||||
turn_detector.register_tracks([person.track_id for person in tracked])
|
||||
turn_results, turn_events = turn_detector.update(face_results, info, states)
|
||||
else:
|
||||
turn_results, turn_events = [], []
|
||||
active_turn_states = turn_detector.get_active_states() if turn_detector is not None else {}
|
||||
if voice_controller is not None:
|
||||
voice_decisions, voice_events = voice_controller.update(
|
||||
tracked, events, turn_results, info, active_turn_states,
|
||||
)
|
||||
else:
|
||||
voice_decisions, voice_events = [], []
|
||||
active_voice_states = voice_controller.get_active_states() if voice_controller is not None else {}
|
||||
if person_state_manager is not None:
|
||||
person_snapshots, person_events = person_state_manager.update(
|
||||
tracked, events, face_results, turn_results, active_turn_states,
|
||||
voice_decisions, voice_events, active_voice_states, info,
|
||||
)
|
||||
else:
|
||||
person_snapshots, person_events = [], []
|
||||
if self._detection_logger is not None and self.config.logging.log_every_frame:
|
||||
self._detection_logger.log_frame(info, detections)
|
||||
if self._tracking_logger is not None:
|
||||
self._tracking_logger.write_frame_tracks(info, tracked, states)
|
||||
self._tracking_logger.write_events(events)
|
||||
for event in events:
|
||||
if event.event_type == "track_finalized" and track_manager is not None:
|
||||
state = track_manager.get_state(event.track_id)
|
||||
if state is not None:
|
||||
self._tracking_logger.write_summary(state)
|
||||
if self._face_logger is not None:
|
||||
self._face_logger.write_results(face_results)
|
||||
if face_manager is not None:
|
||||
for event in events:
|
||||
if event.event_type == "track_finalized":
|
||||
face_state = face_manager.finalize_track(event.track_id)
|
||||
if face_state is not None and self._face_logger is not None:
|
||||
self._face_logger.write_summary(face_state)
|
||||
finalized_turn_states = []
|
||||
if turn_manager is not None:
|
||||
finalized_turn_states, finalized_turn_events = turn_manager.finalize_from_track_events(events, info)
|
||||
turn_events.extend(finalized_turn_events)
|
||||
if self._turn_logger is not None:
|
||||
self._turn_logger.write_frames(turn_results)
|
||||
self._turn_logger.write_events(turn_events)
|
||||
for turn_state in finalized_turn_states:
|
||||
self._turn_logger.write_summary(turn_state)
|
||||
finalized_voice_states = []
|
||||
if voice_controller is not None:
|
||||
for event in events:
|
||||
if event.event_type == "track_finalized":
|
||||
turn_state = turn_detector.get_state(event.track_id) if turn_detector else None
|
||||
voice_state, final_voice_events = voice_controller.finalize_track(event.track_id, info, turn_state)
|
||||
if voice_state is not None:
|
||||
finalized_voice_states.append(voice_state)
|
||||
voice_events.extend(final_voice_events)
|
||||
if self._voice_logger is not None:
|
||||
self._voice_logger.write_decisions(voice_decisions)
|
||||
self._voice_logger.write_events(voice_events)
|
||||
for voice_state in finalized_voice_states:
|
||||
self._voice_logger.write_summary(voice_state)
|
||||
finalized_person_summaries = []
|
||||
if person_state_manager is not None:
|
||||
for event in events:
|
||||
if event.event_type == "track_finalized":
|
||||
summary, final_person_events = person_state_manager.finalize_track(
|
||||
event.track_id, info,
|
||||
track_manager.get_state(event.track_id) if track_manager else None,
|
||||
face_manager.get_state(event.track_id) if face_manager else None,
|
||||
turn_detector.get_state(event.track_id) if turn_detector else None,
|
||||
voice_controller.get_state(event.track_id) if voice_controller else None,
|
||||
)
|
||||
if summary is not None: finalized_person_summaries.append(summary)
|
||||
person_events.extend(final_person_events)
|
||||
if self._person_state_logger is not None:
|
||||
self._person_state_logger.write_snapshots(person_snapshots)
|
||||
self._person_state_logger.write_events(person_events)
|
||||
for summary in finalized_person_summaries: self._person_state_logger.write_summary(summary)
|
||||
analysis_person_summaries.extend(finalized_person_summaries)
|
||||
if unified_builder is not None and self._unified_logger is not None:
|
||||
unified_events = []
|
||||
if not session_started_written:
|
||||
unified_events.append(unified_builder.build_session_event("session_started", info,
|
||||
{"camera_position_note": self.config.unified_logging.camera_position_note}))
|
||||
session_started_written = True
|
||||
unified_events.extend(unified_builder.from_track_events(events, info))
|
||||
unified_events.extend(unified_builder.from_turn_events(turn_events, info))
|
||||
unified_events.extend(unified_builder.from_voice_events(voice_events, info))
|
||||
unified_events.extend(unified_builder.from_person_state_events(person_events, info))
|
||||
self._unified_logger.write_events(unified_builder.stable_sort(unified_events))
|
||||
counters = self._tracking_counters(track_manager)
|
||||
latest_faces = face_manager.get_latest_results() if face_manager is not None else {}
|
||||
active_person_states = person_state_manager.get_active_states() if person_state_manager is not None else {}
|
||||
display_frame = visualizer.draw(
|
||||
frame, detections, info, region, self.config.trigger_line, detector.device_name, debug=debug,
|
||||
tracked_persons=tracked, track_states=states, tracking_counters=counters,
|
||||
tracker_name=tracker.tracker_name if tracker else "disabled",
|
||||
face_results=latest_faces, face_enabled=face_processor is not None,
|
||||
turn_states=active_turn_states, turn_enabled=turn_detector is not None,
|
||||
voice_states=active_voice_states, voice_mode=self.config.voice_prompt.mode if voice_controller else "disabled",
|
||||
voice_cooldown=voice_controller.cooldown_remaining(info.elapsed_time_sec) if voice_controller else 0.0,
|
||||
person_states=active_person_states, person_state_enabled=person_state_manager is not None,
|
||||
)
|
||||
state_counts: dict[str, int] = {}
|
||||
for state in active_person_states.values():
|
||||
status = getattr(state, "status", "unknown")
|
||||
key_name = getattr(status, "value", str(status))
|
||||
state_counts[key_name] = state_counts.get(key_name, 0) + 1
|
||||
voice_values = list(active_voice_states.values())
|
||||
metrics = RuntimeMetrics(
|
||||
frame_number=frame_number, elapsed_time_sec=info.elapsed_time_sec, fps=measured,
|
||||
active_tracks=counters["active"] if counters else 0,
|
||||
total_tracks=counters["unique"] if counters else 0,
|
||||
region_visitors=counters["visitors"] if counters else 0,
|
||||
trigger_crossings=counters["crossings"] if counters else 0,
|
||||
face_detected_tracks=sum(1 for value in latest_faces.values() if value.face_detected),
|
||||
turn_confirmed_count=sum(1 for value in active_turn_states.values() if value.turn_confirmed),
|
||||
voice_mode=self.config.voice_prompt.mode if voice_controller else "disabled",
|
||||
prompt_played_count=sum(bool(getattr(value, "prompt_played", False)) for value in voice_values),
|
||||
pseudo_prompt_count=sum(bool(getattr(value, "pseudo_prompt", False)) for value in voice_values),
|
||||
response_detected_count=sum(bool(getattr(value, "response_detected", False)) for value in voice_values),
|
||||
person_state_counts=state_counts,
|
||||
current_log_directory=self.config.unified_logging.directory if self.config.unified_logging.enabled else self.config.logging.runtime_log_directory,
|
||||
)
|
||||
self._emit_runtime_update(display_frame, metrics)
|
||||
last_info = info
|
||||
if self._writer is not None:
|
||||
self._writer.write(display_frame)
|
||||
processed_frames += 1
|
||||
if self.config.display.enabled:
|
||||
shown = display_frame
|
||||
if self.config.display.resize_scale != 1.0:
|
||||
shown = cv2.resize(shown, None, fx=self.config.display.resize_scale, fy=self.config.display.resize_scale)
|
||||
cv2.imshow(self.config.application.window_name, shown)
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
if key in (ord(self.config.application.exit_key), 27):
|
||||
break
|
||||
if key == ord(self.config.application.pause_key):
|
||||
paused = True
|
||||
pause_started = time.perf_counter()
|
||||
display_frame = visualizer.draw(
|
||||
frame, detections, info, region, self.config.trigger_line, detector.device_name,
|
||||
paused=True, debug=debug, tracked_persons=tracked, track_states=states,
|
||||
tracking_counters=counters, tracker_name=tracker.tracker_name if tracker else "disabled",
|
||||
face_results=latest_faces, face_enabled=face_processor is not None,
|
||||
turn_states=active_turn_states, turn_enabled=turn_detector is not None,
|
||||
voice_states=active_voice_states, voice_mode=self.config.voice_prompt.mode if voice_controller else "disabled",
|
||||
voice_cooldown=voice_controller.cooldown_remaining(info.elapsed_time_sec) if voice_controller else 0.0,
|
||||
person_states=active_person_states, person_state_enabled=person_state_manager is not None,
|
||||
)
|
||||
cv2.imshow(self.config.application.window_name, display_frame)
|
||||
elif key == ord(self.config.application.screenshot_key):
|
||||
self._save_screenshot(display_frame, frame_number)
|
||||
elif key == ord("d"):
|
||||
debug = not debug
|
||||
elif key == ord("r"):
|
||||
fps.reset()
|
||||
except KeyboardInterrupt:
|
||||
LOGGER.info("Stopped by user")
|
||||
finally:
|
||||
if track_manager is not None and last_info is not None:
|
||||
final_events = track_manager.finalize_all(last_info)
|
||||
if self._tracking_logger is not None:
|
||||
self._tracking_logger.write_events(final_events)
|
||||
for state in track_manager.get_finalized_states():
|
||||
self._tracking_logger.write_summary(state)
|
||||
if face_manager is not None:
|
||||
face_manager.finalize_all()
|
||||
if self._face_logger is not None:
|
||||
for event in final_events:
|
||||
face_state = face_manager.get_state(event.track_id)
|
||||
if face_state is not None:
|
||||
self._face_logger.write_summary(face_state)
|
||||
final_turn_states, remaining_states, final_turn_events, remaining_events = [], [], [], []
|
||||
if turn_manager is not None:
|
||||
final_turn_states, final_turn_events = turn_manager.finalize_from_track_events(final_events, last_info)
|
||||
remaining_states, remaining_events = turn_detector.finalize_all(last_info) if turn_detector else ([], [])
|
||||
if self._turn_logger is not None:
|
||||
self._turn_logger.write_events(final_turn_events + remaining_events)
|
||||
for turn_state in final_turn_states + remaining_states:
|
||||
self._turn_logger.write_summary(turn_state)
|
||||
if voice_controller is not None:
|
||||
known_turn_states = {state.track_id: state for state in (final_turn_states + remaining_states)}
|
||||
voice_states, voice_events = voice_controller.finalize_all(last_info, known_turn_states)
|
||||
if self._voice_logger is not None:
|
||||
self._voice_logger.write_events(voice_events)
|
||||
for voice_state in voice_states:
|
||||
self._voice_logger.write_summary(voice_state)
|
||||
else:
|
||||
voice_states, voice_events = [], []
|
||||
if person_state_manager is not None:
|
||||
final_turn_map = {state.track_id: state for state in final_turn_states + remaining_states}
|
||||
final_voice_map = {state.track_id: state for state in voice_states} if voice_controller is not None else {}
|
||||
person_summaries, person_events = person_state_manager.finalize_all(last_info, final_turn_map, final_voice_map)
|
||||
if self._person_state_logger is not None:
|
||||
self._person_state_logger.write_events(person_events)
|
||||
for summary in person_summaries: self._person_state_logger.write_summary(summary)
|
||||
analysis_person_summaries.extend(person_summaries)
|
||||
else:
|
||||
person_summaries, person_events = [], []
|
||||
if unified_builder is not None and self._unified_logger is not None:
|
||||
final_unified = []
|
||||
final_unified.extend(unified_builder.from_track_events(final_events, last_info))
|
||||
final_unified.extend(unified_builder.from_turn_events(final_turn_events + remaining_events, last_info))
|
||||
final_unified.extend(unified_builder.from_voice_events(voice_events, last_info))
|
||||
final_unified.extend(unified_builder.from_person_state_events(person_events, last_info))
|
||||
final_unified.append(unified_builder.build_session_event("session_ended", last_info))
|
||||
self._unified_logger.write_events(unified_builder.stable_sort(final_unified))
|
||||
records = analysis_builder.build_records(analysis_person_summaries) if analysis_builder else []
|
||||
issues = LogConsistencyChecker().check(records) if self.config.unified_logging.run_consistency_checks else []
|
||||
average_fps = processed_frames / last_info.elapsed_time_sec if last_info.elapsed_time_sec > 0 else None
|
||||
session_summary = SessionSummaryBuilder(
|
||||
self.config.unified_logging, self.config.experiment.experiment_id, session_id,
|
||||
session_started_at,
|
||||
).build(records, last_info.timestamp_iso, last_info.elapsed_time_sec,
|
||||
self.config.voice_prompt.mode, average_fps, issues)
|
||||
self._unified_logger.write_analysis(records)
|
||||
self._unified_logger.write_issues(issues)
|
||||
self._unified_logger.write_session(session_summary)
|
||||
LOGGER.info("Session summary: tracks=%d valid_voice=%d responses=%d response_rate=%s issues=%d",
|
||||
session_summary.total_tracks, session_summary.valid_voice_analysis_tracks,
|
||||
session_summary.response_detected_count, session_summary.response_rate, len(issues))
|
||||
if self._unified_logger is not None:
|
||||
self._unified_logger.close()
|
||||
if self._person_state_logger is not None:
|
||||
self._person_state_logger.close()
|
||||
if self._voice_logger is not None:
|
||||
self._voice_logger.close()
|
||||
if self._turn_logger is not None:
|
||||
self._turn_logger.close()
|
||||
if self._face_logger is not None:
|
||||
self._face_logger.close()
|
||||
if face_processor is not None:
|
||||
face_processor.close()
|
||||
if audio_player is not None:
|
||||
audio_player.close()
|
||||
if self._tracking_logger is not None:
|
||||
self._tracking_logger.close()
|
||||
if self._detection_logger is not None:
|
||||
self._detection_logger.close()
|
||||
if tracker is not None:
|
||||
tracker.close()
|
||||
camera.release()
|
||||
if self._writer is not None:
|
||||
self._writer.release()
|
||||
cv2.destroyAllWindows()
|
||||
elapsed = time.perf_counter() - start
|
||||
LOGGER.info("Run summary: frames=%d elapsed=%.2fs average=%.2f FPS", processed_frames, elapsed, processed_frames / elapsed if elapsed else 0.0)
|
||||
|
||||
@staticmethod
|
||||
def _tracking_counters(manager: TrackManager | None) -> dict[str, int] | None:
|
||||
if manager is None:
|
||||
return None
|
||||
return {
|
||||
"active": manager.active_track_count, "unique": manager.total_unique_tracks,
|
||||
"visitors": manager.region_visitors, "crossings": manager.trigger_crossings,
|
||||
"completed": manager.completed_passers,
|
||||
}
|
||||
|
||||
def _save_screenshot(self, frame: np.ndarray, frame_number: int) -> None:
|
||||
now = datetime.now()
|
||||
stamp = now.strftime("%Y%m%d_%H%M%S_") + f"{now.microsecond // 1000:03d}"
|
||||
path = Path(self.config.output.screenshot_directory) / f"{stamp}_frame_{frame_number}.jpg"
|
||||
if not cv2.imwrite(str(path), frame):
|
||||
LOGGER.error("Failed to save screenshot: %s", path)
|
||||
else:
|
||||
LOGGER.info("Saved screenshot: %s", path)
|
||||
|
||||
def _emit_runtime_update(self, frame: np.ndarray, metrics: RuntimeMetrics) -> None:
|
||||
"""Deliver copied GUI data without allowing callback failures to stop a run."""
|
||||
try:
|
||||
if self._frame_callback is not None:
|
||||
self._frame_callback(frame.copy(), metrics)
|
||||
if self._status_callback is not None:
|
||||
self._status_callback(metrics)
|
||||
except Exception:
|
||||
LOGGER.exception("Runtime callback failed")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Fault-tolerant pygame audio playback isolated from the application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config_loader import AudioSettings
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AudioPlayer:
|
||||
"""Load and play one prompt sound without exposing pygame types."""
|
||||
|
||||
def __init__(self, config: AudioSettings, backend: Any | None = None) -> None:
|
||||
self.config = config
|
||||
self._backend = backend
|
||||
self._sound: Any | None = None
|
||||
self._channel: Any | None = None
|
||||
self._loaded_path: Path | None = None
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def audio_file(self) -> str | None:
|
||||
return str(self._loaded_path) if self._loaded_path else self.config.file_path or None
|
||||
|
||||
def load(self, audio_path: Path) -> None:
|
||||
if not self.config.enabled or self.config.backend == "noop":
|
||||
return
|
||||
if not audio_path.is_file():
|
||||
LOGGER.warning("Audio prompt file does not exist: %s", audio_path)
|
||||
return
|
||||
try:
|
||||
backend = self._backend or importlib.import_module("pygame")
|
||||
self._backend = backend
|
||||
if not backend.mixer.get_init():
|
||||
backend.mixer.init()
|
||||
self._sound = backend.mixer.Sound(str(audio_path))
|
||||
self._sound.set_volume(self.config.volume)
|
||||
self._loaded_path = audio_path
|
||||
except Exception as exc: # pygame raises backend-specific exceptions
|
||||
LOGGER.error("Failed to load audio prompt %s: %s", audio_path, exc)
|
||||
self._sound = None
|
||||
|
||||
def play(self) -> bool:
|
||||
if not self.config.enabled or self._sound is None or self._closed:
|
||||
return False
|
||||
try:
|
||||
if self.is_playing():
|
||||
if self.config.overlap_policy == "skip":
|
||||
return False
|
||||
if self.config.overlap_policy == "restart":
|
||||
self.stop()
|
||||
self._channel = self._sound.play()
|
||||
return self._channel is not None
|
||||
except Exception as exc:
|
||||
LOGGER.error("Failed to play audio prompt: %s", exc)
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
try:
|
||||
if self._channel is not None:
|
||||
self._channel.stop()
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Failed to stop audio prompt: %s", exc)
|
||||
finally:
|
||||
self._channel = None
|
||||
|
||||
def is_playing(self) -> bool:
|
||||
try:
|
||||
return bool(self._channel is not None and self._channel.get_busy())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self.stop()
|
||||
try:
|
||||
if self._backend is not None and self._backend.mixer.get_init():
|
||||
self._backend.mixer.quit()
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Failed to close audio backend: %s", exc)
|
||||
self._closed = True
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Append-only UTF-8 BOM calibration change history."""
|
||||
from __future__ import annotations
|
||||
import csv
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from .models import CalibrationChangeRecord
|
||||
class CalibrationHistory:
|
||||
FIELDS=["change_id","created_at","operator","category","before_value","after_value","reason","related_report_id","notes"]
|
||||
def __init__(self,path:Path)->None:self.path=path
|
||||
def load(self)->list[CalibrationChangeRecord]:
|
||||
if not self.path.exists():return []
|
||||
with self.path.open(encoding="utf-8-sig",newline="") as f:return [CalibrationChangeRecord(**r) for r in csv.DictReader(f)]
|
||||
def append(self,record:CalibrationChangeRecord)->Path:
|
||||
records=self.load()
|
||||
if any(x.change_id==record.change_id for x in records):raise ValueError("duplicate change_id")
|
||||
self.path.parent.mkdir(parents=True,exist_ok=True);exists=self.path.exists()
|
||||
with self.path.open("a",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=self.FIELDS);w.writeheader() if not exists else None;w.writerow(asdict(record))
|
||||
return self.path
|
||||
@@ -0,0 +1,78 @@
|
||||
"""OpenCV camera and video input abstraction."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from .config_loader import InputSettings
|
||||
from .exceptions import CameraOpenError, FrameReadError
|
||||
|
||||
|
||||
class Camera:
|
||||
"""Read frames from a camera device or video file."""
|
||||
|
||||
def __init__(self, config: InputSettings) -> None:
|
||||
self.config = config
|
||||
self._capture: cv2.VideoCapture | None = None
|
||||
self._consecutive_failures = 0
|
||||
|
||||
def open(self) -> None:
|
||||
backend_map = {"auto": cv2.CAP_ANY, "dshow": cv2.CAP_DSHOW, "msmf": cv2.CAP_MSMF, "v4l2": cv2.CAP_V4L2}
|
||||
source: int | str
|
||||
if self.config.type == "camera":
|
||||
source = self.config.camera_id
|
||||
else:
|
||||
path = Path(self.config.video_path or "")
|
||||
if not path.is_file():
|
||||
raise CameraOpenError(f"Video file does not exist: {path}")
|
||||
source = str(path)
|
||||
self._capture = cv2.VideoCapture(source, backend_map[self.config.backend])
|
||||
if not self._capture.isOpened():
|
||||
self.release()
|
||||
raise CameraOpenError(f"Could not open {self.config.type} input: {source}")
|
||||
if self.config.type == "camera":
|
||||
self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, self.config.width)
|
||||
self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, self.config.height)
|
||||
self._capture.set(cv2.CAP_PROP_FPS, self.config.fps)
|
||||
|
||||
def read(self) -> np.ndarray | None:
|
||||
if self._capture is None or not self._capture.isOpened():
|
||||
raise CameraOpenError("Input is not open")
|
||||
ok, frame = self._capture.read()
|
||||
if ok and frame is not None:
|
||||
self._consecutive_failures = 0
|
||||
return frame
|
||||
if self.config.type == "video":
|
||||
return None
|
||||
self._consecutive_failures += 1
|
||||
if self._consecutive_failures >= self.config.max_consecutive_read_failures:
|
||||
raise FrameReadError(f"Camera read failed {self._consecutive_failures} consecutive times")
|
||||
return None
|
||||
|
||||
def is_opened(self) -> bool:
|
||||
return self._capture is not None and self._capture.isOpened()
|
||||
|
||||
def _property(self, property_id: int) -> float:
|
||||
return float(self._capture.get(property_id)) if self._capture is not None else 0.0
|
||||
|
||||
def get_actual_width(self) -> int:
|
||||
return round(self._property(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
|
||||
def get_actual_height(self) -> int:
|
||||
return round(self._property(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
|
||||
def get_actual_fps(self) -> float:
|
||||
return self._property(cv2.CAP_PROP_FPS)
|
||||
|
||||
def release(self) -> None:
|
||||
if self._capture is not None:
|
||||
self._capture.release()
|
||||
self._capture = None
|
||||
|
||||
def __enter__(self) -> "Camera":
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.release()
|
||||
@@ -0,0 +1,729 @@
|
||||
"""YAML configuration loading, recursive merging, and validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, TypeVar
|
||||
|
||||
import yaml
|
||||
|
||||
from .exceptions import ConfigurationError
|
||||
from .models import PixelRegion, PixelTriggerLine
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationSettings:
|
||||
name: str
|
||||
version: str
|
||||
window_name: str
|
||||
exit_key: str
|
||||
screenshot_key: str
|
||||
pause_key: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExperimentSettings:
|
||||
experiment_id: str
|
||||
location: str
|
||||
operator: str
|
||||
notes: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InputSettings:
|
||||
type: str
|
||||
camera_id: int
|
||||
video_path: str | None
|
||||
width: int
|
||||
height: int
|
||||
fps: float
|
||||
backend: str
|
||||
horizontal_flip: bool
|
||||
max_consecutive_read_failures: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DetectorSettings:
|
||||
model_path: str
|
||||
device: str
|
||||
confidence_threshold: float
|
||||
iou_threshold: float
|
||||
image_size: int
|
||||
person_class_id: int
|
||||
max_detections: int
|
||||
half_precision: bool
|
||||
use_agnostic_nms: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DisplaySettings:
|
||||
enabled: bool
|
||||
show_bounding_boxes: bool
|
||||
show_confidence: bool
|
||||
show_detection_count: bool
|
||||
show_fps: bool
|
||||
show_timestamp: bool
|
||||
show_frame_number: bool
|
||||
show_evaluation_region: bool
|
||||
show_trigger_line: bool
|
||||
show_track_ids: bool
|
||||
show_trajectories: bool
|
||||
show_movement_direction: bool
|
||||
show_tracking_counters: bool
|
||||
trajectory_max_points: int
|
||||
show_face_roi: bool
|
||||
show_head_pose: bool
|
||||
show_face_landmarks: bool
|
||||
show_face_counters: bool
|
||||
show_turn_status: bool
|
||||
show_yaw_delta: bool
|
||||
show_turn_counters: bool
|
||||
show_voice_status: bool
|
||||
show_voice_counters: bool
|
||||
show_person_state: bool
|
||||
show_person_state_counters: bool
|
||||
resize_scale: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EvaluationRegionSettings:
|
||||
enabled: bool
|
||||
coordinate_mode: str
|
||||
x1: float
|
||||
y1: float
|
||||
x2: float
|
||||
y2: float
|
||||
|
||||
def to_pixels(self, width: int, height: int) -> PixelRegion | None:
|
||||
if not self.enabled:
|
||||
return None
|
||||
if self.coordinate_mode == "normalized":
|
||||
return PixelRegion(
|
||||
round(self.x1 * (width - 1)), round(self.y1 * (height - 1)),
|
||||
round(self.x2 * (width - 1)), round(self.y2 * (height - 1)),
|
||||
)
|
||||
return PixelRegion(round(self.x1), round(self.y1), round(self.x2), round(self.y2))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TriggerLineSettings:
|
||||
enabled: bool
|
||||
orientation: str
|
||||
coordinate_mode: str
|
||||
position: float
|
||||
|
||||
def pixel_position(self, width: int, height: int) -> int | None:
|
||||
if not self.enabled:
|
||||
return None
|
||||
extent = width - 1 if self.orientation == "vertical" else height - 1
|
||||
return round(self.position * extent) if self.coordinate_mode == "normalized" else round(self.position)
|
||||
|
||||
def to_pixels(self, width: int, height: int) -> PixelTriggerLine | None:
|
||||
position = self.pixel_position(width, height)
|
||||
return PixelTriggerLine(self.orientation, position) if position is not None else None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackerSettings:
|
||||
enabled: bool
|
||||
type: str
|
||||
track_high_thresh: float
|
||||
track_low_thresh: float
|
||||
new_track_thresh: float
|
||||
match_thresh: float
|
||||
track_buffer_frames: int
|
||||
min_confirmed_frames: int
|
||||
max_missed_frames: int
|
||||
fuse_score: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackManagementSettings:
|
||||
trajectory_max_points: int
|
||||
direction_window_points: int
|
||||
minimum_direction_displacement_pixels: float
|
||||
region_entry_confirm_frames: int
|
||||
region_exit_confirm_frames: int
|
||||
minimum_visible_frames_for_count: int
|
||||
minimum_duration_sec_for_count: float
|
||||
require_region_entry_for_count: bool
|
||||
require_trigger_crossing_for_count: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CrossingSettings:
|
||||
enabled: bool
|
||||
count_once_per_track: bool
|
||||
deadband_pixels: float
|
||||
minimum_displacement_pixels: float
|
||||
minimum_track_points: int
|
||||
require_confirmed_track: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackingLoggingSettings:
|
||||
enabled: bool
|
||||
log_frame_tracks: bool
|
||||
log_events: bool
|
||||
log_summaries: bool
|
||||
flush_interval_frames: int
|
||||
directory: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FaceSettings:
|
||||
enabled: bool
|
||||
backend: str
|
||||
process_every_n_frames: int
|
||||
max_persons_per_frame: int
|
||||
require_confirmed_track: bool
|
||||
process_only_inside_region: bool
|
||||
roi_height_ratio: float
|
||||
roi_expand_ratio: float
|
||||
min_roi_width: int
|
||||
min_roi_height: int
|
||||
static_image_mode: bool
|
||||
max_num_faces: int
|
||||
refine_landmarks: bool
|
||||
min_detection_confidence: float
|
||||
min_tracking_confidence: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HeadPoseSettings:
|
||||
enabled: bool
|
||||
method: str
|
||||
smoothing_enabled: bool
|
||||
smoothing_method: str
|
||||
ema_alpha: float
|
||||
max_abs_angle: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FaceLoggingSettings:
|
||||
enabled: bool
|
||||
log_frame_faces: bool
|
||||
log_summaries: bool
|
||||
flush_interval_frames: int
|
||||
directory: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TurnDetectionSettings:
|
||||
enabled: bool
|
||||
signage_yaw_direction: str
|
||||
baseline_strategy: str
|
||||
baseline_window_sec: float
|
||||
baseline_min_samples: int
|
||||
subtle_threshold_deg: float
|
||||
weak_threshold_deg: float
|
||||
medium_threshold_deg: float
|
||||
strong_threshold_deg: float
|
||||
min_subtle_duration_sec: float
|
||||
min_turn_duration_sec: float
|
||||
max_pose_gap_sec: float
|
||||
min_pose_samples_for_evaluation: int
|
||||
min_face_detection_rate_for_evaluation: float
|
||||
min_pose_estimation_rate_for_evaluation: float
|
||||
face_appearance_enabled: bool
|
||||
face_missing_before_appearance_sec: float
|
||||
weak_or_higher_is_turn: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TurnLoggingSettings:
|
||||
enabled: bool
|
||||
log_frame_turns: bool
|
||||
log_events: bool
|
||||
log_summaries: bool
|
||||
flush_interval_frames: int
|
||||
directory: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AudioSettings:
|
||||
enabled: bool
|
||||
backend: str
|
||||
file_path: str
|
||||
volume: float
|
||||
overlap_policy: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VoicePromptSettings:
|
||||
enabled: bool
|
||||
mode: str
|
||||
trigger_strategy: str
|
||||
require_confirmed_track: bool
|
||||
require_inside_region: bool
|
||||
skip_if_already_turned: bool
|
||||
one_prompt_per_track: bool
|
||||
global_cooldown_sec: float
|
||||
minimum_track_age_sec: float
|
||||
response_window_sec: float
|
||||
fixed_position_axis: str
|
||||
fixed_position_value: float
|
||||
fixed_position_direction: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VoiceLoggingSettings:
|
||||
enabled: bool
|
||||
log_decisions: bool
|
||||
log_events: bool
|
||||
log_summaries: bool
|
||||
flush_interval_frames: int
|
||||
directory: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonStateSettings:
|
||||
enabled: bool
|
||||
min_track_duration_sec: float
|
||||
require_trigger_crossing_for_completion: bool
|
||||
require_voice_eligibility_for_voice_analysis: bool
|
||||
mark_not_evaluable_as_excluded: bool
|
||||
finalize_on_track_lost: bool
|
||||
keep_completed_state_after_response: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonStateLoggingSettings:
|
||||
enabled: bool
|
||||
log_frame_states: bool
|
||||
log_events: bool
|
||||
log_summaries: bool
|
||||
flush_interval_frames: int
|
||||
directory: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnifiedLogSettings:
|
||||
enabled: bool
|
||||
directory: str
|
||||
log_unified_events: bool
|
||||
log_person_analysis_table: bool
|
||||
log_session_summary: bool
|
||||
log_consistency_issues: bool
|
||||
flush_interval_frames: int
|
||||
include_tracking_events: bool
|
||||
include_turn_events: bool
|
||||
include_voice_events: bool
|
||||
include_person_state_events: bool
|
||||
include_face_events: bool
|
||||
important_events_only: bool
|
||||
camera_position_note: str
|
||||
run_consistency_checks: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AnalysisSettings:
|
||||
enabled: bool
|
||||
input_directory: str
|
||||
output_directory: str
|
||||
recursive: bool
|
||||
include_prompt_condition: bool
|
||||
include_control_condition: bool
|
||||
valid_voice_analysis_only: bool
|
||||
response_level_threshold: str
|
||||
response_rate_test: str
|
||||
numeric_test: str
|
||||
yaw_metric_denominator: str
|
||||
generate_plots: bool
|
||||
overwrite_outputs: bool
|
||||
alpha: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GuiSettings:
|
||||
enabled: bool
|
||||
default_config_path: str
|
||||
default_analysis_input: str
|
||||
default_analysis_output: str
|
||||
preview_max_fps: float
|
||||
remember_last_paths: bool
|
||||
show_opencv_window_when_gui: bool
|
||||
auto_run_analysis_after_experiment: bool
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExperimentProtocolSettings:
|
||||
default_output_markdown: str; default_output_yaml: str; voice_prompt_text: str
|
||||
privacy_note: str; planned_session_duration_min: float
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QualityCheckSettings:
|
||||
enabled: bool; min_valid_voice_analysis_rate: float; max_not_evaluable_rate: float
|
||||
max_no_pose_rate: float; require_prompt_and_control: bool
|
||||
require_camera_position_note: bool; require_plots: bool
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PilotReportSettings:
|
||||
enabled: bool; output_markdown: str
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PresentationSummarySettings:
|
||||
enabled: bool; output_markdown: str
|
||||
@dataclass(frozen=True,slots=True)
|
||||
class PilotDiagnosticsSettings:
|
||||
enabled:bool;target_valid_records_per_condition:int;min_valid_records_per_condition:int
|
||||
min_valid_voice_analysis_rate:float;max_not_evaluable_rate:float;max_no_pose_estimated_rate:float
|
||||
max_condition_valid_count_ratio:float;min_sessions_per_condition:int;require_camera_position_note:bool
|
||||
ready_requires_prompt_and_control:bool;output_report:str;output_metrics_csv:str
|
||||
output_recommendations_csv:str;output_session_quality_csv:str
|
||||
@dataclass(frozen=True,slots=True)
|
||||
class CalibrationHistorySettings:
|
||||
enabled:bool;path:str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DetectionLoggingSettings:
|
||||
runtime_log_enabled: bool
|
||||
detection_log_enabled: bool
|
||||
log_every_frame: bool
|
||||
log_empty_frames: bool
|
||||
flush_interval_frames: int
|
||||
runtime_log_directory: str
|
||||
detection_log_directory: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OutputSettings:
|
||||
save_video: bool
|
||||
video_directory: str
|
||||
video_codec: str
|
||||
screenshot_directory: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PerformanceSettings:
|
||||
skip_frames: int
|
||||
fps_average_window: int
|
||||
warn_fps_threshold: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DebugSettings:
|
||||
enabled: bool
|
||||
show_raw_model_output: bool
|
||||
print_detections: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AppConfig:
|
||||
application: ApplicationSettings
|
||||
experiment: ExperimentSettings
|
||||
input: InputSettings
|
||||
detector: DetectorSettings
|
||||
tracker: TrackerSettings
|
||||
track_management: TrackManagementSettings
|
||||
crossing: CrossingSettings
|
||||
tracking_logging: TrackingLoggingSettings
|
||||
face: FaceSettings
|
||||
head_pose: HeadPoseSettings
|
||||
face_logging: FaceLoggingSettings
|
||||
turn_detection: TurnDetectionSettings
|
||||
turn_logging: TurnLoggingSettings
|
||||
audio: AudioSettings
|
||||
voice_prompt: VoicePromptSettings
|
||||
voice_logging: VoiceLoggingSettings
|
||||
person_state: PersonStateSettings
|
||||
person_state_logging: PersonStateLoggingSettings
|
||||
unified_logging: UnifiedLogSettings
|
||||
analysis: AnalysisSettings
|
||||
gui: GuiSettings
|
||||
experiment_protocol: ExperimentProtocolSettings
|
||||
quality_check: QualityCheckSettings
|
||||
pilot_report: PilotReportSettings
|
||||
presentation_summary: PresentationSummarySettings
|
||||
pilot_diagnostics:PilotDiagnosticsSettings
|
||||
calibration_history:CalibrationHistorySettings
|
||||
display: DisplaySettings
|
||||
evaluation_region: EvaluationRegionSettings
|
||||
trigger_line: TriggerLineSettings
|
||||
logging: DetectionLoggingSettings
|
||||
output: OutputSettings
|
||||
performance: PerformanceSettings
|
||||
debug: DebugSettings
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def recursive_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
||||
result = dict(base)
|
||||
for key, value in override.items():
|
||||
if isinstance(value, Mapping) and isinstance(result.get(key), Mapping):
|
||||
result[key] = recursive_merge(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _read_yaml(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
value = yaml.safe_load(stream) or {}
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise ConfigurationError(f"Failed to read configuration {path}: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ConfigurationError(f"Configuration root must be a mapping: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def _make(cls: type[T], values: Any, section: str) -> T:
|
||||
if not isinstance(values, Mapping):
|
||||
raise ConfigurationError(f"'{section}' must be a mapping")
|
||||
expected = {field.name for field in fields(cls)}
|
||||
missing = expected - values.keys()
|
||||
unknown = values.keys() - expected
|
||||
if missing:
|
||||
raise ConfigurationError(f"Missing settings in '{section}': {sorted(missing)}")
|
||||
if unknown:
|
||||
raise ConfigurationError(f"Unknown settings in '{section}': {sorted(unknown)}")
|
||||
try:
|
||||
return cls(**values)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ConfigurationError(f"Invalid '{section}' settings: {exc}") from exc
|
||||
|
||||
|
||||
def _validate(config: AppConfig) -> None:
|
||||
i, d, e, t = config.input, config.detector, config.evaluation_region, config.trigger_line
|
||||
if i.type not in {"camera", "video"}:
|
||||
raise ConfigurationError("input.type must be camera or video")
|
||||
if i.type == "video" and not i.video_path:
|
||||
raise ConfigurationError("input.video_path is required for video input")
|
||||
if i.width <= 0 or i.height <= 0 or i.fps <= 0 or i.max_consecutive_read_failures <= 0:
|
||||
raise ConfigurationError("input dimensions, fps, and read failure limit must be positive")
|
||||
if i.backend not in {"auto", "dshow", "msmf", "v4l2"}:
|
||||
raise ConfigurationError("input.backend must be auto, dshow, msmf, or v4l2")
|
||||
if not 0.0 <= d.confidence_threshold <= 1.0 or not 0.0 <= d.iou_threshold <= 1.0:
|
||||
raise ConfigurationError("detector confidence and IoU thresholds must be in [0, 1]")
|
||||
if d.image_size <= 0 or d.max_detections <= 0 or d.person_class_id < 0:
|
||||
raise ConfigurationError("detector image_size/max_detections must be positive and class ID non-negative")
|
||||
if config.performance.fps_average_window <= 0 or config.performance.skip_frames < 0:
|
||||
raise ConfigurationError("performance window must be positive and skip_frames non-negative")
|
||||
if config.display.resize_scale <= 0:
|
||||
raise ConfigurationError("display.resize_scale must be positive")
|
||||
if config.display.trajectory_max_points <= 0:
|
||||
raise ConfigurationError("display.trajectory_max_points must be positive")
|
||||
if config.logging.flush_interval_frames <= 0:
|
||||
raise ConfigurationError("logging.flush_interval_frames must be positive")
|
||||
for name in ("exit_key", "screenshot_key", "pause_key"):
|
||||
if len(getattr(config.application, name)) != 1:
|
||||
raise ConfigurationError(f"application.{name} must be one character")
|
||||
if e.coordinate_mode not in {"normalized", "pixel"} or t.coordinate_mode not in {"normalized", "pixel"}:
|
||||
raise ConfigurationError("coordinate_mode must be normalized or pixel")
|
||||
if not (e.x1 < e.x2 and e.y1 < e.y2):
|
||||
raise ConfigurationError("evaluation region requires x1 < x2 and y1 < y2")
|
||||
if e.coordinate_mode == "normalized" and any(not 0.0 <= v <= 1.0 for v in (e.x1, e.y1, e.x2, e.y2)):
|
||||
raise ConfigurationError("normalized evaluation coordinates must be in [0, 1]")
|
||||
if t.orientation not in {"vertical", "horizontal"}:
|
||||
raise ConfigurationError("trigger_line.orientation must be vertical or horizontal")
|
||||
if t.position < 0 or (t.coordinate_mode == "normalized" and t.position > 1.0):
|
||||
raise ConfigurationError("trigger_line.position is outside its valid range")
|
||||
tracker = config.tracker
|
||||
thresholds = (tracker.track_high_thresh, tracker.track_low_thresh, tracker.new_track_thresh, tracker.match_thresh)
|
||||
if tracker.type != "bytetrack":
|
||||
raise ConfigurationError("tracker.type must be bytetrack")
|
||||
if any(not 0.0 <= value <= 1.0 for value in thresholds):
|
||||
raise ConfigurationError("tracker thresholds must be in [0, 1]")
|
||||
if tracker.track_low_thresh > tracker.track_high_thresh:
|
||||
raise ConfigurationError("tracker.track_low_thresh must not exceed track_high_thresh")
|
||||
if min(tracker.track_buffer_frames, tracker.min_confirmed_frames, tracker.max_missed_frames) <= 0:
|
||||
raise ConfigurationError("tracker frame counts must be positive")
|
||||
management = config.track_management
|
||||
counts = (management.trajectory_max_points, management.direction_window_points, management.region_entry_confirm_frames,
|
||||
management.region_exit_confirm_frames, management.minimum_visible_frames_for_count)
|
||||
if min(counts) <= 0 or management.minimum_direction_displacement_pixels < 0 or management.minimum_duration_sec_for_count < 0:
|
||||
raise ConfigurationError("track_management counts must be positive and thresholds non-negative")
|
||||
crossing = config.crossing
|
||||
if crossing.deadband_pixels < 0 or crossing.minimum_displacement_pixels < 0 or crossing.minimum_track_points < 2:
|
||||
raise ConfigurationError("crossing distances must be non-negative and minimum_track_points >= 2")
|
||||
if config.tracking_logging.flush_interval_frames <= 0:
|
||||
raise ConfigurationError("tracking_logging.flush_interval_frames must be positive")
|
||||
if tracker.enabled and config.performance.skip_frames > 0:
|
||||
raise ConfigurationError("performance.skip_frames must be 0 while tracking is enabled")
|
||||
face = config.face
|
||||
if not isinstance(face.enabled, bool) or face.backend != "mediapipe":
|
||||
raise ConfigurationError("face.enabled must be bool and face.backend must be mediapipe")
|
||||
if face.process_every_n_frames <= 0 or face.max_persons_per_frame <= 0 or face.max_num_faces <= 0:
|
||||
raise ConfigurationError("face processing intervals and counts must be positive")
|
||||
if not 0.0 < face.roi_height_ratio <= 1.0 or face.roi_expand_ratio < 0:
|
||||
raise ConfigurationError("face ROI ratios are outside their valid range")
|
||||
if face.min_roi_width <= 0 or face.min_roi_height <= 0:
|
||||
raise ConfigurationError("face minimum ROI dimensions must be positive")
|
||||
if not 0.0 <= face.min_detection_confidence <= 1.0 or not 0.0 <= face.min_tracking_confidence <= 1.0:
|
||||
raise ConfigurationError("face confidence thresholds must be in [0, 1]")
|
||||
pose = config.head_pose
|
||||
if pose.method != "solvepnp" or pose.smoothing_method != "ema":
|
||||
raise ConfigurationError("head_pose method must be solvepnp and smoothing_method must be ema")
|
||||
if not 0.0 < pose.ema_alpha <= 1.0 or pose.max_abs_angle <= 0:
|
||||
raise ConfigurationError("head_pose EMA alpha and maximum angle are invalid")
|
||||
if config.face_logging.flush_interval_frames <= 0:
|
||||
raise ConfigurationError("face_logging.flush_interval_frames must be positive")
|
||||
display_bools = (config.display.show_face_roi, config.display.show_head_pose,
|
||||
config.display.show_face_landmarks, config.display.show_face_counters)
|
||||
if any(not isinstance(value, bool) for value in display_bools):
|
||||
raise ConfigurationError("face display settings must be boolean")
|
||||
turn = config.turn_detection
|
||||
if not isinstance(turn.enabled, bool) or turn.signage_yaw_direction not in {"positive", "negative"}:
|
||||
raise ConfigurationError("turn enabled/direction settings are invalid")
|
||||
if turn.baseline_strategy not in {"first_valid", "before_trigger", "region_entry_window"}:
|
||||
raise ConfigurationError("invalid turn baseline_strategy")
|
||||
if turn.baseline_window_sec <= 0 or turn.baseline_min_samples <= 0:
|
||||
raise ConfigurationError("turn baseline window and sample count must be positive")
|
||||
thresholds = (turn.subtle_threshold_deg, turn.weak_threshold_deg,
|
||||
turn.medium_threshold_deg, turn.strong_threshold_deg)
|
||||
if any(value <= 0 for value in thresholds) or not all(a < b for a, b in zip(thresholds, thresholds[1:])):
|
||||
raise ConfigurationError("turn thresholds must be positive and strictly increasing")
|
||||
if min(turn.min_subtle_duration_sec, turn.min_turn_duration_sec, turn.max_pose_gap_sec,
|
||||
turn.face_missing_before_appearance_sec) < 0:
|
||||
raise ConfigurationError("turn durations must be non-negative")
|
||||
if turn.min_pose_samples_for_evaluation <= 0:
|
||||
raise ConfigurationError("turn minimum pose samples must be positive")
|
||||
rates = (turn.min_face_detection_rate_for_evaluation, turn.min_pose_estimation_rate_for_evaluation)
|
||||
if any(not 0.0 <= value <= 1.0 for value in rates):
|
||||
raise ConfigurationError("turn evaluation rates must be in [0, 1]")
|
||||
if config.turn_logging.flush_interval_frames <= 0:
|
||||
raise ConfigurationError("turn_logging.flush_interval_frames must be positive")
|
||||
turn_display = (config.display.show_turn_status, config.display.show_yaw_delta, config.display.show_turn_counters)
|
||||
if any(not isinstance(value, bool) for value in turn_display):
|
||||
raise ConfigurationError("turn display settings must be boolean")
|
||||
audio = config.audio
|
||||
if not isinstance(audio.enabled, bool) or audio.backend not in {"pygame", "noop"}:
|
||||
raise ConfigurationError("audio.enabled/backend is invalid")
|
||||
if (not isinstance(audio.file_path, str) or not isinstance(audio.volume, (int, float))
|
||||
or not 0.0 <= audio.volume <= 1.0):
|
||||
raise ConfigurationError("audio file_path/volume is invalid")
|
||||
if audio.overlap_policy not in {"skip", "restart", "allow"}:
|
||||
raise ConfigurationError("invalid audio overlap_policy")
|
||||
voice = config.voice_prompt
|
||||
if not isinstance(voice.enabled, bool) or voice.mode not in {"prompt", "control", "disabled"}:
|
||||
raise ConfigurationError("voice enabled/mode is invalid")
|
||||
if voice.trigger_strategy not in {"trigger_line_crossing", "region_entry", "fixed_x_position", "first_confirmed_track"}:
|
||||
raise ConfigurationError("invalid voice trigger_strategy")
|
||||
bools = (voice.require_confirmed_track, voice.require_inside_region, voice.skip_if_already_turned,
|
||||
voice.one_prompt_per_track)
|
||||
if any(not isinstance(value, bool) for value in bools):
|
||||
raise ConfigurationError("voice eligibility settings must be boolean")
|
||||
durations = (voice.global_cooldown_sec, voice.minimum_track_age_sec, voice.response_window_sec)
|
||||
if any(not isinstance(value, (int, float)) for value in durations) or min(durations[:2]) < 0 or durations[2] <= 0:
|
||||
raise ConfigurationError("voice durations are invalid")
|
||||
if voice.fixed_position_axis not in {"x", "y"} or voice.fixed_position_direction not in {"increasing", "decreasing"}:
|
||||
raise ConfigurationError("voice fixed-position settings are invalid")
|
||||
vlog = config.voice_logging
|
||||
if (not isinstance(vlog.enabled, bool) or not isinstance(vlog.directory, str)
|
||||
or vlog.flush_interval_frames <= 0
|
||||
or any(not isinstance(value, bool) for value in (vlog.log_decisions, vlog.log_events, vlog.log_summaries))):
|
||||
raise ConfigurationError("voice_logging settings are invalid")
|
||||
if any(not isinstance(value, bool) for value in (config.display.show_voice_status, config.display.show_voice_counters)):
|
||||
raise ConfigurationError("voice display settings must be boolean")
|
||||
person = config.person_state
|
||||
person_bools = (person.enabled, person.require_trigger_crossing_for_completion,
|
||||
person.require_voice_eligibility_for_voice_analysis, person.mark_not_evaluable_as_excluded,
|
||||
person.finalize_on_track_lost, person.keep_completed_state_after_response)
|
||||
if (any(not isinstance(value, bool) for value in person_bools)
|
||||
or not isinstance(person.min_track_duration_sec, (int, float))
|
||||
or person.min_track_duration_sec < 0):
|
||||
raise ConfigurationError("person_state settings are invalid")
|
||||
plog = config.person_state_logging
|
||||
if (not isinstance(plog.enabled, bool) or not isinstance(plog.directory, str)
|
||||
or plog.flush_interval_frames <= 0
|
||||
or any(not isinstance(value, bool) for value in (plog.log_frame_states, plog.log_events, plog.log_summaries))):
|
||||
raise ConfigurationError("person_state_logging settings are invalid")
|
||||
if any(not isinstance(value, bool) for value in (config.display.show_person_state,
|
||||
config.display.show_person_state_counters)):
|
||||
raise ConfigurationError("person-state display settings must be boolean")
|
||||
unified = config.unified_logging
|
||||
unified_bools = (unified.enabled, unified.log_unified_events, unified.log_person_analysis_table,
|
||||
unified.log_session_summary, unified.log_consistency_issues, unified.include_tracking_events,
|
||||
unified.include_turn_events, unified.include_voice_events, unified.include_person_state_events,
|
||||
unified.include_face_events, unified.important_events_only, unified.run_consistency_checks)
|
||||
if (any(not isinstance(value, bool) for value in unified_bools) or not isinstance(unified.directory, str)
|
||||
or not isinstance(unified.camera_position_note, str)
|
||||
or not isinstance(unified.flush_interval_frames, int) or unified.flush_interval_frames <= 0):
|
||||
raise ConfigurationError("unified_logging settings are invalid")
|
||||
analysis = config.analysis
|
||||
analysis_bools = (analysis.enabled, analysis.recursive, analysis.include_prompt_condition,
|
||||
analysis.include_control_condition, analysis.valid_voice_analysis_only,
|
||||
analysis.generate_plots, analysis.overwrite_outputs)
|
||||
if any(not isinstance(value, bool) for value in analysis_bools): raise ConfigurationError("analysis flags must be boolean")
|
||||
if not isinstance(analysis.input_directory, str) or not isinstance(analysis.output_directory, str): raise ConfigurationError("analysis directories must be strings")
|
||||
if analysis.response_level_threshold not in {"subtle","weak","medium","strong"}: raise ConfigurationError("invalid analysis response threshold")
|
||||
if analysis.response_rate_test not in {"auto","chi_square","fisher"}: raise ConfigurationError("invalid response_rate_test")
|
||||
if analysis.numeric_test not in {"mannwhitney","ttest"}: raise ConfigurationError("invalid numeric_test")
|
||||
if analysis.yaw_metric_denominator not in {"valid_turn_analysis","valid_voice_analysis","all_non_null"}: raise ConfigurationError("invalid yaw denominator")
|
||||
if not isinstance(analysis.alpha,(int,float)) or not 0 < analysis.alpha < 1: raise ConfigurationError("analysis alpha must be in (0,1)")
|
||||
gui = config.gui
|
||||
if (not isinstance(gui.enabled, bool)
|
||||
or not isinstance(gui.default_config_path, str)
|
||||
or not isinstance(gui.default_analysis_input, str)
|
||||
or not isinstance(gui.default_analysis_output, str)
|
||||
or not isinstance(gui.preview_max_fps, (int, float))
|
||||
or gui.preview_max_fps <= 0
|
||||
or not isinstance(gui.remember_last_paths, bool)
|
||||
or not isinstance(gui.show_opencv_window_when_gui, bool)
|
||||
or not isinstance(gui.auto_run_analysis_after_experiment, bool)):
|
||||
raise ConfigurationError("gui settings are invalid")
|
||||
protocol=config.experiment_protocol
|
||||
if not all(isinstance(v,str) for v in (protocol.default_output_markdown,protocol.default_output_yaml,protocol.voice_prompt_text,protocol.privacy_note)) or protocol.planned_session_duration_min<=0: raise ConfigurationError("experiment_protocol settings are invalid")
|
||||
quality=config.quality_check
|
||||
if any(not 0<=v<=1 for v in (quality.min_valid_voice_analysis_rate,quality.max_not_evaluable_rate,quality.max_no_pose_rate)) or any(not isinstance(v,bool) for v in (quality.enabled,quality.require_prompt_and_control,quality.require_camera_position_note,quality.require_plots)): raise ConfigurationError("quality_check settings are invalid")
|
||||
pd=config.pilot_diagnostics
|
||||
if (not isinstance(pd.enabled,bool) or min(pd.target_valid_records_per_condition,pd.min_valid_records_per_condition,pd.min_sessions_per_condition)<=0 or pd.min_valid_records_per_condition>pd.target_valid_records_per_condition or any(not 0<=v<=1 for v in (pd.min_valid_voice_analysis_rate,pd.max_not_evaluable_rate,pd.max_no_pose_estimated_rate)) or pd.max_condition_valid_count_ratio<1 or any(not isinstance(v,bool) for v in (pd.require_camera_position_note,pd.ready_requires_prompt_and_control))):raise ConfigurationError("pilot_diagnostics settings are invalid")
|
||||
if not isinstance(config.calibration_history.enabled,bool) or not isinstance(config.calibration_history.path,str):raise ConfigurationError("calibration_history settings are invalid")
|
||||
|
||||
|
||||
def load_config(
|
||||
default_path: Path | str = Path("config/default.yaml"),
|
||||
experiment_path: Path | str | None = Path("config/experiment.yaml"),
|
||||
cli_overrides: Mapping[str, Any] | None = None,
|
||||
) -> AppConfig:
|
||||
"""Load default, experiment, then CLI overrides using recursive merging."""
|
||||
merged = _read_yaml(Path(default_path))
|
||||
if experiment_path is not None:
|
||||
merged = recursive_merge(merged, _read_yaml(Path(experiment_path)))
|
||||
if cli_overrides:
|
||||
merged = recursive_merge(merged, cli_overrides)
|
||||
config = AppConfig(
|
||||
application=_make(ApplicationSettings, merged.get("application"), "application"),
|
||||
experiment=_make(ExperimentSettings, merged.get("experiment"), "experiment"),
|
||||
input=_make(InputSettings, merged.get("input"), "input"),
|
||||
detector=_make(DetectorSettings, merged.get("detector"), "detector"),
|
||||
tracker=_make(TrackerSettings, merged.get("tracker"), "tracker"),
|
||||
track_management=_make(TrackManagementSettings, merged.get("track_management"), "track_management"),
|
||||
crossing=_make(CrossingSettings, merged.get("crossing"), "crossing"),
|
||||
tracking_logging=_make(TrackingLoggingSettings, merged.get("tracking_logging"), "tracking_logging"),
|
||||
face=_make(FaceSettings, merged.get("face"), "face"),
|
||||
head_pose=_make(HeadPoseSettings, merged.get("head_pose"), "head_pose"),
|
||||
face_logging=_make(FaceLoggingSettings, merged.get("face_logging"), "face_logging"),
|
||||
turn_detection=_make(TurnDetectionSettings, merged.get("turn_detection"), "turn_detection"),
|
||||
turn_logging=_make(TurnLoggingSettings, merged.get("turn_logging"), "turn_logging"),
|
||||
audio=_make(AudioSettings, merged.get("audio"), "audio"),
|
||||
voice_prompt=_make(VoicePromptSettings, merged.get("voice_prompt"), "voice_prompt"),
|
||||
voice_logging=_make(VoiceLoggingSettings, merged.get("voice_logging"), "voice_logging"),
|
||||
person_state=_make(PersonStateSettings, merged.get("person_state"), "person_state"),
|
||||
person_state_logging=_make(PersonStateLoggingSettings, merged.get("person_state_logging"), "person_state_logging"),
|
||||
unified_logging=_make(UnifiedLogSettings, merged.get("unified_logging"), "unified_logging"),
|
||||
analysis=_make(AnalysisSettings, merged.get("analysis"), "analysis"),
|
||||
gui=_make(GuiSettings, merged.get("gui"), "gui"),
|
||||
experiment_protocol=_make(ExperimentProtocolSettings,merged.get("experiment_protocol"),"experiment_protocol"),
|
||||
quality_check=_make(QualityCheckSettings,merged.get("quality_check"),"quality_check"),
|
||||
pilot_report=_make(PilotReportSettings,merged.get("pilot_report"),"pilot_report"),
|
||||
presentation_summary=_make(PresentationSummarySettings,merged.get("presentation_summary"),"presentation_summary"),
|
||||
pilot_diagnostics=_make(PilotDiagnosticsSettings,merged.get("pilot_diagnostics"),"pilot_diagnostics"),
|
||||
calibration_history=_make(CalibrationHistorySettings,merged.get("calibration_history"),"calibration_history"),
|
||||
display=_make(DisplaySettings, merged.get("display"), "display"),
|
||||
evaluation_region=_make(EvaluationRegionSettings, merged.get("evaluation_region"), "evaluation_region"),
|
||||
trigger_line=_make(TriggerLineSettings, merged.get("trigger_line"), "trigger_line"),
|
||||
logging=_make(DetectionLoggingSettings, merged.get("logging"), "logging"),
|
||||
output=_make(OutputSettings, merged.get("output"), "output"),
|
||||
performance=_make(PerformanceSettings, merged.get("performance"), "performance"),
|
||||
debug=_make(DebugSettings, merged.get("debug"), "debug"),
|
||||
)
|
||||
_validate(config)
|
||||
return config
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Trigger-line crossing detection independent of tracking libraries."""
|
||||
|
||||
from .config_loader import CrossingSettings
|
||||
from .models import PixelTriggerLine, TrackPoint
|
||||
|
||||
|
||||
class CrossingDetector:
|
||||
"""Detect robust directional crossings with deadband and deduplication."""
|
||||
|
||||
def __init__(self, config: CrossingSettings, trigger_line: PixelTriggerLine | None) -> None:
|
||||
self.config = config
|
||||
self.trigger_line = trigger_line
|
||||
self._crossed_ids: set[int] = set()
|
||||
|
||||
def detect(self, track_id: int, trajectory: list[TrackPoint], is_confirmed: bool) -> str | None:
|
||||
if not self.config.enabled or self.trigger_line is None:
|
||||
return None
|
||||
if self.config.require_confirmed_track and not is_confirmed:
|
||||
return None
|
||||
if len(trajectory) < self.config.minimum_track_points:
|
||||
return None
|
||||
if self.config.count_once_per_track and track_id in self._crossed_ids:
|
||||
return None
|
||||
current = trajectory[-1]
|
||||
if self.trigger_line.orientation == "vertical":
|
||||
values = [point.foot_x for point in trajectory]
|
||||
forward, backward = "left_to_right", "right_to_left"
|
||||
else:
|
||||
values = [point.foot_y for point in trajectory]
|
||||
forward, backward = "top_to_bottom", "bottom_to_top"
|
||||
line, deadband = self.trigger_line.position, self.config.deadband_pixels
|
||||
after = values[-1]
|
||||
direction: str | None = None
|
||||
if after >= line + deadband:
|
||||
before = next((value for value in reversed(values[:-1]) if value < line - deadband), None)
|
||||
if before is not None and after - before >= self.config.minimum_displacement_pixels:
|
||||
direction = forward
|
||||
elif after <= line - deadband:
|
||||
before = next((value for value in reversed(values[:-1]) if value > line + deadband), None)
|
||||
if before is not None and before - after >= self.config.minimum_displacement_pixels:
|
||||
direction = backward
|
||||
if direction is not None and self.config.count_once_per_track:
|
||||
self._crossed_ids.add(track_id)
|
||||
return direction
|
||||
|
||||
def reset(self) -> None:
|
||||
self._crossed_ids.clear()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Streaming CSV detection logger."""
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
|
||||
from .config_loader import DetectionLoggingSettings
|
||||
from .models import Detection, FrameInfo
|
||||
|
||||
|
||||
class DetectionLogger:
|
||||
"""Write one CSV row per detection, with optional empty-frame rows."""
|
||||
|
||||
FIELDNAMES = [
|
||||
"experiment_id", "frame_number", "timestamp", "elapsed_time_sec", "frame_width", "frame_height",
|
||||
"measured_fps", "detection_index", "class_id", "class_name", "confidence", "bbox_x1", "bbox_y1",
|
||||
"bbox_x2", "bbox_y2", "bbox_width", "bbox_height", "center_x", "center_y", "foot_x", "foot_y",
|
||||
"inside_evaluation_region",
|
||||
]
|
||||
|
||||
def __init__(self, config: DetectionLoggingSettings, experiment_id: str, path: Path) -> None:
|
||||
self.config = config
|
||||
self.experiment_id = experiment_id
|
||||
self.path = path
|
||||
self._file: TextIO | None = None
|
||||
self._writer: csv.DictWriter[str] | None = None
|
||||
self._frames_since_flush = 0
|
||||
|
||||
def open(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._file = self.path.open("w", encoding="utf-8-sig", newline="")
|
||||
self._writer = csv.DictWriter(self._file, fieldnames=self.FIELDNAMES)
|
||||
self._writer.writeheader()
|
||||
|
||||
def log_frame(self, info: FrameInfo, detections: list[Detection]) -> None:
|
||||
if self._writer is None:
|
||||
return
|
||||
base = {
|
||||
"experiment_id": self.experiment_id, "frame_number": info.frame_number, "timestamp": info.timestamp_iso,
|
||||
"elapsed_time_sec": f"{info.elapsed_time_sec:.6f}", "frame_width": info.width, "frame_height": info.height,
|
||||
"measured_fps": f"{info.measured_fps:.3f}",
|
||||
}
|
||||
if detections:
|
||||
for index, detection in enumerate(detections):
|
||||
b = detection.bbox
|
||||
self._writer.writerow(base | {
|
||||
"detection_index": index, "class_id": detection.class_id, "class_name": detection.class_name,
|
||||
"confidence": f"{detection.confidence:.6f}", "bbox_x1": b.x1, "bbox_y1": b.y1,
|
||||
"bbox_x2": b.x2, "bbox_y2": b.y2, "bbox_width": b.width, "bbox_height": b.height,
|
||||
"center_x": b.center_x, "center_y": b.center_y, "foot_x": b.foot_x, "foot_y": b.foot_y,
|
||||
"inside_evaluation_region": detection.inside_evaluation_region,
|
||||
})
|
||||
elif self.config.log_empty_frames:
|
||||
self._writer.writerow(base)
|
||||
self._frames_since_flush += 1
|
||||
if self._frames_since_flush >= self.config.flush_interval_frames:
|
||||
self._file.flush()
|
||||
self._frames_since_flush = 0
|
||||
|
||||
def close(self) -> None:
|
||||
if self._file is not None:
|
||||
self._file.close()
|
||||
self._file = None
|
||||
self._writer = None
|
||||
|
||||
def __enter__(self) -> "DetectionLogger":
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.close()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Ultralytics YOLO adapter that exposes application domain models."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .config_loader import DetectorSettings
|
||||
from .exceptions import ModelLoadError
|
||||
from .models import BoundingBox, Detection, PixelRegion
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PersonDetector:
|
||||
"""Detect people and isolate the application from Ultralytics result types."""
|
||||
|
||||
def __init__(self, config: DetectorSettings) -> None:
|
||||
self.config = config
|
||||
self._model: Any = None
|
||||
self._device_name = "cpu"
|
||||
self._half = False
|
||||
self._half_warning_emitted = False
|
||||
|
||||
def load_model(self) -> None:
|
||||
try:
|
||||
import torch
|
||||
from ultralytics import YOLO
|
||||
|
||||
cuda_available = torch.cuda.is_available()
|
||||
self._device_name = ("cuda" if cuda_available else "cpu") if self.config.device == "auto" else self.config.device
|
||||
cuda_device_selected = self._device_name.lower().startswith("cuda") or self._device_name.isdigit()
|
||||
self._half = self.config.half_precision and cuda_available and cuda_device_selected
|
||||
if self.config.half_precision and not self._half and not self._half_warning_emitted:
|
||||
LOGGER.warning("half_precision is disabled because the selected device is not CUDA")
|
||||
self._half_warning_emitted = True
|
||||
self._model = YOLO(self.config.model_path)
|
||||
except Exception as exc:
|
||||
raise ModelLoadError(f"Failed to load YOLO model '{self.config.model_path}': {exc}") from exc
|
||||
|
||||
def detect(self, frame: np.ndarray, evaluation_region: PixelRegion | None = None) -> list[Detection]:
|
||||
if self._model is None:
|
||||
raise ModelLoadError("Model has not been loaded")
|
||||
height, width = frame.shape[:2]
|
||||
try:
|
||||
predict_kwargs: dict[str, Any] = {
|
||||
"source": frame,
|
||||
"conf": self.config.confidence_threshold,
|
||||
"iou": self.config.iou_threshold,
|
||||
"imgsz": self.config.image_size,
|
||||
"classes": [self.config.person_class_id],
|
||||
"max_det": self.config.max_detections,
|
||||
"agnostic_nms": self.config.use_agnostic_nms,
|
||||
"device": self._device_name,
|
||||
"verbose": False,
|
||||
}
|
||||
# Ultralytics emits a deprecation warning whenever the ``half`` key
|
||||
# is supplied, even when its value is False. Only request FP16 when
|
||||
# it is explicitly enabled on CUDA; regular CPU inference needs
|
||||
# neither ``half`` nor ``quantize``.
|
||||
if self._half:
|
||||
predict_kwargs["half"] = True
|
||||
results = self._model.predict(**predict_kwargs)
|
||||
except Exception:
|
||||
LOGGER.exception("Inference failed for one frame")
|
||||
return []
|
||||
detections: list[Detection] = []
|
||||
for result in results:
|
||||
boxes = getattr(result, "boxes", None)
|
||||
if boxes is None:
|
||||
continue
|
||||
for box in boxes:
|
||||
class_id = int(box.cls.item())
|
||||
if class_id != self.config.person_class_id:
|
||||
continue
|
||||
raw = box.xyxy[0].tolist()
|
||||
x1 = max(0, min(width - 1, round(raw[0])))
|
||||
y1 = max(0, min(height - 1, round(raw[1])))
|
||||
x2 = max(0, min(width - 1, round(raw[2])))
|
||||
y2 = max(0, min(height - 1, round(raw[3])))
|
||||
if x1 >= x2 or y1 >= y2:
|
||||
continue
|
||||
bbox = BoundingBox(x1, y1, x2, y2)
|
||||
inside = evaluation_region.contains(bbox.foot_x, bbox.foot_y) if evaluation_region else False
|
||||
detections.append(Detection(class_id, "person", float(box.conf.item()), bbox, inside))
|
||||
return detections
|
||||
|
||||
@property
|
||||
def device_name(self) -> str:
|
||||
return self._device_name
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Application-specific exception hierarchy."""
|
||||
|
||||
|
||||
class ApplicationError(Exception):
|
||||
"""Base class for expected application errors."""
|
||||
|
||||
|
||||
class ConfigurationError(ApplicationError):
|
||||
"""Raised when configuration is missing or invalid."""
|
||||
|
||||
|
||||
class CameraOpenError(ApplicationError):
|
||||
"""Raised when an input source cannot be opened."""
|
||||
|
||||
|
||||
class FrameReadError(ApplicationError):
|
||||
"""Raised after repeated camera frame read failures."""
|
||||
|
||||
|
||||
class ModelLoadError(ApplicationError):
|
||||
"""Raised when the YOLO model cannot be loaded."""
|
||||
|
||||
|
||||
class VideoWriterError(ApplicationError):
|
||||
"""Raised when an output video writer cannot be opened."""
|
||||
|
||||
|
||||
class TrackerError(ApplicationError):
|
||||
"""Raised when the external tracker cannot be initialized or updated."""
|
||||
|
||||
|
||||
class TrackManagementError(ApplicationError):
|
||||
"""Raised when accumulated track state is inconsistent."""
|
||||
|
||||
|
||||
class TrackingLogError(ApplicationError):
|
||||
"""Raised when tracking CSV files cannot be written."""
|
||||
|
||||
|
||||
class FaceProcessingError(ApplicationError):
|
||||
"""Raised when the face backend cannot be initialized."""
|
||||
|
||||
|
||||
class FaceLogError(ApplicationError):
|
||||
"""Raised when face CSV logs cannot be opened or written."""
|
||||
|
||||
|
||||
class TurnLogError(ApplicationError):
|
||||
"""Raised when turn-analysis CSV files cannot be opened or written."""
|
||||
|
||||
|
||||
class VoiceLogError(ApplicationError):
|
||||
"""Raised when voice-prompt CSV files cannot be opened or written."""
|
||||
|
||||
|
||||
class PersonStateLogError(ApplicationError):
|
||||
"""Raised when integrated person-state CSV files cannot be written."""
|
||||
|
||||
|
||||
class UnifiedLogError(ApplicationError):
|
||||
"""Raised when Phase 7 unified CSV files cannot be written."""
|
||||
@@ -0,0 +1,11 @@
|
||||
"""UTF-8 YAML persistence for one experiment session note."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
from .models import ExperimentSessionNote
|
||||
class ExperimentNotes:
|
||||
def save(self,note:ExperimentSessionNote,output_path:Path)->Path:
|
||||
output_path.parent.mkdir(parents=True,exist_ok=True);output_path.write_text(yaml.safe_dump(asdict(note),allow_unicode=True,sort_keys=False),encoding="utf-8");return output_path
|
||||
def load(self,path:Path)->ExperimentSessionNote:
|
||||
return ExperimentSessionNote(**yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Build and export a reproducible research experiment protocol."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import yaml
|
||||
from .models import ExperimentProtocol
|
||||
|
||||
class ExperimentProtocolBuilder:
|
||||
def __init__(self,config:dict[str,Any])->None:self.config=config
|
||||
def build_default_protocol(self)->ExperimentProtocol:
|
||||
c=self.config;p=c["experiment_protocol"]
|
||||
return ExperimentProtocol(f"protocol_{datetime.now():%Y%m%d}","デジタルサイネージ声掛けパイロット実験",c["application"]["version"],datetime.now().astimezone().isoformat(timespec="seconds"),"音声提示と通行人の顔向き反応を観測する",c["experiment"]["location"],"デジタルサイネージ",c["unified_logging"]["camera_position_note"],None,"要記録","要記録","要記録",p["voice_prompt_text"],c["audio"]["file_path"],c["audio"]["volume"],c["voice_prompt"]["trigger_strategy"],f"{c['trigger_line']['orientation']} {c['trigger_line']['position']}",c["voice_prompt"]["response_window_sec"],c["turn_detection"]["weak_threshold_deg"],c["face"]["process_every_n_frames"],"トリガー時に音声を提示する","同じトリガー時刻を記録し音声は提示しない",p["privacy_note"],"カメラ位置、照明、通行方向をセッションごとに固定・記録する")
|
||||
def export_markdown(self,protocol:ExperimentProtocol,output_path:Path)->Path:
|
||||
output_path.parent.mkdir(parents=True,exist_ok=True);d=asdict(protocol);lines=[f"# {protocol.title}",""]+[f"- **{k}**: {v}" for k,v in d.items()];output_path.write_text("\n".join(lines)+"\n",encoding="utf-8");return output_path
|
||||
def export_yaml(self,protocol:ExperimentProtocol,output_path:Path)->Path:
|
||||
output_path.parent.mkdir(parents=True,exist_ok=True);output_path.write_text(yaml.safe_dump(asdict(protocol),allow_unicode=True,sort_keys=False),encoding="utf-8");return output_path
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Pure geometry helpers for deriving face candidate regions."""
|
||||
|
||||
from .config_loader import FaceSettings
|
||||
from .models import FaceROI, TrackedPerson
|
||||
|
||||
|
||||
def create_face_roi(person: TrackedPerson, frame_width: int, frame_height: int, config: FaceSettings) -> FaceROI | None:
|
||||
"""Create a clipped upper-body ROI or return None when the track is ineligible."""
|
||||
if config.require_confirmed_track and not person.is_confirmed:
|
||||
return None
|
||||
if config.process_only_inside_region and not person.inside_evaluation_region:
|
||||
return None
|
||||
bbox = person.bbox
|
||||
candidate_height = bbox.height * config.roi_height_ratio
|
||||
expand_x = bbox.width * config.roi_expand_ratio
|
||||
expand_y = candidate_height * config.roi_expand_ratio
|
||||
x1 = max(0, round(bbox.x1 - expand_x))
|
||||
y1 = max(0, round(bbox.y1 - expand_y))
|
||||
x2 = min(frame_width, round(bbox.x2 + expand_x))
|
||||
y2 = min(frame_height, round(bbox.y1 + candidate_height + expand_y))
|
||||
if x2 - x1 < config.min_roi_width or y2 - y1 < config.min_roi_height:
|
||||
return None
|
||||
return FaceROI(person.track_id, x1, y1, x2, y2, bbox, person.confidence)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""UTF-8 BOM frame and summary logs for Phase 3 face processing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
|
||||
from .config_loader import FaceLoggingSettings
|
||||
from .exceptions import FaceLogError
|
||||
from .models import FaceTrackState, HeadPoseResult
|
||||
|
||||
|
||||
class FaceLogger:
|
||||
"""Write per-frame head pose and deduplicated per-track face summaries."""
|
||||
|
||||
FRAME_FIELDS = ["experiment_id", "frame_number", "timestamp", "elapsed_time_sec", "track_id", "face_detected",
|
||||
"pose_estimated", "pitch", "yaw", "roll", "landmarks_count", "roi_x1", "roi_y1", "roi_x2",
|
||||
"roi_y2", "roi_width", "roi_height", "confidence", "failure_reason"]
|
||||
SUMMARY_FIELDS = ["experiment_id", "track_id", "total_frames", "face_detected_frames", "pose_estimated_frames",
|
||||
"face_detection_rate", "pose_estimation_rate", "min_yaw", "max_yaw", "mean_yaw", "last_pitch",
|
||||
"last_yaw", "last_roll", "valid_face_track", "invalid_reason"]
|
||||
|
||||
def __init__(self, config: FaceLoggingSettings, experiment_id: str, run_stamp: str | None = None) -> None:
|
||||
self.config = config
|
||||
self.experiment_id = 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", "face_frames", self.FRAME_FIELDS, self.config.log_frame_faces)
|
||||
self._open("summary", "face_summary", self.SUMMARY_FIELDS, self.config.log_summaries)
|
||||
except OSError as exc:
|
||||
self.close()
|
||||
raise FaceLogError(f"Failed to open face logs: {exc}") from exc
|
||||
|
||||
def _open(self, key: str, suffix: str, fields: list[str], enabled: bool) -> None:
|
||||
if not enabled:
|
||||
return
|
||||
path = self.directory / f"{self.run_stamp}_{self.experiment_id}_{suffix}.csv"
|
||||
stream = path.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_results(self, results: list[HeadPoseResult]) -> None:
|
||||
writer = self._writers.get("frames")
|
||||
if writer is None:
|
||||
return
|
||||
for result in results:
|
||||
roi = result.roi
|
||||
writer.writerow({
|
||||
"experiment_id": self.experiment_id, "frame_number": result.frame_number,
|
||||
"timestamp": result.timestamp_iso, "elapsed_time_sec": f"{result.elapsed_time_sec:.6f}",
|
||||
"track_id": result.track_id, "face_detected": result.face_detected,
|
||||
"pose_estimated": result.pose_estimated,
|
||||
"pitch": self._number(result.pitch), "yaw": self._number(result.yaw), "roll": self._number(result.roll),
|
||||
"landmarks_count": result.landmarks_count, "roi_x1": roi.x1 if roi else "", "roi_y1": roi.y1 if roi else "",
|
||||
"roi_x2": roi.x2 if roi else "", "roi_y2": roi.y2 if roi else "",
|
||||
"roi_width": roi.width if roi else "", "roi_height": roi.height if roi else "",
|
||||
"confidence": self._number(result.confidence), "failure_reason": result.failure_reason,
|
||||
})
|
||||
self._frames_since_flush += 1
|
||||
if self._frames_since_flush >= self.config.flush_interval_frames:
|
||||
self.flush()
|
||||
|
||||
def write_summary(self, state: FaceTrackState) -> None:
|
||||
writer = self._writers.get("summary")
|
||||
if writer is None or state.track_id in self._summary_ids:
|
||||
return
|
||||
valid = state.pose_estimated_frames > 0
|
||||
writer.writerow({
|
||||
"experiment_id": self.experiment_id, "track_id": state.track_id, "total_frames": state.total_frames,
|
||||
"face_detected_frames": state.face_detected_frames, "pose_estimated_frames": state.pose_estimated_frames,
|
||||
"face_detection_rate": f"{state.face_detection_rate:.6f}",
|
||||
"pose_estimation_rate": f"{state.pose_estimation_rate:.6f}", "min_yaw": self._number(state.min_yaw),
|
||||
"max_yaw": self._number(state.max_yaw), "mean_yaw": self._number(state.mean_yaw),
|
||||
"last_pitch": self._number(state.last_pitch), "last_yaw": self._number(state.last_yaw),
|
||||
"last_roll": self._number(state.last_roll), "valid_face_track": valid,
|
||||
"invalid_reason": "" if valid else "no_pose_estimation",
|
||||
})
|
||||
self._summary_ids.add(state.track_id)
|
||||
|
||||
@staticmethod
|
||||
def _number(value: float | None) -> str:
|
||||
return f"{value:.6f}" if value is not None else ""
|
||||
|
||||
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) -> "FaceLogger":
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.close()
|
||||
@@ -0,0 +1,192 @@
|
||||
"""MediaPipe face processing and per-track face statistics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
from .config_loader import FaceSettings, HeadPoseSettings
|
||||
from .exceptions import FaceProcessingError
|
||||
from .face_geometry import create_face_roi
|
||||
from .head_pose import HeadPoseEstimator
|
||||
from .models import FaceLandmark, FaceTrackState, FrameInfo, HeadPoseResult, TrackedPerson
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FaceProcessor:
|
||||
"""Run MediaPipe Face Mesh per tracked-person ROI and estimate head pose."""
|
||||
|
||||
def __init__(self, config: FaceSettings, head_pose_config: HeadPoseSettings) -> None:
|
||||
self.config = config
|
||||
self.head_pose_config = head_pose_config
|
||||
self.estimator = HeadPoseEstimator(head_pose_config)
|
||||
self._mesh: Any = None
|
||||
self._smoothed: dict[int, tuple[float, float, float]] = {}
|
||||
if config.enabled:
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
try:
|
||||
import mediapipe as mp
|
||||
|
||||
self._mesh = mp.solutions.face_mesh.FaceMesh(
|
||||
static_image_mode=self.config.static_image_mode,
|
||||
max_num_faces=self.config.max_num_faces,
|
||||
refine_landmarks=self.config.refine_landmarks,
|
||||
min_detection_confidence=self.config.min_detection_confidence,
|
||||
min_tracking_confidence=self.config.min_tracking_confidence,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise FaceProcessingError(f"Failed to initialize MediaPipe Face Mesh: {exc}") from exc
|
||||
|
||||
def process(
|
||||
self, frame: Any, tracked_persons: list[TrackedPerson], frame_info: FrameInfo,
|
||||
) -> list[HeadPoseResult]:
|
||||
if not self.config.enabled or self._mesh is None:
|
||||
return []
|
||||
if (frame_info.frame_number - 1) % self.config.process_every_n_frames != 0:
|
||||
return []
|
||||
eligible = [person for person in tracked_persons if self._eligible(person)]
|
||||
eligible.sort(key=lambda person: (person.inside_evaluation_region, person.bbox.area), reverse=True)
|
||||
results: list[HeadPoseResult] = []
|
||||
for person in eligible[:self.config.max_persons_per_frame]:
|
||||
results.append(self._process_person(frame, person, frame_info))
|
||||
return results
|
||||
|
||||
def _eligible(self, person: TrackedPerson) -> bool:
|
||||
if self.config.require_confirmed_track and not person.is_confirmed:
|
||||
return False
|
||||
if self.config.process_only_inside_region and not person.inside_evaluation_region:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _process_person(self, frame: Any, person: TrackedPerson, info: FrameInfo) -> HeadPoseResult:
|
||||
height, width = frame.shape[:2]
|
||||
roi = create_face_roi(person, width, height, self.config)
|
||||
if roi is None:
|
||||
return self._failure(person.track_id, info, "roi_too_small")
|
||||
try:
|
||||
image = frame[roi.y1:roi.y2, roi.x1:roi.x2]
|
||||
import cv2
|
||||
|
||||
output = self._mesh.process(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
|
||||
faces = getattr(output, "multi_face_landmarks", None) or []
|
||||
if not faces:
|
||||
return self._failure(person.track_id, info, "face_not_detected", roi)
|
||||
selected = min(faces, key=self._face_center_distance)
|
||||
landmarks = self._convert_landmarks(selected.landmark, roi)
|
||||
except Exception:
|
||||
LOGGER.exception("MediaPipe failed for track %d on frame %d", person.track_id, info.frame_number)
|
||||
return self._failure(person.track_id, info, "mediapipe_error", roi)
|
||||
estimation = self.estimator.estimate(landmarks, width, height)
|
||||
if not estimation.success:
|
||||
return HeadPoseResult(
|
||||
person.track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec,
|
||||
True, False, estimation.pitch, estimation.yaw, estimation.roll, len(landmarks), roi, roi.confidence,
|
||||
estimation.failure_reason, tuple(landmarks),
|
||||
)
|
||||
pitch, yaw, roll = self._smooth(person.track_id, estimation.pitch, estimation.yaw, estimation.roll)
|
||||
return HeadPoseResult(
|
||||
person.track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec,
|
||||
True, True, pitch, yaw, roll, len(landmarks), roi, roi.confidence, "", tuple(landmarks),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _face_center_distance(face: Any) -> float:
|
||||
landmarks = face.landmark
|
||||
center_x = sum(point.x for point in landmarks) / len(landmarks)
|
||||
center_y = sum(point.y for point in landmarks) / len(landmarks)
|
||||
return (center_x - 0.5) ** 2 + (center_y - 0.5) ** 2
|
||||
|
||||
@staticmethod
|
||||
def _convert_landmarks(raw_landmarks: Any, roi: Any) -> list[FaceLandmark]:
|
||||
return [
|
||||
FaceLandmark(
|
||||
index=index, x=roi.x1 + float(point.x) * roi.width,
|
||||
y=roi.y1 + float(point.y) * roi.height, z=float(point.z) * roi.width,
|
||||
visibility=float(point.visibility) if hasattr(point, "visibility") else None,
|
||||
)
|
||||
for index, point in enumerate(raw_landmarks)
|
||||
]
|
||||
|
||||
def _smooth(
|
||||
self, track_id: int, pitch: float | None, yaw: float | None, roll: float | None,
|
||||
) -> tuple[float, float, float]:
|
||||
assert pitch is not None and yaw is not None and roll is not None
|
||||
current = (pitch, yaw, roll)
|
||||
if not self.head_pose_config.smoothing_enabled or track_id not in self._smoothed:
|
||||
smoothed = current
|
||||
else:
|
||||
alpha = self.head_pose_config.ema_alpha
|
||||
previous = self._smoothed[track_id]
|
||||
smoothed = tuple(alpha * value + (1.0 - alpha) * old for value, old in zip(current, previous))
|
||||
self._smoothed[track_id] = smoothed
|
||||
return smoothed
|
||||
|
||||
@staticmethod
|
||||
def _failure(track_id: int, info: FrameInfo, reason: str, roi: Any = None) -> HeadPoseResult:
|
||||
return HeadPoseResult(track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec,
|
||||
False, False, None, None, None, 0, roi, None, reason)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._smoothed.clear()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._mesh is not None:
|
||||
self._mesh.close()
|
||||
self._mesh = None
|
||||
self._smoothed.clear()
|
||||
|
||||
|
||||
class FaceTrackManager:
|
||||
"""Accumulate face success rates, yaw statistics, and recent results by track ID."""
|
||||
|
||||
def __init__(self, max_history_points: int) -> None:
|
||||
if max_history_points <= 0:
|
||||
raise ValueError("max_history_points must be positive")
|
||||
self.max_history_points = max_history_points
|
||||
self._states: dict[int, FaceTrackState] = {}
|
||||
self._finalized: dict[int, FaceTrackState] = {}
|
||||
|
||||
def update(self, results: list[HeadPoseResult]) -> None:
|
||||
for result in results:
|
||||
state = self._states.get(result.track_id)
|
||||
if state is None:
|
||||
state = FaceTrackState(result.track_id, history=deque(maxlen=self.max_history_points))
|
||||
self._states[result.track_id] = state
|
||||
state.total_frames += 1
|
||||
state.face_detected_frames += int(result.face_detected)
|
||||
state.pose_estimated_frames += int(result.pose_estimated)
|
||||
state.last_result = result
|
||||
state.history.append(result)
|
||||
if result.pose_estimated and result.yaw is not None:
|
||||
state.last_pitch, state.last_yaw, state.last_roll = result.pitch, result.yaw, result.roll
|
||||
state.min_yaw = result.yaw if state.min_yaw is None else min(state.min_yaw, result.yaw)
|
||||
state.max_yaw = result.yaw if state.max_yaw is None else max(state.max_yaw, result.yaw)
|
||||
state.yaw_sum += result.yaw
|
||||
|
||||
def get_state(self, track_id: int) -> FaceTrackState | None:
|
||||
return self._states.get(track_id) or self._finalized.get(track_id)
|
||||
|
||||
def get_latest_result(self, track_id: int) -> HeadPoseResult | None:
|
||||
state = self.get_state(track_id)
|
||||
return state.last_result if state else None
|
||||
|
||||
def get_latest_results(self) -> dict[int, HeadPoseResult]:
|
||||
return {track_id: state.last_result for track_id, state in self._states.items() if state.last_result is not None}
|
||||
|
||||
def finalize_track(self, track_id: int) -> FaceTrackState | None:
|
||||
state = self._states.pop(track_id, None)
|
||||
if state is not None:
|
||||
state.finalized = True
|
||||
self._finalized[track_id] = state
|
||||
return state
|
||||
|
||||
def finalize_all(self) -> list[FaceTrackState]:
|
||||
return [state for track_id in list(self._states) if (state := self.finalize_track(track_id)) is not None]
|
||||
|
||||
def reset(self) -> None:
|
||||
self._states.clear()
|
||||
self._finalized.clear()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Frame-rate measurement."""
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
|
||||
class FPSCounter:
|
||||
"""Calculate instantaneous and moving-average FPS."""
|
||||
|
||||
def __init__(self, average_window: int) -> None:
|
||||
if average_window <= 0:
|
||||
raise ValueError("average_window must be positive")
|
||||
self._samples: deque[float] = deque(maxlen=average_window)
|
||||
self._last_time: float | None = None
|
||||
self._current_fps = 0.0
|
||||
|
||||
def tick(self) -> float:
|
||||
now = time.perf_counter()
|
||||
if self._last_time is None:
|
||||
self._last_time = now
|
||||
return 0.0
|
||||
elapsed = now - self._last_time
|
||||
self._last_time = now
|
||||
self._current_fps = 1.0 / elapsed if elapsed > 0.0 else 0.0
|
||||
if self._current_fps > 0.0:
|
||||
self._samples.append(self._current_fps)
|
||||
return self._current_fps
|
||||
|
||||
@property
|
||||
def current_fps(self) -> float:
|
||||
return self._current_fps
|
||||
|
||||
@property
|
||||
def average_fps(self) -> float:
|
||||
return sum(self._samples) / len(self._samples) if self._samples else 0.0
|
||||
|
||||
@property
|
||||
def sample_count(self) -> int:
|
||||
return len(self._samples)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._samples.clear()
|
||||
self._last_time = None
|
||||
self._current_fps = 0.0
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Phase 9 desktop GUI package."""
|
||||
|
||||
from .gui_models import GuiAnalysisRequest, GuiExperimentRequest
|
||||
|
||||
__all__ = ["GuiAnalysisRequest", "GuiExperimentRequest"]
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Threaded Phase 8 analysis controller; it never initializes camera or YOLO."""
|
||||
from __future__ import annotations
|
||||
import threading
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from ..analysis_cli import run_analysis_cli
|
||||
from ..config_loader import AnalysisSettings, load_config
|
||||
from .gui_models import GuiAnalysisRequest
|
||||
|
||||
class AnalysisController:
|
||||
def __init__(self, runner: Callable[[AnalysisSettings, Path, Path], list[Path]] = run_analysis_cli) -> None:
|
||||
self._runner=runner; self._thread: threading.Thread|None=None
|
||||
self._finished_callback: Callable[[list[Path]],None]|None=None; self._error_callback: Callable[[str],None]|None=None
|
||||
def run_analysis(self, request: GuiAnalysisRequest, config_path: str = "config/experiment.yaml") -> None:
|
||||
if self.is_running(): raise RuntimeError("Analysis is already running")
|
||||
if request.response_test not in {"auto","chi_square","fisher"}: raise ValueError("Invalid response test")
|
||||
if request.numeric_test not in {"mannwhitney","ttest"}: raise ValueError("Invalid numeric test")
|
||||
config=load_config(Path("config/default.yaml"),Path(config_path))
|
||||
settings=replace(config.analysis,input_directory=request.input_directory,output_directory=request.output_directory,
|
||||
recursive=request.recursive,generate_plots=request.generate_plots,
|
||||
response_rate_test=request.response_test,numeric_test=request.numeric_test)
|
||||
self._thread=threading.Thread(target=self._worker,args=(settings,),name="analysis-worker",daemon=True);self._thread.start()
|
||||
def _worker(self, settings: AnalysisSettings) -> None:
|
||||
try:
|
||||
paths=self._runner(settings,Path(settings.input_directory),Path(settings.output_directory))
|
||||
if self._finished_callback:self._finished_callback(paths)
|
||||
except Exception as exc:
|
||||
if self._error_callback:self._error_callback(str(exc))
|
||||
def is_running(self)->bool:return self._thread is not None and self._thread.is_alive()
|
||||
def set_finished_callback(self,callback:Callable[[list[Path]],None])->None:self._finished_callback=callback
|
||||
def set_error_callback(self,callback:Callable[[str],None])->None:self._error_callback=callback
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Phase 8 analysis controls and output list."""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QCheckBox,QComboBox,QFileDialog,QFormLayout,QHBoxLayout,QLineEdit,QListWidget,QPushButton,QVBoxLayout,QWidget
|
||||
from .gui_models import GuiAnalysisRequest
|
||||
|
||||
class AnalysisPanel(QWidget):
|
||||
run_requested=Signal(object);open_requested=Signal(str)
|
||||
def __init__(self,input_dir:str,output_dir:str)->None:
|
||||
super().__init__();layout=QVBoxLayout(self);form=QFormLayout();self.input=QLineEdit(input_dir);self.output=QLineEdit(output_dir)
|
||||
form.addRow("Input directory",self._path_row(self.input,False));form.addRow("Output directory",self._path_row(self.output,False))
|
||||
self.recursive=QCheckBox();self.recursive.setChecked(True);self.plots=QCheckBox();self.plots.setChecked(True)
|
||||
self.response=QComboBox();self.response.addItems(["auto","chi_square","fisher"]);self.numeric=QComboBox();self.numeric.addItems(["mannwhitney","ttest"])
|
||||
form.addRow("Recursive",self.recursive);form.addRow("Generate plots",self.plots);form.addRow("Response test",self.response);form.addRow("Numeric test",self.numeric);layout.addLayout(form)
|
||||
run=QPushButton("Run analysis");run.clicked.connect(self._emit);layout.addWidget(run);self.files=QListWidget();self.files.itemDoubleClicked.connect(lambda i:self.open_requested.emit(i.text()));layout.addWidget(self.files)
|
||||
def _path_row(self,line:QLineEdit,file:bool)->QWidget:
|
||||
widget=QWidget();row=QHBoxLayout(widget);row.setContentsMargins(0,0,0,0);row.addWidget(line);button=QPushButton("Browse")
|
||||
button.clicked.connect(lambda:self._browse(line));row.addWidget(button);return widget
|
||||
def _browse(self,line:QLineEdit)->None:
|
||||
value=QFileDialog.getExistingDirectory(self,"Select directory",line.text());
|
||||
if value:line.setText(value)
|
||||
def _emit(self)->None:self.run_requested.emit(GuiAnalysisRequest(self.input.text(),self.output.text(),self.recursive.isChecked(),self.plots.isChecked(),self.response.currentText(),self.numeric.currentText()))
|
||||
def set_outputs(self,paths:list[Path])->None:self.files.clear();self.files.addItems([str(p) for p in paths])
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Validation and override conversion for GUI experiment fields."""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from .gui_models import GuiExperimentRequest, GuiValidationResult
|
||||
|
||||
def validate_experiment_request(request: GuiExperimentRequest) -> GuiValidationResult:
|
||||
errors: list[str] = []; warnings: list[str] = []
|
||||
if request.input_type not in {"camera", "video"}: errors.append("Input type must be camera or video")
|
||||
if request.camera_index < 0: errors.append("Camera index must be zero or greater")
|
||||
if request.input_type == "video" and (not request.video_path or not Path(request.video_path).is_file()): errors.append("Select an existing video file")
|
||||
if request.voice_mode not in {"prompt", "control", "disabled"}: errors.append("Voice mode is invalid")
|
||||
if not 0.0 <= request.voice_volume <= 1.0: errors.append("Volume must be between 0.0 and 1.0")
|
||||
if request.face_every is not None and request.face_every < 1: errors.append("Face interval must be at least 1")
|
||||
if request.max_face_persons is not None and request.max_face_persons < 1: errors.append("Maximum face persons must be at least 1")
|
||||
if request.signage_yaw_direction not in {None, "positive", "negative"}: errors.append("Signage yaw direction is invalid")
|
||||
if request.voice_mode == "prompt" and (not request.audio_file or not Path(request.audio_file).is_file()): warnings.append("Prompt mode has no existing audio file")
|
||||
if not Path(request.config_path).is_file(): errors.append("Configuration file does not exist")
|
||||
return GuiValidationResult(tuple(errors), tuple(warnings))
|
||||
|
||||
def experiment_overrides(request: GuiExperimentRequest) -> dict[str, Any]:
|
||||
values: dict[str, Any] = {
|
||||
"input": {"type": request.input_type, "camera_id": request.camera_index,
|
||||
"video_path": request.video_path if request.input_type == "video" else None},
|
||||
"display": {"enabled": request.show_opencv_window},
|
||||
"voice_prompt": {"enabled": request.voice_mode != "disabled", "mode": request.voice_mode},
|
||||
"audio": {"volume": request.voice_volume},
|
||||
"unified_logging": {"enabled": request.enable_unified_logging,
|
||||
"camera_position_note": request.camera_position_note},
|
||||
}
|
||||
if request.audio_file: values["audio"]["file_path"] = request.audio_file
|
||||
if request.face_every is not None: values.setdefault("face", {})["process_every_n_frames"] = request.face_every
|
||||
if request.max_face_persons is not None: values.setdefault("face", {})["max_persons_per_frame"] = request.max_face_persons
|
||||
if request.signage_yaw_direction: values.setdefault("turn_detection", {})["signage_yaw_direction"] = request.signage_yaw_direction
|
||||
return values
|
||||
|
||||
class ConfigPanelLogic:
|
||||
"""Compatibility facade used by tests and a future richer config editor."""
|
||||
validate = staticmethod(validate_experiment_request)
|
||||
to_overrides = staticmethod(experiment_overrides)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Phase 11 pilot diagnostic controls."""
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QFormLayout,QLineEdit,QListWidget,QPushButton,QVBoxLayout,QWidget
|
||||
class DiagnosticsPanel(QWidget):
|
||||
run_requested=Signal(str,str,str);open_requested=Signal(str)
|
||||
def __init__(self)->None:
|
||||
super().__init__();layout=QVBoxLayout(self);form=QFormLayout();self.analysis=QLineEdit("data/analysis");self.logs=QLineEdit("data/logs/unified");self.output=QLineEdit("data/analysis");form.addRow("Analysis",self.analysis);form.addRow("Logs",self.logs);form.addRow("Output",self.output);layout.addLayout(form);b=QPushButton("Run Pilot Diagnostics");b.clicked.connect(lambda:self.run_requested.emit(self.analysis.text(),self.logs.text(),self.output.text()));layout.addWidget(b);self.status=QLineEdit();self.status.setReadOnly(True);layout.addWidget(self.status);self.files=QListWidget();self.files.itemDoubleClicked.connect(lambda i:self.open_requested.emit(i.text()));layout.addWidget(self.files)
|
||||
def set_outputs(self,paths:list[Path])->None:self.files.clear();self.files.addItems([str(p) for p in paths]);self.status.setText("Diagnostics completed")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Threaded controller for running the existing Application from a GUI."""
|
||||
from __future__ import annotations
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Protocol
|
||||
from ..config_loader import load_config
|
||||
from ..models import RuntimeMetrics
|
||||
from .config_panel import experiment_overrides, validate_experiment_request
|
||||
from .gui_models import GuiExperimentRequest
|
||||
|
||||
class ApplicationLike(Protocol):
|
||||
def run(self) -> None: ...
|
||||
def request_stop(self) -> None: ...
|
||||
def request_pause(self) -> None: ...
|
||||
def request_resume(self) -> None: ...
|
||||
|
||||
class ExperimentController:
|
||||
"""Own one worker thread and cooperatively control an Application instance."""
|
||||
def __init__(self, application_factory: Callable[..., ApplicationLike] | None = None, preview_max_fps: float = 15.0) -> None:
|
||||
self._factory = application_factory
|
||||
self._application: ApplicationLike | None = None; self._thread: threading.Thread | None = None
|
||||
self._frame_callback: Callable[[Any, RuntimeMetrics], None] | None = None
|
||||
self._status_callback: Callable[[RuntimeMetrics], None] | None = None
|
||||
self._error_callback: Callable[[str], None] | None = None
|
||||
self._finished_callback: Callable[[], None] | None = None
|
||||
self._preview_interval = 1.0 / preview_max_fps
|
||||
self._last_preview_time = 0.0
|
||||
|
||||
def start_experiment(self, request: GuiExperimentRequest) -> None:
|
||||
if self.is_running(): raise RuntimeError("An experiment is already running")
|
||||
validation = validate_experiment_request(request)
|
||||
if not validation.valid: raise ValueError("; ".join(validation.errors))
|
||||
config = load_config(Path("config/default.yaml"), Path(request.config_path), experiment_overrides(request))
|
||||
if self._factory is None:
|
||||
from ..application import Application
|
||||
factory: Callable[..., ApplicationLike] = Application
|
||||
else: factory = self._factory
|
||||
self._application = factory(config, on_frame_rendered=self._deliver_frame, on_status_updated=self._status_callback)
|
||||
self._thread = threading.Thread(target=self._run_worker, name="experiment-worker", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _run_worker(self) -> None:
|
||||
try:
|
||||
assert self._application is not None; self._application.run()
|
||||
except Exception as exc:
|
||||
if self._error_callback: self._error_callback(str(exc))
|
||||
finally:
|
||||
if self._finished_callback: self._finished_callback()
|
||||
|
||||
def _deliver_frame(self, frame: Any, metrics: RuntimeMetrics) -> None:
|
||||
now = time.monotonic()
|
||||
if self._frame_callback is not None and now - self._last_preview_time >= self._preview_interval:
|
||||
self._last_preview_time = now
|
||||
self._frame_callback(frame, metrics)
|
||||
|
||||
def stop_experiment(self) -> None:
|
||||
if self._application is not None: self._application.request_stop()
|
||||
def pause_experiment(self) -> None:
|
||||
if self._application is not None: self._application.request_pause()
|
||||
def resume_experiment(self) -> None:
|
||||
if self._application is not None: self._application.request_resume()
|
||||
def is_running(self) -> bool: return self._thread is not None and self._thread.is_alive()
|
||||
def set_frame_callback(self, callback: Callable[[Any, RuntimeMetrics], None]) -> None: self._frame_callback = callback
|
||||
def set_status_callback(self, callback: Callable[[RuntimeMetrics], None]) -> None: self._status_callback = callback
|
||||
def set_error_callback(self, callback: Callable[[str], None]) -> None: self._error_callback = callback
|
||||
def set_finished_callback(self, callback: Callable[[], None]) -> None: self._finished_callback = callback
|
||||
@@ -0,0 +1,11 @@
|
||||
"""PySide6 application bootstrap with lazy dependency loading."""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def run_gui(config_path:Path=Path("config/experiment.yaml"))->int:
|
||||
try:from PySide6.QtWidgets import QApplication
|
||||
except ImportError as exc:raise RuntimeError("Phase 9 GUI requires PySide6. Run: pip install -r requirements.txt") from exc
|
||||
from ..config_loader import load_config
|
||||
from .main_window import MainWindow
|
||||
config=load_config(Path("config/default.yaml"),config_path);app=QApplication.instance() or QApplication(sys.argv);window=MainWindow(config.gui);window.show();return app.exec()
|
||||
@@ -0,0 +1,39 @@
|
||||
"""GUI request models kept independent from PySide6 for unit testing."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GuiExperimentRequest:
|
||||
config_path: str = "config/experiment.yaml"
|
||||
input_type: str = "camera"
|
||||
camera_index: int = 0
|
||||
video_path: str | None = None
|
||||
voice_mode: str = "disabled"
|
||||
audio_file: str | None = None
|
||||
voice_volume: float = 0.8
|
||||
camera_position_note: str = ""
|
||||
face_every: int | None = None
|
||||
max_face_persons: int | None = None
|
||||
signage_yaw_direction: str | None = None
|
||||
enable_unified_logging: bool = True
|
||||
enable_analysis_after_run: bool = False
|
||||
analysis_output_directory: str = "data/analysis"
|
||||
show_opencv_window: bool = False
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GuiAnalysisRequest:
|
||||
input_directory: str = "data/logs/unified"
|
||||
output_directory: str = "data/analysis"
|
||||
recursive: bool = True
|
||||
generate_plots: bool = True
|
||||
response_test: str = "auto"
|
||||
numeric_test: str = "mannwhitney"
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GuiValidationResult:
|
||||
errors: tuple[str, ...] = ()
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def valid(self) -> bool:
|
||||
return not self.errors
|
||||
@@ -0,0 +1,5 @@
|
||||
"""GUI log and message view."""
|
||||
from PySide6.QtWidgets import QPlainTextEdit
|
||||
class LogPanel(QPlainTextEdit):
|
||||
def __init__(self)->None:super().__init__();self.setReadOnly(True);self.setPlaceholderText("Runtime messages")
|
||||
def add_message(self,message:str)->None:self.appendPlainText(message)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Phase 9 main desktop window."""
|
||||
from __future__ import annotations
|
||||
import os,sys
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QObject,Signal,Slot
|
||||
from PySide6.QtWidgets import (QCheckBox,QComboBox,QDoubleSpinBox,QFileDialog,QFormLayout,QHBoxLayout,QLineEdit,QMainWindow,QMessageBox,QPushButton,QSpinBox,QTabWidget,QVBoxLayout,QWidget)
|
||||
from .analysis_controller import AnalysisController
|
||||
from .analysis_panel import AnalysisPanel
|
||||
from .experiment_controller import ExperimentController
|
||||
from .gui_models import GuiExperimentRequest
|
||||
from .log_panel import LogPanel
|
||||
from .operations_controller import OperationsController
|
||||
from .diagnostics_panel import DiagnosticsPanel
|
||||
from .quality_panel import QualityPanel
|
||||
from .status_panel import StatusPanel
|
||||
from .video_widget import VideoWidget
|
||||
|
||||
def open_path_in_file_manager(path:Path)->bool:
|
||||
target=path if path.is_dir() else path.parent
|
||||
try:
|
||||
if sys.platform.startswith("win"):os.startfile(str(target)) # type: ignore[attr-defined]
|
||||
elif sys.platform=="darwin":__import__("subprocess").Popen(["open",str(target)])
|
||||
else:__import__("subprocess").Popen(["xdg-open",str(target)])
|
||||
return True
|
||||
except OSError:return False
|
||||
|
||||
class _Bridge(QObject):
|
||||
frame=Signal(object,object);status=Signal(object);error=Signal(str);finished=Signal();analysis_finished=Signal(object);operation_finished=Signal(object)
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self,gui_settings)->None:
|
||||
super().__init__();self.setWindowTitle("Pedestrian Signage Research System - Phase 10");self.resize(1200,800);self.bridge=_Bridge();self.experiment=ExperimentController(preview_max_fps=gui_settings.preview_max_fps);self.analysis=AnalysisController();self.operations=OperationsController();self._paused=False;self._last_request=None
|
||||
self.experiment.set_frame_callback(lambda f,m:self.bridge.frame.emit(f,m));self.experiment.set_status_callback(lambda m:self.bridge.status.emit(m));self.experiment.set_error_callback(self.bridge.error.emit);self.experiment.set_finished_callback(self.bridge.finished.emit)
|
||||
self.analysis.set_finished_callback(self.bridge.analysis_finished.emit);self.analysis.set_error_callback(self.bridge.error.emit)
|
||||
self.operations.set_finished_callback(self.bridge.operation_finished.emit);self.operations.set_error_callback(self.bridge.error.emit)
|
||||
tabs=QTabWidget();tabs.addTab(self._experiment_tab(gui_settings),"Experiment");self.analysis_panel=AnalysisPanel(gui_settings.default_analysis_input,gui_settings.default_analysis_output);tabs.addTab(self.analysis_panel,"Analysis");self.quality_panel=QualityPanel();tabs.addTab(self.quality_panel,"Quality & Reports");self.diagnostics_panel=DiagnosticsPanel();tabs.addTab(self.diagnostics_panel,"Diagnostics");self.setCentralWidget(tabs)
|
||||
self.bridge.frame.connect(lambda f,m:self.preview.set_frame(f));self.bridge.status.connect(self.status.update_metrics);self.bridge.error.connect(self._error);self.bridge.finished.connect(self._finished);self.bridge.analysis_finished.connect(self._analysis_finished);self.bridge.operation_finished.connect(self._operation_finished);self.analysis_panel.run_requested.connect(self._run_analysis);self.analysis_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)));self.quality_panel.operation_requested.connect(self._run_operation);self.quality_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)));self.diagnostics_panel.run_requested.connect(lambda a,l,o:self._run_operation("diagnostics",l,a,o));self.diagnostics_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)))
|
||||
def _experiment_tab(self,s)->QWidget:
|
||||
root=QWidget();layout=QHBoxLayout(root);left=QWidget();form=QFormLayout(left);self.config=QLineEdit(s.default_config_path);form.addRow("Config",self._file_row(self.config,"yaml"));self.input_type=QComboBox();self.input_type.addItems(["camera","video"]);form.addRow("Input",self.input_type);self.camera=QSpinBox();self.camera.setMinimum(0);form.addRow("Camera index",self.camera);self.video=QLineEdit();form.addRow("Video",self._file_row(self.video,"video"));self.voice=QComboBox();self.voice.addItems(["prompt","control","disabled"]);form.addRow("Voice mode",self.voice);self.audio=QLineEdit();form.addRow("Audio",self._file_row(self.audio,"audio"));self.volume=QDoubleSpinBox();self.volume.setRange(0,1);self.volume.setSingleStep(.1);self.volume.setValue(.8);form.addRow("Volume",self.volume);self.note=QLineEdit();form.addRow("Camera position note",self.note);self.yaw=QComboBox();self.yaw.addItems(["positive","negative"]);form.addRow("Signage yaw",self.yaw);self.face_every=QSpinBox();self.face_every.setMinimum(1);self.face_every.setValue(1);form.addRow("Face every",self.face_every);self.max_faces=QSpinBox();self.max_faces.setMinimum(1);self.max_faces.setValue(3);form.addRow("Max face persons",self.max_faces);self.unified=QCheckBox();self.unified.setChecked(True);form.addRow("Unified logging",self.unified)
|
||||
buttons=QWidget();row=QHBoxLayout(buttons);self.start=QPushButton("Start");self.stop=QPushButton("Stop");self.pause=QPushButton("Pause");row.addWidget(self.start);row.addWidget(self.stop);row.addWidget(self.pause);form.addRow(buttons);self.start.clicked.connect(self._start);self.stop.clicked.connect(self.experiment.stop_experiment);self.pause.clicked.connect(self._pause)
|
||||
self.auto_analysis=QCheckBox();self.auto_analysis.setChecked(s.auto_run_analysis_after_experiment);form.addRow("Analyze after run",self.auto_analysis)
|
||||
right=QWidget();r=QVBoxLayout(right);self.preview=VideoWidget();self.status=StatusPanel();self.logs=LogPanel();r.addWidget(self.preview,3);r.addWidget(self.status);r.addWidget(self.logs,1);layout.addWidget(left);layout.addWidget(right,1);return root
|
||||
def _file_row(self,line:QLineEdit,kind:str)->QWidget:
|
||||
w=QWidget();row=QHBoxLayout(w);row.setContentsMargins(0,0,0,0);row.addWidget(line);b=QPushButton("Browse");b.clicked.connect(lambda:self._browse_file(line,kind));row.addWidget(b);return w
|
||||
def _browse_file(self,line:QLineEdit,kind:str)->None:
|
||||
filters={"yaml":"YAML (*.yaml *.yml)","video":"Video (*.mp4 *.avi *.mov)","audio":"Audio (*.wav *.mp3)"};path,_=QFileDialog.getOpenFileName(self,"Select file",line.text(),filters[kind]);
|
||||
if path:line.setText(path)
|
||||
def _request(self)->GuiExperimentRequest:return GuiExperimentRequest(self.config.text(),self.input_type.currentText(),self.camera.value(),self.video.text() or None,self.voice.currentText(),self.audio.text() or None,self.volume.value(),self.note.text(),self.face_every.value(),self.max_faces.value(),self.yaw.currentText(),self.unified.isChecked(),self.auto_analysis.isChecked(),self.analysis_panel.output.text() if hasattr(self,"analysis_panel") else "data/analysis",False)
|
||||
@Slot()
|
||||
def _start(self)->None:
|
||||
try:self._last_request=self._request();self.experiment.start_experiment(self._last_request);self.logs.add_message("Experiment started");self.start.setEnabled(False)
|
||||
except Exception as exc:self._error(str(exc))
|
||||
@Slot()
|
||||
def _pause(self)->None:
|
||||
self._paused=not self._paused
|
||||
if self._paused:self.experiment.pause_experiment();self.pause.setText("Resume")
|
||||
else:self.experiment.resume_experiment();self.pause.setText("Pause")
|
||||
@Slot(str)
|
||||
def _error(self,message:str)->None:self.logs.add_message("ERROR: "+message);QMessageBox.critical(self,"Error",message)
|
||||
@Slot()
|
||||
def _finished(self)->None:
|
||||
self.start.setEnabled(True);self._paused=False;self.pause.setText("Pause");self.logs.add_message("Experiment finished")
|
||||
if self._last_request is not None and self._last_request.enable_analysis_after_run:
|
||||
self.analysis_panel._emit()
|
||||
@Slot(object)
|
||||
def _run_analysis(self,request)->None:
|
||||
try:self.analysis.run_analysis(request,self.config.text());self.logs.add_message("Analysis started")
|
||||
except Exception as exc:self._error(str(exc))
|
||||
@Slot(object)
|
||||
def _analysis_finished(self,paths)->None:self.analysis_panel.set_outputs(paths);self.logs.add_message(f"Analysis finished: {len(paths)} files")
|
||||
@Slot(str,str,str,str)
|
||||
def _run_operation(self,operation,logs,analysis,output)->None:
|
||||
try:self.operations.run(operation,Path(self.config.text()),Path(logs),Path(analysis),Path(output));self.logs.add_message(f"Phase 10 operation started: {operation}")
|
||||
except Exception as exc:self._error(str(exc))
|
||||
@Slot(object)
|
||||
def _operation_finished(self,paths)->None:self.quality_panel.set_outputs(paths);self.diagnostics_panel.set_outputs(paths);self.logs.add_message(f"Offline operation outputs: {len(paths)} files")
|
||||
def closeEvent(self,event)->None: # type: ignore[no-untyped-def]
|
||||
if self.experiment.is_running():self.experiment.stop_experiment()
|
||||
event.accept()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Background Phase 10 operation controller."""
|
||||
from __future__ import annotations
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from ..config_loader import load_config
|
||||
from ..phase10_cli import generate_protocol,run_pilot_report,run_presentation,run_quality
|
||||
from ..phase11_cli import run_diagnostics
|
||||
class OperationsController:
|
||||
def __init__(self)->None:self._thread:threading.Thread|None=None;self._finished:Callable[[list[Path]],None]|None=None;self._error:Callable[[str],None]|None=None
|
||||
def run(self,operation:str,config_path:Path,logs:Path,analysis:Path,output:Path)->None:
|
||||
if self._thread and self._thread.is_alive():raise RuntimeError("An operation is already running")
|
||||
self._thread=threading.Thread(target=self._worker,args=(operation,config_path,logs,analysis,output),daemon=True,name="phase10-worker");self._thread.start()
|
||||
def _worker(self,operation:str,config_path:Path,logs:Path,analysis:Path,output:Path)->None:
|
||||
try:
|
||||
c=load_config("config/default.yaml",config_path)
|
||||
if operation=="diagnostics":paths=run_diagnostics(c,analysis,logs,output)
|
||||
elif operation=="quality":paths=run_quality(c,logs,analysis,output)
|
||||
elif operation=="pilot":paths=[run_pilot_report(c,analysis,output/"pilot_report.md",logs)]
|
||||
elif operation=="presentation":paths=[run_presentation(c,analysis,output/"midterm_summary.md",logs)]
|
||||
else:paths=generate_protocol(c,output/"experiment_protocol.md",output/"experiment_protocol.yaml")
|
||||
if self._finished:self._finished(paths)
|
||||
except Exception as exc:
|
||||
if self._error:self._error(str(exc))
|
||||
def set_finished_callback(self,callback:Callable[[list[Path]],None])->None:self._finished=callback
|
||||
def set_error_callback(self,callback:Callable[[str],None])->None:self._error=callback
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Small read-only pointer to Phase 10 protocol documentation."""
|
||||
from PySide6.QtWidgets import QLabel,QVBoxLayout,QWidget
|
||||
class ProtocolPanel(QWidget):
|
||||
def __init__(self)->None:super().__init__();layout=QVBoxLayout(self);layout.addWidget(QLabel("Phase 10 protocol and checklist generation is available in the Quality tab."))
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Phase 10 quality and reporting controls."""
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QFormLayout,QHBoxLayout,QLineEdit,QListWidget,QPushButton,QVBoxLayout,QWidget
|
||||
class QualityPanel(QWidget):
|
||||
operation_requested=Signal(str,str,str,str);open_requested=Signal(str)
|
||||
def __init__(self)->None:
|
||||
super().__init__();layout=QVBoxLayout(self);form=QFormLayout();self.logs=QLineEdit("data/logs/unified");self.analysis=QLineEdit("data/analysis");self.output=QLineEdit("data/analysis");form.addRow("Unified logs",self.logs);form.addRow("Analysis",self.analysis);form.addRow("Output",self.output);layout.addLayout(form);row=QHBoxLayout()
|
||||
for text,op in (("Run quality check","quality"),("Generate pilot report","pilot"),("Generate midterm summary","presentation"),("Generate protocol","protocol")):
|
||||
button=QPushButton(text);button.clicked.connect(lambda checked=False,value=op:self.operation_requested.emit(value,self.logs.text(),self.analysis.text(),self.output.text()));row.addWidget(button)
|
||||
layout.addLayout(row);self.files=QListWidget();self.files.itemDoubleClicked.connect(lambda i:self.open_requested.emit(i.text()));layout.addWidget(self.files)
|
||||
def set_outputs(self,paths:list[Path])->None:self.files.clear();self.files.addItems([str(p) for p in paths])
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Compact runtime metric display."""
|
||||
from __future__ import annotations
|
||||
from PySide6.QtWidgets import QFormLayout,QLabel,QWidget
|
||||
from ..models import RuntimeMetrics
|
||||
|
||||
class StatusPanel(QWidget):
|
||||
def __init__(self)->None:
|
||||
super().__init__();layout=QFormLayout(self);self._labels={}
|
||||
for key,label in (("fps","FPS"),("frame","Frame"),("active","Active tracks"),("total","Total tracks"),("crossings","Crossings"),("prompt","Prompts"),("pseudo","Pseudo prompts"),("response","Responses"),("turn","Turn confirmed"),("voice","Voice mode"),("logs","Log directory")):
|
||||
value=QLabel("-");self._labels[key]=value;layout.addRow(label,value)
|
||||
def update_metrics(self,m:RuntimeMetrics)->None:
|
||||
values={"fps":f"{m.fps:.1f}" if m.fps is not None else "-","frame":str(m.frame_number),"active":str(m.active_tracks),"total":str(m.total_tracks),"crossings":str(m.trigger_crossings),"prompt":str(m.prompt_played_count),"pseudo":str(m.pseudo_prompt_count),"response":str(m.response_detected_count),"turn":str(m.turn_confirmed_count),"voice":m.voice_mode or "-","logs":m.current_log_directory or "-"}
|
||||
for key,value in values.items():self._labels[key].setText(value)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""OpenCV preview widget with aspect-ratio preserving Qt rendering."""
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
try:
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QImage, QPixmap
|
||||
from PySide6.QtWidgets import QLabel
|
||||
except ImportError as exc: # pragma: no cover - exercised only without optional GUI dependency
|
||||
raise ImportError("Phase 9 GUI requires PySide6. Install it with: pip install -r requirements.txt") from exc
|
||||
|
||||
def bgr_to_rgb(frame: np.ndarray) -> np.ndarray:
|
||||
if frame.ndim != 3 or frame.shape[2] != 3: raise ValueError("Expected a BGR image with three channels")
|
||||
return np.ascontiguousarray(frame[:, :, ::-1])
|
||||
|
||||
class VideoWidget(QLabel):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Preview starts after Start is pressed")
|
||||
self.setAlignment(Qt.AlignmentFlag.AlignCenter); self.setMinimumSize(640,360); self._pixmap:QPixmap|None=None
|
||||
def set_frame(self, frame: np.ndarray) -> None:
|
||||
rgb=bgr_to_rgb(frame);h,w,c=rgb.shape
|
||||
image=QImage(rgb.data,w,h,c*w,QImage.Format.Format_RGB888).copy();self._pixmap=QPixmap.fromImage(image);self._refresh()
|
||||
def resizeEvent(self,event)->None: # type: ignore[no-untyped-def]
|
||||
super().resizeEvent(event);self._refresh()
|
||||
def _refresh(self)->None:
|
||||
if self._pixmap is not None:self.setPixmap(self._pixmap.scaled(self.size(),Qt.AspectRatioMode.KeepAspectRatio,Qt.TransformationMode.SmoothTransformation))
|
||||
@@ -0,0 +1,100 @@
|
||||
"""OpenCV solvePnP head-pose estimation with no MediaPipe dependency."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from .config_loader import HeadPoseSettings
|
||||
from .models import FaceLandmark, HeadPoseEstimation
|
||||
|
||||
|
||||
class HeadPoseEstimator:
|
||||
"""Estimate approximate pitch, yaw, and roll from six facial landmarks."""
|
||||
|
||||
LANDMARK_INDICES = (1, 152, 33, 263, 61, 291)
|
||||
# Arbitrary units: only relative shape matters. Y is positive downward to
|
||||
# match OpenCV image coordinates; using a Y-up model can make a frontal
|
||||
# face solve as an approximately 180-degree rotation.
|
||||
MODEL_POINTS = (
|
||||
(0.0, 0.0, 0.0), (0.0, 63.6, -12.5), (-43.3, -32.7, -26.0),
|
||||
(43.3, -32.7, -26.0), (-28.9, 28.9, -24.1), (28.9, 28.9, -24.1),
|
||||
)
|
||||
|
||||
def __init__(self, config: HeadPoseSettings) -> None:
|
||||
self.config = config
|
||||
|
||||
def build_image_points(self, landmarks: list[FaceLandmark]) -> list[tuple[float, float]] | None:
|
||||
by_index = {landmark.index: landmark for landmark in landmarks}
|
||||
if any(index not in by_index for index in self.LANDMARK_INDICES):
|
||||
return None
|
||||
return [(by_index[index].x, by_index[index].y) for index in self.LANDMARK_INDICES]
|
||||
|
||||
@staticmethod
|
||||
def build_camera_matrix(frame_width: int, frame_height: int) -> list[list[float]]:
|
||||
focal = float(frame_width)
|
||||
return [[focal, 0.0, frame_width / 2.0], [0.0, focal, frame_height / 2.0], [0.0, 0.0, 1.0]]
|
||||
|
||||
@staticmethod
|
||||
def rotation_matrix_to_euler(rotation: list[list[float]]) -> tuple[float, float, float]:
|
||||
"""Return X/Y/Z Euler rotations as pitch/yaw/roll in degrees."""
|
||||
sy = math.sqrt(rotation[0][0] ** 2 + rotation[1][0] ** 2)
|
||||
singular = sy < 1e-6
|
||||
if not singular:
|
||||
pitch = math.atan2(rotation[2][1], rotation[2][2])
|
||||
yaw = math.atan2(-rotation[2][0], sy)
|
||||
roll = math.atan2(rotation[1][0], rotation[0][0])
|
||||
else:
|
||||
pitch = math.atan2(-rotation[1][2], rotation[1][1])
|
||||
yaw = math.atan2(-rotation[2][0], sy)
|
||||
roll = 0.0
|
||||
return tuple(float(math.degrees(value)) for value in (pitch, yaw, roll))
|
||||
|
||||
def estimate(self, landmarks: list[FaceLandmark], frame_width: int, frame_height: int) -> HeadPoseEstimation:
|
||||
if not self.config.enabled:
|
||||
return HeadPoseEstimation(False, failure_reason="head_pose_disabled")
|
||||
image_points = self.build_image_points(landmarks)
|
||||
if image_points is None:
|
||||
return HeadPoseEstimation(False, failure_reason="insufficient_landmarks")
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
model = np.asarray(self.MODEL_POINTS, dtype=np.float64)
|
||||
image = np.asarray(image_points, dtype=np.float64)
|
||||
camera = np.asarray(self.build_camera_matrix(frame_width, frame_height), dtype=np.float64)
|
||||
distortion = np.zeros((4, 1), dtype=np.float64)
|
||||
success, rotation_vector, translation_vector = cv2.solvePnP(
|
||||
model, image, camera, distortion, flags=cv2.SOLVEPNP_ITERATIVE,
|
||||
)
|
||||
if not success:
|
||||
return HeadPoseEstimation(False, failure_reason="solvepnp_failed")
|
||||
rotation_matrix, _ = cv2.Rodrigues(rotation_vector)
|
||||
angles = self.rotation_matrix_to_euler(rotation_matrix.tolist())
|
||||
projected, _ = cv2.projectPoints(model, rotation_vector, translation_vector, camera, distortion)
|
||||
error = float(np.sqrt(np.mean(np.sum((projected.reshape(-1, 2) - image) ** 2, axis=1))))
|
||||
except Exception:
|
||||
return HeadPoseEstimation(False, failure_reason="solvepnp_failed")
|
||||
return self.result_from_angles(angles, error)
|
||||
|
||||
def result_from_angles(
|
||||
self, angles: tuple[float, float, float], reprojection_error: float | None = None,
|
||||
) -> HeadPoseEstimation:
|
||||
"""Validate calculated angles and construct a normalized result."""
|
||||
normalized = self.normalize_angles(angles)
|
||||
if any(not math.isfinite(value) or abs(value) > self.config.max_abs_angle for value in normalized):
|
||||
return HeadPoseEstimation(False, *normalized, reprojection_error=reprojection_error,
|
||||
failure_reason="angle_out_of_range")
|
||||
return HeadPoseEstimation(True, *normalized, reprojection_error=reprojection_error)
|
||||
|
||||
@staticmethod
|
||||
def normalize_angles(angles: tuple[float, float, float]) -> tuple[float, float, float]:
|
||||
"""Canonicalize roll for upright subjects while preserving pitch and yaw.
|
||||
|
||||
A planar face can be returned by solvePnP with its in-plane axes reversed,
|
||||
producing roll near +/-180 degrees for an upright frontal face. Roll is an
|
||||
axial orientation here, so the equivalent value in [-90, 90) is used.
|
||||
"""
|
||||
pitch, yaw, roll = angles
|
||||
if math.isfinite(roll):
|
||||
roll = (roll + 90.0) % 180.0 - 90.0
|
||||
return float(pitch), float(yaw), float(roll)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Rule-based, transparent recommendations for pilot experiment improvement."""
|
||||
from __future__ import annotations
|
||||
from .models import ConditionBalanceDiagnostic,ImprovementRecommendation
|
||||
class ImprovementRecommender:
|
||||
def recommend(self,valid_rate:float|None,not_eval:float|None,no_pose:float|None,balance:ConditionBalanceDiagnostic,prompt_valid:int,control_valid:int,camera_missing:bool=False)->list[ImprovementRecommendation]:
|
||||
values=[]
|
||||
def add(cat,pri,title,reason,action,metrics,effect):values.append(ImprovementRecommendation(f"rec_{len(values)+1:03d}",cat,pri,title,reason,action,metrics,effect))
|
||||
if no_pose is not None and no_pose>.4:add("camera_position","high","顔が映るカメラ位置へ調整","no_pose_estimated率が高い","通行人の顔が正面〜斜め前から映る位置へカメラを移動する",{"no_pose_estimated_rate":no_pose},"Pose推定可能率の改善")
|
||||
if not_eval is not None and not_eval>.4:add("face_detection","high","顔処理条件を改善","not_evaluable率が高い","カメラ位置、照明、face処理間隔、最大処理人数を確認する",{"not_evaluable_rate":not_eval},"評価可能率の改善")
|
||||
if valid_rate is not None and valid_rate<.5:add("protocol","high","有効解析率を改善","valid_voice_analysis_rateが低い","追跡・顔向き・response windowの除外理由を確認する",{"valid_voice_analysis_rate":valid_rate},"有効解析人数の増加")
|
||||
if not balance.balanced:add("condition_balance","high","Prompt/Control人数を揃える","条件間の有効人数差が大きい","同じ場所・時間帯で不足条件のセッションを追加する",{"ratio":balance.valid_count_ratio},"比較可能性の改善")
|
||||
if min(prompt_valid,control_valid)<20:add("sample_size","medium","有効解析人数を追加","目標有効人数に未達","各条件20人以上を目安に追加収集する",{"prompt":prompt_valid,"control":control_valid},"推定の安定化")
|
||||
if camera_missing:add("camera_position","high","カメラ位置を記録","camera_position_noteが欠損","位置、高さ、角度、サイネージとの関係を記録する",{},"再現性の確保")
|
||||
return values
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Non-fatal consistency checks for Phase 7 analysis records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import LogConsistencyIssue, PersonAnalysisRecord
|
||||
|
||||
|
||||
class LogConsistencyChecker:
|
||||
"""Report suspicious analysis combinations without stopping a run."""
|
||||
|
||||
def check(self, records: list[PersonAnalysisRecord]) -> list[LogConsistencyIssue]:
|
||||
issues: list[LogConsistencyIssue] = []
|
||||
seen: set[int] = set()
|
||||
for item in records:
|
||||
if item.track_id in seen: self._add(issues, "duplicate_summary", item, "Duplicate track summary")
|
||||
seen.add(item.track_id)
|
||||
if item.response_detected and not (item.prompt_played or item.pseudo_prompt):
|
||||
self._add(issues, "missing_prompt_for_response", item, "Response has no prompt or pseudo prompt")
|
||||
if item.reaction_time_sec is not None and item.reaction_time_sec < 0:
|
||||
self._add(issues, "invalid_reaction_time", item, "Reaction time is negative")
|
||||
if item.response_detected and not item.turn_confirmed:
|
||||
self._add(issues, "response_without_turn", item, "Response has no confirmed turn")
|
||||
if item.prompt_played and not item.response_window_started:
|
||||
self._add(issues, "prompt_without_response_window", item, "Prompt has no response window")
|
||||
if item.pseudo_prompt and item.voice_mode != "control":
|
||||
self._add(issues, "inconsistent_voice_mode", item, "Pseudo prompt is not control mode")
|
||||
if item.valid_for_voice_analysis and item.prompt_condition == "unknown":
|
||||
self._add(issues, "inconsistent_voice_mode", item, "Valid record has unknown condition")
|
||||
if item.excluded and item.exclusion_reason == "none":
|
||||
self._add(issues, "unknown", item, "Excluded record has no reason")
|
||||
return issues
|
||||
|
||||
@staticmethod
|
||||
def _add(items: list[LogConsistencyIssue], kind: str, record: PersonAnalysisRecord, message: str) -> None:
|
||||
items.append(LogConsistencyIssue(kind, "warning", record.track_id, None, message, {}))
|
||||
+877
@@ -0,0 +1,877 @@
|
||||
"""Domain models independent of OpenCV and Ultralytics."""
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BoundingBox:
|
||||
x1: int
|
||||
y1: int
|
||||
x2: int
|
||||
y2: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.x1 >= self.x2 or self.y1 >= self.y2:
|
||||
raise ValueError("BoundingBox requires x1 < x2 and y1 < y2")
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return self.x2 - self.x1
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return self.y2 - self.y1
|
||||
|
||||
@property
|
||||
def center_x(self) -> int:
|
||||
return (self.x1 + self.x2) // 2
|
||||
|
||||
@property
|
||||
def center_y(self) -> int:
|
||||
return (self.y1 + self.y2) // 2
|
||||
|
||||
@property
|
||||
def foot_x(self) -> int:
|
||||
return self.center_x
|
||||
|
||||
@property
|
||||
def foot_y(self) -> int:
|
||||
return self.y2
|
||||
|
||||
@property
|
||||
def area(self) -> int:
|
||||
return self.width * self.height
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Detection:
|
||||
class_id: int
|
||||
class_name: str
|
||||
confidence: float
|
||||
bbox: BoundingBox
|
||||
inside_evaluation_region: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrameInfo:
|
||||
frame_number: int
|
||||
timestamp_iso: str
|
||||
elapsed_time_sec: float
|
||||
width: int
|
||||
height: int
|
||||
measured_fps: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PixelRegion:
|
||||
x1: int
|
||||
y1: int
|
||||
x2: int
|
||||
y2: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.x1 >= self.x2 or self.y1 >= self.y2:
|
||||
raise ValueError("PixelRegion requires x1 < x2 and y1 < y2")
|
||||
|
||||
def contains(self, x: int, y: int) -> bool:
|
||||
return self.x1 <= x <= self.x2 and self.y1 <= y <= self.y2
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PixelTriggerLine:
|
||||
"""Trigger line in frame pixel coordinates."""
|
||||
|
||||
orientation: str
|
||||
position: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.orientation not in {"vertical", "horizontal"}:
|
||||
raise ValueError("orientation must be vertical or horizontal")
|
||||
if self.position < 0:
|
||||
raise ValueError("position must be non-negative")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackedPerson:
|
||||
"""Library-independent snapshot of one tracker output."""
|
||||
|
||||
track_id: int
|
||||
bbox: BoundingBox
|
||||
confidence: float
|
||||
class_id: int
|
||||
class_name: str
|
||||
center_x: float
|
||||
center_y: float
|
||||
foot_x: float
|
||||
foot_y: float
|
||||
inside_evaluation_region: bool
|
||||
is_confirmed: bool
|
||||
track_age_frames: int
|
||||
missed_frames: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.track_id < 0 or self.track_age_frames < 0 or self.missed_frames < 0:
|
||||
raise ValueError("track identifiers and frame counters must be non-negative")
|
||||
if not 0.0 <= self.confidence <= 1.0:
|
||||
raise ValueError("confidence must be in [0, 1]")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackPoint:
|
||||
timestamp_monotonic: float
|
||||
timestamp_iso: str
|
||||
frame_number: int
|
||||
center_x: float
|
||||
center_y: float
|
||||
foot_x: float
|
||||
foot_y: float
|
||||
bbox: BoundingBox
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.timestamp_monotonic < 0 or self.frame_number < 0:
|
||||
raise ValueError("track point time and frame must be non-negative")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TrackState:
|
||||
"""Research state accumulated for one temporary tracking ID."""
|
||||
|
||||
track_id: int
|
||||
first_seen_time: str
|
||||
last_seen_time: str
|
||||
first_seen_frame: int
|
||||
last_seen_frame: int
|
||||
entered_evaluation_region: bool = False
|
||||
entry_time: str | None = None
|
||||
entry_frame: int | None = None
|
||||
entry_monotonic: float | None = None
|
||||
exited_evaluation_region: bool = False
|
||||
exit_time: str | None = None
|
||||
exit_frame: int | None = None
|
||||
exit_monotonic: float | None = None
|
||||
trigger_crossed: bool = False
|
||||
trigger_crossing_time: str | None = None
|
||||
trigger_crossing_frame: int | None = None
|
||||
trigger_crossing_direction: str | None = None
|
||||
movement_direction: str = "unknown"
|
||||
total_visible_frames: int = 0
|
||||
total_missed_frames: int = 0
|
||||
max_missed_frames: int = 0
|
||||
trajectory: deque[TrackPoint] = field(default_factory=deque)
|
||||
finalized: bool = False
|
||||
is_confirmed: bool = False
|
||||
first_seen_monotonic: float = 0.0
|
||||
last_seen_monotonic: float = 0.0
|
||||
current_inside_region: bool = False
|
||||
pending_entry_frames: int = 0
|
||||
pending_exit_frames: int = 0
|
||||
valid_for_counting: bool = False
|
||||
invalid_reason: str = "not_finalized"
|
||||
|
||||
@property
|
||||
def duration_sec(self) -> float:
|
||||
return max(0.0, self.last_seen_monotonic - self.first_seen_monotonic)
|
||||
|
||||
@property
|
||||
def dwell_time_sec(self) -> float:
|
||||
if self.entry_monotonic is None:
|
||||
return 0.0
|
||||
end = self.exit_monotonic if self.exit_monotonic is not None else self.last_seen_monotonic
|
||||
return max(0.0, end - self.entry_monotonic)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackEvent:
|
||||
event_type: str
|
||||
track_id: int
|
||||
frame_number: int
|
||||
timestamp_iso: str
|
||||
elapsed_time_sec: float
|
||||
x: float
|
||||
y: float
|
||||
direction: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
valid = {"track_created", "region_entered", "trigger_crossed", "region_exited", "track_lost", "track_finalized"}
|
||||
if self.event_type not in valid:
|
||||
raise ValueError(f"Unsupported track event: {self.event_type}")
|
||||
if self.track_id < 0 or self.frame_number < 0:
|
||||
raise ValueError("event track ID and frame must be non-negative")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FaceROI:
|
||||
"""Frame-coordinate face candidate region derived from a person box."""
|
||||
|
||||
track_id: int
|
||||
x1: int
|
||||
y1: int
|
||||
x2: int
|
||||
y2: int
|
||||
source_bbox: BoundingBox
|
||||
confidence: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.track_id < 0 or self.x1 >= self.x2 or self.y1 >= self.y2:
|
||||
raise ValueError("FaceROI requires a non-negative track ID and valid coordinates")
|
||||
if not 0.0 <= self.confidence <= 1.0:
|
||||
raise ValueError("FaceROI confidence must be in [0, 1]")
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return self.x2 - self.x1
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return self.y2 - self.y1
|
||||
|
||||
@property
|
||||
def center_x(self) -> float:
|
||||
return (self.x1 + self.x2) / 2.0
|
||||
|
||||
@property
|
||||
def center_y(self) -> float:
|
||||
return (self.y1 + self.y2) / 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FaceLandmark:
|
||||
index: int
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
visibility: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.index < 0:
|
||||
raise ValueError("landmark index must be non-negative")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HeadPoseEstimation:
|
||||
"""Geometry-only result returned by HeadPoseEstimator."""
|
||||
|
||||
success: bool
|
||||
pitch: float | None = None
|
||||
yaw: float | None = None
|
||||
roll: float | None = None
|
||||
reprojection_error: float | None = None
|
||||
failure_reason: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HeadPoseResult:
|
||||
"""Head pose linked to a temporary tracking ID and frame."""
|
||||
|
||||
track_id: int
|
||||
frame_number: int
|
||||
timestamp_iso: str
|
||||
elapsed_time_sec: float
|
||||
face_detected: bool
|
||||
pose_estimated: bool
|
||||
pitch: float | None
|
||||
yaw: float | None
|
||||
roll: float | None
|
||||
landmarks_count: int
|
||||
roi: FaceROI | None
|
||||
confidence: float | None
|
||||
failure_reason: str = ""
|
||||
landmarks: tuple[FaceLandmark, ...] = field(default_factory=tuple, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.track_id < 0 or self.frame_number < 0 or self.landmarks_count < 0:
|
||||
raise ValueError("head pose identifiers and counts must be non-negative")
|
||||
if self.pose_estimated and any(value is None for value in (self.pitch, self.yaw, self.roll)):
|
||||
raise ValueError("successful pose estimation requires pitch, yaw, and roll")
|
||||
if self.confidence is not None and not 0.0 <= self.confidence <= 1.0:
|
||||
raise ValueError("face confidence must be in [0, 1]")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FaceTrackState:
|
||||
"""Per-track face statistics and Phase 4-ready recent history."""
|
||||
|
||||
track_id: int
|
||||
total_frames: int = 0
|
||||
face_detected_frames: int = 0
|
||||
pose_estimated_frames: int = 0
|
||||
last_pitch: float | None = None
|
||||
last_yaw: float | None = None
|
||||
last_roll: float | None = None
|
||||
min_yaw: float | None = None
|
||||
max_yaw: float | None = None
|
||||
yaw_sum: float = 0.0
|
||||
last_result: HeadPoseResult | None = None
|
||||
history: deque[HeadPoseResult] = field(default_factory=deque, repr=False)
|
||||
finalized: bool = False
|
||||
|
||||
@property
|
||||
def mean_yaw(self) -> float | None:
|
||||
return self.yaw_sum / self.pose_estimated_frames if self.pose_estimated_frames else None
|
||||
|
||||
@property
|
||||
def face_detection_rate(self) -> float:
|
||||
return self.face_detected_frames / self.total_frames if self.total_frames else 0.0
|
||||
|
||||
@property
|
||||
def pose_estimation_rate(self) -> float:
|
||||
return self.pose_estimated_frames / self.total_frames if self.total_frames else 0.0
|
||||
|
||||
|
||||
class TurnLevel(str, Enum):
|
||||
NONE = "none"
|
||||
FACE_APPEARED = "face_appeared"
|
||||
SUBTLE = "subtle"
|
||||
WEAK = "weak"
|
||||
MEDIUM = "medium"
|
||||
STRONG = "strong"
|
||||
NOT_EVALUABLE = "not_evaluable"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TurnFrameResult:
|
||||
track_id: int
|
||||
frame_number: int
|
||||
timestamp_iso: str
|
||||
elapsed_time_sec: float
|
||||
face_detected: bool
|
||||
pose_estimated: bool
|
||||
baseline_available: bool
|
||||
baseline_yaw: float | None
|
||||
current_yaw: float | None
|
||||
yaw_delta_raw: float | None
|
||||
yaw_delta_toward_signage: float | None
|
||||
signage_direction: str
|
||||
turn_candidate: bool
|
||||
turn_confirmed: bool
|
||||
turn_level: str
|
||||
continuous_duration_sec: float
|
||||
failure_reason: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.track_id < 0 or self.frame_number < 0 or self.continuous_duration_sec < 0:
|
||||
raise ValueError("turn frame identifiers and duration must be non-negative")
|
||||
if self.signage_direction not in {"positive", "negative"}:
|
||||
raise ValueError("invalid signage direction")
|
||||
if self.turn_level not in {level.value for level in TurnLevel}:
|
||||
raise ValueError("invalid turn level")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TurnTrackState:
|
||||
track_id: int
|
||||
total_frames: int = 0
|
||||
pose_frames: int = 0
|
||||
face_detected_frames: int = 0
|
||||
baseline_yaw: float | None = None
|
||||
baseline_frame: int | None = None
|
||||
baseline_time: str | None = None
|
||||
baseline_sample_count: int = 0
|
||||
baseline_acquired: bool = False
|
||||
first_face_detected_frame: int | None = None
|
||||
first_face_detected_time: str | None = None
|
||||
face_appeared_after_missing: bool = False
|
||||
max_yaw_delta_toward_signage: float | None = None
|
||||
max_yaw_delta_frame: int | None = None
|
||||
max_yaw_delta_time: str | None = None
|
||||
turn_started: bool = False
|
||||
turn_start_frame: int | None = None
|
||||
turn_start_time: str | None = None
|
||||
turn_confirmed: bool = False
|
||||
turn_confirmed_frame: int | None = None
|
||||
turn_confirmed_time: str | None = None
|
||||
turn_level: str = TurnLevel.NOT_EVALUABLE.value
|
||||
turn_duration_sec: float = 0.0
|
||||
evaluable: bool = False
|
||||
valid_for_turn_analysis: bool = False
|
||||
exclusion_reason: str = "not_finalized"
|
||||
finalized: bool = False
|
||||
yaw_history: deque[tuple[float, float]] = field(default_factory=deque, repr=False)
|
||||
baseline_samples: deque[tuple[float, float, int, str]] = field(default_factory=deque, repr=False)
|
||||
candidate_start_elapsed: float | None = None
|
||||
last_pose_elapsed: float | None = None
|
||||
missing_started_elapsed: float | None = None
|
||||
strongest_level: str = TurnLevel.NONE.value
|
||||
emitted_events: set[str] = field(default_factory=set, repr=False)
|
||||
|
||||
@property
|
||||
def face_detection_rate(self) -> float:
|
||||
return self.face_detected_frames / self.total_frames if self.total_frames else 0.0
|
||||
|
||||
@property
|
||||
def pose_estimation_rate(self) -> float:
|
||||
return self.pose_frames / self.total_frames if self.total_frames else 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TurnEvent:
|
||||
event_type: str
|
||||
track_id: int
|
||||
frame_number: int
|
||||
timestamp_iso: str
|
||||
elapsed_time_sec: float
|
||||
yaw: float | None
|
||||
yaw_delta_toward_signage: float | None
|
||||
turn_level: str
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
valid = {"baseline_acquired", "face_appeared", "turn_candidate_started", "turn_confirmed",
|
||||
"turn_level_changed", "turn_lost", "turn_track_finalized"}
|
||||
if self.event_type not in valid or self.track_id < 0 or self.frame_number < 0:
|
||||
raise ValueError("invalid turn event")
|
||||
|
||||
|
||||
class VoicePromptMode(str, Enum):
|
||||
DISABLED = "disabled"
|
||||
PROMPT = "prompt"
|
||||
CONTROL = "control"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VoicePromptDecision:
|
||||
track_id: int
|
||||
frame_number: int
|
||||
timestamp_iso: str
|
||||
elapsed_time_sec: float
|
||||
mode: str
|
||||
eligible: bool
|
||||
should_prompt: bool
|
||||
prompt_played: bool
|
||||
pseudo_prompt: bool
|
||||
reason: str
|
||||
audio_file: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VoicePromptEvent:
|
||||
event_type: str
|
||||
track_id: int
|
||||
frame_number: int
|
||||
timestamp_iso: str
|
||||
elapsed_time_sec: float
|
||||
mode: str
|
||||
prompt_played: bool = False
|
||||
pseudo_prompt: bool = False
|
||||
audio_file: str | None = None
|
||||
turn_confirmed: bool = False
|
||||
turn_level: str | None = None
|
||||
reaction_time_sec: float | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VoicePromptTrackState:
|
||||
track_id: int
|
||||
mode: str
|
||||
eligible: bool = False
|
||||
eligibility_frame: int | None = None
|
||||
eligibility_time: str | None = None
|
||||
prompt_attempted: bool = False
|
||||
prompt_played: bool = False
|
||||
prompt_failed: bool = False
|
||||
pseudo_prompt: bool = False
|
||||
prompt_frame: int | None = None
|
||||
prompt_time: str | None = None
|
||||
prompt_elapsed_time_sec: float | None = None
|
||||
audio_file: str | None = None
|
||||
response_window_started: bool = False
|
||||
response_window_start_frame: int | None = None
|
||||
response_window_start_time: str | None = None
|
||||
response_window_end_time_sec: float | None = None
|
||||
response_detected: bool = False
|
||||
response_frame: int | None = None
|
||||
response_time: str | None = None
|
||||
reaction_time_sec: float | None = None
|
||||
response_turn_level: str | None = None
|
||||
max_yaw_delta_after_prompt: float | None = None
|
||||
final_turn_level: str | None = None
|
||||
valid_for_voice_analysis: bool = False
|
||||
exclusion_reason: str = "not_finalized"
|
||||
finalized: bool = False
|
||||
response_window_expired: bool = False
|
||||
|
||||
|
||||
class PersonExperimentStatus(str, Enum):
|
||||
NEW = "new"
|
||||
TRACKED = "tracked"
|
||||
INSIDE_REGION = "inside_region"
|
||||
ELIGIBLE = "eligible"
|
||||
PROMPTED = "prompted"
|
||||
PSEUDO_PROMPTED = "pseudo_prompted"
|
||||
OBSERVING_RESPONSE = "observing_response"
|
||||
RESPONDED = "responded"
|
||||
NO_RESPONSE = "no_response"
|
||||
COMPLETED = "completed"
|
||||
EXCLUDED = "excluded"
|
||||
NOT_EVALUABLE = "not_evaluable"
|
||||
LOST = "lost"
|
||||
|
||||
|
||||
class PersonExclusionReason(str, Enum):
|
||||
NONE = "none"
|
||||
TRACKER_DISABLED = "tracker_disabled"
|
||||
VOICE_DISABLED = "voice_disabled"
|
||||
TURN_DISABLED = "turn_disabled"
|
||||
NO_POSE_ESTIMATED = "no_pose_estimated"
|
||||
NO_FACE_DETECTED = "no_face_detected"
|
||||
BASELINE_NOT_ACQUIRED = "baseline_not_acquired"
|
||||
INSUFFICIENT_POSE_SAMPLES = "insufficient_pose_samples"
|
||||
LOW_FACE_DETECTION_RATE = "low_face_detection_rate"
|
||||
LOW_POSE_ESTIMATION_RATE = "low_pose_estimation_rate"
|
||||
NOT_PROMPT_ELIGIBLE = "not_prompt_eligible"
|
||||
ALREADY_TURNED_BEFORE_PROMPT = "already_turned_before_prompt"
|
||||
PROMPT_FAILED = "prompt_failed"
|
||||
AUDIO_MISSING = "audio_missing"
|
||||
RESPONSE_NOT_EVALUABLE = "response_not_evaluable"
|
||||
TRACK_TOO_SHORT = "track_too_short"
|
||||
ID_SWITCH_SUSPECTED = "id_switch_suspected"
|
||||
TRACK_LOST_BEFORE_PROMPT = "track_lost_before_prompt"
|
||||
TRACK_LOST_DURING_RESPONSE_WINDOW = "track_lost_during_response_window"
|
||||
OUTSIDE_REGION = "outside_region"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonStateSnapshot:
|
||||
track_id: int
|
||||
frame_number: int
|
||||
timestamp_iso: str
|
||||
elapsed_time_sec: float
|
||||
status: str
|
||||
previous_status: str | None = None
|
||||
inside_region: bool = False
|
||||
track_confirmed: bool = False
|
||||
track_lost: bool = False
|
||||
trigger_crossed: bool = False
|
||||
movement_direction: str | None = None
|
||||
face_detected: bool | None = None
|
||||
pose_estimated: bool | None = None
|
||||
turn_level: str | None = None
|
||||
turn_confirmed: bool = False
|
||||
voice_mode: str | None = None
|
||||
voice_eligible: bool = False
|
||||
prompt_played: bool = False
|
||||
pseudo_prompt: bool = False
|
||||
response_window_started: bool = False
|
||||
response_detected: bool = False
|
||||
reaction_time_sec: float | None = None
|
||||
valid_for_turn_analysis: bool | None = None
|
||||
valid_for_voice_analysis: bool | None = None
|
||||
exclusion_reason: str = PersonExclusionReason.NONE.value
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonStateEvent:
|
||||
event_type: str
|
||||
track_id: int
|
||||
frame_number: int
|
||||
timestamp_iso: str
|
||||
elapsed_time_sec: float
|
||||
previous_status: str | None
|
||||
new_status: str
|
||||
reason: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonStateSummary:
|
||||
experiment_id: str
|
||||
track_id: int
|
||||
first_frame: int | None = None
|
||||
last_frame: int | None = None
|
||||
first_seen_time: str | None = None
|
||||
last_seen_time: str | None = None
|
||||
duration_sec: float | None = None
|
||||
final_status: str = PersonExperimentStatus.COMPLETED.value
|
||||
completed: bool = False
|
||||
excluded: bool = False
|
||||
exclusion_reason: str = PersonExclusionReason.NONE.value
|
||||
inside_region_ever: bool = False
|
||||
trigger_crossed: bool = False
|
||||
trigger_crossing_direction: str | None = None
|
||||
movement_direction: str | None = None
|
||||
face_detected_ever: bool = False
|
||||
pose_estimated_ever: bool = False
|
||||
face_detection_rate: float | None = None
|
||||
pose_estimation_rate: float | None = None
|
||||
baseline_acquired: bool | None = None
|
||||
turn_confirmed: bool = False
|
||||
turn_level: str | None = None
|
||||
max_yaw_delta_toward_signage: float | None = None
|
||||
voice_mode: str | None = None
|
||||
voice_eligible: bool = False
|
||||
prompt_played: bool = False
|
||||
pseudo_prompt: bool = False
|
||||
prompt_time: str | None = None
|
||||
prompt_elapsed_time_sec: float | None = None
|
||||
response_window_started: bool = False
|
||||
response_detected: bool = False
|
||||
reaction_time_sec: float | None = None
|
||||
response_turn_level: str | None = None
|
||||
valid_for_turn_analysis: bool | None = None
|
||||
valid_for_voice_analysis: bool | None = None
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnifiedEvent:
|
||||
experiment_id: str
|
||||
session_id: str
|
||||
event_id: str
|
||||
source: str
|
||||
event_type: str
|
||||
track_id: int | None
|
||||
frame_number: int
|
||||
timestamp_iso: str
|
||||
elapsed_time_sec: float
|
||||
person_status: str | None = None
|
||||
voice_mode: str | None = None
|
||||
prompt_played: bool | None = None
|
||||
pseudo_prompt: bool | None = None
|
||||
turn_confirmed: bool | None = None
|
||||
turn_level: str | None = None
|
||||
response_detected: bool | None = None
|
||||
reaction_time_sec: float | None = None
|
||||
exclusion_reason: str | None = None
|
||||
severity: str = "info"
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.source not in {"tracking", "face", "turn", "voice", "person_state", "session", "system"}:
|
||||
raise ValueError("invalid unified event source")
|
||||
if self.severity not in {"info", "warning", "error"}:
|
||||
raise ValueError("invalid unified event severity")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonAnalysisRecord:
|
||||
experiment_id: str
|
||||
session_id: str
|
||||
track_id: int
|
||||
voice_mode: str | None = None
|
||||
prompt_condition: str = "unknown"
|
||||
prompt_played: bool = False
|
||||
pseudo_prompt: bool = False
|
||||
prompt_time: str | None = None
|
||||
prompt_elapsed_time_sec: float | None = None
|
||||
response_window_started: bool = False
|
||||
response_detected: bool = False
|
||||
reaction_time_sec: float | None = None
|
||||
response_turn_level: str | None = None
|
||||
turn_confirmed: bool = False
|
||||
turn_level: str | None = None
|
||||
max_yaw_delta_toward_signage: float | None = None
|
||||
face_detected_ever: bool = False
|
||||
pose_estimated_ever: bool = False
|
||||
face_detection_rate: float | None = None
|
||||
pose_estimation_rate: float | None = None
|
||||
valid_for_turn_analysis: bool | None = None
|
||||
valid_for_voice_analysis: bool | None = None
|
||||
final_status: str = "completed"
|
||||
completed: bool = False
|
||||
excluded: bool = False
|
||||
exclusion_reason: str = "none"
|
||||
track_duration_sec: float | None = None
|
||||
trigger_crossed: bool = False
|
||||
trigger_crossing_direction: str | None = None
|
||||
movement_direction: str | None = None
|
||||
camera_position_note: str | None = None
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionSummary:
|
||||
experiment_id: str
|
||||
session_id: str
|
||||
started_at: str
|
||||
ended_at: str | None = None
|
||||
duration_sec: float | None = None
|
||||
voice_mode: str | None = None
|
||||
total_tracks: int = 0
|
||||
completed_tracks: int = 0
|
||||
excluded_tracks: int = 0
|
||||
evaluable_turn_tracks: int = 0
|
||||
valid_voice_analysis_tracks: int = 0
|
||||
prompt_played_count: int = 0
|
||||
pseudo_prompt_count: int = 0
|
||||
response_detected_count: int = 0
|
||||
no_response_count: int = 0
|
||||
not_evaluable_count: int = 0
|
||||
response_rate: float | None = None
|
||||
mean_reaction_time_sec: float | None = None
|
||||
median_reaction_time_sec: float | None = None
|
||||
weak_or_higher_count: int = 0
|
||||
subtle_count: int = 0
|
||||
medium_or_higher_count: int = 0
|
||||
strong_count: int = 0
|
||||
average_fps: float | None = None
|
||||
camera_position_note: str | None = None
|
||||
warnings_count: int = 0
|
||||
errors_count: int = 0
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LogConsistencyIssue:
|
||||
issue_type: str
|
||||
severity: str
|
||||
track_id: int | None
|
||||
frame_number: int | None
|
||||
message: str
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AnalysisConditionSummary:
|
||||
condition: str; total_records: int; valid_voice_analysis_count: int; response_detected_count: int
|
||||
no_response_count: int; response_rate: float | None; excluded_count: int; not_evaluable_count: int
|
||||
mean_reaction_time_sec: float | None; median_reaction_time_sec: float | None; std_reaction_time_sec: float | None
|
||||
mean_max_yaw_delta: float | None; median_max_yaw_delta: float | None; std_max_yaw_delta: float | None
|
||||
subtle_count: int; weak_count: int; medium_count: int; strong_count: int
|
||||
weak_or_higher_count: int; medium_or_higher_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResponseRateTestResult:
|
||||
test_name: str; prompt_success: int; prompt_total: int; control_success: int; control_total: int
|
||||
prompt_rate: float | None; control_rate: float | None; rate_difference: float | None
|
||||
statistic: float | None; p_value: float | None; effect_size_name: str; effect_size: float | None
|
||||
odds_ratio: float | None; method_note: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NumericComparisonResult:
|
||||
metric_name: str; test_name: str; prompt_count: int; control_count: int
|
||||
prompt_mean: float | None; control_mean: float | None; prompt_median: float | None; control_median: float | None
|
||||
mean_difference: float | None; statistic: float | None; p_value: float | None
|
||||
effect_size_name: str; effect_size: float | None; method_note: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TurnLevelDistributionResult:
|
||||
condition: str; none_count: int; face_appeared_count: int; subtle_count: int; weak_count: int
|
||||
medium_count: int; strong_count: int; not_evaluable_count: int; total_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExclusionSummary:
|
||||
condition: str; exclusion_reason: str; count: int; ratio: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AnalysisRunSummary:
|
||||
analysis_id: str; input_files_count: int; total_records: int; prompt_records: int; control_records: int
|
||||
valid_prompt_records: int; valid_control_records: int; generated_at: str; output_directory: str; notes: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AnalysisResult:
|
||||
condition_summaries: list[AnalysisConditionSummary]
|
||||
response_rate_test: ResponseRateTestResult
|
||||
numeric_comparisons: list[NumericComparisonResult]
|
||||
turn_level_distributions: list[TurnLevelDistributionResult]
|
||||
exclusion_summaries: list[ExclusionSummary]
|
||||
run_summary: AnalysisRunSummary
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeMetrics:
|
||||
"""Small, GUI-safe snapshot of the current experiment state."""
|
||||
|
||||
frame_number: int = 0
|
||||
elapsed_time_sec: float = 0.0
|
||||
fps: float | None = None
|
||||
active_tracks: int = 0
|
||||
total_tracks: int = 0
|
||||
region_visitors: int = 0
|
||||
trigger_crossings: int = 0
|
||||
face_detected_tracks: int = 0
|
||||
turn_confirmed_count: int = 0
|
||||
voice_mode: str | None = None
|
||||
prompt_played_count: int = 0
|
||||
pseudo_prompt_count: int = 0
|
||||
response_detected_count: int = 0
|
||||
person_state_counts: dict[str, int] = field(default_factory=dict)
|
||||
current_log_directory: str | None = None
|
||||
latest_message: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExperimentProtocol:
|
||||
protocol_id: str; title: str; version: str; created_at: str; purpose: str
|
||||
target_location: str; signage_description: str; camera_position_description: str
|
||||
camera_height_cm: float | None; camera_angle_description: str; walking_direction_description: str
|
||||
lighting_condition: str; voice_prompt_text: str; voice_file_path: str; voice_volume: float
|
||||
trigger_strategy: str; trigger_line_description: str; response_window_sec: float
|
||||
turn_weak_threshold_deg: float; face_process_every_n_frames: int
|
||||
prompt_condition_rule: str; control_condition_rule: str; privacy_note: str; notes: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExperimentChecklistItem:
|
||||
item_id: str; category: str; description: str; required: bool = True; checked: bool = False; note: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExperimentSessionNote:
|
||||
session_note_id: str; session_id: str | None; experiment_id: str; date: str; condition: str
|
||||
operator: str; location: str; camera_position_note: str; camera_height_cm: float | None
|
||||
camera_angle_note: str; signage_position_note: str; walking_direction_note: str; lighting_note: str
|
||||
audio_file: str | None; voice_prompt_text: str; voice_volume: float | None; config_path: str
|
||||
started_at: str | None; ended_at: str | None; planned_duration_min: float | None
|
||||
actual_duration_sec: float | None; issues: str; notes: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LogQualityIssue:
|
||||
issue_type: str; severity: str; file_path: str | None; session_id: str | None
|
||||
track_id: int | None; message: str; suggested_action: str; metadata: dict[str, Any] = field(default_factory=dict)
|
||||
def __post_init__(self) -> None:
|
||||
if self.severity not in {"info", "warning", "error"}: raise ValueError("invalid quality issue severity")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PilotSessionReport:
|
||||
report_id: str; generated_at: str; input_directory: str; output_directory: str
|
||||
total_sessions: int; prompt_sessions: int; control_sessions: int; total_tracks: int
|
||||
valid_voice_analysis_tracks: int; valid_voice_analysis_rate: float | None
|
||||
response_rate_prompt: float | None; response_rate_control: float | None; rate_difference: float | None
|
||||
mean_reaction_time_prompt: float | None; mean_reaction_time_control: float | None
|
||||
high_level_findings: str; quality_notes: str; recommended_next_actions: str
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PilotDiagnosticMetric:
|
||||
metric_name:str;value:float|int|str|None;threshold:float|int|str|None;passed:bool|None;severity:str;message:str;suggested_action:str
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConditionBalanceDiagnostic:
|
||||
prompt_total_records:int;control_total_records:int;prompt_valid_records:int;control_valid_records:int
|
||||
prompt_response_rate:float|None;control_response_rate:float|None;valid_count_difference:int
|
||||
valid_count_ratio:float|None;balanced:bool;message:str;suggested_action:str
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionQualitySummary:
|
||||
session_id:str;condition:str;total_tracks:int;valid_voice_analysis_tracks:int;valid_voice_analysis_rate:float|None
|
||||
response_detected_count:int;response_rate:float|None;not_evaluable_count:int;not_evaluable_rate:float|None
|
||||
no_pose_estimated_count:int;no_pose_estimated_rate:float|None;mean_reaction_time_sec:float|None
|
||||
median_reaction_time_sec:float|None;camera_position_note:str|None;quality_level:str;main_issue:str;suggested_action:str
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ImprovementRecommendation:
|
||||
recommendation_id:str;category:str;priority:str;title:str;reason:str;suggested_action:str
|
||||
related_metrics:dict[str,Any]=field(default_factory=dict);expected_effect:str=""
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PilotDiagnosticReport:
|
||||
report_id:str;generated_at:str;input_analysis_dir:str;input_logs_dir:str|None;overall_status:str
|
||||
ready_for_main_experiment:bool;total_records:int;prompt_valid_records:int;control_valid_records:int
|
||||
prompt_response_rate:float|None;control_response_rate:float|None;valid_voice_analysis_rate:float|None
|
||||
not_evaluable_rate:float|None;no_pose_estimated_rate:float|None;condition_balance:ConditionBalanceDiagnostic
|
||||
session_summaries:list[SessionQualitySummary];metrics:list[PilotDiagnosticMetric]
|
||||
recommendations:list[ImprovementRecommendation];notes:str
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalibrationChangeRecord:
|
||||
change_id:str;created_at:str;operator:str;category:str;before_value:str;after_value:str;reason:str
|
||||
related_report_id:str|None=None;notes:str=""
|
||||
@@ -0,0 +1,96 @@
|
||||
"""CSV persistence for integrated Phase 6 person state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
|
||||
from .config_loader import PersonStateLoggingSettings
|
||||
from .exceptions import PersonStateLogError
|
||||
from .models import PersonStateEvent, PersonStateSnapshot, PersonStateSummary
|
||||
|
||||
|
||||
class PersonStateLogger:
|
||||
"""Write snapshots, transitions, and deduplicated person summaries."""
|
||||
|
||||
FRAME_FIELDS = ["experiment_id", "frame_number", "timestamp", "elapsed_time_sec", "track_id", "status",
|
||||
"previous_status", "inside_region", "track_confirmed", "track_lost", "trigger_crossed",
|
||||
"movement_direction", "face_detected", "pose_estimated", "turn_level", "turn_confirmed",
|
||||
"voice_mode", "voice_eligible", "prompt_played", "pseudo_prompt", "response_window_started",
|
||||
"response_detected", "reaction_time_sec", "valid_for_turn_analysis", "valid_for_voice_analysis",
|
||||
"exclusion_reason", "metadata_json"]
|
||||
EVENT_FIELDS = ["experiment_id", "event_type", "track_id", "frame_number", "timestamp", "elapsed_time_sec",
|
||||
"previous_status", "new_status", "reason", "metadata_json"]
|
||||
SUMMARY_FIELDS = ["experiment_id", "track_id", "first_frame", "last_frame", "first_seen_time", "last_seen_time",
|
||||
"duration_sec", "final_status", "completed", "excluded", "exclusion_reason", "inside_region_ever",
|
||||
"trigger_crossed", "trigger_crossing_direction", "movement_direction", "face_detected_ever",
|
||||
"pose_estimated_ever", "face_detection_rate", "pose_estimation_rate", "baseline_acquired",
|
||||
"turn_confirmed", "turn_level", "max_yaw_delta_toward_signage", "voice_mode", "voice_eligible",
|
||||
"prompt_played", "pseudo_prompt", "prompt_time", "prompt_elapsed_time_sec",
|
||||
"response_window_started", "response_detected", "reaction_time_sec", "response_turn_level",
|
||||
"valid_for_turn_analysis", "valid_for_voice_analysis", "notes"]
|
||||
|
||||
def __init__(self, config: PersonStateLoggingSettings, 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 = 0
|
||||
|
||||
def open(self) -> None:
|
||||
if not self.config.enabled: return
|
||||
self.directory.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
self._open("frames", "person_state_frames", self.FRAME_FIELDS, self.config.log_frame_states)
|
||||
self._open("events", "person_state_events", self.EVENT_FIELDS, self.config.log_events)
|
||||
self._open("summary", "person_state_summary", self.SUMMARY_FIELDS, self.config.log_summaries)
|
||||
except OSError as exc:
|
||||
self.close(); raise PersonStateLogError(f"Failed to open person-state 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_snapshots(self, items: list[PersonStateSnapshot]) -> None:
|
||||
writer = self._writers.get("frames")
|
||||
if writer:
|
||||
for item in items:
|
||||
row = {field: getattr(item, field, "") for field in self.FRAME_FIELDS
|
||||
if field not in {"experiment_id", "timestamp", "metadata_json"}}
|
||||
row.update(experiment_id=self.experiment_id, timestamp=item.timestamp_iso,
|
||||
metadata_json=json.dumps(item.metadata, ensure_ascii=False))
|
||||
writer.writerow(row)
|
||||
self._frames += 1
|
||||
if self._frames >= self.config.flush_interval_frames: self.flush()
|
||||
|
||||
def write_events(self, items: list[PersonStateEvent]) -> None:
|
||||
writer = self._writers.get("events")
|
||||
if not writer: return
|
||||
for item in items:
|
||||
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": item.elapsed_time_sec, "previous_status": item.previous_status or "",
|
||||
"new_status": item.new_status, "reason": item.reason,
|
||||
"metadata_json": json.dumps(item.metadata, ensure_ascii=False)})
|
||||
|
||||
def write_summary(self, item: PersonStateSummary) -> None:
|
||||
writer = self._writers.get("summary")
|
||||
if not writer or item.track_id in self._summary_ids: return
|
||||
writer.writerow({field: getattr(item, field) for field in self.SUMMARY_FIELDS})
|
||||
self._summary_ids.add(item.track_id)
|
||||
|
||||
def flush(self) -> None:
|
||||
for stream in self._files.values(): stream.flush()
|
||||
self._frames = 0
|
||||
|
||||
def close(self) -> None:
|
||||
for stream in self._files.values(): stream.close()
|
||||
self._files.clear(); self._writers.clear()
|
||||
|
||||
def __enter__(self) -> "PersonStateLogger": self.open(); return self
|
||||
def __exit__(self, *_args: object) -> None: self.close()
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Integration layer combining Track, Face, Turn, and Voice by track ID."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .config_loader import PersonStateSettings
|
||||
from .models import (FrameInfo, HeadPoseResult, PersonExperimentStatus, PersonExclusionReason,
|
||||
PersonStateEvent, PersonStateSnapshot, PersonStateSummary, TrackEvent, TrackedPerson,
|
||||
TurnFrameResult, TurnTrackState, VoicePromptDecision, VoicePromptEvent,
|
||||
VoicePromptTrackState)
|
||||
from .person_state_rules import choose_status, final_exclusion_reason
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Record:
|
||||
track_id: int
|
||||
status: str = PersonExperimentStatus.NEW.value
|
||||
first_frame: int | None = None
|
||||
last_frame: int | None = None
|
||||
first_time: str | None = None
|
||||
last_time: str | None = None
|
||||
first_elapsed: float | None = None
|
||||
last_elapsed: float | None = None
|
||||
inside: bool = False
|
||||
inside_ever: bool = False
|
||||
confirmed: bool = False
|
||||
lost: bool = False
|
||||
trigger_crossed: bool = False
|
||||
trigger_direction: str | None = None
|
||||
movement_direction: str | None = None
|
||||
face_detected: bool | None = None
|
||||
pose_estimated: bool | None = None
|
||||
face_ever: bool = False
|
||||
pose_ever: bool = False
|
||||
turn_level: str | None = None
|
||||
turn_confirmed: bool = False
|
||||
turn_valid: bool | None = None
|
||||
turn_reason: str | None = None
|
||||
baseline_acquired: bool | None = None
|
||||
max_delta: float | None = None
|
||||
voice_mode: str | None = None
|
||||
eligible: bool = False
|
||||
prompt_played: bool = False
|
||||
pseudo_prompt: bool = False
|
||||
prompt_time: str | None = None
|
||||
prompt_elapsed: float | None = None
|
||||
observing: bool = False
|
||||
window_started: bool = False
|
||||
response: bool = False
|
||||
no_response: bool = False
|
||||
reaction: float | None = None
|
||||
response_level: str | None = None
|
||||
voice_valid: bool | None = None
|
||||
voice_reason: str | None = None
|
||||
exclusion: str = PersonExclusionReason.NONE.value
|
||||
finalized: bool = False
|
||||
emitted: set[tuple[str, str]] = field(default_factory=set)
|
||||
|
||||
|
||||
class PersonStateManager:
|
||||
"""Build research-facing person state without replacing source managers."""
|
||||
|
||||
def __init__(
|
||||
self, config: PersonStateSettings, experiment_id: str = "",
|
||||
turn_available: bool = True, voice_available: bool = True,
|
||||
) -> None:
|
||||
self.config, self.experiment_id = config, experiment_id
|
||||
self.turn_available, self.voice_available = turn_available, voice_available
|
||||
self._records: dict[int, _Record] = {}
|
||||
self._snapshots: dict[int, PersonStateSnapshot] = {}
|
||||
self._summaries: dict[int, PersonStateSummary] = {}
|
||||
|
||||
def update(
|
||||
self, tracked_persons: list[TrackedPerson], track_events: list[TrackEvent],
|
||||
face_results: list[HeadPoseResult], turn_results: list[TurnFrameResult],
|
||||
turn_states: dict[int, TurnTrackState] | None, voice_decisions: list[VoicePromptDecision],
|
||||
voice_events: list[VoicePromptEvent], voice_states: dict[int, VoicePromptTrackState] | None,
|
||||
frame_info: FrameInfo,
|
||||
) -> tuple[list[PersonStateSnapshot], list[PersonStateEvent]]:
|
||||
if not self.config.enabled:
|
||||
return [], []
|
||||
people = {item.track_id: item for item in tracked_persons}
|
||||
faces = {item.track_id: item for item in face_results}
|
||||
turns = {item.track_id: item for item in turn_results}
|
||||
decisions = {item.track_id: item for item in voice_decisions}
|
||||
ids = set(people) | {e.track_id for e in track_events} | set(faces) | set(turns) | set(decisions)
|
||||
ids |= {e.track_id for e in voice_events}
|
||||
snapshots: list[PersonStateSnapshot] = []
|
||||
events: list[PersonStateEvent] = []
|
||||
for track_id in sorted(ids):
|
||||
record = self._record(track_id, frame_info, events)
|
||||
previous = record.status
|
||||
self._apply_track(record, people.get(track_id), [e for e in track_events if e.track_id == track_id])
|
||||
self._apply_face(record, faces.get(track_id))
|
||||
self._apply_turn(record, turns.get(track_id), (turn_states or {}).get(track_id))
|
||||
self._apply_voice(record, decisions.get(track_id), [e for e in voice_events if e.track_id == track_id],
|
||||
(voice_states or {}).get(track_id))
|
||||
record.last_frame, record.last_time, record.last_elapsed = frame_info.frame_number, frame_info.timestamp_iso, frame_info.elapsed_time_sec
|
||||
record.status = choose_status(
|
||||
lost=record.lost, responded=record.response, no_response=record.no_response,
|
||||
observing=record.observing and not record.no_response, prompt_played=record.prompt_played,
|
||||
pseudo_prompt=record.pseudo_prompt, eligible=record.eligible, inside_region=record.inside,
|
||||
tracked=record.confirmed, not_evaluable=record.turn_valid is False,
|
||||
)
|
||||
if record.status != previous:
|
||||
events.append(self._event("status_changed", record, frame_info, previous, record.status, "state_priority"))
|
||||
event_name = {
|
||||
"tracked": "person_tracked", "inside_region": "entered_region", "eligible": "became_eligible",
|
||||
"prompted": "prompt_played", "pseudo_prompted": "pseudo_prompted",
|
||||
"observing_response": "response_observation_started", "responded": "responded",
|
||||
"no_response": "no_response", "not_evaluable": "became_not_evaluable",
|
||||
"excluded": "excluded", "completed": "completed", "lost": "lost",
|
||||
}.get(record.status)
|
||||
signature = (event_name or "", "")
|
||||
if event_name and signature not in record.emitted:
|
||||
events.append(self._event(event_name, record, frame_info, previous, record.status, "source_state"))
|
||||
record.emitted.add(signature)
|
||||
source_kinds = {
|
||||
"region_entered": "entered_region", "region_exited": "exited_region", "track_lost": "lost",
|
||||
"prompt_eligible": "became_eligible", "prompt_played": "prompt_played",
|
||||
"pseudo_prompt_triggered": "pseudo_prompted",
|
||||
"response_window_started": "response_observation_started",
|
||||
"response_detected": "responded", "response_window_expired": "no_response",
|
||||
}
|
||||
source_events: list[tuple[str, str]] = []
|
||||
source_events.extend((event.event_type, "track_event") for event in track_events if event.track_id == track_id)
|
||||
source_events.extend((event.event_type, "voice_event") for event in voice_events if event.track_id == track_id)
|
||||
for source, reason in source_events:
|
||||
kind = source_kinds.get(source)
|
||||
signature = (kind or "", "")
|
||||
if kind and signature not in record.emitted:
|
||||
events.append(self._event(kind, record, frame_info, previous, record.status, reason))
|
||||
record.emitted.add(signature)
|
||||
snapshot = self._snapshot(record, frame_info, previous)
|
||||
self._snapshots[track_id] = snapshot
|
||||
snapshots.append(snapshot)
|
||||
return snapshots, events
|
||||
|
||||
def _record(self, track_id: int, info: FrameInfo, events: list[PersonStateEvent]) -> _Record:
|
||||
record = self._records.get(track_id)
|
||||
if record is None:
|
||||
record = _Record(track_id, first_frame=info.frame_number, first_time=info.timestamp_iso,
|
||||
first_elapsed=info.elapsed_time_sec)
|
||||
self._records[track_id] = record
|
||||
events.append(self._event("person_created", record, info, None, record.status, "new_track"))
|
||||
return record
|
||||
|
||||
@staticmethod
|
||||
def _apply_track(record: _Record, person: TrackedPerson | None, events: list[TrackEvent]) -> None:
|
||||
if person is not None:
|
||||
record.confirmed = person.is_confirmed
|
||||
record.inside = person.inside_evaluation_region
|
||||
record.inside_ever |= record.inside
|
||||
for event in events:
|
||||
if event.event_type == "region_entered":
|
||||
record.inside = record.inside_ever = True
|
||||
elif event.event_type == "region_exited":
|
||||
record.inside = False
|
||||
elif event.event_type == "trigger_crossed":
|
||||
record.trigger_crossed, record.trigger_direction = True, event.direction
|
||||
elif event.event_type == "track_lost":
|
||||
record.lost = True
|
||||
record.movement_direction = event.direction or record.movement_direction
|
||||
|
||||
@staticmethod
|
||||
def _apply_face(record: _Record, face: HeadPoseResult | None) -> None:
|
||||
if face is not None:
|
||||
record.face_detected, record.pose_estimated = face.face_detected, face.pose_estimated
|
||||
record.face_ever |= face.face_detected
|
||||
record.pose_ever |= face.pose_estimated
|
||||
|
||||
@staticmethod
|
||||
def _apply_turn(record: _Record, result: TurnFrameResult | None, state: TurnTrackState | None) -> None:
|
||||
if result is not None:
|
||||
record.turn_level, record.turn_confirmed = result.turn_level, result.turn_confirmed
|
||||
if result.yaw_delta_toward_signage is not None:
|
||||
record.max_delta = result.yaw_delta_toward_signage if record.max_delta is None else max(record.max_delta, result.yaw_delta_toward_signage)
|
||||
if state is not None:
|
||||
record.turn_level, record.turn_confirmed = state.turn_level, state.turn_confirmed
|
||||
record.baseline_acquired, record.max_delta = state.baseline_acquired, state.max_yaw_delta_toward_signage
|
||||
if state.finalized:
|
||||
record.turn_valid, record.turn_reason = state.valid_for_turn_analysis, state.exclusion_reason
|
||||
|
||||
@staticmethod
|
||||
def _apply_voice(
|
||||
record: _Record, decision: VoicePromptDecision | None, events: list[VoicePromptEvent],
|
||||
state: VoicePromptTrackState | None,
|
||||
) -> None:
|
||||
if decision is not None:
|
||||
record.voice_mode, record.eligible = decision.mode, record.eligible or decision.eligible
|
||||
record.prompt_played |= decision.prompt_played
|
||||
record.pseudo_prompt |= decision.pseudo_prompt
|
||||
for event in events:
|
||||
record.voice_mode = event.mode
|
||||
if event.event_type == "prompt_eligible": record.eligible = True
|
||||
if event.event_type == "prompt_played": record.prompt_played = True
|
||||
if event.event_type == "pseudo_prompt_triggered": record.pseudo_prompt = True
|
||||
if event.event_type == "response_window_started": record.observing = record.window_started = True
|
||||
if event.event_type == "response_detected":
|
||||
record.response, record.observing = True, False
|
||||
record.reaction, record.response_level = event.reaction_time_sec, event.turn_level
|
||||
if event.event_type == "response_window_expired":
|
||||
record.no_response, record.observing = True, False
|
||||
if state is not None:
|
||||
record.voice_mode, record.eligible = state.mode, state.eligible
|
||||
record.prompt_played, record.pseudo_prompt = state.prompt_played, state.pseudo_prompt
|
||||
record.prompt_time, record.prompt_elapsed = state.prompt_time, state.prompt_elapsed_time_sec
|
||||
record.observing = state.response_window_started and not state.response_detected and not state.response_window_expired
|
||||
record.window_started |= state.response_window_started
|
||||
record.response, record.no_response = state.response_detected, state.response_window_expired
|
||||
record.reaction, record.response_level = state.reaction_time_sec, state.response_turn_level
|
||||
if state.finalized:
|
||||
record.voice_valid, record.voice_reason = state.valid_for_voice_analysis, state.exclusion_reason
|
||||
|
||||
def finalize_track(
|
||||
self, track_id: int, frame_info: FrameInfo, track_summary: Any | None = None,
|
||||
face_state: Any | None = None, turn_state: TurnTrackState | None = None,
|
||||
voice_state: VoicePromptTrackState | None = None,
|
||||
) -> tuple[PersonStateSummary | None, list[PersonStateEvent]]:
|
||||
if not self.config.enabled or track_id in self._summaries:
|
||||
return self._summaries.get(track_id), []
|
||||
record = self._records.setdefault(track_id, _Record(track_id, first_frame=frame_info.frame_number,
|
||||
first_time=frame_info.timestamp_iso,
|
||||
first_elapsed=frame_info.elapsed_time_sec))
|
||||
if track_summary is not None:
|
||||
record.inside_ever = bool(getattr(track_summary, "entered_evaluation_region", record.inside_ever))
|
||||
record.trigger_crossed = bool(getattr(track_summary, "trigger_crossed", record.trigger_crossed))
|
||||
record.trigger_direction = getattr(track_summary, "trigger_crossing_direction", record.trigger_direction)
|
||||
record.movement_direction = getattr(track_summary, "movement_direction", record.movement_direction)
|
||||
if face_state is not None:
|
||||
record.face_ever |= bool(getattr(face_state, "face_detected_frames", 0))
|
||||
record.pose_ever |= bool(getattr(face_state, "pose_estimated_frames", 0))
|
||||
self._apply_turn(record, None, turn_state)
|
||||
self._apply_voice(record, None, [], voice_state)
|
||||
record.last_frame, record.last_time, record.last_elapsed = frame_info.frame_number, frame_info.timestamp_iso, frame_info.elapsed_time_sec
|
||||
duration = max(0.0, (record.last_elapsed or 0.0) - (record.first_elapsed or 0.0))
|
||||
if not self.turn_available:
|
||||
record.turn_valid, record.turn_reason = False, PersonExclusionReason.TURN_DISABLED.value
|
||||
if not self.voice_available:
|
||||
record.voice_valid, record.voice_reason = False, PersonExclusionReason.VOICE_DISABLED.value
|
||||
reason = final_exclusion_reason(self.config, duration, record.trigger_crossed,
|
||||
record.eligible or not self.voice_available,
|
||||
record.turn_reason, record.voice_reason, record.lost,
|
||||
record.prompt_played or record.pseudo_prompt, record.observing)
|
||||
not_evaluable = record.turn_valid is False
|
||||
excluded = reason != PersonExclusionReason.NONE.value and (not not_evaluable or self.config.mark_not_evaluable_as_excluded)
|
||||
final_status = PersonExperimentStatus.EXCLUDED.value if excluded else (
|
||||
PersonExperimentStatus.NOT_EVALUABLE.value if not_evaluable else PersonExperimentStatus.COMPLETED.value)
|
||||
record.finalized, record.status, record.exclusion = True, final_status, reason
|
||||
summary = self._summary(record, duration, face_state, turn_state, voice_state, excluded)
|
||||
self._summaries[track_id] = summary
|
||||
self._records.pop(track_id, None)
|
||||
previous = self._snapshots.get(track_id).status if track_id in self._snapshots else None
|
||||
events = [self._event("completed" if final_status == PersonExperimentStatus.COMPLETED.value else
|
||||
("excluded" if final_status == PersonExperimentStatus.EXCLUDED.value else "became_not_evaluable"),
|
||||
record, frame_info, previous, final_status, reason),
|
||||
self._event("person_finalized", record, frame_info, previous, final_status, reason)]
|
||||
return summary, events
|
||||
|
||||
def finalize_all(
|
||||
self, frame_info: FrameInfo, turn_states: dict[int, TurnTrackState] | None = None,
|
||||
voice_states: dict[int, VoicePromptTrackState] | None = None,
|
||||
) -> tuple[list[PersonStateSummary], list[PersonStateEvent]]:
|
||||
summaries: list[PersonStateSummary] = []
|
||||
events: list[PersonStateEvent] = []
|
||||
for track_id in list(self._records):
|
||||
summary, new = self.finalize_track(track_id, frame_info, turn_state=(turn_states or {}).get(track_id),
|
||||
voice_state=(voice_states or {}).get(track_id))
|
||||
if summary is not None: summaries.append(summary)
|
||||
events.extend(new)
|
||||
return summaries, events
|
||||
|
||||
def get_state(self, track_id: int) -> PersonStateSnapshot | None:
|
||||
return self._snapshots.get(track_id)
|
||||
|
||||
def get_active_states(self) -> dict[int, PersonStateSnapshot]:
|
||||
return {key: value for key, value in self._snapshots.items() if key in self._records}
|
||||
|
||||
def get_summary_preview(self, track_id: int) -> PersonStateSummary | None:
|
||||
return self._summaries.get(track_id)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._records.clear(); self._snapshots.clear(); self._summaries.clear()
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(r: _Record, info: FrameInfo, previous: str | None) -> PersonStateSnapshot:
|
||||
return PersonStateSnapshot(r.track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec, r.status,
|
||||
previous, r.inside, r.confirmed, r.lost, r.trigger_crossed, r.movement_direction,
|
||||
r.face_detected, r.pose_estimated, r.turn_level, r.turn_confirmed, r.voice_mode,
|
||||
r.eligible, r.prompt_played, r.pseudo_prompt, r.window_started, r.response, r.reaction,
|
||||
r.turn_valid, r.voice_valid, r.exclusion, {})
|
||||
|
||||
def _summary(self, r: _Record, duration: float, face: Any, turn: TurnTrackState | None,
|
||||
voice: VoicePromptTrackState | None, excluded: bool) -> PersonStateSummary:
|
||||
return PersonStateSummary(self.experiment_id, r.track_id, r.first_frame, r.last_frame, r.first_time, r.last_time,
|
||||
duration, r.status, True, excluded, r.exclusion, r.inside_ever, r.trigger_crossed, r.trigger_direction,
|
||||
r.movement_direction, r.face_ever, r.pose_ever, getattr(face, "face_detection_rate", None),
|
||||
getattr(face, "pose_estimation_rate", None), r.baseline_acquired, r.turn_confirmed, r.turn_level, r.max_delta,
|
||||
r.voice_mode, r.eligible, r.prompt_played, r.pseudo_prompt, r.prompt_time, r.prompt_elapsed, r.window_started,
|
||||
r.response, r.reaction, r.response_level, r.turn_valid, r.voice_valid,
|
||||
"response observed" if r.response else "")
|
||||
|
||||
@staticmethod
|
||||
def _event(kind: str, r: _Record, info: FrameInfo, previous: str | None, new: str, reason: str) -> PersonStateEvent:
|
||||
return PersonStateEvent(kind, r.track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec,
|
||||
previous, new, reason, {})
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Pure priority and exclusion rules for integrated person state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .config_loader import PersonStateSettings
|
||||
from .models import PersonExperimentStatus, PersonExclusionReason
|
||||
|
||||
|
||||
def choose_status(
|
||||
*, excluded: bool = False, finalized: bool = False, lost: bool = False,
|
||||
responded: bool = False, no_response: bool = False, observing: bool = False,
|
||||
prompt_played: bool = False, pseudo_prompt: bool = False, eligible: bool = False,
|
||||
inside_region: bool = False, tracked: bool = False, not_evaluable: bool = False,
|
||||
) -> str:
|
||||
"""Choose the single display status using research-priority ordering."""
|
||||
if excluded:
|
||||
return PersonExperimentStatus.EXCLUDED.value
|
||||
if finalized:
|
||||
return PersonExperimentStatus.COMPLETED.value
|
||||
if lost:
|
||||
return PersonExperimentStatus.LOST.value
|
||||
if responded:
|
||||
return PersonExperimentStatus.RESPONDED.value
|
||||
if no_response:
|
||||
return PersonExperimentStatus.NO_RESPONSE.value
|
||||
if observing:
|
||||
return PersonExperimentStatus.OBSERVING_RESPONSE.value
|
||||
if prompt_played:
|
||||
return PersonExperimentStatus.PROMPTED.value
|
||||
if pseudo_prompt:
|
||||
return PersonExperimentStatus.PSEUDO_PROMPTED.value
|
||||
if eligible:
|
||||
return PersonExperimentStatus.ELIGIBLE.value
|
||||
if not_evaluable:
|
||||
return PersonExperimentStatus.NOT_EVALUABLE.value
|
||||
if inside_region:
|
||||
return PersonExperimentStatus.INSIDE_REGION.value
|
||||
if tracked:
|
||||
return PersonExperimentStatus.TRACKED.value
|
||||
return PersonExperimentStatus.NEW.value
|
||||
|
||||
|
||||
def normalize_exclusion_reason(reason: str | None) -> str:
|
||||
"""Map module-specific final reasons to the unified vocabulary."""
|
||||
if not reason or reason in {"none", "not_finalized", "evaluable_no_turn", "turn_detected",
|
||||
"response_detected", "no_response"}:
|
||||
return PersonExclusionReason.NONE.value
|
||||
aliases = {"not_eligible": "not_prompt_eligible", "no_turn_state": "response_not_evaluable"}
|
||||
value = aliases.get(reason, reason)
|
||||
valid = {item.value for item in PersonExclusionReason}
|
||||
return value if value in valid else PersonExclusionReason.UNKNOWN.value
|
||||
|
||||
|
||||
def final_exclusion_reason(
|
||||
config: PersonStateSettings, duration_sec: float, trigger_crossed: bool,
|
||||
voice_eligible: bool, turn_reason: str | None, voice_reason: str | None,
|
||||
lost: bool, prompt_started: bool, observing: bool,
|
||||
) -> str:
|
||||
if duration_sec < config.min_track_duration_sec:
|
||||
return PersonExclusionReason.TRACK_TOO_SHORT.value
|
||||
if config.require_trigger_crossing_for_completion and not trigger_crossed:
|
||||
return PersonExclusionReason.OUTSIDE_REGION.value
|
||||
if lost and observing:
|
||||
return PersonExclusionReason.TRACK_LOST_DURING_RESPONSE_WINDOW.value
|
||||
if lost and not prompt_started:
|
||||
return PersonExclusionReason.TRACK_LOST_BEFORE_PROMPT.value
|
||||
if config.require_voice_eligibility_for_voice_analysis and not voice_eligible:
|
||||
return PersonExclusionReason.NOT_PROMPT_ELIGIBLE.value
|
||||
voice = normalize_exclusion_reason(voice_reason)
|
||||
if voice != PersonExclusionReason.NONE.value:
|
||||
return voice
|
||||
return normalize_exclusion_reason(turn_reason)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Camera-free Phase 10 operation commands."""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from .config_loader import AppConfig
|
||||
from .experiment_protocol import ExperimentProtocolBuilder
|
||||
from .pilot_session_reporter import PilotSessionReporter
|
||||
from .presentation_summary_builder import PresentationSummaryBuilder
|
||||
from .quality_checker import QualityChecker
|
||||
def generate_protocol(config:AppConfig,markdown:Path,yaml_path:Path|None=None)->list[Path]:
|
||||
builder=ExperimentProtocolBuilder(config.to_dict());protocol=builder.build_default_protocol();paths=[builder.export_markdown(protocol,markdown)]
|
||||
if yaml_path:paths.append(builder.export_yaml(protocol,yaml_path))
|
||||
return paths
|
||||
def run_quality(config:AppConfig,logs:Path,analysis:Path,output:Path)->list[Path]:
|
||||
checker=QualityChecker(config.quality_check);issues=checker.check(logs,analysis);return [checker.export_csv(issues,output/"quality_issues.csv"),checker.export_markdown(issues,output/"quality_report.md")]
|
||||
def run_pilot_report(config:AppConfig,analysis:Path,output:Path,logs:Path=Path("data/logs/unified"))->Path:
|
||||
issues=QualityChecker(config.quality_check).check(logs,analysis);reporter=PilotSessionReporter();return reporter.export_markdown(reporter.build_report(analysis,issues),output)
|
||||
def run_presentation(config:AppConfig,analysis:Path,output:Path,logs:Path=Path("data/logs/unified"))->Path:
|
||||
issues=QualityChecker(config.quality_check).check(logs,analysis);report=PilotSessionReporter().build_report(analysis,issues);return PresentationSummaryBuilder().build(report,analysis,output)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Camera-free Phase 11 diagnostics and calibration commands."""
|
||||
from pathlib import Path
|
||||
from .calibration_history import CalibrationHistory
|
||||
from .config_loader import AppConfig
|
||||
from .models import CalibrationChangeRecord
|
||||
from .pilot_diagnostics import PilotDiagnostics
|
||||
def run_diagnostics(config:AppConfig,analysis:Path,logs:Path,output:Path)->list[Path]:return PilotDiagnostics(config.pilot_diagnostics).export(PilotDiagnostics(config.pilot_diagnostics).run(analysis,logs),output)
|
||||
def add_calibration(config:AppConfig,record:CalibrationChangeRecord,path:Path|None=None)->Path:return CalibrationHistory(path or Path(config.calibration_history.path)).append(record)
|
||||
def list_calibrations(config:AppConfig,path:Path|None=None)->list[CalibrationChangeRecord]:return CalibrationHistory(path or Path(config.calibration_history.path)).load()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Pilot experiment readiness diagnostics and transparent artifact export."""
|
||||
from __future__ import annotations
|
||||
import csv,json
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from .config_loader import PilotDiagnosticsSettings
|
||||
from .improvement_recommender import ImprovementRecommender
|
||||
from .models import ConditionBalanceDiagnostic,PilotDiagnosticMetric,PilotDiagnosticReport
|
||||
from .session_comparator import SessionComparator
|
||||
def _b(v)->bool:return str(v).lower() in {"true","1","yes"}
|
||||
class PilotDiagnostics:
|
||||
def __init__(self,config:PilotDiagnosticsSettings)->None:self.config=config
|
||||
def _rows(self,root:Path|None)->list[dict[str,str]]:
|
||||
rows=[]
|
||||
if root and root.exists():
|
||||
for p in root.rglob("*person_analysis_table.csv"):
|
||||
with p.open(encoding="utf-8-sig",newline="") as f:rows.extend(csv.DictReader(f))
|
||||
return rows
|
||||
def run(self,analysis_dir:Path,logs_dir:Path|None=None)->PilotDiagnosticReport:
|
||||
rows=self._rows(logs_dir);total=len(rows);pr=[r for r in rows if r.get("prompt_condition")=="prompt"];cr=[r for r in rows if r.get("prompt_condition")=="control"];pv=sum(_b(r.get("valid_for_voice_analysis")) for r in pr);cv=sum(_b(r.get("valid_for_voice_analysis")) for r in cr);valid=pv+cv
|
||||
def rate(items,key,predicate=lambda r:True):return sum(predicate(r) for r in items)/len(items) if items else None
|
||||
pvr=[r for r in pr if _b(r.get("valid_for_voice_analysis"))];cvr=[r for r in cr if _b(r.get("valid_for_voice_analysis"))];p_resp=rate(pvr,"",lambda r:_b(r.get("response_detected")));c_resp=rate(cvr,"",lambda r:_b(r.get("response_detected")));ne=rate(rows,"",lambda r:r.get("turn_level")=="not_evaluable");np=rate(rows,"",lambda r:not _b(r.get("pose_estimated_ever")));vr=valid/total if total else None;ratio=max(pv,cv)/min(pv,cv) if min(pv,cv)>0 else None;balanced=ratio is not None and ratio<=self.config.max_condition_valid_count_ratio;balance=ConditionBalanceDiagnostic(len(pr),len(cr),pv,cv,p_resp,c_resp,abs(pv-cv),ratio,balanced,"条件人数は許容範囲" if balanced else "条件間の有効人数差が大きい","不足条件を同じ環境で追加収集する")
|
||||
camera_missing=any(not r.get("camera_position_note","").strip() for r in rows);metrics=[self._metric("valid_voice_analysis_rate",vr,self.config.min_valid_voice_analysis_rate,vr is not None and vr>=self.config.min_valid_voice_analysis_rate,"有効音声解析率","除外理由を確認する"),self._metric("not_evaluable_rate",ne,self.config.max_not_evaluable_rate,ne is not None and ne<=self.config.max_not_evaluable_rate,"評価不能率","カメラ位置と顔処理を確認する"),self._metric("no_pose_estimated_rate",np,self.config.max_no_pose_estimated_rate,np is not None and np<=self.config.max_no_pose_estimated_rate,"Pose未推定率","顔が映る位置へ調整する")]
|
||||
sessions=SessionComparator().summarize(rows);recs=ImprovementRecommender().recommend(vr,ne,np,balance,pv,cv,camera_missing)
|
||||
if not rows or (self.config.ready_requires_prompt_and_control and (not pr or not cr)) or min(pv,cv)<self.config.min_valid_records_per_condition:status="insufficient_data"
|
||||
elif (vr is not None and vr<self.config.min_valid_voice_analysis_rate) or (ne is not None and ne>self.config.max_not_evaluable_rate) or (np is not None and np>self.config.max_no_pose_estimated_rate) or camera_missing:status="needs_major_adjustment"
|
||||
elif min(pv,cv)<self.config.target_valid_records_per_condition or not balanced:status="needs_minor_adjustment"
|
||||
else:status="ready"
|
||||
return PilotDiagnosticReport(f"diagnostic_{datetime.now():%Y%m%d_%H%M%S}",datetime.now().astimezone().isoformat(timespec="seconds"),str(analysis_dir),str(logs_dir) if logs_dir else None,status,status=="ready",total,pv,cv,p_resp,c_resp,vr,ne,np,balance,sessions,metrics,recs,"診断は本実験移行の目安であり、研究妥当性や因果関係を保証しない")
|
||||
def _metric(self,name,value,threshold,passed,message,action):return PilotDiagnosticMetric(name,value,threshold,passed,"info" if passed else "warning",message,action)
|
||||
def export(self,report:PilotDiagnosticReport,output:Path)->list[Path]:
|
||||
output.mkdir(parents=True,exist_ok=True);paths=[]
|
||||
paths.append(self._csv(output/"pilot_diagnostic_metrics.csv",[asdict(x) for x in report.metrics],["metric_name","value","threshold","passed","severity","message","suggested_action"]))
|
||||
recs=[]
|
||||
for x in report.recommendations:d=asdict(x);d["related_metrics_json"]=json.dumps(d.pop("related_metrics"),ensure_ascii=False);recs.append(d)
|
||||
paths.append(self._csv(output/"improvement_recommendations.csv",recs,["recommendation_id","category","priority","title","reason","suggested_action","related_metrics_json","expected_effect"]))
|
||||
paths.append(self._csv(output/"session_quality_summary.csv",[asdict(x) for x in report.session_summaries],list(asdict(report.session_summaries[0]).keys()) if report.session_summaries else ["session_id","condition","total_tracks","valid_voice_analysis_tracks","valid_voice_analysis_rate","response_detected_count","response_rate","not_evaluable_count","not_evaluable_rate","no_pose_estimated_count","no_pose_estimated_rate","mean_reaction_time_sec","median_reaction_time_sec","camera_position_note","quality_level","main_issue","suggested_action"]))
|
||||
md=output/"pilot_diagnostic_report.md";lines=["# パイロット実験診断",f"- overall_status: {report.overall_status}",f"- ready_for_main_experiment: {report.ready_for_main_experiment}",f"- Prompt有効人数: {report.prompt_valid_records}",f"- Control有効人数: {report.control_valid_records}",f"- valid_voice_analysis_rate: {report.valid_voice_analysis_rate}",f"- not_evaluable_rate: {report.not_evaluable_rate}",f"- no_pose_estimated_rate: {report.no_pose_estimated_rate}","","## 改善提案"]+[f"- [{r.priority}] {r.title}: {r.suggested_action}" for r in report.recommendations]+["","## 注意",report.notes];md.write_text("\n".join(lines)+"\n",encoding="utf-8");paths.insert(0,md);return paths
|
||||
def _csv(self,path,rows,fields):
|
||||
with path.open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=fields);w.writeheader();w.writerows(rows)
|
||||
return path
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Build a cautious pilot-session summary from Phase 8 outputs."""
|
||||
from __future__ import annotations
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from .models import LogQualityIssue,PilotSessionReport
|
||||
class PilotSessionReporter:
|
||||
def build_report(self,analysis_dir:Path,quality_issues:list[LogQualityIssue]|None=None)->PilotSessionReport:
|
||||
rows=[];path=analysis_dir/"condition_summary.csv"
|
||||
if path.exists():
|
||||
with path.open(encoding="utf-8-sig",newline="") as f:rows=list(csv.DictReader(f))
|
||||
by={r.get("condition"):r for r in rows};p=by.get("prompt",{});c=by.get("control",{})
|
||||
def integer(d,k):return int(float(d.get(k) or 0))
|
||||
def number(d,k):return float(d[k]) if d.get(k) not in {None,""} else None
|
||||
pr,cr=number(p,"response_rate"),number(c,"response_rate")
|
||||
total=integer(p,"total_records")+integer(c,"total_records");valid=integer(p,"valid_voice_analysis_count")+integer(c,"valid_voice_analysis_count")
|
||||
issues=quality_issues or [];quality="; ".join(i.message for i in issues) or "重大な品質Issueは記録されていません"
|
||||
finding="Prompt/Controlの記述的比較。因果関係は判定しない。" if rows else "解析データなし。テンプレートとして生成。"
|
||||
return PilotSessionReport(f"pilot_{datetime.now():%Y%m%d_%H%M%S}",datetime.now().astimezone().isoformat(timespec="seconds"),str(analysis_dir),str(analysis_dir),0,0,0,total,valid,valid/total if total else None,pr,cr,(pr-cr) if pr is not None and cr is not None else None,number(p,"mean_reaction_time_sec"),number(c,"mean_reaction_time_sec"),finding,quality,"有効解析人数を増やし、カメラ位置とPrompt/Control条件を固定して再確認する")
|
||||
def export_markdown(self,report:PilotSessionReport,output_path:Path)->Path:
|
||||
output_path.parent.mkdir(parents=True,exist_ok=True);lines=["# パイロット実験レポート","",f"生成日時: {report.generated_at}","","## 記述的結果",f"- 総track数: {report.total_tracks}",f"- 有効音声解析track数: {report.valid_voice_analysis_tracks}",f"- Prompt反応率: {report.response_rate_prompt}",f"- Control反応率: {report.response_rate_control}",f"- 反応率差: {report.rate_difference}",f"- Prompt平均反応時間: {report.mean_reaction_time_prompt}",f"- Control平均反応時間: {report.mean_reaction_time_control}","","## 品質",report.quality_notes,"","## 次の対応",report.recommended_next_actions,"","## 注意","これは予備実験の記述的要約であり、統計的有意差や因果関係を断定しない。"]
|
||||
output_path.write_text("\n".join(lines)+"\n",encoding="utf-8");return output_path
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Generate a slide-friendly Markdown outline from the pilot report."""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from .models import PilotSessionReport
|
||||
class PresentationSummaryBuilder:
|
||||
def build(self,report:PilotSessionReport,analysis_dir:Path,output_path:Path)->Path:
|
||||
plots=sorted((analysis_dir/"plots").glob("*.png"));output_path.parent.mkdir(parents=True,exist_ok=True)
|
||||
lines=["# 中間発表結果整理","","## 1. 研究目的","デジタルサイネージ付近の通行人に対する音声提示と顔向き反応を観測する。","","## 2. 実装済みシステム","YOLO人物検出、ByteTrack追跡、Head Pose、振り向き判定、音声制御、統合ログ、統計解析、GUI。","","## 3. 実験条件","PromptとControlで音声以外の条件を可能な限り揃える。","","## 4. パイロット結果",f"- Prompt反応率: {report.response_rate_prompt}",f"- Control反応率: {report.response_rate_control}",f"- 反応率差: {report.rate_difference}",f"- Prompt平均反応時間: {report.mean_reaction_time_prompt}",f"- Control平均反応時間: {report.mean_reaction_time_control}","","## 5. グラフ"]
|
||||
lines.extend(f"- " for p in plots);lines += ["","## 6. ログ品質",report.quality_notes,"","## 7. 現時点の課題",report.recommended_next_actions,"","## 8. 今後の予定","パイロット実験を実施し、除外理由と有効解析人数を確認して条件を改善する。","","## 発表上の注意","少数サンプルの有意差や因果関係を断定しない。顔未検出を『見ていない』と扱わない。"]
|
||||
output_path.write_text("\n".join(lines)+"\n",encoding="utf-8");return output_path
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Offline Phase 7/8 artifact quality checks using the standard CSV module."""
|
||||
from __future__ import annotations
|
||||
import csv,json
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from .config_loader import QualityCheckSettings
|
||||
from .models import LogQualityIssue
|
||||
|
||||
def _bool(value:str|None)->bool:return str(value).strip().lower() in {"true","1","yes"}
|
||||
class QualityChecker:
|
||||
def __init__(self,config:QualityCheckSettings)->None:self.config=config
|
||||
def _find(self,root:Path,suffix:str)->list[Path]:return sorted(root.rglob(f"*{suffix}")) if root.exists() else []
|
||||
def check(self,unified_log_dir:Path,analysis_dir:Path)->list[LogQualityIssue]:
|
||||
issues=[];tables=self._find(unified_log_dir,"person_analysis_table.csv");sessions=self._find(unified_log_dir,"session_summary.csv")
|
||||
def add(kind,severity,path,message,action,metadata=None):issues.append(LogQualityIssue(kind,severity,str(path) if path else None,None,None,message,action,metadata or {}))
|
||||
rows: list[dict[str, str]] = []
|
||||
if not tables:
|
||||
add("missing_file","error",None,"person_analysis_table.csv がありません","統合ログを有効にして実験を再確認する")
|
||||
for path in tables:
|
||||
with path.open(encoding="utf-8-sig",newline="") as f:rows.extend(dict(r,_file=str(path)) for r in csv.DictReader(f))
|
||||
if tables and not rows:add("empty_analysis_table","error",tables[0],"解析テーブルが空です","追跡とfinalize処理を確認する")
|
||||
if not sessions:add("missing_session_summary","warning",None,"session_summary.csv がありません","正常終了と統合ログ設定を確認する")
|
||||
conditions={r.get("prompt_condition") for r in rows}
|
||||
if self.config.require_prompt_and_control:
|
||||
if "prompt" not in conditions:add("no_prompt_records","warning",None,"Prompt記録がありません","Prompt条件を収集する")
|
||||
if "control" not in conditions:add("no_control_records","warning",None,"Control記録がありません","Control条件を収集する")
|
||||
total=len(rows);valid=sum(_bool(r.get("valid_for_voice_analysis")) for r in rows);not_eval=sum(r.get("turn_level")=="not_evaluable" for r in rows);no_pose=sum(not _bool(r.get("pose_estimated_ever")) for r in rows)
|
||||
if total and valid/total<self.config.min_valid_voice_analysis_rate:add("low_valid_voice_analysis_rate","warning",None,"有効音声解析率が閾値未満です","顔向き・追跡・カメラ位置を確認する",{"rate":valid/total})
|
||||
if total and not_eval/total>self.config.max_not_evaluable_rate:add("high_not_evaluable_rate","warning",None,"評価不能率が閾値を超えています","顔が映るカメラ位置へ調整する",{"rate":not_eval/total})
|
||||
if total and no_pose/total>self.config.max_no_pose_rate:add("high_no_pose_rate","warning",None,"Pose未推定率が閾値を超えています","照明、顔ROI、カメラ角度を確認する",{"rate":no_pose/total})
|
||||
if self.config.require_camera_position_note and any(not r.get("camera_position_note","").strip() for r in rows):add("missing_camera_position_note","warning",None,"camera_position_noteが空です","セッションメモと統合ログへ記録する")
|
||||
if any(float(r["reaction_time_sec"])<0 for r in rows if r.get("reaction_time_sec") not in {None,""}):add("negative_reaction_time","error",None,"負の反応時間があります","prompt時刻とturn時刻を確認する")
|
||||
if any(_bool(r.get("response_detected")) and not r.get("reaction_time_sec") for r in rows):add("low_response_window_count","warning",None,"反応ありで反応時間が欠損しています","response window処理を確認する")
|
||||
if not self._find(analysis_dir,"condition_summary.csv"):add("missing_file","warning",None,"condition_summary.csv がありません","Phase 8解析を実行する")
|
||||
if self.config.require_plots and not list((analysis_dir/"plots").glob("*.png")):add("no_plot_outputs","warning",analysis_dir/"plots","解析グラフがありません","グラフ生成を有効にする")
|
||||
return issues
|
||||
def export_csv(self,issues:list[LogQualityIssue],output_path:Path)->Path:
|
||||
output_path.parent.mkdir(parents=True,exist_ok=True);fields=["issue_type","severity","file_path","session_id","track_id","message","suggested_action","metadata_json"]
|
||||
with output_path.open("w",encoding="utf-8-sig",newline="") as f:
|
||||
w=csv.DictWriter(f,fieldnames=fields);w.writeheader()
|
||||
for i in issues:d=asdict(i);d["metadata_json"]=json.dumps(d.pop("metadata"),ensure_ascii=False);w.writerow(d)
|
||||
return output_path
|
||||
def export_markdown(self,issues:list[LogQualityIssue],output_path:Path)->Path:
|
||||
output_path.parent.mkdir(parents=True,exist_ok=True);counts={s:sum(i.severity==s for i in issues) for s in ("error","warning","info")};lines=["# ログ品質レポート","",f"- 総Issue数: {len(issues)}",f"- Error: {counts['error']}",f"- Warning: {counts['warning']}",f"- Info: {counts['info']}","","## 詳細"]
|
||||
lines.extend(f"- [{i.severity.upper()}] {i.message} — 推奨: {i.suggested_action}" for i in issues);output_path.write_text("\n".join(lines)+"\n",encoding="utf-8");return output_path
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Aggregate person analysis rows into comparable session quality summaries."""
|
||||
from __future__ import annotations
|
||||
import statistics
|
||||
from collections import defaultdict
|
||||
from .models import SessionQualitySummary
|
||||
def _b(v)->bool:return str(v).lower() in {"true","1","yes"}
|
||||
class SessionComparator:
|
||||
def summarize(self,rows:list[dict[str,str]])->list[SessionQualitySummary]:
|
||||
groups=defaultdict(list)
|
||||
for r in rows:groups[r.get("session_id") or "unknown"].append(r)
|
||||
out=[]
|
||||
for sid,items in groups.items():
|
||||
total=len(items);valid=sum(_b(x.get("valid_for_voice_analysis")) for x in items);resp=sum(_b(x.get("response_detected")) and _b(x.get("valid_for_voice_analysis")) for x in items);ne=sum(x.get("turn_level")=="not_evaluable" for x in items);np=sum(not _b(x.get("pose_estimated_ever")) for x in items);times=[float(x["reaction_time_sec"]) for x in items if x.get("reaction_time_sec") not in {None,""} and _b(x.get("response_detected"))];camera=next((x.get("camera_position_note") for x in items if x.get("camera_position_note")),None);vr=valid/total if total else None;ner=ne/total if total else None;npr=np/total if total else None
|
||||
poor=(vr is not None and vr<.5) or (ner is not None and ner>.4) or (npr is not None and npr>.4);caution=not camera or valid<5;level="poor" if poor else "caution" if caution else "good";issue="評価可能率・Pose取得率" if poor else "カメラ位置メモまたはサンプル数" if caution else "none";action="カメラ位置と顔処理条件を見直す" if poor else "記録と有効人数を追加する" if caution else "条件を維持する"
|
||||
out.append(SessionQualitySummary(sid,items[0].get("prompt_condition","unknown"),total,valid,vr,resp,resp/valid if valid else None,ne,ner,np,npr,statistics.mean(times) if times else None,statistics.median(times) if times else None,camera,level,issue,action))
|
||||
return out
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Aggregate analysis records without mixing non-evaluable tracks into rates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
|
||||
from .config_loader import UnifiedLogSettings
|
||||
from .models import LogConsistencyIssue, PersonAnalysisRecord, SessionSummary
|
||||
from .turn_rules import is_weak_or_higher
|
||||
|
||||
|
||||
class SessionSummaryBuilder:
|
||||
"""Calculate session-level counts and rates using valid denominators."""
|
||||
|
||||
def __init__(self, config: UnifiedLogSettings, experiment_id: str, session_id: str, started_at: str) -> None:
|
||||
self.config, self.experiment_id, self.session_id, self.started_at = config, experiment_id, session_id, started_at
|
||||
|
||||
def build(
|
||||
self, records: list[PersonAnalysisRecord], ended_at: str, duration_sec: float,
|
||||
voice_mode: str | None, average_fps: float | None = None,
|
||||
issues: list[LogConsistencyIssue] | None = None,
|
||||
) -> SessionSummary:
|
||||
valid = [item for item in records if item.valid_for_voice_analysis is True]
|
||||
reactions = [item.reaction_time_sec for item in valid if item.response_detected and item.reaction_time_sec is not None]
|
||||
warnings = sum(item.severity == "warning" for item in (issues or []))
|
||||
errors = sum(item.severity == "error" for item in (issues or []))
|
||||
levels = [item.turn_level for item in records]
|
||||
return SessionSummary(self.experiment_id, self.session_id, self.started_at, ended_at, duration_sec, voice_mode,
|
||||
len(records), sum(item.completed for item in records), sum(item.excluded for item in records),
|
||||
sum(item.valid_for_turn_analysis is True for item in records), len(valid),
|
||||
sum(item.prompt_played for item in records), sum(item.pseudo_prompt for item in records),
|
||||
sum(item.response_detected for item in valid), sum(not item.response_detected for item in valid),
|
||||
sum(item.valid_for_turn_analysis is False for item in records),
|
||||
(sum(item.response_detected for item in valid) / len(valid)) if valid else None,
|
||||
statistics.mean(reactions) if reactions else None, statistics.median(reactions) if reactions else None,
|
||||
sum(bool(level and is_weak_or_higher(level)) for level in levels), sum(level == "subtle" for level in levels),
|
||||
sum(level in {"medium", "strong"} for level in levels), sum(level == "strong" for level in levels),
|
||||
average_fps, self.config.camera_position_note or None, warnings, errors, "observational summary")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Phase 8 condition summaries and statistical comparisons."""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import math
|
||||
from .config_loader import AnalysisSettings
|
||||
from .models import (AnalysisConditionSummary,AnalysisResult,AnalysisRunSummary,ExclusionSummary,
|
||||
TurnLevelDistributionResult)
|
||||
from .statistical_tests import compare_numeric,compare_response_counts
|
||||
|
||||
class StatisticalAnalyzer:
|
||||
"""Analyze prompt/control records while preserving evaluability distinctions."""
|
||||
def __init__(self,config:AnalysisSettings)->None:self.config=config
|
||||
def _conditions(self,df):
|
||||
allowed=[]
|
||||
if self.config.include_prompt_condition:allowed.append("prompt")
|
||||
if self.config.include_control_condition:allowed.append("control")
|
||||
return df[df["prompt_condition"].isin(allowed)]
|
||||
def summarize_conditions(self,df)->list[AnalysisConditionSummary]:
|
||||
out=[]
|
||||
for condition in ("prompt","control"):
|
||||
group=df[df.prompt_condition==condition];valid=group[group.valid_for_voice_analysis==True];responses=valid[valid.response_detected==True]
|
||||
rt=responses.reaction_time_sec.dropna();yaw=group.max_yaw_delta_toward_signage.dropna();levels=group.turn_level.fillna("not_evaluable")
|
||||
out.append(AnalysisConditionSummary(condition,len(group),len(valid),len(responses),len(valid)-len(responses),len(responses)/len(valid) if len(valid) else None,int(group.excluded.fillna(False).sum()),int((group.valid_for_turn_analysis==False).sum()),_mean(rt),_median(rt),_std(rt),_mean(yaw),_median(yaw),_std(yaw),int((levels=="subtle").sum()),int((levels=="weak").sum()),int((levels=="medium").sum()),int((levels=="strong").sum()),int(levels.isin(["weak","medium","strong"]).sum()),int(levels.isin(["medium","strong"]).sum())))
|
||||
return out
|
||||
def compare_response_rates(self,df):
|
||||
d=self._conditions(df);p=d[(d.prompt_condition=="prompt")&(d.valid_for_voice_analysis==True)];c=d[(d.prompt_condition=="control")&(d.valid_for_voice_analysis==True)]
|
||||
return compare_response_counts(int((p.response_detected==True).sum()),len(p),int((c.response_detected==True).sum()),len(c),self.config.response_rate_test)
|
||||
def compare_numeric_metric(self,df,metric_name:str):
|
||||
d=self._conditions(df)
|
||||
if metric_name=="reaction_time_sec":d=d[d.response_detected==True]
|
||||
elif metric_name=="max_yaw_delta_toward_signage":
|
||||
if self.config.yaw_metric_denominator=="valid_turn_analysis":d=d[d.valid_for_turn_analysis==True]
|
||||
elif self.config.yaw_metric_denominator=="valid_voice_analysis":d=d[d.valid_for_voice_analysis==True]
|
||||
p=d[d.prompt_condition=="prompt"][metric_name].dropna().astype(float).tolist();c=d[d.prompt_condition=="control"][metric_name].dropna().astype(float).tolist()
|
||||
return compare_numeric(p,c,metric_name,self.config.numeric_test)
|
||||
def summarize_turn_levels(self,df)->list[TurnLevelDistributionResult]:
|
||||
out=[]
|
||||
for condition in ("prompt","control"):
|
||||
s=df[df.prompt_condition==condition].turn_level.fillna("not_evaluable")
|
||||
counts={x:int((s==x).sum()) for x in ("none","face_appeared","subtle","weak","medium","strong","not_evaluable")}
|
||||
out.append(TurnLevelDistributionResult(condition,counts["none"],counts["face_appeared"],counts["subtle"],counts["weak"],counts["medium"],counts["strong"],counts["not_evaluable"],len(s)))
|
||||
return out
|
||||
def summarize_exclusions(self,df)->list[ExclusionSummary]:
|
||||
out=[]
|
||||
for condition in ("prompt","control"):
|
||||
group=df[df.prompt_condition==condition];excluded=group[group.excluded==True];counts=excluded.exclusion_reason.fillna("unknown").value_counts()
|
||||
out.extend(ExclusionSummary(condition,str(reason),int(count),count/len(group) if len(group) else None) for reason,count in counts.items())
|
||||
return out
|
||||
def run_analysis(self,df,input_files_count:int=0)->AnalysisResult:
|
||||
filtered=self._conditions(df);now=datetime.now().astimezone();run=AnalysisRunSummary(now.strftime("analysis_%Y%m%d_%H%M%S"),input_files_count,len(filtered),int((filtered.prompt_condition=="prompt").sum()),int((filtered.prompt_condition=="control").sum()),int(((filtered.prompt_condition=="prompt")&(filtered.valid_for_voice_analysis==True)).sum()),int(((filtered.prompt_condition=="control")&(filtered.valid_for_voice_analysis==True)).sum()),now.isoformat(timespec="seconds"),self.config.output_directory,"Observational analysis; causality is not established")
|
||||
return AnalysisResult(self.summarize_conditions(filtered),self.compare_response_rates(filtered),[self.compare_numeric_metric(filtered,"reaction_time_sec"),self.compare_numeric_metric(filtered,"max_yaw_delta_toward_signage")],self.summarize_turn_levels(filtered),self.summarize_exclusions(filtered),run)
|
||||
|
||||
def _values(s):return [float(x) for x in s if not (isinstance(x,float) and math.isnan(x))]
|
||||
def _mean(s):
|
||||
v=_values(s);return sum(v)/len(v) if v else None
|
||||
def _median(s):
|
||||
import statistics
|
||||
v=_values(s);return statistics.median(v) if v else None
|
||||
def _std(s):
|
||||
import statistics
|
||||
v=_values(s);return statistics.stdev(v) if len(v)>1 else None
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Statistical tests with SciPy when available and safe empty-data handling."""
|
||||
from __future__ import annotations
|
||||
import math,statistics
|
||||
from .models import NumericComparisonResult,ResponseRateTestResult
|
||||
|
||||
def compare_response_counts(ps:int,pt:int,cs:int,ct:int,method:str="auto")->ResponseRateTestResult:
|
||||
pr=ps/pt if pt else None;cr=cs/ct if ct else None;diff=pr-cr if pr is not None and cr is not None else None
|
||||
if not pt or not ct:return ResponseRateTestResult("insufficient_data",ps,pt,cs,ct,pr,cr,diff,None,None,"none",None,None,"Both conditions require valid records")
|
||||
table=[[ps,pt-ps],[cs,ct-cs]];expected=_expected(table);chosen="fisher" if method=="auto" and min(expected)<5 else ("chi_square" if method=="auto" else method)
|
||||
odds=_odds_ratio(table);stat=p=None;effect=None;ename="odds_ratio" if chosen=="fisher" else "phi"
|
||||
try:
|
||||
from scipy import stats
|
||||
if chosen=="fisher":stat,p=stats.fisher_exact(table);odds=float(stat)
|
||||
else:stat,p,_,_=stats.chi2_contingency(table,correction=False);effect=math.sqrt(float(stat)/(pt+ct))
|
||||
except ImportError:
|
||||
if chosen=="fisher":stat,p=odds,_fisher_two_sided(table)
|
||||
else:stat=_chi_square(table);p=math.erfc(math.sqrt(stat/2));effect=math.sqrt(stat/(pt+ct))
|
||||
return ResponseRateTestResult(chosen,ps,pt,cs,ct,pr,cr,diff,float(stat),float(p),ename,effect,odds,"Observational comparison")
|
||||
|
||||
def compare_numeric(prompt:list[float],control:list[float],metric:str,method:str="mannwhitney")->NumericComparisonResult:
|
||||
pc,cc=len(prompt),len(control);pm=statistics.mean(prompt) if prompt else None;cm=statistics.mean(control) if control else None;pmed=statistics.median(prompt) if prompt else None;cmed=statistics.median(control) if control else None
|
||||
if not prompt or not control:return NumericComparisonResult(metric,"insufficient_data",pc,cc,pm,cm,pmed,cmed,None,None,None,"none",None,"Both groups require values")
|
||||
stat=p=effect=None;name="cohens_d" if method=="ttest" else "rank_biserial"
|
||||
try:
|
||||
from scipy import stats
|
||||
if method=="ttest":stat,p=stats.ttest_ind(prompt,control,equal_var=False);effect=_cohens_d(prompt,control)
|
||||
else:stat,p=stats.mannwhitneyu(prompt,control,alternative="two-sided");effect=2*float(stat)/(pc*cc)-1
|
||||
except ImportError:
|
||||
if method=="ttest":stat,p,effect=0.,None,_cohens_d(prompt,control)
|
||||
else:stat=_mann_u(prompt,control);p=None;effect=2*stat/(pc*cc)-1
|
||||
return NumericComparisonResult(metric,method,pc,cc,pm,cm,pmed,cmed,pm-cm,float(stat),None if p is None else float(p),name,effect,"Two-sided observational comparison")
|
||||
|
||||
def _expected(t):
|
||||
total=sum(map(sum,t));rows=[sum(r) for r in t];cols=[sum(t[r][c] for r in range(2)) for c in range(2)];return [rows[r]*cols[c]/total for r in range(2) for c in range(2)]
|
||||
def _chi_square(t):return sum((t[r][c]-_expected(t)[r*2+c])**2/_expected(t)[r*2+c] for r in range(2) for c in range(2) if _expected(t)[r*2+c])
|
||||
def _odds_ratio(t):return (t[0][0]*t[1][1])/(t[0][1]*t[1][0]) if t[0][1]*t[1][0] else (math.inf if t[0][0]*t[1][1] else None)
|
||||
def _fisher_two_sided(t):
|
||||
a,b=t[0];c,d=t[1];n=a+b+c+d;r=a+b;k=a+c
|
||||
def prob(x):return math.comb(k,x)*math.comb(n-k,r-x)/math.comb(n,r)
|
||||
observed=prob(a);lo=max(0,r-(n-k));hi=min(r,k);return min(1.,sum(prob(x) for x in range(lo,hi+1) if prob(x)<=observed+1e-12))
|
||||
def _mann_u(a,b):return sum(1 if x>y else .5 if x==y else 0 for x in a for y in b)
|
||||
def _cohens_d(a,b):
|
||||
if len(a)<2 or len(b)<2:return None
|
||||
pooled=math.sqrt(((len(a)-1)*statistics.variance(a)+(len(b)-1)*statistics.variance(b))/(len(a)+len(b)-2));return (statistics.mean(a)-statistics.mean(b))/pooled if pooled else 0.
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Research-oriented lifecycle management for tracked people."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
from .config_loader import CrossingSettings, TrackManagementSettings
|
||||
from .crossing_detector import CrossingDetector
|
||||
from .models import FrameInfo, PixelRegion, PixelTriggerLine, TrackEvent, TrackPoint, TrackedPerson, TrackState
|
||||
|
||||
|
||||
class TrackManager:
|
||||
"""Accumulate trajectories, events, counters, and finalized summaries."""
|
||||
|
||||
def __init__(
|
||||
self, config: TrackManagementSettings, evaluation_region: PixelRegion | None,
|
||||
trigger_line: PixelTriggerLine | None, crossing_config: CrossingSettings, max_missed_frames: int = 30,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.evaluation_region = evaluation_region
|
||||
self.crossing = CrossingDetector(crossing_config, trigger_line)
|
||||
self.max_missed_frames = max_missed_frames
|
||||
self._active: dict[int, TrackState] = {}
|
||||
self._finalized: dict[int, TrackState] = {}
|
||||
self._unique_confirmed_ids: set[int] = set()
|
||||
|
||||
def update(self, tracked_persons: list[TrackedPerson], frame_info: FrameInfo) -> list[TrackEvent]:
|
||||
events: list[TrackEvent] = []
|
||||
visible_ids = {person.track_id for person in tracked_persons}
|
||||
for state in self._active.values():
|
||||
if state.track_id not in visible_ids:
|
||||
state.total_missed_frames += 1
|
||||
missed = frame_info.frame_number - state.last_seen_frame
|
||||
state.max_missed_frames = max(state.max_missed_frames, missed)
|
||||
for person in tracked_persons:
|
||||
events.extend(self._update_person(person, frame_info))
|
||||
return events
|
||||
|
||||
def _update_person(self, person: TrackedPerson, info: FrameInfo) -> list[TrackEvent]:
|
||||
events: list[TrackEvent] = []
|
||||
inside = self.evaluation_region.contains(round(person.foot_x), round(person.foot_y)) if self.evaluation_region else False
|
||||
point = TrackPoint(
|
||||
timestamp_monotonic=info.elapsed_time_sec, timestamp_iso=info.timestamp_iso, frame_number=info.frame_number,
|
||||
center_x=person.center_x, center_y=person.center_y, foot_x=person.foot_x, foot_y=person.foot_y, bbox=person.bbox,
|
||||
)
|
||||
state = self._active.get(person.track_id)
|
||||
if state is None:
|
||||
state = TrackState(
|
||||
track_id=person.track_id, first_seen_time=info.timestamp_iso, last_seen_time=info.timestamp_iso,
|
||||
first_seen_frame=info.frame_number, last_seen_frame=info.frame_number,
|
||||
trajectory=deque(maxlen=self.config.trajectory_max_points), first_seen_monotonic=info.elapsed_time_sec,
|
||||
last_seen_monotonic=info.elapsed_time_sec, current_inside_region=inside,
|
||||
)
|
||||
self._active[person.track_id] = state
|
||||
events.append(self._event("track_created", state, info, person.foot_x, person.foot_y))
|
||||
if inside:
|
||||
self._mark_entry(state, info)
|
||||
events.append(self._event("region_entered", state, info, person.foot_x, person.foot_y))
|
||||
state.last_seen_time = info.timestamp_iso
|
||||
state.last_seen_frame = info.frame_number
|
||||
state.last_seen_monotonic = info.elapsed_time_sec
|
||||
state.total_visible_frames += 1
|
||||
state.is_confirmed = state.is_confirmed or person.is_confirmed
|
||||
if state.is_confirmed:
|
||||
self._unique_confirmed_ids.add(state.track_id)
|
||||
state.trajectory.append(point)
|
||||
if len(state.trajectory) > 1:
|
||||
events.extend(self._update_region(state, inside, info, person))
|
||||
state.movement_direction = self._resolved_direction(state)
|
||||
crossing_direction = self.crossing.detect(state.track_id, list(state.trajectory), state.is_confirmed)
|
||||
if crossing_direction is not None and not state.trigger_crossed:
|
||||
state.trigger_crossed = True
|
||||
state.trigger_crossing_time = info.timestamp_iso
|
||||
state.trigger_crossing_frame = info.frame_number
|
||||
state.trigger_crossing_direction = crossing_direction
|
||||
state.movement_direction = crossing_direction
|
||||
events.append(self._event("trigger_crossed", state, info, person.foot_x, person.foot_y, crossing_direction))
|
||||
return events
|
||||
|
||||
def _update_region(self, state: TrackState, inside: bool, info: FrameInfo, person: TrackedPerson) -> list[TrackEvent]:
|
||||
events: list[TrackEvent] = []
|
||||
if inside:
|
||||
state.pending_exit_frames = 0
|
||||
if not state.current_inside_region:
|
||||
state.pending_entry_frames += 1
|
||||
if state.pending_entry_frames >= self.config.region_entry_confirm_frames:
|
||||
state.current_inside_region = True
|
||||
state.pending_entry_frames = 0
|
||||
if not state.entered_evaluation_region:
|
||||
self._mark_entry(state, info)
|
||||
events.append(self._event("region_entered", state, info, person.foot_x, person.foot_y))
|
||||
else:
|
||||
state.pending_entry_frames = 0
|
||||
if state.current_inside_region:
|
||||
state.pending_exit_frames += 1
|
||||
if state.pending_exit_frames >= self.config.region_exit_confirm_frames:
|
||||
state.current_inside_region = False
|
||||
state.pending_exit_frames = 0
|
||||
if not state.exited_evaluation_region:
|
||||
state.exited_evaluation_region = True
|
||||
state.exit_time = info.timestamp_iso
|
||||
state.exit_frame = info.frame_number
|
||||
state.exit_monotonic = info.elapsed_time_sec
|
||||
events.append(self._event("region_exited", state, info, person.foot_x, person.foot_y))
|
||||
return events
|
||||
|
||||
@staticmethod
|
||||
def _mark_entry(state: TrackState, info: FrameInfo) -> None:
|
||||
state.entered_evaluation_region = True
|
||||
state.entry_time = info.timestamp_iso
|
||||
state.entry_frame = info.frame_number
|
||||
state.entry_monotonic = info.elapsed_time_sec
|
||||
|
||||
def _movement_direction(self, state: TrackState) -> str:
|
||||
points = list(state.trajectory)
|
||||
if len(points) < 2:
|
||||
return "unknown"
|
||||
start = points[max(0, len(points) - self.config.direction_window_points)]
|
||||
end = points[-1]
|
||||
dx, dy = end.foot_x - start.foot_x, end.foot_y - start.foot_y
|
||||
if max(abs(dx), abs(dy)) < self.config.minimum_direction_displacement_pixels:
|
||||
return "stationary"
|
||||
if abs(dx) >= abs(dy):
|
||||
return "left_to_right" if dx > 0 else "right_to_left"
|
||||
return "top_to_bottom" if dy > 0 else "bottom_to_top"
|
||||
|
||||
def _resolved_direction(self, state: TrackState) -> str:
|
||||
"""Prefer an observed line-crossing direction over trajectory estimation."""
|
||||
if state.trigger_crossed and state.trigger_crossing_direction:
|
||||
return state.trigger_crossing_direction
|
||||
return self._movement_direction(state)
|
||||
|
||||
def finalize_stale_tracks(self, frame_info: FrameInfo) -> list[TrackEvent]:
|
||||
stale = [state for state in self._active.values() if frame_info.frame_number - state.last_seen_frame > self.max_missed_frames]
|
||||
events: list[TrackEvent] = []
|
||||
for state in stale:
|
||||
point = state.trajectory[-1]
|
||||
events.append(self._event("track_lost", state, frame_info, point.foot_x, point.foot_y))
|
||||
events.append(self._finalize(state, frame_info))
|
||||
return events
|
||||
|
||||
def finalize_all(self, frame_info: FrameInfo) -> list[TrackEvent]:
|
||||
return [self._finalize(state, frame_info) for state in list(self._active.values())]
|
||||
|
||||
def _finalize(self, state: TrackState, info: FrameInfo) -> TrackEvent:
|
||||
state.finalized = True
|
||||
state.movement_direction = self._resolved_direction(state)
|
||||
state.valid_for_counting, state.invalid_reason = self._counting_validity(state)
|
||||
self._active.pop(state.track_id, None)
|
||||
self._finalized[state.track_id] = state
|
||||
point = state.trajectory[-1]
|
||||
return self._event("track_finalized", state, info, point.foot_x, point.foot_y, state.movement_direction,
|
||||
{"valid_for_counting": state.valid_for_counting, "invalid_reason": state.invalid_reason})
|
||||
|
||||
def _counting_validity(self, state: TrackState) -> tuple[bool, str]:
|
||||
if not state.is_confirmed:
|
||||
return False, "unconfirmed"
|
||||
if state.total_visible_frames < self.config.minimum_visible_frames_for_count:
|
||||
return False, "insufficient_visible_frames"
|
||||
if state.duration_sec < self.config.minimum_duration_sec_for_count:
|
||||
return False, "insufficient_duration"
|
||||
if self.config.require_region_entry_for_count and not state.entered_evaluation_region:
|
||||
return False, "no_region_entry"
|
||||
if self.config.require_trigger_crossing_for_count and not state.trigger_crossed:
|
||||
return False, "no_trigger_crossing"
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def _event(
|
||||
event_type: str, state: TrackState, info: FrameInfo, x: float, y: float,
|
||||
direction: str | None = None, metadata: dict[str, object] | None = None,
|
||||
) -> TrackEvent:
|
||||
return TrackEvent(event_type, state.track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec,
|
||||
x, y, direction, dict(metadata or {}))
|
||||
|
||||
def get_active_states(self) -> list[TrackState]:
|
||||
return list(self._active.values())
|
||||
|
||||
def get_finalized_states(self) -> list[TrackState]:
|
||||
return list(self._finalized.values())
|
||||
|
||||
def get_state(self, track_id: int) -> TrackState | None:
|
||||
return self._active.get(track_id) or self._finalized.get(track_id)
|
||||
|
||||
@property
|
||||
def active_track_count(self) -> int:
|
||||
return sum(state.is_confirmed for state in self._active.values())
|
||||
|
||||
@property
|
||||
def total_unique_tracks(self) -> int:
|
||||
return len(self._unique_confirmed_ids)
|
||||
|
||||
@property
|
||||
def region_visitors(self) -> int:
|
||||
return sum(state.is_confirmed and state.entered_evaluation_region for state in (*self._active.values(), *self._finalized.values()))
|
||||
|
||||
@property
|
||||
def trigger_crossings(self) -> int:
|
||||
return sum(state.is_confirmed and state.trigger_crossed for state in (*self._active.values(), *self._finalized.values()))
|
||||
|
||||
@property
|
||||
def completed_passers(self) -> int:
|
||||
return sum(state.valid_for_counting for state in self._finalized.values())
|
||||
|
||||
def reset(self) -> None:
|
||||
self._active.clear()
|
||||
self._finalized.clear()
|
||||
self._unique_confirmed_ids.clear()
|
||||
self.crossing.reset()
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
"""Isolated adapter for Ultralytics' ByteTrack implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import inspect
|
||||
from argparse import Namespace
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .config_loader import TrackerSettings
|
||||
from .exceptions import TrackerError
|
||||
from .models import BoundingBox, Detection, FrameInfo, TrackedPerson
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _TrackerInput:
|
||||
"""Minimal Results-like input expected by current Ultralytics ByteTrack."""
|
||||
|
||||
def __init__(self, detections: list[Detection]) -> None:
|
||||
self.xyxy = np.asarray([[d.bbox.x1, d.bbox.y1, d.bbox.x2, d.bbox.y2] for d in detections], dtype=np.float32).reshape(-1, 4)
|
||||
self.conf = np.asarray([d.confidence for d in detections], dtype=np.float32)
|
||||
self.cls = np.asarray([d.class_id for d in detections], dtype=np.float32)
|
||||
|
||||
@property
|
||||
def xywh(self) -> np.ndarray:
|
||||
values = self.xyxy.copy()
|
||||
values[:, 0] = (self.xyxy[:, 0] + self.xyxy[:, 2]) / 2
|
||||
values[:, 1] = (self.xyxy[:, 1] + self.xyxy[:, 3]) / 2
|
||||
values[:, 2] = self.xyxy[:, 2] - self.xyxy[:, 0]
|
||||
values[:, 3] = self.xyxy[:, 3] - self.xyxy[:, 1]
|
||||
return values
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.conf)
|
||||
|
||||
def __getitem__(self, index: Any) -> "_TrackerInput":
|
||||
"""Support boolean/slice indexing used by newer Ultralytics releases."""
|
||||
selected = object.__new__(_TrackerInput)
|
||||
selected.xyxy = self.xyxy[index]
|
||||
selected.conf = self.conf[index]
|
||||
selected.cls = self.cls[index]
|
||||
if selected.xyxy.ndim == 1:
|
||||
selected.xyxy = selected.xyxy.reshape(1, 4)
|
||||
selected.conf = selected.conf.reshape(1)
|
||||
selected.cls = selected.cls.reshape(1)
|
||||
return selected
|
||||
|
||||
|
||||
class PersonTracker:
|
||||
"""Convert domain detections to ByteTrack and normalize its output."""
|
||||
|
||||
def __init__(self, config: TrackerSettings, frame_rate: float = 30.0) -> None:
|
||||
self.config = config
|
||||
self._frame_rate = frame_rate if frame_rate > 0 else 30.0
|
||||
self._tracker: Any = None
|
||||
self._raw_to_public: dict[int, int] = {}
|
||||
self._next_public_id = 1
|
||||
self._ages: dict[int, int] = {}
|
||||
self._first_frames: dict[int, int] = {}
|
||||
if config.enabled:
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
try:
|
||||
from ultralytics.trackers.byte_tracker import BYTETracker
|
||||
|
||||
args = Namespace(
|
||||
tracker_type="bytetrack",
|
||||
track_high_thresh=self.config.track_high_thresh,
|
||||
track_low_thresh=self.config.track_low_thresh,
|
||||
new_track_thresh=self.config.new_track_thresh,
|
||||
match_thresh=self.config.match_thresh,
|
||||
track_buffer=self.config.track_buffer_frames,
|
||||
fuse_score=self.config.fuse_score,
|
||||
)
|
||||
self._tracker = self._create_backend(BYTETracker, args, round(self._frame_rate))
|
||||
except Exception as exc:
|
||||
raise TrackerError(f"Failed to initialize Ultralytics ByteTrack: {exc}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _create_backend(backend_class: type[Any], args: Namespace, frame_rate: int) -> Any:
|
||||
"""Initialize ByteTrack across old and new Ultralytics constructor APIs."""
|
||||
try:
|
||||
parameters = inspect.signature(backend_class.__init__).parameters
|
||||
except (TypeError, ValueError):
|
||||
parameters = {}
|
||||
if "frame_rate" in parameters:
|
||||
return backend_class(args=args, frame_rate=frame_rate)
|
||||
return backend_class(args)
|
||||
|
||||
def update(self, detections: list[Detection], frame: np.ndarray, frame_info: FrameInfo) -> list[TrackedPerson]:
|
||||
"""Update ByteTrack; a one-frame failure is logged and returned as empty."""
|
||||
if not self.config.enabled:
|
||||
return []
|
||||
if self._tracker is None:
|
||||
raise TrackerError("Tracker is not initialized")
|
||||
try:
|
||||
raw_tracks = self._tracker.update(_TrackerInput([d for d in detections if d.class_id == 0]), frame)
|
||||
return self._normalize(raw_tracks, frame.shape[1], frame.shape[0], detections, frame_info.frame_number)
|
||||
except Exception:
|
||||
LOGGER.exception("ByteTrack update failed for one frame")
|
||||
return []
|
||||
|
||||
def _normalize(
|
||||
self, raw_tracks: Any, width: int, height: int,
|
||||
detections: list[Detection] | None = None, frame_number: int | None = None,
|
||||
) -> list[TrackedPerson]:
|
||||
persons: list[TrackedPerson] = []
|
||||
for raw in raw_tracks if raw_tracks is not None else []:
|
||||
row = raw.tolist() if hasattr(raw, "tolist") else list(raw)
|
||||
if len(row) < 7:
|
||||
continue
|
||||
x1, y1, x2, y2 = (round(float(value)) for value in row[:4])
|
||||
x1, y1 = max(0, min(width - 1, x1)), max(0, min(height - 1, y1))
|
||||
x2, y2 = max(0, min(width - 1, x2)), max(0, min(height - 1, y2))
|
||||
if x1 >= x2 or y1 >= y2:
|
||||
continue
|
||||
raw_id, confidence, class_id = int(row[4]), float(row[5]), int(row[6])
|
||||
if class_id != 0:
|
||||
continue
|
||||
if raw_id not in self._raw_to_public:
|
||||
self._raw_to_public[raw_id] = self._allocate_id()
|
||||
public_id = self._raw_to_public[raw_id]
|
||||
if frame_number is not None:
|
||||
self._first_frames.setdefault(public_id, frame_number)
|
||||
self._ages[public_id] = frame_number - self._first_frames[public_id] + 1
|
||||
else:
|
||||
self._ages[public_id] = self._ages.get(public_id, 0) + 1
|
||||
bbox = BoundingBox(x1, y1, x2, y2)
|
||||
detection_index = int(row[7]) if len(row) > 7 else -1
|
||||
source_detection = detections[detection_index] if detections and 0 <= detection_index < len(detections) else None
|
||||
persons.append(TrackedPerson(
|
||||
track_id=public_id, bbox=bbox, confidence=max(0.0, min(1.0, confidence)), class_id=class_id,
|
||||
class_name="person", center_x=float(bbox.center_x), center_y=float(bbox.center_y),
|
||||
foot_x=float(bbox.foot_x), foot_y=float(bbox.foot_y),
|
||||
inside_evaluation_region=source_detection.inside_evaluation_region if source_detection else False,
|
||||
is_confirmed=self._ages[public_id] >= self.config.min_confirmed_frames,
|
||||
track_age_frames=self._ages[public_id], missed_frames=0,
|
||||
))
|
||||
return persons
|
||||
|
||||
def _allocate_id(self) -> int:
|
||||
value = self._next_public_id
|
||||
self._next_public_id += 1
|
||||
return value
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset tracker state while keeping public IDs monotonic for this run."""
|
||||
self._raw_to_public.clear()
|
||||
self._ages.clear()
|
||||
self._first_frames.clear()
|
||||
if self.config.enabled:
|
||||
self._initialize()
|
||||
|
||||
def close(self) -> None:
|
||||
self._tracker = None
|
||||
self._raw_to_public.clear()
|
||||
self._ages.clear()
|
||||
self._first_frames.clear()
|
||||
|
||||
@property
|
||||
def tracker_name(self) -> str:
|
||||
return "ByteTrack (Ultralytics)" if self.config.enabled else "disabled"
|
||||
@@ -0,0 +1,139 @@
|
||||
"""UTF-8 BOM CSV logs for tracking frames, events, and summaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
|
||||
from .config_loader import TrackingLoggingSettings
|
||||
from .exceptions import TrackingLogError
|
||||
from .models import FrameInfo, TrackEvent, TrackedPerson, TrackState
|
||||
|
||||
|
||||
class TrackingLogger:
|
||||
"""Own the three Phase 2 CSV streams and prevent duplicate summaries."""
|
||||
|
||||
FRAME_FIELDS = ["experiment_id", "frame_number", "timestamp", "elapsed_time_sec", "track_id", "is_confirmed",
|
||||
"confidence", "bbox_x1", "bbox_y1", "bbox_x2", "bbox_y2", "bbox_width", "bbox_height",
|
||||
"center_x", "center_y", "foot_x", "foot_y", "inside_evaluation_region", "track_age_frames",
|
||||
"missed_frames", "movement_direction", "trigger_crossed"]
|
||||
SUMMARY_FIELDS = ["experiment_id", "track_id", "first_seen_time", "last_seen_time", "first_seen_frame",
|
||||
"last_seen_frame", "duration_sec", "total_visible_frames", "total_missed_frames", "max_missed_frames",
|
||||
"entered_evaluation_region", "entry_time", "entry_frame", "exited_evaluation_region", "exit_time",
|
||||
"exit_frame", "dwell_time_sec", "trigger_crossed", "trigger_crossing_time", "trigger_crossing_frame",
|
||||
"trigger_crossing_direction", "movement_direction", "trajectory_points", "valid_for_counting", "invalid_reason"]
|
||||
EVENT_FIELDS = ["experiment_id", "event_type", "track_id", "frame_number", "timestamp", "elapsed_time_sec",
|
||||
"x", "y", "direction", "metadata_json"]
|
||||
|
||||
def __init__(self, config: TrackingLoggingSettings, experiment_id: str, run_stamp: str | None = None) -> None:
|
||||
self.config = config
|
||||
self.experiment_id = 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_stream("frames", "tracking_frames", self.FRAME_FIELDS, self.config.log_frame_tracks)
|
||||
self._open_stream("events", "events", self.EVENT_FIELDS, self.config.log_events)
|
||||
self._open_stream("summary", "track_summary", self.SUMMARY_FIELDS, self.config.log_summaries)
|
||||
except OSError as exc:
|
||||
self.close()
|
||||
raise TrackingLogError(f"Failed to open tracking logs: {exc}") from exc
|
||||
|
||||
def _open_stream(self, key: str, suffix: str, fields: list[str], enabled: bool) -> None:
|
||||
if not enabled:
|
||||
return
|
||||
path = self.directory / f"{self.run_stamp}_{self.experiment_id}_{suffix}.csv"
|
||||
stream = path.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_frame_tracks(
|
||||
self, frame_info: FrameInfo, tracked_persons: list[TrackedPerson],
|
||||
track_states: dict[int, TrackState] | None = None,
|
||||
) -> None:
|
||||
writer = self._writers.get("frames")
|
||||
if writer is None:
|
||||
return
|
||||
states = track_states or {}
|
||||
for person in tracked_persons:
|
||||
bbox, state = person.bbox, states.get(person.track_id)
|
||||
writer.writerow({
|
||||
"experiment_id": self.experiment_id, "frame_number": frame_info.frame_number,
|
||||
"timestamp": frame_info.timestamp_iso, "elapsed_time_sec": f"{frame_info.elapsed_time_sec:.6f}",
|
||||
"track_id": person.track_id, "is_confirmed": person.is_confirmed,
|
||||
"confidence": f"{person.confidence:.6f}", "bbox_x1": bbox.x1, "bbox_y1": bbox.y1,
|
||||
"bbox_x2": bbox.x2, "bbox_y2": bbox.y2, "bbox_width": bbox.width, "bbox_height": bbox.height,
|
||||
"center_x": person.center_x, "center_y": person.center_y, "foot_x": person.foot_x, "foot_y": person.foot_y,
|
||||
"inside_evaluation_region": state.current_inside_region if state else person.inside_evaluation_region,
|
||||
"track_age_frames": person.track_age_frames, "missed_frames": person.missed_frames,
|
||||
"movement_direction": state.movement_direction if state else "unknown",
|
||||
"trigger_crossed": state.trigger_crossed if state else False,
|
||||
})
|
||||
self._frames_since_flush += 1
|
||||
if self._frames_since_flush >= self.config.flush_interval_frames:
|
||||
self.flush()
|
||||
|
||||
def write_events(self, events: list[TrackEvent]) -> None:
|
||||
writer = self._writers.get("events")
|
||||
if writer is None:
|
||||
return
|
||||
for event in events:
|
||||
writer.writerow({
|
||||
"experiment_id": self.experiment_id, "event_type": event.event_type, "track_id": event.track_id,
|
||||
"frame_number": event.frame_number, "timestamp": event.timestamp_iso,
|
||||
"elapsed_time_sec": f"{event.elapsed_time_sec:.6f}", "x": event.x, "y": event.y,
|
||||
"direction": event.direction or "", "metadata_json": json.dumps(event.metadata, ensure_ascii=False),
|
||||
})
|
||||
|
||||
def write_summary(self, state: TrackState) -> None:
|
||||
writer = self._writers.get("summary")
|
||||
if writer is None or state.track_id in self._summary_ids:
|
||||
return
|
||||
writer.writerow({
|
||||
"experiment_id": self.experiment_id, "track_id": state.track_id,
|
||||
"first_seen_time": state.first_seen_time, "last_seen_time": state.last_seen_time,
|
||||
"first_seen_frame": state.first_seen_frame, "last_seen_frame": state.last_seen_frame,
|
||||
"duration_sec": f"{state.duration_sec:.6f}", "total_visible_frames": state.total_visible_frames,
|
||||
"total_missed_frames": state.total_missed_frames, "max_missed_frames": state.max_missed_frames,
|
||||
"entered_evaluation_region": state.entered_evaluation_region, "entry_time": state.entry_time or "",
|
||||
"entry_frame": state.entry_frame if state.entry_frame is not None else "",
|
||||
"exited_evaluation_region": state.exited_evaluation_region, "exit_time": state.exit_time or "",
|
||||
"exit_frame": state.exit_frame if state.exit_frame is not None else "",
|
||||
"dwell_time_sec": f"{state.dwell_time_sec:.6f}", "trigger_crossed": state.trigger_crossed,
|
||||
"trigger_crossing_time": state.trigger_crossing_time or "",
|
||||
"trigger_crossing_frame": state.trigger_crossing_frame if state.trigger_crossing_frame is not None else "",
|
||||
"trigger_crossing_direction": state.trigger_crossing_direction or "",
|
||||
"movement_direction": state.movement_direction, "trajectory_points": len(state.trajectory),
|
||||
"valid_for_counting": state.valid_for_counting, "invalid_reason": state.invalid_reason,
|
||||
})
|
||||
self._summary_ids.add(state.track_id)
|
||||
|
||||
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) -> "TrackingLogger":
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.close()
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Track-wise yaw baseline and turn-reaction state machine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from collections import deque
|
||||
|
||||
from .config_loader import TurnDetectionSettings
|
||||
from .models import FrameInfo, HeadPoseResult, TrackState, TurnEvent, TurnFrameResult, TurnLevel, TurnTrackState
|
||||
from .turn_rules import classify_turn_level, is_weak_or_higher, stronger_level, yaw_delta_toward_signage
|
||||
|
||||
|
||||
class TurnDetector:
|
||||
"""Convert HeadPoseResult streams into turn states, events, and summaries."""
|
||||
|
||||
def __init__(self, config: TurnDetectionSettings, history_max_points: int = 120) -> None:
|
||||
self.config = config
|
||||
self.history_max_points = history_max_points
|
||||
self._states: dict[int, TurnTrackState] = {}
|
||||
self._finalized: dict[int, TurnTrackState] = {}
|
||||
|
||||
def register_tracks(self, track_ids: list[int]) -> None:
|
||||
for track_id in track_ids:
|
||||
self._state(track_id)
|
||||
|
||||
def update(
|
||||
self, head_pose_results: list[HeadPoseResult], frame_info: FrameInfo,
|
||||
track_states: dict[int, TrackState] | None = None,
|
||||
) -> tuple[list[TurnFrameResult], list[TurnEvent]]:
|
||||
frame_results: list[TurnFrameResult] = []
|
||||
events: list[TurnEvent] = []
|
||||
tracks = track_states or {}
|
||||
for pose in head_pose_results:
|
||||
state = self._state(pose.track_id)
|
||||
result, new_events = self._update_one(state, pose, frame_info, tracks.get(pose.track_id))
|
||||
frame_results.append(result)
|
||||
events.extend(new_events)
|
||||
return frame_results, events
|
||||
|
||||
def _update_one(
|
||||
self, state: TurnTrackState, pose: HeadPoseResult, info: FrameInfo, track: TrackState | None,
|
||||
) -> tuple[TurnFrameResult, list[TurnEvent]]:
|
||||
events: list[TurnEvent] = []
|
||||
state.total_frames += 1
|
||||
state.face_detected_frames += int(pose.face_detected)
|
||||
state.pose_frames += int(pose.pose_estimated)
|
||||
if pose.face_detected and state.first_face_detected_frame is None:
|
||||
state.first_face_detected_frame, state.first_face_detected_time = info.frame_number, info.timestamp_iso
|
||||
if not pose.pose_estimated or pose.yaw is None:
|
||||
if state.missing_started_elapsed is None:
|
||||
state.missing_started_elapsed = info.elapsed_time_sec
|
||||
if (state.candidate_start_elapsed is not None and state.last_pose_elapsed is not None
|
||||
and info.elapsed_time_sec - state.last_pose_elapsed > self.config.max_pose_gap_sec):
|
||||
events.extend(self._end_candidate(state, info, pose.yaw))
|
||||
return self._frame(state, pose, None, None, 0.0, pose.failure_reason or "pose_not_estimated"), events
|
||||
|
||||
yaw = pose.yaw
|
||||
if (self.config.face_appearance_enabled and state.missing_started_elapsed is not None
|
||||
and info.elapsed_time_sec - state.missing_started_elapsed >= self.config.face_missing_before_appearance_sec
|
||||
and not state.face_appeared_after_missing):
|
||||
state.face_appeared_after_missing = True
|
||||
events.append(self._event("face_appeared", state, info, yaw, None, TurnLevel.FACE_APPEARED.value))
|
||||
state.missing_started_elapsed = None
|
||||
state.last_pose_elapsed = info.elapsed_time_sec
|
||||
state.yaw_history.append((info.elapsed_time_sec, yaw))
|
||||
self._collect_baseline(state, pose, info, track, events)
|
||||
if not state.baseline_acquired or state.baseline_yaw is None:
|
||||
return self._frame(state, pose, None, None, 0.0, "baseline_not_acquired"), events
|
||||
|
||||
raw, toward = yaw_delta_toward_signage(yaw, state.baseline_yaw, self.config.signage_yaw_direction)
|
||||
if state.max_yaw_delta_toward_signage is None or toward > state.max_yaw_delta_toward_signage:
|
||||
state.max_yaw_delta_toward_signage = toward
|
||||
state.max_yaw_delta_frame, state.max_yaw_delta_time = info.frame_number, info.timestamp_iso
|
||||
level = classify_turn_level(toward, self.config)
|
||||
duration = 0.0
|
||||
if level != TurnLevel.NONE.value:
|
||||
if state.candidate_start_elapsed is None:
|
||||
state.candidate_start_elapsed = info.elapsed_time_sec
|
||||
state.turn_started = True
|
||||
state.turn_start_frame, state.turn_start_time = info.frame_number, info.timestamp_iso
|
||||
events.append(self._event("turn_candidate_started", state, info, yaw, toward, level))
|
||||
duration = max(0.0, info.elapsed_time_sec - state.candidate_start_elapsed)
|
||||
state.turn_duration_sec = max(state.turn_duration_sec, duration)
|
||||
eligible_level = level != TurnLevel.SUBTLE.value or duration >= self.config.min_subtle_duration_sec
|
||||
if eligible_level:
|
||||
previous = state.strongest_level
|
||||
state.strongest_level = stronger_level(state.strongest_level, level)
|
||||
if state.strongest_level != previous:
|
||||
events.append(self._event("turn_level_changed", state, info, yaw, toward, state.strongest_level))
|
||||
should_confirm = is_weak_or_higher(level) if self.config.weak_or_higher_is_turn else level != TurnLevel.NONE.value
|
||||
if should_confirm and duration >= self.config.min_turn_duration_sec and not state.turn_confirmed:
|
||||
state.turn_confirmed = True
|
||||
state.turn_confirmed_frame, state.turn_confirmed_time = info.frame_number, info.timestamp_iso
|
||||
events.append(self._event("turn_confirmed", state, info, yaw, toward, level))
|
||||
else:
|
||||
events.extend(self._end_candidate(state, info, yaw))
|
||||
state.turn_level = state.strongest_level if state.strongest_level != TurnLevel.NONE.value else level
|
||||
return self._frame(state, pose, raw, toward, duration, ""), events
|
||||
|
||||
def _collect_baseline(
|
||||
self, state: TurnTrackState, pose: HeadPoseResult, info: FrameInfo,
|
||||
track: TrackState | None, events: list[TurnEvent],
|
||||
) -> None:
|
||||
if state.baseline_acquired or pose.yaw is None:
|
||||
return
|
||||
allowed = self.config.baseline_strategy == "first_valid"
|
||||
if self.config.baseline_strategy == "before_trigger":
|
||||
allowed = track is None or not track.trigger_crossed
|
||||
elif self.config.baseline_strategy == "region_entry_window":
|
||||
allowed = bool(track and track.entry_monotonic is not None
|
||||
and 0 <= info.elapsed_time_sec - track.entry_monotonic <= self.config.baseline_window_sec)
|
||||
if not allowed:
|
||||
return
|
||||
state.baseline_samples.append((info.elapsed_time_sec, pose.yaw, info.frame_number, info.timestamp_iso))
|
||||
cutoff = info.elapsed_time_sec - self.config.baseline_window_sec
|
||||
while state.baseline_samples and state.baseline_samples[0][0] < cutoff:
|
||||
state.baseline_samples.popleft()
|
||||
state.baseline_sample_count = len(state.baseline_samples)
|
||||
if state.baseline_sample_count >= self.config.baseline_min_samples:
|
||||
state.baseline_yaw = float(statistics.median(item[1] for item in state.baseline_samples))
|
||||
sample = state.baseline_samples[-1]
|
||||
state.baseline_frame, state.baseline_time = sample[2], sample[3]
|
||||
state.baseline_acquired = True
|
||||
events.append(self._event("baseline_acquired", state, info, pose.yaw, 0.0, TurnLevel.NONE.value,
|
||||
{"sample_count": state.baseline_sample_count}))
|
||||
|
||||
def _end_candidate(self, state: TurnTrackState, info: FrameInfo, yaw: float | None) -> list[TurnEvent]:
|
||||
if state.candidate_start_elapsed is None:
|
||||
return []
|
||||
state.candidate_start_elapsed = None
|
||||
return [self._event("turn_lost", state, info, yaw, None, state.turn_level)]
|
||||
|
||||
def _frame(
|
||||
self, state: TurnTrackState, pose: HeadPoseResult, raw: float | None,
|
||||
toward: float | None, duration: float, reason: str,
|
||||
) -> TurnFrameResult:
|
||||
level = state.turn_level
|
||||
if not pose.pose_estimated:
|
||||
level = TurnLevel.NOT_EVALUABLE.value
|
||||
elif not state.baseline_acquired:
|
||||
level = TurnLevel.NOT_EVALUABLE.value
|
||||
return TurnFrameResult(
|
||||
pose.track_id, pose.frame_number, pose.timestamp_iso, pose.elapsed_time_sec,
|
||||
pose.face_detected, pose.pose_estimated, state.baseline_acquired, state.baseline_yaw,
|
||||
pose.yaw, raw, toward, self.config.signage_yaw_direction,
|
||||
state.candidate_start_elapsed is not None, state.turn_confirmed, level, duration, reason,
|
||||
)
|
||||
|
||||
def finalize_track(self, track_id: int, frame_info: FrameInfo) -> tuple[TurnTrackState | None, list[TurnEvent]]:
|
||||
state = self._states.pop(track_id, None)
|
||||
if state is None:
|
||||
state = self._state(track_id)
|
||||
self._states.pop(track_id, None)
|
||||
self._finalize_validity(state)
|
||||
state.finalized = True
|
||||
self._finalized[track_id] = state
|
||||
event = self._event("turn_track_finalized", state, frame_info, None,
|
||||
state.max_yaw_delta_toward_signage, state.turn_level,
|
||||
{"valid": state.valid_for_turn_analysis, "reason": state.exclusion_reason})
|
||||
return state, [event]
|
||||
|
||||
def _finalize_validity(self, state: TurnTrackState) -> None:
|
||||
if state.face_detected_frames == 0:
|
||||
reason = "no_face_detected"
|
||||
elif state.pose_frames == 0:
|
||||
reason = "no_pose_estimated"
|
||||
elif not state.baseline_acquired:
|
||||
reason = "baseline_not_acquired"
|
||||
elif state.pose_frames < self.config.min_pose_samples_for_evaluation:
|
||||
reason = "insufficient_pose_samples"
|
||||
elif state.face_detection_rate < self.config.min_face_detection_rate_for_evaluation:
|
||||
reason = "low_face_detection_rate"
|
||||
elif state.pose_estimation_rate < self.config.min_pose_estimation_rate_for_evaluation:
|
||||
reason = "low_pose_estimation_rate"
|
||||
else:
|
||||
state.evaluable = True
|
||||
state.valid_for_turn_analysis = True
|
||||
reason = "turn_detected" if state.turn_confirmed else "evaluable_no_turn"
|
||||
state.exclusion_reason = reason
|
||||
if not state.evaluable:
|
||||
state.turn_level = TurnLevel.NOT_EVALUABLE.value
|
||||
elif state.strongest_level != TurnLevel.NONE.value:
|
||||
state.turn_level = state.strongest_level
|
||||
else:
|
||||
state.turn_level = TurnLevel.NONE.value
|
||||
|
||||
def finalize_all(self, frame_info: FrameInfo) -> tuple[list[TurnTrackState], list[TurnEvent]]:
|
||||
states: list[TurnTrackState] = []
|
||||
events: list[TurnEvent] = []
|
||||
for track_id in list(self._states):
|
||||
state, new_events = self.finalize_track(track_id, frame_info)
|
||||
if state is not None:
|
||||
states.append(state)
|
||||
events.extend(new_events)
|
||||
return states, events
|
||||
|
||||
def get_state(self, track_id: int) -> TurnTrackState | None:
|
||||
return self._states.get(track_id) or self._finalized.get(track_id)
|
||||
|
||||
def get_active_states(self) -> dict[int, TurnTrackState]:
|
||||
return dict(self._states)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._states.clear()
|
||||
self._finalized.clear()
|
||||
|
||||
def _state(self, track_id: int) -> TurnTrackState:
|
||||
state = self._states.get(track_id)
|
||||
if state is None:
|
||||
state = TurnTrackState(
|
||||
track_id, yaw_history=deque(maxlen=self.history_max_points),
|
||||
baseline_samples=deque(maxlen=self.history_max_points),
|
||||
)
|
||||
self._states[track_id] = state
|
||||
return state
|
||||
|
||||
@staticmethod
|
||||
def _event(
|
||||
event_type: str, state: TurnTrackState, info: FrameInfo, yaw: float | None,
|
||||
delta: float | None, level: str, metadata: dict[str, object] | None = None,
|
||||
) -> TurnEvent:
|
||||
return TurnEvent(event_type, state.track_id, info.frame_number, info.timestamp_iso,
|
||||
info.elapsed_time_sec, yaw, delta, level, dict(metadata or {}))
|
||||
@@ -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()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Pure Phase 4 yaw and turn-level rules."""
|
||||
|
||||
from .config_loader import TurnDetectionSettings
|
||||
from .models import TurnLevel
|
||||
|
||||
|
||||
LEVEL_ORDER = {
|
||||
TurnLevel.NONE.value: 0,
|
||||
TurnLevel.FACE_APPEARED.value: 0,
|
||||
TurnLevel.SUBTLE.value: 1,
|
||||
TurnLevel.WEAK.value: 2,
|
||||
TurnLevel.MEDIUM.value: 3,
|
||||
TurnLevel.STRONG.value: 4,
|
||||
TurnLevel.NOT_EVALUABLE.value: -1,
|
||||
}
|
||||
|
||||
|
||||
def yaw_delta_toward_signage(current_yaw: float, baseline_yaw: float, direction: str) -> tuple[float, float]:
|
||||
"""Return raw delta and a delta whose positive sign points toward signage."""
|
||||
if direction not in {"positive", "negative"}:
|
||||
raise ValueError("direction must be positive or negative")
|
||||
raw = current_yaw - baseline_yaw
|
||||
return raw, raw if direction == "positive" else -raw
|
||||
|
||||
|
||||
def classify_turn_level(delta: float, config: TurnDetectionSettings) -> str:
|
||||
if delta < config.subtle_threshold_deg:
|
||||
return TurnLevel.NONE.value
|
||||
if delta < config.weak_threshold_deg:
|
||||
return TurnLevel.SUBTLE.value
|
||||
if delta < config.medium_threshold_deg:
|
||||
return TurnLevel.WEAK.value
|
||||
if delta < config.strong_threshold_deg:
|
||||
return TurnLevel.MEDIUM.value
|
||||
return TurnLevel.STRONG.value
|
||||
|
||||
|
||||
def is_weak_or_higher(level: str) -> bool:
|
||||
return LEVEL_ORDER.get(level, -1) >= LEVEL_ORDER[TurnLevel.WEAK.value]
|
||||
|
||||
|
||||
def stronger_level(first: str, second: str) -> str:
|
||||
return second if LEVEL_ORDER.get(second, -1) > LEVEL_ORDER.get(first, -1) else first
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Synchronization between tracking finalization and turn analysis."""
|
||||
|
||||
from .models import FrameInfo, TrackEvent, TurnEvent, TurnTrackState
|
||||
from .turn_detector import TurnDetector
|
||||
|
||||
|
||||
class TurnTrackManager:
|
||||
"""Finalize turn states when the corresponding person track is finalized."""
|
||||
|
||||
def __init__(self, detector: TurnDetector) -> None:
|
||||
self.detector = detector
|
||||
|
||||
def finalize_from_track_events(
|
||||
self, track_events: list[TrackEvent], frame_info: FrameInfo,
|
||||
) -> tuple[list[TurnTrackState], list[TurnEvent]]:
|
||||
states: list[TurnTrackState] = []
|
||||
events: list[TurnEvent] = []
|
||||
for event in track_events:
|
||||
if event.event_type != "track_finalized":
|
||||
continue
|
||||
state, new_events = self.detector.finalize_track(event.track_id, frame_info)
|
||||
if state is not None:
|
||||
states.append(state)
|
||||
events.extend(new_events)
|
||||
return states, events
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Convert Phase 2-6 events to a stable unified schema."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .config_loader import UnifiedLogSettings
|
||||
from .models import (FrameInfo, PersonStateEvent, TrackEvent, TurnEvent, UnifiedEvent,
|
||||
VoicePromptEvent)
|
||||
|
||||
|
||||
class UnifiedEventBuilder:
|
||||
"""Normalize events and generate collision-resistant deterministic IDs."""
|
||||
|
||||
IMPORTANT = {"trigger_crossed", "track_finalized", "turn_confirmed", "prompt_played",
|
||||
"pseudo_prompt_triggered", "response_detected", "response_window_expired",
|
||||
"person_finalized", "excluded", "became_not_evaluable"}
|
||||
|
||||
def __init__(self, config: UnifiedLogSettings, experiment_id: str, session_id: str) -> None:
|
||||
self.config, self.experiment_id, self.session_id = config, experiment_id, session_id
|
||||
self._sequence = 0
|
||||
|
||||
def _build(self, source: str, event: object, **values: object) -> UnifiedEvent | None:
|
||||
event_type = str(getattr(event, "event_type"))
|
||||
if self.config.important_events_only and event_type not in self.IMPORTANT:
|
||||
return None
|
||||
self._sequence += 1
|
||||
frame = int(getattr(event, "frame_number"))
|
||||
event_id = f"{self.session_id}-{frame:08d}-{source}-{self._sequence:08d}"
|
||||
return UnifiedEvent(self.experiment_id, self.session_id, event_id, source, event_type,
|
||||
getattr(event, "track_id", None), frame, str(getattr(event, "timestamp_iso")),
|
||||
float(getattr(event, "elapsed_time_sec")), metadata=dict(getattr(event, "metadata", {})), **values)
|
||||
|
||||
def from_track_events(self, events: list[TrackEvent], frame_info: FrameInfo) -> list[UnifiedEvent]:
|
||||
del frame_info
|
||||
if not self.config.include_tracking_events: return []
|
||||
return [item for event in events if (item := self._build("tracking", event)) is not None]
|
||||
|
||||
def from_turn_events(self, events: list[TurnEvent], frame_info: FrameInfo) -> list[UnifiedEvent]:
|
||||
del frame_info
|
||||
if not self.config.include_turn_events: return []
|
||||
return [item for event in events if (item := self._build("turn", event,
|
||||
turn_confirmed=event.event_type == "turn_confirmed", turn_level=event.turn_level)) is not None]
|
||||
|
||||
def from_voice_events(self, events: list[VoicePromptEvent], frame_info: FrameInfo) -> list[UnifiedEvent]:
|
||||
del frame_info
|
||||
if not self.config.include_voice_events: return []
|
||||
return [item for event in events if (item := self._build("voice", event, voice_mode=event.mode,
|
||||
prompt_played=event.prompt_played, pseudo_prompt=event.pseudo_prompt,
|
||||
turn_confirmed=event.turn_confirmed, turn_level=event.turn_level,
|
||||
response_detected=event.event_type == "response_detected",
|
||||
reaction_time_sec=event.reaction_time_sec)) is not None]
|
||||
|
||||
def from_person_state_events(self, events: list[PersonStateEvent], frame_info: FrameInfo) -> list[UnifiedEvent]:
|
||||
del frame_info
|
||||
if not self.config.include_person_state_events: return []
|
||||
return [item for event in events if (item := self._build("person_state", event,
|
||||
person_status=event.new_status, exclusion_reason=event.reason or None)) is not None]
|
||||
|
||||
def build_session_event(
|
||||
self, event_type: str, frame_info: FrameInfo, metadata: dict[str, object] | None = None,
|
||||
severity: str = "info",
|
||||
) -> UnifiedEvent:
|
||||
self._sequence += 1
|
||||
return UnifiedEvent(self.experiment_id, self.session_id,
|
||||
f"{self.session_id}-{frame_info.frame_number:08d}-session-{self._sequence:08d}",
|
||||
"session", event_type, None, frame_info.frame_number, frame_info.timestamp_iso,
|
||||
frame_info.elapsed_time_sec, severity=severity, metadata=dict(metadata or {}))
|
||||
|
||||
@staticmethod
|
||||
def stable_sort(events: list[UnifiedEvent]) -> list[UnifiedEvent]:
|
||||
return sorted(events, key=lambda item: (item.elapsed_time_sec, item.frame_number, item.source, item.event_id))
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Fixed-column Phase 7 unified CSV writer."""
|
||||
|
||||
from __future__ import annotations
|
||||
import csv, json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
from .config_loader import UnifiedLogSettings
|
||||
from .exceptions import UnifiedLogError
|
||||
from .models import LogConsistencyIssue, PersonAnalysisRecord, SessionSummary, UnifiedEvent
|
||||
|
||||
|
||||
class UnifiedEventLogger:
|
||||
"""Write unified events and deduplicated Phase 8 input tables."""
|
||||
|
||||
EVENT_FIELDS = ["experiment_id","session_id","event_id","source","event_type","track_id","frame_number","timestamp","elapsed_time_sec","person_status","voice_mode","prompt_played","pseudo_prompt","turn_confirmed","turn_level","response_detected","reaction_time_sec","exclusion_reason","severity","metadata_json"]
|
||||
ANALYSIS_FIELDS = ["experiment_id","session_id","track_id","voice_mode","prompt_condition","prompt_played","pseudo_prompt","prompt_time","prompt_elapsed_time_sec","response_window_started","response_detected","reaction_time_sec","response_turn_level","turn_confirmed","turn_level","max_yaw_delta_toward_signage","face_detected_ever","pose_estimated_ever","face_detection_rate","pose_estimation_rate","valid_for_turn_analysis","valid_for_voice_analysis","final_status","completed","excluded","exclusion_reason","track_duration_sec","trigger_crossed","trigger_crossing_direction","movement_direction","camera_position_note","notes"]
|
||||
SESSION_FIELDS = ["experiment_id","session_id","started_at","ended_at","duration_sec","voice_mode","total_tracks","completed_tracks","excluded_tracks","evaluable_turn_tracks","valid_voice_analysis_tracks","prompt_played_count","pseudo_prompt_count","response_detected_count","no_response_count","not_evaluable_count","response_rate","mean_reaction_time_sec","median_reaction_time_sec","weak_or_higher_count","subtle_count","medium_or_higher_count","strong_count","average_fps","camera_position_note","warnings_count","errors_count","notes"]
|
||||
ISSUE_FIELDS = ["experiment_id","session_id","issue_type","severity","track_id","frame_number","message","metadata_json"]
|
||||
|
||||
def __init__(self, config: UnifiedLogSettings, experiment_id: str, session_id: str, run_stamp: str | None = None) -> None:
|
||||
self.config,self.experiment_id,self.session_id=config,experiment_id,session_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._event_ids:set[str]=set(); self._analysis_ids:set[int]=set(); self._session_written=False; self._frames=0
|
||||
|
||||
def open(self) -> None:
|
||||
if not self.config.enabled:return
|
||||
self.directory.mkdir(parents=True,exist_ok=True)
|
||||
try:
|
||||
self._open("events","unified_events",self.EVENT_FIELDS,self.config.log_unified_events)
|
||||
self._open("analysis","person_analysis_table",self.ANALYSIS_FIELDS,self.config.log_person_analysis_table)
|
||||
self._open("session","session_summary",self.SESSION_FIELDS,self.config.log_session_summary)
|
||||
self._open("issues","log_consistency_issues",self.ISSUE_FIELDS,self.config.log_consistency_issues)
|
||||
except OSError as exc:self.close();raise UnifiedLogError(str(exc)) from exc
|
||||
|
||||
def _open(self,key:str,suffix:str,fields:list[str],enabled:bool)->None:
|
||||
if not enabled:return
|
||||
f=(self.directory/f"{self.run_stamp}_{self.experiment_id}_{suffix}.csv").open("w",encoding="utf-8-sig",newline="");w=csv.DictWriter(f,fieldnames=fields);w.writeheader();self._files[key]=f;self._writers[key]=w
|
||||
|
||||
def write_events(self,events:list[UnifiedEvent])->None:
|
||||
w=self._writers.get("events")
|
||||
if w:
|
||||
for e in sorted(events,key=lambda x:(x.elapsed_time_sec,x.frame_number,x.source,x.event_id)):
|
||||
if e.event_id in self._event_ids:continue
|
||||
row={k:getattr(e,k,"") for k in self.EVENT_FIELDS if k not in {"timestamp","metadata_json"}};row["timestamp"]=e.timestamp_iso;row["metadata_json"]=json.dumps(e.metadata,ensure_ascii=False);w.writerow(row);self._event_ids.add(e.event_id)
|
||||
self._frames+=1
|
||||
if self._frames>=self.config.flush_interval_frames:self.flush()
|
||||
|
||||
def write_analysis(self,items:list[PersonAnalysisRecord])->None:
|
||||
w=self._writers.get("analysis")
|
||||
if not w:return
|
||||
for x in items:
|
||||
if x.track_id in self._analysis_ids:continue
|
||||
w.writerow({k:getattr(x,k) for k in self.ANALYSIS_FIELDS});self._analysis_ids.add(x.track_id)
|
||||
|
||||
def write_session(self,item:SessionSummary)->None:
|
||||
w=self._writers.get("session")
|
||||
if w and not self._session_written:w.writerow({k:getattr(item,k) for k in self.SESSION_FIELDS});self._session_written=True
|
||||
|
||||
def write_issues(self,items:list[LogConsistencyIssue])->None:
|
||||
w=self._writers.get("issues")
|
||||
if not w:return
|
||||
for x in items:w.writerow({"experiment_id":self.experiment_id,"session_id":self.session_id,"issue_type":x.issue_type,"severity":x.severity,"track_id":x.track_id,"frame_number":x.frame_number,"message":x.message,"metadata_json":json.dumps(x.metadata,ensure_ascii=False)})
|
||||
|
||||
def flush(self)->None:
|
||||
for f in self._files.values():f.flush()
|
||||
self._frames=0
|
||||
def close(self)->None:
|
||||
for f in self._files.values():f.close()
|
||||
self._files.clear();self._writers.clear()
|
||||
def __enter__(self)->"UnifiedEventLogger":self.open();return self
|
||||
def __exit__(self,*_args:object)->None:self.close()
|
||||
@@ -0,0 +1,184 @@
|
||||
"""OpenCV overlay rendering."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from .config_loader import DisplaySettings, TriggerLineSettings
|
||||
from .models import (Detection, FrameInfo, HeadPoseResult, PixelRegion, TrackedPerson, TrackState,
|
||||
TurnTrackState, VoicePromptTrackState, PersonStateSnapshot)
|
||||
from .turn_rules import is_weak_or_higher
|
||||
|
||||
|
||||
class Visualizer:
|
||||
"""Render detections, experimental geometry, and runtime status."""
|
||||
|
||||
def __init__(self, config: DisplaySettings, warn_fps_threshold: float, stable_frames: int) -> None:
|
||||
self.config = config
|
||||
self.warn_fps_threshold = warn_fps_threshold
|
||||
self.stable_frames = stable_frames
|
||||
|
||||
def draw(
|
||||
self, frame: np.ndarray, detections: list[Detection], info: FrameInfo, region: PixelRegion | None,
|
||||
trigger: TriggerLineSettings, device: str, paused: bool = False, debug: bool = False,
|
||||
tracked_persons: list[TrackedPerson] | None = None, track_states: dict[int, TrackState] | None = None,
|
||||
tracking_counters: dict[str, int] | None = None, tracker_name: str = "disabled",
|
||||
face_results: dict[int, HeadPoseResult] | None = None, face_enabled: bool = False,
|
||||
turn_states: dict[int, TurnTrackState] | None = None, turn_enabled: bool = False,
|
||||
voice_states: dict[int, VoicePromptTrackState] | None = None, voice_mode: str = "disabled",
|
||||
voice_cooldown: float = 0.0,
|
||||
person_states: dict[int, PersonStateSnapshot] | None = None, person_state_enabled: bool = False,
|
||||
) -> np.ndarray:
|
||||
output = frame.copy()
|
||||
height, width = output.shape[:2]
|
||||
if region and self.config.show_evaluation_region:
|
||||
cv2.rectangle(output, (region.x1, region.y1), (region.x2, region.y2), (255, 180, 0), 2)
|
||||
cv2.putText(output, "Evaluation Region", (region.x1 + 4, max(20, region.y1 + 20)), cv2.FONT_HERSHEY_SIMPLEX, .55, (255, 180, 0), 2)
|
||||
position = trigger.pixel_position(width, height)
|
||||
if position is not None and self.config.show_trigger_line:
|
||||
points = ((position, 0), (position, height - 1)) if trigger.orientation == "vertical" else ((0, position), (width - 1, position))
|
||||
cv2.line(output, *points, (255, 0, 255), 2)
|
||||
tracked_persons = tracked_persons or []
|
||||
track_states = track_states or {}
|
||||
face_results = face_results or {}
|
||||
turn_states = turn_states or {}
|
||||
voice_states = voice_states or {}
|
||||
person_states = person_states or {}
|
||||
if self.config.show_bounding_boxes and not tracked_persons:
|
||||
for detection in detections:
|
||||
b = detection.bbox
|
||||
color = (0, 220, 0) if detection.inside_evaluation_region else (0, 140, 255)
|
||||
cv2.rectangle(output, (b.x1, b.y1), (b.x2, b.y2), color, 2)
|
||||
label = "person"
|
||||
if self.config.show_confidence:
|
||||
label += f" {detection.confidence:.2f}"
|
||||
(_, text_h), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, .55, 2)
|
||||
label_y = max(text_h + 4, b.y1 - 5)
|
||||
cv2.putText(output, label, (max(0, b.x1), label_y), cv2.FONT_HERSHEY_SIMPLEX, .55, color, 2)
|
||||
for person in tracked_persons:
|
||||
state = track_states.get(person.track_id)
|
||||
inside = state.current_inside_region if state else person.inside_evaluation_region
|
||||
color = (0, 220, 0) if inside else (0, 140, 255)
|
||||
b = person.bbox
|
||||
if self.config.show_bounding_boxes:
|
||||
cv2.rectangle(output, (b.x1, b.y1), (b.x2, b.y2), color, 2)
|
||||
labels: list[str] = []
|
||||
if self.config.show_track_ids:
|
||||
labels.append(f"ID:{person.track_id}")
|
||||
if self.config.show_confidence:
|
||||
labels.append(f"{person.confidence:.2f}")
|
||||
if self.config.show_movement_direction and state:
|
||||
labels.append(state.movement_direction)
|
||||
if state and state.trigger_crossed:
|
||||
labels.append("CROSSED")
|
||||
if labels:
|
||||
cv2.putText(output, " ".join(labels), (max(0, b.x1), max(18, b.y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, .52, color, 2)
|
||||
if self.config.show_trajectories and state:
|
||||
points = list(state.trajectory)[-self.config.trajectory_max_points:]
|
||||
for previous, current in zip(points, points[1:]):
|
||||
cv2.line(output, (round(previous.foot_x), round(previous.foot_y)),
|
||||
(round(current.foot_x), round(current.foot_y)), color, 2)
|
||||
face = face_results.get(person.track_id)
|
||||
if face is not None:
|
||||
if self.config.show_face_roi and face.roi is not None:
|
||||
roi = face.roi
|
||||
face_color = (255, 255, 0) if face.face_detected else (0, 0, 255)
|
||||
cv2.rectangle(output, (roi.x1, roi.y1), (roi.x2, roi.y2), face_color, 1)
|
||||
if self.config.show_head_pose:
|
||||
if face.pose_estimated:
|
||||
pose_label = f"Yaw:{face.yaw:.1f} Pitch:{face.pitch:.1f} Roll:{face.roll:.1f}"
|
||||
else:
|
||||
raw = ""
|
||||
if all(value is not None for value in (face.pitch, face.yaw, face.roll)):
|
||||
raw = f" P:{face.pitch:.1f} Y:{face.yaw:.1f} R:{face.roll:.1f}"
|
||||
pose_label = f"Pose: NG ({face.failure_reason}){raw}"
|
||||
cv2.putText(output, pose_label, (max(0, b.x1), min(height - 5, b.y1 + 20)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, .45, (255, 255, 0), 1)
|
||||
if self.config.show_face_landmarks:
|
||||
for landmark in face.landmarks:
|
||||
cv2.circle(output, (round(landmark.x), round(landmark.y)), 1, (255, 255, 0), -1)
|
||||
turn = turn_states.get(person.track_id)
|
||||
if self.config.show_turn_status and turn is not None:
|
||||
if turn.baseline_acquired:
|
||||
delta = turn.max_yaw_delta_toward_signage
|
||||
delta_text = f" dYaw:{delta:.1f}" if self.config.show_yaw_delta and delta is not None else ""
|
||||
turn_label = f"Turn:{turn.turn_level}{delta_text}"
|
||||
else:
|
||||
turn_label = "Turn:N/A"
|
||||
turn_color = (0, 0, 255) if turn.turn_confirmed else (255, 255, 255)
|
||||
cv2.putText(output, turn_label, (max(0, b.x1), min(height - 5, b.y1 + 40)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, .48, turn_color, 2)
|
||||
voice = voice_states.get(person.track_id)
|
||||
if self.config.show_voice_status and voice is not None:
|
||||
status = "control" if voice.pseudo_prompt else ("played" if voice.prompt_played else
|
||||
("failed" if voice.prompt_failed else "waiting"))
|
||||
cv2.putText(output, f"Voice:{status}", (max(0, b.x1), min(height - 5, b.y1 + 60)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, .48, (255, 180, 80), 2)
|
||||
person_state = person_states.get(person.track_id)
|
||||
if self.config.show_person_state and person_state is not None:
|
||||
short = {"observing_response": "observing", "pseudo_prompted": "pseudo",
|
||||
"not_evaluable": "not_eval"}.get(person_state.status, person_state.status)
|
||||
cv2.putText(output, f"State:{short}", (max(0, b.x1), min(height - 5, b.y1 + 80)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, .48, (180, 255, 180), 2)
|
||||
lines: list[str] = []
|
||||
if self.config.show_fps:
|
||||
lines.append(f"FPS: {info.measured_fps:.1f}")
|
||||
if self.config.show_detection_count:
|
||||
lines.append(f"Raw Detections: {len(detections)}")
|
||||
if self.config.show_tracking_counters and tracking_counters is not None:
|
||||
lines.extend((
|
||||
f"Active Tracks: {tracking_counters.get('active', 0)}",
|
||||
f"Unique Tracks: {tracking_counters.get('unique', 0)}",
|
||||
f"Region Visitors: {tracking_counters.get('visitors', 0)}",
|
||||
f"Trigger Crossings: {tracking_counters.get('crossings', 0)}",
|
||||
))
|
||||
if self.config.show_face_counters:
|
||||
current_faces = [face_results.get(person.track_id) for person in tracked_persons]
|
||||
lines.append(f"Face: {'enabled' if face_enabled else 'disabled'}")
|
||||
if face_enabled:
|
||||
lines.append(f"Face OK: {sum(bool(item and item.face_detected) for item in current_faces)}/{len(tracked_persons)}")
|
||||
lines.append(f"Pose OK: {sum(bool(item and item.pose_estimated) for item in current_faces)}/{len(tracked_persons)}")
|
||||
if self.config.show_turn_counters:
|
||||
active_turns = [turn_states.get(person.track_id) for person in tracked_persons]
|
||||
lines.append(f"Turn: {'enabled' if turn_enabled else 'disabled'}")
|
||||
if turn_enabled:
|
||||
lines.append(f"Turn Eval: {sum(bool(item and item.baseline_acquired) for item in active_turns)}/{len(tracked_persons)}")
|
||||
lines.append(f"Turn Confirmed: {sum(bool(item and item.turn_confirmed) for item in active_turns)}")
|
||||
lines.append(f"Subtle: {sum(bool(item and item.strongest_level == 'subtle') for item in active_turns)}")
|
||||
lines.append(f"Weak+: {sum(bool(item and is_weak_or_higher(item.strongest_level)) for item in active_turns)}")
|
||||
if self.config.show_voice_counters:
|
||||
voice_values = list(voice_states.values())
|
||||
lines.append(f"Voice: {voice_mode}")
|
||||
if voice_mode != "disabled":
|
||||
lines.append(f"Prompted: {sum(item.prompt_played or item.pseudo_prompt for item in voice_values)}")
|
||||
lines.append(f"Responses: {sum(item.response_detected for item in voice_values)}")
|
||||
lines.append(f"Cooldown: {voice_cooldown:.1f}s")
|
||||
if self.config.show_person_state_counters:
|
||||
counts: dict[str, int] = {}
|
||||
for state in person_states.values(): counts[state.status] = counts.get(state.status, 0) + 1
|
||||
lines.append(f"State: {'enabled' if person_state_enabled else 'disabled'}")
|
||||
if person_state_enabled:
|
||||
labels = ("tracked", "eligible", "prompted", "observing_response", "responded", "excluded")
|
||||
lines.append(" ".join(f"{name[:4]}={counts.get(name, 0)}" for name in labels))
|
||||
if self.config.show_frame_number:
|
||||
lines.append(f"Frame: {info.frame_number}")
|
||||
if self.config.show_timestamp:
|
||||
try:
|
||||
shown_time = datetime.fromisoformat(info.timestamp_iso).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
||||
except ValueError:
|
||||
shown_time = info.timestamp_iso
|
||||
lines.append(f"Time: {shown_time}")
|
||||
lines.append(f"Device: {device.upper()}")
|
||||
lines.append(f"Tracker: {tracker_name}")
|
||||
if debug:
|
||||
lines.append(f"Resolution: {width}x{height}")
|
||||
for index, text in enumerate(lines):
|
||||
y = 25 + index * 24
|
||||
cv2.putText(output, text, (11, y + 1), cv2.FONT_HERSHEY_SIMPLEX, .62, (0, 0, 0), 3)
|
||||
cv2.putText(output, text, (10, y), cv2.FONT_HERSHEY_SIMPLEX, .62, (255, 255, 255), 1)
|
||||
if info.frame_number >= self.stable_frames and 0 < info.measured_fps < self.warn_fps_threshold:
|
||||
cv2.putText(output, "LOW FPS", (width - 130, 30), cv2.FONT_HERSHEY_SIMPLEX, .75, (0, 0, 255), 2)
|
||||
if paused:
|
||||
cv2.putText(output, "PAUSED", (max(10, width // 2 - 70), max(40, height // 2)), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 255), 3)
|
||||
return output
|
||||
@@ -0,0 +1,122 @@
|
||||
"""UTF-8 BOM CSV logging for Phase 5 voice prompts and responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
|
||||
from .config_loader import VoiceLoggingSettings
|
||||
from .exceptions import VoiceLogError
|
||||
from .models import VoicePromptDecision, VoicePromptEvent, VoicePromptTrackState
|
||||
|
||||
|
||||
class VoiceLogger:
|
||||
"""Write voice decisions, events, and one summary per track."""
|
||||
|
||||
DECISION_FIELDS = ["experiment_id", "frame_number", "timestamp", "elapsed_time_sec", "track_id", "mode",
|
||||
"eligible", "should_prompt", "prompt_played", "pseudo_prompt", "reason", "audio_file"]
|
||||
EVENT_FIELDS = ["experiment_id", "event_type", "track_id", "frame_number", "timestamp", "elapsed_time_sec",
|
||||
"mode", "prompt_played", "pseudo_prompt", "audio_file", "turn_confirmed", "turn_level",
|
||||
"reaction_time_sec", "metadata_json"]
|
||||
SUMMARY_FIELDS = ["experiment_id", "track_id", "mode", "eligible", "eligibility_frame", "eligibility_time",
|
||||
"prompt_attempted", "prompt_played", "prompt_failed", "pseudo_prompt", "prompt_frame",
|
||||
"prompt_time", "prompt_elapsed_time_sec", "audio_file", "response_window_started",
|
||||
"response_window_start_frame", "response_window_start_time", "response_window_end_time_sec",
|
||||
"response_detected", "response_frame", "response_time", "reaction_time_sec",
|
||||
"response_turn_level", "max_yaw_delta_after_prompt", "final_turn_level",
|
||||
"valid_for_voice_analysis", "exclusion_reason"]
|
||||
|
||||
def __init__(self, config: VoiceLoggingSettings, 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 = 0
|
||||
|
||||
def open(self) -> None:
|
||||
if not self.config.enabled:
|
||||
return
|
||||
self.directory.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
self._open("decisions", "voice_decisions", self.DECISION_FIELDS, self.config.log_decisions)
|
||||
self._open("events", "voice_events", self.EVENT_FIELDS, self.config.log_events)
|
||||
self._open("summary", "voice_summary", self.SUMMARY_FIELDS, self.config.log_summaries)
|
||||
except OSError as exc:
|
||||
self.close()
|
||||
raise VoiceLogError(f"Failed to open voice 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_decisions(self, items: list[VoicePromptDecision]) -> None:
|
||||
writer = self._writers.get("decisions")
|
||||
if writer is not None:
|
||||
for item in items:
|
||||
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, "mode": item.mode, "eligible": item.eligible,
|
||||
"should_prompt": item.should_prompt, "prompt_played": item.prompt_played,
|
||||
"pseudo_prompt": item.pseudo_prompt, "reason": item.reason,
|
||||
"audio_file": item.audio_file or ""})
|
||||
self._frames += 1
|
||||
if self._frames >= self.config.flush_interval_frames:
|
||||
self.flush()
|
||||
|
||||
def write_events(self, items: list[VoicePromptEvent]) -> None:
|
||||
writer = self._writers.get("events")
|
||||
if writer is None:
|
||||
return
|
||||
for item in items:
|
||||
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),
|
||||
"mode": item.mode, "prompt_played": item.prompt_played,
|
||||
"pseudo_prompt": item.pseudo_prompt, "audio_file": item.audio_file or "",
|
||||
"turn_confirmed": item.turn_confirmed, "turn_level": item.turn_level or "",
|
||||
"reaction_time_sec": self._number(item.reaction_time_sec),
|
||||
"metadata_json": json.dumps(item.metadata, ensure_ascii=False)})
|
||||
|
||||
def write_summary(self, state: VoicePromptTrackState) -> 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 ("prompt_elapsed_time_sec", "response_window_end_time_sec", "reaction_time_sec", "max_yaw_delta_after_prompt"):
|
||||
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 = 0
|
||||
|
||||
def close(self) -> None:
|
||||
for stream in self._files.values():
|
||||
stream.close()
|
||||
self._files.clear()
|
||||
self._writers.clear()
|
||||
|
||||
def __enter__(self) -> "VoiceLogger":
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.close()
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Track-aware voice prompt and post-prompt response controller."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .audio_player import AudioPlayer
|
||||
from .config_loader import VoicePromptSettings
|
||||
from .models import (FrameInfo, TrackEvent, TrackedPerson, TurnFrameResult, TurnTrackState,
|
||||
VoicePromptDecision, VoicePromptEvent, VoicePromptTrackState)
|
||||
from .turn_rules import is_weak_or_higher
|
||||
from .voice_rules import eligibility_reason, trigger_matches
|
||||
|
||||
|
||||
class VoicePromptController:
|
||||
"""Issue at most one prompt per track and associate later turn responses."""
|
||||
|
||||
def __init__(
|
||||
self, config: VoicePromptSettings, audio_player: AudioPlayer,
|
||||
turn_available: bool = True, nominal_fps: float = 30.0,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.audio_player = audio_player
|
||||
self.turn_available = turn_available
|
||||
self.nominal_fps = nominal_fps if nominal_fps > 0 else 30.0
|
||||
self._states: dict[int, VoicePromptTrackState] = {}
|
||||
self._finalized: dict[int, VoicePromptTrackState] = {}
|
||||
self._last_prompt_elapsed: float | None = None
|
||||
self._skip_events: set[tuple[int, str]] = set()
|
||||
|
||||
def update(
|
||||
self, tracked_persons: list[TrackedPerson], track_events: list[TrackEvent],
|
||||
turn_results: list[TurnFrameResult], frame_info: FrameInfo,
|
||||
turn_states: dict[int, TurnTrackState] | None = None,
|
||||
) -> tuple[list[VoicePromptDecision], list[VoicePromptEvent]]:
|
||||
if not self.config.enabled or self.config.mode == "disabled":
|
||||
return [], []
|
||||
decisions: list[VoicePromptDecision] = []
|
||||
events: list[VoicePromptEvent] = []
|
||||
turns = turn_states or {}
|
||||
results = {item.track_id: item for item in turn_results}
|
||||
people = {person.track_id: person for person in tracked_persons}
|
||||
for person in tracked_persons:
|
||||
state = self._state(person.track_id)
|
||||
self._observe_response(state, results.get(person.track_id), frame_info, events)
|
||||
if not trigger_matches(self.config, person, track_events):
|
||||
continue
|
||||
remaining = self.cooldown_remaining(frame_info.elapsed_time_sec)
|
||||
reason = eligibility_reason(self.config, person, state, turns.get(person.track_id), remaining, self.nominal_fps)
|
||||
eligible = reason == "eligible"
|
||||
decision = self._prompt(state, person, frame_info, eligible, reason, events)
|
||||
decisions.append(decision)
|
||||
for track_id, state in list(self._states.items()):
|
||||
if track_id not in people:
|
||||
self._observe_response(state, results.get(track_id), frame_info, events)
|
||||
self._expire_window(state, frame_info, events)
|
||||
return decisions, events
|
||||
|
||||
def _prompt(
|
||||
self, state: VoicePromptTrackState, person: TrackedPerson, info: FrameInfo,
|
||||
eligible: bool, reason: str, events: list[VoicePromptEvent],
|
||||
) -> VoicePromptDecision:
|
||||
if not eligible:
|
||||
key = (state.track_id, reason)
|
||||
if key not in self._skip_events:
|
||||
events.append(self._event("prompt_skipped", state, info, metadata={"reason": reason}))
|
||||
self._skip_events.add(key)
|
||||
return self._decision(state, info, False, False, reason)
|
||||
state.eligible = True
|
||||
state.eligibility_frame, state.eligibility_time = info.frame_number, info.timestamp_iso
|
||||
events.append(self._event("prompt_eligible", state, info))
|
||||
state.prompt_attempted = self.config.mode == "prompt"
|
||||
state.prompt_frame, state.prompt_time = info.frame_number, info.timestamp_iso
|
||||
state.prompt_elapsed_time_sec = info.elapsed_time_sec
|
||||
state.audio_file = self.audio_player.audio_file
|
||||
if self.config.mode == "control":
|
||||
state.pseudo_prompt = True
|
||||
reason = "pseudo_prompt"
|
||||
events.append(self._event("pseudo_prompt_triggered", state, info))
|
||||
else:
|
||||
state.prompt_played = self.audio_player.play()
|
||||
state.prompt_failed = not state.prompt_played
|
||||
reason = "prompt_played" if state.prompt_played else "prompt_failed"
|
||||
events.append(self._event(reason, state, info))
|
||||
if state.prompt_played or state.pseudo_prompt:
|
||||
self._last_prompt_elapsed = info.elapsed_time_sec
|
||||
state.response_window_started = True
|
||||
state.response_window_start_frame, state.response_window_start_time = info.frame_number, info.timestamp_iso
|
||||
state.response_window_end_time_sec = info.elapsed_time_sec + self.config.response_window_sec
|
||||
events.append(self._event("response_window_started", state, info))
|
||||
return self._decision(state, info, True, True, reason)
|
||||
|
||||
def _observe_response(
|
||||
self, state: VoicePromptTrackState, result: TurnFrameResult | None,
|
||||
info: FrameInfo, events: list[VoicePromptEvent],
|
||||
) -> None:
|
||||
if result is None or not state.response_window_started or state.response_detected:
|
||||
return
|
||||
if result.yaw_delta_toward_signage is not None:
|
||||
current = state.max_yaw_delta_after_prompt
|
||||
state.max_yaw_delta_after_prompt = result.yaw_delta_toward_signage if current is None else max(current, result.yaw_delta_toward_signage)
|
||||
state.final_turn_level = result.turn_level
|
||||
if (result.turn_confirmed and is_weak_or_higher(result.turn_level)
|
||||
and state.response_window_end_time_sec is not None
|
||||
and info.elapsed_time_sec <= state.response_window_end_time_sec):
|
||||
state.response_detected = True
|
||||
state.response_frame, state.response_time = info.frame_number, info.timestamp_iso
|
||||
state.reaction_time_sec = max(0.0, info.elapsed_time_sec - (state.prompt_elapsed_time_sec or 0.0))
|
||||
state.response_turn_level = result.turn_level
|
||||
events.append(self._event("response_detected", state, info, True, result.turn_level,
|
||||
state.reaction_time_sec))
|
||||
|
||||
def _expire_window(self, state: VoicePromptTrackState, info: FrameInfo, events: list[VoicePromptEvent]) -> None:
|
||||
if (state.response_window_started and not state.response_detected and not state.response_window_expired
|
||||
and state.response_window_end_time_sec is not None
|
||||
and info.elapsed_time_sec > state.response_window_end_time_sec):
|
||||
state.response_window_expired = True
|
||||
events.append(self._event("response_window_expired", state, info))
|
||||
|
||||
def cooldown_remaining(self, elapsed: float) -> float:
|
||||
if self._last_prompt_elapsed is None:
|
||||
return 0.0
|
||||
return max(0.0, self.config.global_cooldown_sec - (elapsed - self._last_prompt_elapsed))
|
||||
|
||||
def finalize_track(
|
||||
self, track_id: int, frame_info: FrameInfo, turn_state: TurnTrackState | None = None,
|
||||
) -> tuple[VoicePromptTrackState | None, list[VoicePromptEvent]]:
|
||||
state = self._states.pop(track_id, None)
|
||||
if state is None:
|
||||
state = VoicePromptTrackState(track_id, self.config.mode)
|
||||
state.final_turn_level = turn_state.turn_level if turn_state else state.final_turn_level
|
||||
if not state.eligible:
|
||||
state.exclusion_reason = "not_eligible"
|
||||
elif not (state.prompt_played or state.pseudo_prompt):
|
||||
state.exclusion_reason = "prompt_failed"
|
||||
elif not self.turn_available:
|
||||
state.exclusion_reason = "turn_disabled"
|
||||
elif turn_state is None:
|
||||
state.exclusion_reason = "no_turn_state"
|
||||
elif turn_state is not None and not turn_state.evaluable:
|
||||
state.exclusion_reason = turn_state.exclusion_reason
|
||||
else:
|
||||
state.valid_for_voice_analysis = True
|
||||
state.exclusion_reason = "response_detected" if state.response_detected else "no_response"
|
||||
state.finalized = True
|
||||
self._finalized[track_id] = state
|
||||
return state, [self._event("voice_track_finalized", state, frame_info)]
|
||||
|
||||
def finalize_all(
|
||||
self, frame_info: FrameInfo, turn_states: dict[int, TurnTrackState] | None = None,
|
||||
) -> tuple[list[VoicePromptTrackState], list[VoicePromptEvent]]:
|
||||
states: list[VoicePromptTrackState] = []
|
||||
events: list[VoicePromptEvent] = []
|
||||
for track_id in list(self._states):
|
||||
state, new = self.finalize_track(track_id, frame_info, (turn_states or {}).get(track_id))
|
||||
if state is not None:
|
||||
states.append(state)
|
||||
events.extend(new)
|
||||
return states, events
|
||||
|
||||
def get_state(self, track_id: int) -> VoicePromptTrackState | None:
|
||||
return self._states.get(track_id) or self._finalized.get(track_id)
|
||||
|
||||
def get_active_states(self) -> dict[int, VoicePromptTrackState]:
|
||||
return dict(self._states)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._states.clear()
|
||||
self._finalized.clear()
|
||||
self._last_prompt_elapsed = None
|
||||
self._skip_events.clear()
|
||||
|
||||
def _state(self, track_id: int) -> VoicePromptTrackState:
|
||||
return self._states.setdefault(track_id, VoicePromptTrackState(track_id, self.config.mode))
|
||||
|
||||
def _decision(self, state: VoicePromptTrackState, info: FrameInfo, eligible: bool, should: bool, reason: str) -> VoicePromptDecision:
|
||||
return VoicePromptDecision(state.track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec,
|
||||
self.config.mode, eligible, should, state.prompt_played, state.pseudo_prompt,
|
||||
reason, state.audio_file)
|
||||
|
||||
def _event(
|
||||
self, kind: str, state: VoicePromptTrackState, info: FrameInfo,
|
||||
confirmed: bool = False, level: str | None = None, reaction: float | None = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
) -> VoicePromptEvent:
|
||||
return VoicePromptEvent(kind, state.track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec,
|
||||
self.config.mode, state.prompt_played, state.pseudo_prompt, state.audio_file,
|
||||
confirmed, level, reaction, dict(metadata or {}))
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Pure eligibility rules for Phase 5 voice prompting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .config_loader import VoicePromptSettings
|
||||
from .models import TrackEvent, TrackedPerson, TurnTrackState, VoicePromptTrackState
|
||||
|
||||
|
||||
def trigger_matches(
|
||||
config: VoicePromptSettings, person: TrackedPerson, events: list[TrackEvent],
|
||||
) -> bool:
|
||||
"""Return whether the configured spatial/track trigger occurred."""
|
||||
own_events = {event.event_type for event in events if event.track_id == person.track_id}
|
||||
if config.trigger_strategy == "trigger_line_crossing":
|
||||
return "trigger_crossed" in own_events
|
||||
if config.trigger_strategy == "region_entry":
|
||||
return "region_entered" in own_events
|
||||
if config.trigger_strategy == "first_confirmed_track":
|
||||
return person.is_confirmed
|
||||
value = person.foot_x if config.fixed_position_axis == "x" else person.foot_y
|
||||
return value >= config.fixed_position_value if config.fixed_position_direction == "increasing" else value <= config.fixed_position_value
|
||||
|
||||
|
||||
def eligibility_reason(
|
||||
config: VoicePromptSettings, person: TrackedPerson, state: VoicePromptTrackState,
|
||||
turn_state: TurnTrackState | None, cooldown_remaining: float, nominal_fps: float = 30.0,
|
||||
) -> str:
|
||||
if config.one_prompt_per_track and (state.prompt_attempted or state.pseudo_prompt):
|
||||
return "already_prompted"
|
||||
if config.require_confirmed_track and not person.is_confirmed:
|
||||
return "unconfirmed_track"
|
||||
if config.require_inside_region and not person.inside_evaluation_region:
|
||||
return "outside_region"
|
||||
if person.missed_frames > 0:
|
||||
return "stale_track"
|
||||
if person.track_age_frames <= 0 or person.track_age_frames / max(nominal_fps, 1.0) < config.minimum_track_age_sec:
|
||||
return "track_too_young"
|
||||
if config.skip_if_already_turned and turn_state is not None and turn_state.turn_confirmed:
|
||||
return "already_turned"
|
||||
if cooldown_remaining > 0:
|
||||
return "global_cooldown"
|
||||
return "eligible"
|
||||
Reference in new issue
Block a user