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

+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=""