This commit is contained in:
agomizi1cm committed 2026-08-09 21:41:44 +09:00
commit c5bcb95d74
172 files changed
+9487

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
"""Phase 1-5 pedestrian detection, tracking, pose, turn, and voice system."""
__version__ = "0.11.0"
+17
View File
@@ -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
+45
View File
@@ -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
+33
View File
@@ -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
+23
View File
@@ -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
+39
View File
@@ -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]
+569
View File
@@ -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")
+88
View File
@@ -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
+18
View File
@@ -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
+78
View File
@@ -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()
+729
View File
@@ -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
+47
View File
@@ -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()
+71
View File
@@ -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()
+90
View File
@@ -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
+61
View File
@@ -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."""
+11
View File
@@ -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")))
+18
View File
@@ -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
+23
View File
@@ -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)
+113
View File
@@ -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()
+192
View File
@@ -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()
+44
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
"""Phase 9 desktop GUI package."""
from .gui_models import GuiAnalysisRequest, GuiExperimentRequest
__all__ = ["GuiAnalysisRequest", "GuiExperimentRequest"]
+32
View File
@@ -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
+24
View File
@@ -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])
+40
View File
@@ -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)
+9
View File
@@ -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")
+67
View File
@@ -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
+11
View File
@@ -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()
+39
View File
@@ -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
+5
View File
@@ -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)
+79
View File
@@ -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()
+26
View File
@@ -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
+4
View File
@@ -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."))
+12
View File
@@ -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])
+13
View File
@@ -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)
+25
View File
@@ -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))
+100
View File
@@ -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)
+14
View File
@@ -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
+35
View File
@@ -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
View File
@@ -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=""
+96
View File
@@ -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()
+308
View File
@@ -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, {})
+73
View File
@@ -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)
+18
View File
@@ -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)
+9
View File
@@ -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()
+42
View File
@@ -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
+22
View File
@@ -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
+10
View File
@@ -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"- ![{p.stem}](plots/{p.name})" 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
+45
View File
@@ -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
+16
View File
@@ -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
+38
View File
@@ -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")
+62
View File
@@ -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
+44
View File
@@ -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.
+209
View File
@@ -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
View File
@@ -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"
+139
View File
@@ -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()
+223
View File
@@ -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 {}))
+132
View File
@@ -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()
+43
View File
@@ -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
+25
View File
@@ -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
+71
View File
@@ -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))
+72
View File
@@ -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()
+184
View File
@@ -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
+122
View File
@@ -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()
+186
View File
@@ -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 {}))
+42
View File
@@ -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"