commit c5bcb95d741548be65aa1d2dcaf51b9808f8f2ef Author: KoyoNoguchi Date: Sun Aug 9 21:41:44 2026 +0900 Phase 11 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c72923 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +.codex-test-venv/ +venv/ +env/ +.idea/ +.vscode/ +*.log +*.pt +*.pth +data/input/* +data/output/videos/* +data/output/screenshots/* +data/logs/detections/* +data/logs/runtime/* +data/logs/tracking/* +data/logs/face/* +data/logs/turn/* +data/logs/voice/* +data/logs/person_state/* +data/logs/unified/* +data/analysis/* +data/gui/* +data/experiment_notes/* +data/calibration_history/* +!data/analysis/plots/ +assets/audio/* +!**/.gitkeep diff --git a/README.md b/README.md new file mode 100644 index 0000000..87f4052 --- /dev/null +++ b/README.md @@ -0,0 +1,442 @@ +# Phase 1〜8 人物検知・声掛け研究/統計解析システム + +デジタルサイネージ付近の通行人に対する声掛け研究の基盤です。Webカメラまたは動画を入力し、Ultralytics YOLOで現在フレームの人物をリアルタイム検出します。 + +## Phase 8の概要 + +Phase 8は複数セッションの `*_person_analysis_table.csv` を読み込み、Prompt条件とControl条件の反応率、reaction time、最大Yaw変化、Turn level、除外理由を比較するオフライン解析です。解析時にカメラ、YOLO、MediaPipeは初期化しません。 + +```powershell +python main.py analyze --input data/logs/unified --output data/analysis +python main.py analyze --input data/logs/unified --output data/analysis --no-analysis-plots +``` + +主解析の反応率は次の定義です。 + +```text +response_rate = response_detected_count / valid_for_voice_analysis=True の人数 +``` + +`valid_for_voice_analysis=False`はno responseではなく分析対象外です。顔向き評価不能者を分母や非反応へ混ぜません。 + +反応率は期待度数が小さい場合にFisherの正確確率検定、十分な場合にカイ二乗検定を自動選択します。数値指標は既定でMann–Whitney U検定を使い、rank-biserial correlationを効果量として保存します。反応率では率差、オッズ比、カイ二乗時のφ係数を保存します。 + +出力CSV: + +- `condition_summary.csv` +- `response_rate_test.csv` +- `numeric_comparisons.csv` +- `turn_level_distribution.csv` +- `exclusion_summary.csv` +- `analysis_run_summary.csv` + +出力グラフ: + +- `response_rate_by_condition.png` +- `reaction_time_by_condition.png` +- `max_yaw_delta_by_condition.png` +- `turn_level_distribution.png` +- `exclusion_reason_counts.png` + +p値は「条件間に差がない」という仮説のもとで、観測差以上が得られる確率です。小さいp値は差の可能性を示しますが、音声が原因で振り向いたことを直接証明しません。サンプル数が少ないと検出力が低いため、p値だけでなく効果量と各条件の分母を確認してください。 + +PromptとControlは同一trigger、response window、カメラ配置で比較し、session IDと `camera_position_note`を確認してください。評価不能が多い場合はカメラ配置、Face ROI、照明を見直します。Phase 9ではログ選択・条件切替・結果閲覧GUIを追加予定です。 + +## Phase 7の概要 + +Phase 7はtracking、turn、voice、person stateの重要イベントを同一のsession ID・時刻軸・track IDへ統合します。既存ログを置き換えず、`data/logs/unified/`へ次の解析用CSVを追加します。 + +- `*_unified_events.csv`: source付き時系列イベント +- `*_person_analysis_table.csv`: prompt/control共通列を持つ人物単位解析表 +- `*_session_summary.csv`: セッション集計 +- `*_log_consistency_issues.csv`: 非致命的な整合性警告 + +sourceはtracking、face、turn、voice、person_state、session、systemです。既定では巨大化を避けるためface frameは統合イベントに含めず、人物summary側へ反映します。全ファイルへ実行ごとのsession IDとexperiment IDを保存します。 + +`person_analysis_table.csv`はprompt condition、prompt/pseudo、response、reaction time、Turn、顔・Pose成功率、分析可否、除外理由、カメラ位置メモを同じ列構造で保持します。`valid_for_turn_analysis=false`は「振り向かなかった」ではなく評価不能です。 + +セッションのresponse rateは次の定義です。 + +```text +response_detected_count / valid_voice_analysis_tracks +``` + +全trackを分母にせず、顔向き評価不能者をno responseへ混ぜません。これは観測結果の集計であり、声掛けとの因果関係を断定するものではありません。 + +カメラ位置は結果に大きく影響するため、実行ごとに記録してください。 + +```powershell +python main.py --config config/experiment.yaml --voice-mode prompt --camera-position-note "サイネージ正面寄り。顔が見える位置。" +python main.py --config config/experiment.yaml --voice-mode control --camera-position-note "サイネージ正面寄り。顔が見える位置。" +python main.py --config config/experiment.yaml --disable-unified-logging +``` + +整合性チェックは、promptなしresponse、負のreaction time、turnなしresponse、windowなしprompt、control以外のpseudo、unknown条件、除外理由欠落、summary重複をwarningとして記録し、アプリを停止しません。 + +## Phase 6の概要 + +`PersonStateManager`はTrack、Face、Turn、Voiceの既存モジュールを置き換えず、track IDをキーに結果を統合します。track IDは実行中だけ有効な一時IDであり、顔認証や個人識別ではありません。 + +状態は `new / tracked / inside_region / eligible / prompted / pseudo_prompted / observing_response / responded / no_response / completed / excluded / not_evaluable / lost` です。promptとcontrolは同じ遷移を使い、音声提示だけを`prompted`、無音の観測開始を`pseudo_prompted`として区別します。 + +顔未検出は「見ていない」ではなく評価不能です。no face、no pose、baseline不足、短いtrack、prompt失敗、prompt前/観測中lostなどを統一除外理由へ変換します。既定ではnot evaluableを即座にexcludedへ統合せず、標本構成を確認できるよう別状態で保存します。 + +追加ログは `data/logs/person_state/` に保存されます。 + +- `*_person_state_frames.csv`: 更新ごとの統合状態 +- `*_person_state_events.csv`: 状態遷移 +- `*_person_state_summary.csv`: 人物単位のTrack/Face/Turn/Voice統合結果 + +Phase 5相当へ戻す場合: + +```powershell +python main.py --config config/experiment.yaml --disable-person-state +``` + +prompt/controlの実機確認では、tracked→inside→eligible→promptedまたはpseudo→observing→responded/no_response→completedの遷移とCSVを確認してください。tracker無効時はtrack IDがないためPerson Stateも自動無効になります。face/turn無効時は関連値をNoneまたはnot evaluable、voice無効時はvoice disabledとして統合します。 + +## Phase 5の概要 + +Phase 5は、track ID付きのライン通過などを契機に短い音声を再生し、その後のPhase 4 `turn_confirmed` を反応観測として関連付けます。記録される `reaction_time_sec` は「音声またはpseudo prompt後に振り向きが観測された時間」であり、声掛けとの因果関係を断定する値ではありません。track IDも実行中だけ有効な一時IDで、個人識別ではありません。 + +モードは次の3種類です。 + +- `prompt`: 条件成立時にpygameで音声を非同期再生 +- `control`: 音声を鳴らさず、同じ条件成立時刻をpseudo promptとして保存 +- `disabled`: Phase 4相当として声掛け制御とvoice CSVを無効化 + +既定の `trigger_line_crossing` は人物との距離・位置を揃えやすく、再現性が高いため推奨します。ほかに `region_entry`、`fixed_x_position`、デバッグ用の `first_confirmed_track` を選択できます。同一trackへの声掛けは原則1回で、連続通過時は `global_cooldown_sec` により過剰再生を抑えます。 + +音声ファイルはユーザーが `assets/audio/prompt.wav` へ配置してください。MP3/WAVに対応するpygameを使用します。ファイルが存在しない、pygame初期化に失敗する、再生デバイスがない場合は警告と `prompt_failed` を記録して映像処理を継続します。実験時は1〜2秒程度の固定音声を使い、PC・スピーカー音量と設置位置を一定にしてください。 + +```powershell +python main.py --config config/experiment.yaml --voice-mode prompt +python main.py --config config/experiment.yaml --voice-mode control +python main.py --config config/experiment.yaml --disable-voice +python main.py --audio-file assets/audio/prompt.wav --voice-volume 0.8 +``` + +`response_window_sec`(既定3秒)以内にweak以上の確定振り向きが発生すると `response_detected=true` とし、prompt/pseudo promptからの経過を `reaction_time_sec` に保存します。subtleとface appearedは補助情報であり、単独ではresponse確定にしません。Turnが無効または評価不能なら `valid_for_voice_analysis=false` として除外理由を残します。 + +出力は `data/logs/voice/` の3種類です。 + +- `*_voice_decisions.csv`: eligibility、skip理由、再生成否 +- `*_voice_events.csv`: prompt/pseudo prompt、観測開始、response、期限切れ、最終化 +- `*_voice_summary.csv`: 人物ごとのprompt時刻、反応時間、最大Yaw変化、分析可否 + +音声が鳴らない場合は、ファイルパス、Windowsの出力デバイス、音量、`audio.enabled`、`voice_prompt.mode`、pygame導入を確認してください。tracker無効時はVoiceも自動無効になります。Turn無効時もprompt/controlは実行できますが、反応分析は無効です。 + +## Phase 4の概要 + +`HeadPoseResult.yaw` のtrack ID別時系列から基準Yawとの差を計算し、サイネージ方向への顔向き変化を検出します。顔が検出できないことは「見ていない」ではなく「評価不能」です。全追跡人物を、評価可能な振り向きあり/なしと、評価不能へ明確に分離します。 + +`turn_detection.signage_yaw_direction` が `positive` ならYaw増加、`negative` ならYaw減少をサイネージ方向とします。サイネージ方向の変化が必ず正になるよう正規化します。カメラ配置、左右反転、Head Poseの符号によって設定が変わるため、実機で左右を向いて必ず確認してください。 + +基準Yaw方式: + +- `first_valid`: 最初の有効Yaw群の中央値。Phase 4の既定値 +- `before_trigger`: トリガー通過前のYaw中央値 +- `region_entry_window`: 評価領域進入直後のYaw中央値 + +`first_valid` は評価しやすい一方、最初からサイネージを向いている人物の変化を過小評価します。Phase 5でも既存baselineを利用し、音声直前baselineへの再取得は今後の実験状態管理で拡張する制約があります。 + +振り向きレベルはサイネージ方向のYaw変化量で分類します。 + +| 変化量 | レベル | 扱い | +|---:|---|---| +| 10°未満 | none | 評価可能・閾値未満 | +| 10°以上 | subtle | 補助的な微弱反応 | +| 20°以上 | weak | 主判定の振り向き | +| 35°以上 | medium | 中程度 | +| 50°以上 | strong | 強い振り向き | + +weak以上が既定0.30秒継続すると `turn_confirmed` になります。subtleは0.20秒以上を補助反応として保持します。0.30秒以内の短いpose欠落は同じ継続として扱います。顔未検出から検出可能へ変わった場合は `face_appeared` を補助イベントとして保存しますが、それだけでは振り向き確定にしません。 + +## Phase 3の概要 + +確定した `TrackedPerson` の人物Box上部からFaceROIを作り、ROIごとにMediaPipe Face Meshを実行します。MediaPipeは軽量で468点(refine有効時は追加点を含む)の顔ランドマークを取得でき、人物track IDと安定して結び付けやすいため採用しました。MediaPipe固有型は `FaceProcessor` 内で独自の `FaceLandmark` と `HeadPoseResult` へ変換します。 + +処理の流れは次のとおりです。 + +```text +TrackedPerson -> FaceROI -> MediaPipe Face Mesh -> 6 landmark points + -> OpenCV solvePnP -> pitch / yaw / roll -> track_id別EMA・統計 +``` + +FaceROIは人物Boxの上部45%を初期値とし、幅・高さを10%拡張して画像内へクリップします。小さすぎるROI、未確定track、設定により領域外trackを除外します。 + +Head Poseではnose、chin、左右目尻、左右口角と簡易3D顔モデルを `cv2.solvePnP` へ渡します。Pitchは上下回転、Yawは左右回転、Rollは首の傾きに相当し、単位はdegreeです。ただし焦点距離を画像幅、主点を画像中心、歪みゼロとした未校正近似なので、角度は絶対的な測定値ではありません。同一環境での時間変化を主目的とします。 + +3D顔モデルのY軸はOpenCV画像と同じ下向きを正としており、正面が約180°回転として解かれる座標系不一致を避けます。真横付近の近似誤差を考慮して `head_pose.max_abs_angle` の既定値は120°です。範囲外になった場合もraw Pitch/Yaw/Rollを画面とface CSVへ残すため、実機で原因を確認できます。 + +solvePnPの顔平面軸が反転し、直立した正面顔のRollが±180°付近になる場合があります。本システムは通常の直立姿勢を対象とするため、Rollを等価な `[-90°, 90°)` へ正規化します(例: -170°は10°)。上下逆さの顔姿勢を識別する用途には対応しません。 + +顔未検出、ランドマーク不足、solvePnP失敗、角度範囲外、MediaPipe例外はfailure reasonとして区別し、人物検出・追跡は継続します。顔処理を間引いたフレームはface CSVへ書かず、画面では直近の結果を表示します。 + +顔向きはtrack ID単位で管理するためtracker有効が必須です。`--disable-tracker` と同時にfaceが設定されている場合、face処理を自動的に無効化して警告を1回出します。track IDは顔認証を意味しません。 + +## Phase 2の概要 + +Phase 2ではYOLOの `list[Detection]` を独立した `PersonTracker` に渡し、Ultralyticsに同梱されたByteTrackでフレーム間を対応付けます。ByteTrackは検出信頼度の高低を二段階で関連付け、一時的な低信頼検出や遮蔽でもIDを維持しやすく、既存のUltralytics依存だけで利用できるため採用しました。Ultralytics固有型は `src/tracker.py` 内だけで扱い、以降は `TrackedPerson`、`TrackState`、`TrackEvent` を使用します。 + +追跡IDは1回の実行内だけで同一人物候補を結び付ける一時IDです。顔認証でも個人識別情報でもなく、再起動後や複数日にわたって継続しません。遮蔽、退出後の再入場、交差、検出漏れによって別IDになることや、人物間でID switchが起きることがあります。 + +### 人数の定義 + +- `Raw Detections`: 現在フレームのYOLO検出数(Phase 1) +- `Active Tracks`: 現在表示中の確定トラック数 +- `Unique Tracks`: 実行中に確定した一時IDの累計 +- `Region Visitors`: 評価領域へ進入した確定トラック数 +- `Trigger Crossings`: トリガーラインを通過した確定トラック数 +- `Completed Passers`: 最終化時に設定された表示時間・領域進入・ライン通過条件を満たした数 + +これらは意味が異なります。特にRaw Detectionsを通過人数として扱わないでください。 + +### 進入・退出、交差、方向 + +位置判定にはBounding Boxの足元点を使います。初回検出が領域内なら即座に進入とし、通常の進入・退出には連続フレーム確認を使用して境界揺れを抑えます。トリガーラインは縦・横に対応し、確定トラック、最低軌跡点数、デッドバンド、最小移動量を満たした場合に方向付きイベントを生成します。既定では同一IDにつき1回です。 + +移動方向は直近の複数軌跡点の変位から、`left_to_right`、`right_to_left`、`top_to_bottom`、`bottom_to_top`、`stationary`、`unknown` のいずれかへ分類します。 + +## Phase 1の機能 + +- `person` クラスのBounding Box・信頼度表示 +- 現在フレームの検出人数と評価領域内人数の表示 +- FPS、フレーム番号、時刻、推論デバイスの表示 +- normalized/pixel指定の評価領域と仮想トリガーライン +- UTF-8 BOM付きCSV検出ログ、ローテーション実行ログ +- 描画済み動画(任意)とスクリーンショットの保存 +- YAMLの再帰マージ(default → experiment → CLI) + +Phase 1由来のRaw Detectionsはフレーム単位であり、通過人数ではありません。Phase 2ではByteTrackによる一時ID、イベント、集計を追加しています。顔向き推定、振り向き判定、音声再生、統計解析はまだ実装しません。 + +## 構成 + +```text +main.py CLIエントリーポイント +config/ 既定・実験・logging設定 +src/ カメラ、検出、描画、ログ、アプリ制御 +models/ ローカルモデル配置先 +data/input/ 入力動画 +data/output/ 動画・スクリーンショット +data/logs/ CSV・実行ログ・実行時設定 +tests/ モデル不要の単体テスト +docs/ 仕様概要・実験環境テンプレート +``` + +## セットアップ + +Python 3.11を推奨します。PowerShell: + +```powershell +py -3.11 -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip +python -m pip install -r requirements.txt +``` + +### Phase 3のOpenCV・MediaPipe互換環境 + +Phase 3ではMediaPipeとの互換性を保つため、次の組み合わせを使用します。 + +- `mediapipe==0.10.21` +- `numpy==1.26.4` +- `opencv-contrib-python==4.11.0.86` + +`opencv-contrib-python` は通常のOpenCV APIを含むため、`opencv-python` と併用しないでください。両方を同じ環境へ入れると、共有する `cv2` パッケージやNumPy要件が衝突する可能性があります。 + +既存環境に `opencv-python` や異なる版が残っている場合は、一度関連パッケージを削除してから再構築します。 + +```powershell +pip uninstall -y opencv-python opencv-contrib-python mediapipe numpy +pip install -r requirements.txt +``` + +一般的なシェルでも同じコマンドを使用できます。 + +一般的なsh: + +```sh +python3.11 -m venv .venv +. .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -r requirements.txt +``` + +## 起動 + +Webカメラ(`--config` 省略時も `config/experiment.yaml`): + +```powershell +python main.py --config config/experiment.yaml +python main.py --camera-id 0 +``` + +動画: + +```powershell +python main.py --input-type video --video data/input/test.mp4 +``` + +主なCLI上書きは `--model`、`--device`、`--confidence`、`--iou`、`--image-size`、`--save-video`、`--no-display`、`--debug` です。全項目は `python main.py --help` で確認できます。 + +追跡用CLIには `--tracker bytetrack`、`--disable-tracker`、`--track-buffer`、`--match-threshold`、`--show-trajectories`、`--no-trajectories` があります。`--disable-tracker` ではPhase 1相当の検出処理を継続します。 + +顔処理用CLIには `--disable-face`、`--face-every N`、`--max-face-persons N`、`--no-head-pose`、`--show-face-landmarks`、`--no-face-roi` があります。CPUではYOLO、ByteTrack、MediaPipeの同時実行でFPSが低下し得るため、まず `face.process_every_n_frames` と `max_persons_per_frame` を調整してください。 + +振り向き用CLIには `--disable-turn`、`--signage-yaw-direction`、`--turn-weak-threshold`、`--turn-medium-threshold`、`--turn-strong-threshold`、`--turn-min-duration`、`--no-turn-frame-log` があります。tracker、face、head poseのいずれかが無効ならturnも自動無効になり、警告を1回出します。 + +## YAML設定 + +`config/default.yaml` の全項目を基礎とし、実験ファイル、明示されたCLI引数の順に上書きします。ネストされた項目は再帰マージされるため、実験ファイルに一項目だけ記載しても同セクションの既定値は残ります。評価領域内の判定にはBounding Box中心ではなく、下辺中央(足元)を使用します。 + +`performance.skip_frames=1` は1フレームおきに推論します。Phase 1では推論しないフレームに前回結果を流用せず、空検出としてCSVへ記録します。 + +追跡有効時はID安定性を優先し、`performance.skip_frames` は必ず `0` とします。正の値は設定エラーです。追跡無効時だけPhase 1のスキップ動作を利用できます。 + +主なPhase 2調整項目は、`tracker.track_high_thresh`、`new_track_thresh`、`match_thresh`、`track_buffer_frames`、`min_confirmed_frames`、進入・退出確認フレーム数、交差デッドバンド、最小移動量です。カメラ角度、人数密度、遮蔽時間に合わせて実映像で調整してください。 + +## キー操作 + +| キー | 動作 | +|---|---| +| `q` / Esc | 終了 | +| `s` | 描画後フレームを保存 | +| `p` | 一時停止・再開 | +| `d` | デバッグ表示切替 | +| `r` | FPS計測リセット | + +一時停止中は同じフレームを再推論・再記録しません。 + +## ログと成果物 + +CSVは `data/logs/detections/YYYYMMDD_HHMMSS__detections.csv` に保存されます。フレーム情報、検出番号、クラス、信頼度、Box座標・寸法、中心・足元座標、評価領域内フラグを含みます。人物ゼロ時は設定により検出列が空の行を記録します。 + +- 実行ログ・マージ済み設定: `data/logs/runtime/` +- 追跡ログ: `data/logs/tracking/` +- 顔向きログ: `data/logs/face/` +- 振り向きログ: `data/logs/turn/` +- 動画: `data/output/videos/` +- スクリーンショット: `data/output/screenshots/` + +プライバシー保護のため、映像保存は既定で無効です。研究倫理・所属機関の規則に従って有効化してください。 + +追跡ログは次の3種類です。 + +- `*_tracking_frames.csv`: 1人物・1フレームの位置、ID、確定状態、方向 +- `*_events.csv`: 作成、進入、通過、退出、消失、最終化イベント(metadataはJSON) +- `*_track_summary.csv`: 人物単位の時刻、滞在、軌跡、集計妥当性 + +いずれもExcelで扱いやすいUTF-8 BOM付きです。Phase 1の検出CSVも維持されます。 + +Phase 3では `*_face_frames.csv` にtrack ID付きの検出成否、Pitch/Yaw/Roll、ROI、失敗理由を保存し、`*_face_summary.csv` に人物ごとの検出率、推定率、Yawの最小・最大・平均、最終角度を保存します。 + +Phase 4では `*_turn_frames.csv` にbaseline、current Yaw、raw/サイネージ方向delta、candidate、confirmed、levelを保存します。`*_turn_events.csv` はbaseline取得、face appeared、candidate開始、確定、level変更、lost、最終化を記録し、`*_turn_summary.csv` は人物ごとの最大変化、時刻、継続時間、評価可否、除外理由を保存します。 + +## GPUとカメラ + +`detector.device: auto` はCUDAが利用可能ならCUDA、それ以外はCPUを選択します。明示する場合は `python main.py --device cuda` を使用します。`half_precision` はCUDAかつ明示的に有効化した場合だけ使用します。CPUでは使用せず、`true` が指定されても警告を1回出して無効化します。通常のPyTorch推論では `quantize` を自動的に有効化しません。実際のCUDA利用には、環境に合うPyTorch/CUDA構成が必要です。 + +Windowsでカメラが開けない場合は `config/experiment.yaml` の `input.backend` を `dshow` または `msmf` に変更してください。カメラID、他アプリによる占有、OSのカメラ権限も確認します。Linuxでは `v4l2` を選択できます。 + +よくあるエラー: + +- モデル読込失敗: ネットワーク、モデル名、ローカルパスを確認(`yolo11n.pt` は初回に自動取得) +- 動画が開けない: パスとコーデックを確認 +- VideoWriterが開けない: `mp4v` 対応と保存先権限を確認 +- 低FPS: `image_size` を下げる、CUDAを使う。`skip_frames` は追跡無効時のみ増やせる + +## テスト + +```powershell +python -m pytest +python -m compileall -q main.py src tests +``` + +標準テストはカメラやYOLOモデルを必要としません。実機では、解像度/FPS、評価領域、照明・逆光、検出漏れ・誤検出、CSV、動画、各キーを確認してください。 + +Phase 2の手動試験では、左→右、右→左、2人並行、2人交差、一時遮蔽、ライン付近停止・往復、領域へ少し入って戻る、画面端進入、退出後再入場、30分連続動作、CSVと画面カウンタの整合を確認します。ID switch数、track fragmentation数、誤トラック数、見逃し人数、実人数とシステム人数、交差判定誤り、平均・最低FPSを記録してください。 + +Phase 3では `TrackedPerson.track_id` とBBoxを使い、顔領域、MediaPipeランドマーク、Head Pose結果を人物トラックへ対応付けます。顔向き推定を無効化すればPhase 1・2の処理を維持できます。 + +Phase 3の実機確認では、正面、右、左、上、下、首の傾きの順に動かし、それぞれのYaw/Pitch/Rollを記録してください。カメラ配置や座標変換により符号が直感と逆になる場合があります。通行中のID付き角度、顔が映らない人物、複数人の上限処理、face CSV、CPUの平均・最低FPSも確認します。Phase 4でサイネージ方向へのYaw変化を使うため、左右の符号確認は必須です。 + +Phase 4では `HeadPoseResult` を使い、track IDごとの基準Yaw、Yaw変化、振り向きレベル、開始時刻、最大変化、継続時間、顔検出率による除外判定を実装しています。音声再生と声掛け後の反応時間計測はPhase 5の対象です。 + +Phase 4の手動確認では、正面、サイネージ方向、反対方向でYaw符号と `signage_yaw_direction` を確定します。通過試験では、見ずに通過、軽く見る、明確に見る、一瞬だけ見る、最初から見る、左右両方向、複数人、顔未検出から検出への変化を試します。baseline/current/delta/level/confirmed、顔・pose成功率、除外理由を記録してください。 + +Phase 5では音声提示時刻、声掛け条件、提示後反応時間、一定時間内Yaw変化への接続を実装しています。Phase 4は `baseline_yaw`、`yaw_delta_toward_signage`、level、confirmed、開始・確定時刻、最大変化、face appeared、評価可否を提供します。 + +## Phase 5の実機確認と実験上の注意 + +`prompt`モードでライン通過時の再生、音量、同一track一回制限、複数人通過時のcooldownを確認します。`control`モードでは音が鳴らず、`pseudo_prompt_triggered`と観測windowが記録されることを確認します。声掛け後に見る/見ない試験でresponseと期限切れを確認し、存在しない音声パスでもアプリが継続することを確認してください。 + +音声内容は初期実験では固定し、周囲への配慮、プライバシー説明、設置場所と音量を事前確認してください。例として「こんにちは」「こちらをご覧ください」「お知らせがあります」のような短い音声を使用できます。controlは声掛けなし条件でも観測開始タイミングを揃えるためのモードです。Phase 5だけで因果関係は断定しません。 + +Phase 6の統合人物状態を、Phase 7では `unified_events.csv`、人物解析表、セッションsummaryへ接続しています。Phase 8ではprompt/controlの反応率、reaction time、最大Yaw変化、除外数を統計解析する予定です。 + +## ライセンス + +本実装は研究用プロトタイプとしてUltralytics YOLOと `yolo11n.pt` を使用します。研究成果、ソース、モデル、生成物を配布・公開する前に、使用時点のUltralyticsおよびモデルのライセンスと利用条件を必ず確認してください。 +# Phase 9 GUI + +Phase 9では、Phase 1〜8の実験実行と統計解析を操作するPySide6デスクトップGUIを追加しました。既存CLIは引き続き利用できます。 + +```powershell +python -m pip install -r requirements.txt +python main.py gui +python main.py gui --help +``` + +GUIではcamera/video入力、Prompt/Control/Disabled条件、音声ファイルと音量、顔処理頻度、サイネージ方向Yaw、統合ログを設定できます。Start/Stop/Pause/Resume、処理済み映像プレビュー、FPS・追跡・prompt・response等の状態表示、Phase 8解析、生成CSV・グラフの保存場所表示に対応します。起動しただけではカメラやYOLOを初期化しません。 + +GUI設定はYAMLを直接変更せず、その実行だけの最優先上書きとして反映されます。Prompt実験では音声ファイルを選択してください。Controlでは音声を再生せずpseudo promptを記録します。実験条件、音量、カメラ位置は研究記録にも残してください。 + +PySide6がない場合は `pip install -r requirements.txt` を実行してください。CPU環境ではプレビューによりFPSが低下する可能性があります。Stop後はログとsummaryのfinalizeに数秒かかる場合があります。GUIは研究用プロトタイプであり、実験前にはCLIでも実機動作を確認してください。顔認証・個人識別は行わず、ID switchも解消しません。統計結果は因果関係を直接証明しません。 + +Phase 10では実験プロトコル、条件ローテーション、チェックリスト、閾値・カメラ位置調整、ログ品質確認を追加予定です。詳細は `docs/phase9_specification.md` を参照してください。 +# Phase 10 実験運用・品質確認 + +Phase 10では、本実験前に条件を固定し、ログ品質を確認し、パイロット結果と中間発表用要約を作る機能を追加しました。新しい認識アルゴリズムではありません。 + +```powershell +python main.py protocol --output docs/experiment_protocol.md +python main.py quality-check --logs data/logs/unified --analysis data/analysis --output data/analysis +python main.py pilot-report --analysis data/analysis --output data/analysis/pilot_report.md +python main.py presentation-summary --analysis data/analysis --output data/analysis/midterm_summary.md +``` + +GUIでは「Quality & Reports」タブから同じ処理を実行できます。これらの操作はカメラ、YOLO、MediaPipe、音声を初期化しません。 + +実験前には[実験プロトコル](C:/Users/koooo/Documents/NITGC/KawamotoLab/SignageSystem/docs/experiment_protocol.md)、[チェックリスト](C:/Users/koooo/Documents/NITGC/KawamotoLab/SignageSystem/docs/experiment_checklist.md)、[パイロット計画](C:/Users/koooo/Documents/NITGC/KawamotoLab/SignageSystem/docs/pilot_experiment_plan.md)を確認してください。PromptとControlでは、音声提示以外のカメラ位置、サイネージ位置、トリガー、通行方向、時間帯、照明、response window、turn閾値、顔処理頻度を可能な限り同一にします。Controlは音声を鳴らさずpseudo prompt時刻を記録します。 + +カメラ位置は主要な実験条件です。位置、高さ、角度、サイネージとの関係、通行方向、照明を`camera_position_note`とセッションメモへ記録してください。顔未検出や`valid_for_voice_analysis=false`は「見ていない」ではなく評価不能です。 + +Quality Checkは`quality_issues.csv`と`quality_report.md`を生成し、必要ファイル、Prompt/Control記録、有効解析率、not_evaluable率、Pose未推定率、カメラ位置メモ、反応時間、plotsを確認します。これはログ品質の目安であり、研究上の妥当性を保証しません。少数サンプルの有意差や因果関係を断定しないでください。 + +`pilot_report.md`と`midterm_summary.md`は予備的な記述要約です。Phase 11では実データ収集、除外理由の確認、有効解析人数の確保、カメラ位置・閾値の改善を行います。 +# Phase 11 パイロット実験診断 + +Phase 11では、実際に収集したPrompt/Controlログを診断し、本実験へ進める状態か、設定調整や追加収集が必要かを判定します。 + +```powershell +python main.py pilot-diagnostics --analysis data/analysis --logs data/logs/unified --output data/analysis +python main.py calibration-add --category camera_position --before "通路横" --after "サイネージ正面寄り" --reason "no_pose_estimated_rateが高かったため" +python main.py calibration-list +``` + +診断結果は`ready`、`needs_minor_adjustment`、`needs_major_adjustment`、`insufficient_data`で出力します。Prompt/Controlの有効解析人数、有効音声解析率、not_evaluable率、no_pose_estimated率、条件人数比、カメラ位置メモを確認します。 + +生成ファイル: + +- `pilot_diagnostic_report.md` +- `pilot_diagnostic_metrics.csv` +- `improvement_recommendations.csv` +- `session_quality_summary.csv` +- `data/calibration_history/calibration_history.csv` + +改善提案にはカメラ位置、顔処理、条件バランス、サンプル数、プロトコル確認などが含まれます。提案は設定を自動変更しません。変更した場合は`calibration-add`で変更前後と理由を記録してください。 + +`valid_for_voice_analysis=false`は「振り向きなし」ではなく評価不能です。カメラ位置は主要な実験条件であり、PromptとControlで音声以外の位置、照明、通行方向、トリガー、閾値を可能な限り揃えてください。 + +診断がreadyでも研究デザインの妥当性や因果関係を保証しません。少数サンプルの統計判断、高い反応率からの因果推論、ID switchの無視を避けてください。詳細は`docs/phase11_specification.md`を参照してください。 diff --git a/assets/audio/.gitkeep b/assets/audio/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/assets/audio/.gitkeep @@ -0,0 +1 @@ + diff --git a/config/default.yaml b/config/default.yaml new file mode 100644 index 0000000..899673e --- /dev/null +++ b/config/default.yaml @@ -0,0 +1,302 @@ +application: + name: pedestrian-signage-system + version: "0.11.0" + window_name: "Pedestrian Detection" + exit_key: "q" + screenshot_key: "s" + pause_key: "p" +experiment: + experiment_id: phase1_default + location: unknown + operator: unknown + notes: "" +input: + type: camera + camera_id: 0 + video_path: null + width: 1280 + height: 720 + fps: 30 + backend: auto + horizontal_flip: false + max_consecutive_read_failures: 5 +detector: + model_path: yolo11n.pt + device: auto + confidence_threshold: 0.40 + iou_threshold: 0.50 + image_size: 640 + person_class_id: 0 + max_detections: 30 + half_precision: false + use_agnostic_nms: false +tracker: + enabled: true + type: bytetrack + track_high_thresh: 0.50 + track_low_thresh: 0.10 + new_track_thresh: 0.60 + match_thresh: 0.80 + track_buffer_frames: 30 + min_confirmed_frames: 3 + max_missed_frames: 30 + fuse_score: true +track_management: + trajectory_max_points: 120 + direction_window_points: 5 + minimum_direction_displacement_pixels: 20 + region_entry_confirm_frames: 2 + region_exit_confirm_frames: 3 + minimum_visible_frames_for_count: 5 + minimum_duration_sec_for_count: 0.3 + require_region_entry_for_count: true + require_trigger_crossing_for_count: true +crossing: + enabled: true + count_once_per_track: true + deadband_pixels: 8 + minimum_displacement_pixels: 15 + minimum_track_points: 3 + require_confirmed_track: true +tracking_logging: + enabled: true + log_frame_tracks: true + log_events: true + log_summaries: true + flush_interval_frames: 30 + directory: data/logs/tracking +face: + enabled: true + backend: mediapipe + process_every_n_frames: 1 + max_persons_per_frame: 3 + require_confirmed_track: true + process_only_inside_region: false + roi_height_ratio: 0.60 + roi_expand_ratio: 0.20 + min_roi_width: 40 + min_roi_height: 40 + static_image_mode: false + max_num_faces: 1 + refine_landmarks: true + min_detection_confidence: 0.40 + min_tracking_confidence: 0.40 +head_pose: + enabled: true + method: solvepnp + smoothing_enabled: true + smoothing_method: ema + ema_alpha: 0.4 + max_abs_angle: 120.0 +face_logging: + enabled: true + log_frame_faces: true + log_summaries: true + flush_interval_frames: 30 + directory: data/logs/face +turn_detection: + enabled: true + signage_yaw_direction: positive + baseline_strategy: first_valid + baseline_window_sec: 0.8 + baseline_min_samples: 3 + subtle_threshold_deg: 10.0 + weak_threshold_deg: 20.0 + medium_threshold_deg: 35.0 + strong_threshold_deg: 50.0 + min_subtle_duration_sec: 0.20 + min_turn_duration_sec: 0.30 + max_pose_gap_sec: 0.30 + min_pose_samples_for_evaluation: 5 + min_face_detection_rate_for_evaluation: 0.20 + min_pose_estimation_rate_for_evaluation: 0.20 + face_appearance_enabled: true + face_missing_before_appearance_sec: 0.30 + weak_or_higher_is_turn: true +turn_logging: + enabled: true + log_frame_turns: true + log_events: true + log_summaries: true + flush_interval_frames: 30 + directory: data/logs/turn +audio: + enabled: true + backend: pygame + file_path: assets/audio/prompt.wav + volume: 0.8 + overlap_policy: skip +voice_prompt: + enabled: true + mode: prompt + trigger_strategy: trigger_line_crossing + require_confirmed_track: true + require_inside_region: true + skip_if_already_turned: true + one_prompt_per_track: true + global_cooldown_sec: 3.0 + minimum_track_age_sec: 0.3 + response_window_sec: 3.0 + fixed_position_axis: x + fixed_position_value: 640 + fixed_position_direction: decreasing +voice_logging: + enabled: true + log_decisions: true + log_events: true + log_summaries: true + flush_interval_frames: 30 + directory: data/logs/voice +person_state: + enabled: true + min_track_duration_sec: 0.5 + require_trigger_crossing_for_completion: false + require_voice_eligibility_for_voice_analysis: true + mark_not_evaluable_as_excluded: false + finalize_on_track_lost: true + keep_completed_state_after_response: true +person_state_logging: + enabled: true + log_frame_states: true + log_events: true + log_summaries: true + flush_interval_frames: 30 + directory: data/logs/person_state +unified_logging: + enabled: true + directory: data/logs/unified + log_unified_events: true + log_person_analysis_table: true + log_session_summary: true + log_consistency_issues: true + flush_interval_frames: 30 + include_tracking_events: true + include_turn_events: true + include_voice_events: true + include_person_state_events: true + include_face_events: false + important_events_only: false + camera_position_note: "" + run_consistency_checks: true +analysis: + enabled: true + input_directory: data/logs/unified + output_directory: data/analysis + recursive: true + include_prompt_condition: true + include_control_condition: true + valid_voice_analysis_only: true + response_level_threshold: weak + response_rate_test: auto + numeric_test: mannwhitney + yaw_metric_denominator: valid_turn_analysis + generate_plots: true + overwrite_outputs: true + alpha: 0.05 +gui: + enabled: false + default_config_path: config/experiment.yaml + default_analysis_input: data/logs/unified + default_analysis_output: data/analysis + preview_max_fps: 15 + remember_last_paths: false + show_opencv_window_when_gui: false + auto_run_analysis_after_experiment: false +experiment_protocol: + default_output_markdown: docs/experiment_protocol.md + default_output_yaml: data/experiment_notes/experiment_protocol.yaml + voice_prompt_text: "こんにちは。こちらをご覧ください。" + privacy_note: "顔認証・個人識別は行わず、track_idは実行中だけの一時IDとして扱う。" + planned_session_duration_min: 10 +quality_check: + enabled: true + min_valid_voice_analysis_rate: 0.5 + max_not_evaluable_rate: 0.4 + max_no_pose_rate: 0.4 + require_prompt_and_control: true + require_camera_position_note: true + require_plots: false +pilot_report: + enabled: true + output_markdown: data/analysis/pilot_report.md +presentation_summary: + enabled: true + output_markdown: data/analysis/midterm_summary.md +pilot_diagnostics: + enabled: true + target_valid_records_per_condition: 20 + min_valid_records_per_condition: 5 + min_valid_voice_analysis_rate: 0.5 + max_not_evaluable_rate: 0.4 + max_no_pose_estimated_rate: 0.4 + max_condition_valid_count_ratio: 2.0 + min_sessions_per_condition: 1 + require_camera_position_note: true + ready_requires_prompt_and_control: true + output_report: data/analysis/pilot_diagnostic_report.md + output_metrics_csv: data/analysis/pilot_diagnostic_metrics.csv + output_recommendations_csv: data/analysis/improvement_recommendations.csv + output_session_quality_csv: data/analysis/session_quality_summary.csv +calibration_history: + enabled: true + path: data/calibration_history/calibration_history.csv +display: + enabled: true + show_bounding_boxes: true + show_confidence: true + show_detection_count: true + show_fps: true + show_timestamp: true + show_frame_number: true + show_evaluation_region: true + show_trigger_line: true + show_track_ids: true + show_trajectories: true + show_movement_direction: true + show_tracking_counters: true + trajectory_max_points: 30 + show_face_roi: true + show_head_pose: true + show_face_landmarks: false + show_face_counters: true + show_turn_status: true + show_yaw_delta: true + show_turn_counters: true + show_voice_status: true + show_voice_counters: true + show_person_state: true + show_person_state_counters: true + resize_scale: 1.0 +evaluation_region: + enabled: true + coordinate_mode: normalized + x1: 0.05 + y1: 0.05 + x2: 0.95 + y2: 0.95 +trigger_line: + enabled: true + orientation: vertical + coordinate_mode: normalized + position: 0.50 +logging: + runtime_log_enabled: true + detection_log_enabled: true + log_every_frame: true + log_empty_frames: true + flush_interval_frames: 30 + runtime_log_directory: data/logs/runtime + detection_log_directory: data/logs/detections +output: + save_video: false + video_directory: data/output/videos + video_codec: mp4v + screenshot_directory: data/output/screenshots +performance: + skip_frames: 0 + fps_average_window: 30 + warn_fps_threshold: 10.0 +debug: + enabled: false + show_raw_model_output: false + print_detections: false diff --git a/config/experiment.yaml b/config/experiment.yaml new file mode 100644 index 0000000..ee14061 --- /dev/null +++ b/config/experiment.yaml @@ -0,0 +1,104 @@ +experiment: + experiment_id: phase1_test_001 + location: laboratory + operator: student + notes: "Phase 2 pedestrian detection and tracking test" +input: + type: camera + camera_id: 0 + width: 1280 + height: 720 + fps: 30 + backend: auto + horizontal_flip: false +detector: + model_path: yolo11n.pt + confidence_threshold: 0.45 + iou_threshold: 0.50 + image_size: 640 + device: auto +tracker: + enabled: true + track_buffer_frames: 30 + match_thresh: 0.80 +track_management: + region_entry_confirm_frames: 2 + region_exit_confirm_frames: 3 +crossing: + enabled: true + count_once_per_track: true +tracking_logging: + enabled: true +face: + enabled: true + process_every_n_frames: 2 + max_persons_per_frame: 3 + require_confirmed_track: true +head_pose: + enabled: true + smoothing_enabled: true + ema_alpha: 0.4 +face_logging: + enabled: true +turn_detection: + enabled: true + signage_yaw_direction: positive + baseline_strategy: first_valid + weak_threshold_deg: 20.0 + min_turn_duration_sec: 0.30 +turn_logging: + enabled: true +audio: + enabled: true + file_path: assets/audio/prompt.wav + volume: 0.8 +voice_prompt: + enabled: true + mode: prompt + trigger_strategy: trigger_line_crossing + response_window_sec: 3.0 + global_cooldown_sec: 3.0 +voice_logging: + enabled: true +person_state: + enabled: true + min_track_duration_sec: 0.5 + mark_not_evaluable_as_excluded: false +person_state_logging: + enabled: true +unified_logging: + enabled: true + camera_position_note: "サイネージ正面寄り。顔が見える位置に調整する。" +analysis: + enabled: true + generate_plots: true +gui: + enabled: false + show_opencv_window_when_gui: false + auto_run_analysis_after_experiment: false +quality_check: + enabled: true + require_camera_position_note: true + require_prompt_and_control: true +pilot_diagnostics: + enabled: true + target_valid_records_per_condition: 20 + require_camera_position_note: true +evaluation_region: + enabled: true + coordinate_mode: normalized + x1: 0.10 + y1: 0.10 + x2: 0.90 + y2: 0.95 +trigger_line: + enabled: true + orientation: vertical + coordinate_mode: normalized + position: 0.55 +logging: + detection_log_enabled: true + log_every_frame: true + log_empty_frames: true +output: + save_video: true diff --git a/config/logging.yaml b/config/logging.yaml new file mode 100644 index 0000000..3a5a2d3 --- /dev/null +++ b/config/logging.yaml @@ -0,0 +1,22 @@ +version: 1 +disable_existing_loggers: false +formatters: + standard: + format: "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" +handlers: + console: + class: logging.StreamHandler + level: INFO + formatter: standard + stream: ext://sys.stdout + file: + class: logging.handlers.RotatingFileHandler + level: DEBUG + formatter: standard + filename: data/logs/runtime/runtime.log + maxBytes: 5242880 + backupCount: 5 + encoding: utf-8 +root: + level: DEBUG + handlers: [console, file] diff --git a/config/presets/pilot_control.yaml b/config/presets/pilot_control.yaml new file mode 100644 index 0000000..05f576c --- /dev/null +++ b/config/presets/pilot_control.yaml @@ -0,0 +1,9 @@ +voice_prompt: + enabled: true + mode: control +audio: + enabled: false +unified_logging: + camera_position_note: "Prompt条件と同じ位置を記入する" +analysis: + generate_plots: true diff --git a/config/presets/pilot_disabled.yaml b/config/presets/pilot_disabled.yaml new file mode 100644 index 0000000..238c99b --- /dev/null +++ b/config/presets/pilot_disabled.yaml @@ -0,0 +1,7 @@ +voice_prompt: + enabled: false + mode: disabled +audio: + enabled: false +unified_logging: + camera_position_note: "動作確認時の位置を記入する" diff --git a/config/presets/pilot_prompt.yaml b/config/presets/pilot_prompt.yaml new file mode 100644 index 0000000..bc608a9 --- /dev/null +++ b/config/presets/pilot_prompt.yaml @@ -0,0 +1,9 @@ +voice_prompt: + enabled: true + mode: prompt +audio: + enabled: true +unified_logging: + camera_position_note: "実験前にカメラ位置を記入する" +analysis: + generate_plots: true diff --git a/data/analysis/.gitkeep b/data/analysis/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/analysis/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/analysis/plots/.gitkeep b/data/analysis/plots/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/analysis/plots/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/analysis/plots/exclusion_reason_counts.png b/data/analysis/plots/exclusion_reason_counts.png new file mode 100644 index 0000000..7cb7fb3 Binary files /dev/null and b/data/analysis/plots/exclusion_reason_counts.png differ diff --git a/data/analysis/plots/max_yaw_delta_by_condition.png b/data/analysis/plots/max_yaw_delta_by_condition.png new file mode 100644 index 0000000..3069bab Binary files /dev/null and b/data/analysis/plots/max_yaw_delta_by_condition.png differ diff --git a/data/analysis/plots/reaction_time_by_condition.png b/data/analysis/plots/reaction_time_by_condition.png new file mode 100644 index 0000000..eb4a97c Binary files /dev/null and b/data/analysis/plots/reaction_time_by_condition.png differ diff --git a/data/analysis/plots/response_rate_by_condition.png b/data/analysis/plots/response_rate_by_condition.png new file mode 100644 index 0000000..c904620 Binary files /dev/null and b/data/analysis/plots/response_rate_by_condition.png differ diff --git a/data/analysis/plots/turn_level_distribution.png b/data/analysis/plots/turn_level_distribution.png new file mode 100644 index 0000000..f1d2a21 Binary files /dev/null and b/data/analysis/plots/turn_level_distribution.png differ diff --git a/data/calibration_history/.gitkeep b/data/calibration_history/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/calibration_history/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/experiment_notes/.gitkeep b/data/experiment_notes/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/experiment_notes/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/gui/.gitkeep b/data/gui/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/gui/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/input/.gitkeep b/data/input/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/input/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/logs/detections/.gitkeep b/data/logs/detections/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/logs/detections/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/logs/face/.gitkeep b/data/logs/face/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/logs/face/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/logs/person_state/.gitkeep b/data/logs/person_state/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/logs/person_state/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/logs/runtime/.gitkeep b/data/logs/runtime/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/logs/runtime/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/logs/tracking/.gitkeep b/data/logs/tracking/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/logs/tracking/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/logs/turn/.gitkeep b/data/logs/turn/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/logs/turn/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/logs/unified/.gitkeep b/data/logs/unified/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/logs/unified/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/logs/voice/.gitkeep b/data/logs/voice/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/logs/voice/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/output/screenshots/.gitkeep b/data/output/screenshots/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/output/screenshots/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/output/videos/.gitkeep b/data/output/videos/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/output/videos/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/experiment_checklist.md b/docs/experiment_checklist.md new file mode 100644 index 0000000..84a4e33 --- /dev/null +++ b/docs/experiment_checklist.md @@ -0,0 +1,23 @@ +# 実験チェックリスト + +## 実験前 + +- [ ] カメラ位置を固定し、高さ・角度・サイネージとの位置関係を記録した +- [ ] 通行方向、顔が映る範囲、照明、逆光を確認した +- [ ] 音声ファイル、内容、音量を確認した +- [ ] Prompt / Control条件を確認した +- [ ] `camera_position_note`を入力した +- [ ] GUI起動だけではカメラが起動せず、Start後に映像が出ることを確認した + +## 実験中 + +- [ ] 条件、カメラ位置、音量を途中変更していない +- [ ] 異常、遮蔽、混雑をセッションメモに記録した + +## 実験後 + +- [ ] unified logsとperson_analysis_tableが保存された +- [ ] Phase 8解析とquality checkを実行した +- [ ] not_evaluable/no_pose_estimatedが過剰でない +- [ ] Prompt / Control双方の有効解析人数を確認した +- [ ] pilot reportを生成した diff --git a/docs/experiment_environment.md b/docs/experiment_environment.md new file mode 100644 index 0000000..ee36db0 --- /dev/null +++ b/docs/experiment_environment.md @@ -0,0 +1,27 @@ +# 実験環境記録テンプレート + +| 項目 | 記録内容 | +|---|---| +| 実験日 | | +| 実験場所 | | +| カメラ製品名 | | +| カメラ設置高さ | | +| カメラ角度 | | +| 解像度 | | +| FPS | | +| サイネージ位置 | | +| 評価領域 | | +| トリガーライン | | +| 照明条件 | | +| 逆光の有無 | | +| 通行方向 | | +| PC仕様 | | +| CPU | | +| GPU | | +| OS | | +| Pythonバージョン | | +| 使用モデル | | +| confidence threshold | | +| image size | | +| 平均FPS | | +| 備考 | | diff --git a/docs/experiment_protocol.md b/docs/experiment_protocol.md new file mode 100644 index 0000000..f8cf243 --- /dev/null +++ b/docs/experiment_protocol.md @@ -0,0 +1,26 @@ +# デジタルサイネージ声掛けパイロット実験 + +- **protocol_id**: protocol_20260809 +- **title**: デジタルサイネージ声掛けパイロット実験 +- **version**: 0.10.0 +- **created_at**: 2026-08-09T21:18:19+09:00 +- **purpose**: 音声提示と通行人の顔向き反応を観測する +- **target_location**: laboratory +- **signage_description**: デジタルサイネージ +- **camera_position_description**: サイネージ正面寄り。顔が見える位置に調整する。 +- **camera_height_cm**: None +- **camera_angle_description**: 要記録 +- **walking_direction_description**: 要記録 +- **lighting_condition**: 要記録 +- **voice_prompt_text**: こんにちは。こちらをご覧ください。 +- **voice_file_path**: assets/audio/prompt.wav +- **voice_volume**: 0.8 +- **trigger_strategy**: trigger_line_crossing +- **trigger_line_description**: vertical 0.55 +- **response_window_sec**: 3.0 +- **turn_weak_threshold_deg**: 20.0 +- **face_process_every_n_frames**: 2 +- **prompt_condition_rule**: トリガー時に音声を提示する +- **control_condition_rule**: 同じトリガー時刻を記録し音声は提示しない +- **privacy_note**: 顔認証・個人識別は行わず、track_idは実行中だけの一時IDとして扱う。 +- **notes**: カメラ位置、照明、通行方向をセッションごとに固定・記録する diff --git a/docs/midterm_presentation_results_template.md b/docs/midterm_presentation_results_template.md new file mode 100644 index 0000000..34e3006 --- /dev/null +++ b/docs/midterm_presentation_results_template.md @@ -0,0 +1,16 @@ +# 中間発表結果テンプレート + +## 研究目的 +## システム構成 +## 実装済み機能 +## 実験条件 +## パイロット結果 +- Prompt/Control有効解析人数 +- 反応率と反応率差 +- 反応時間 +- 最大Yaw変化 +## 除外理由とログ品質 +## 現時点の課題 +## 今後の予定 +## 注意 +少数サンプルの有意差や因果関係を断定しない。顔未検出を「見ていない」と扱わない。 diff --git a/docs/phase10_specification.md b/docs/phase10_specification.md new file mode 100644 index 0000000..538ebd4 --- /dev/null +++ b/docs/phase10_specification.md @@ -0,0 +1,29 @@ +# Phase 10 実験運用・品質確認仕様 + +Phase 10は新しい認識アルゴリズムではなく、本実験前の運用固定、記録、品質確認、中間発表準備を行う。 + +## 固定すべき条件 + +カメラ位置・高さ・角度、サイネージ位置、通行方向、照明、顔が映る向き、音声内容・音量、トリガーライン、response window、turn閾値、顔処理頻度を記録する。カメラ位置は主要な実験条件である。 + +PromptとControlでは音声提示以外を可能な限り同一にする。Controlは音声なしでpseudo prompt時刻を記録する。顔未検出は反応なしではなく評価不能である。 + +## 生成物 + +- `experiment_protocol.md/.yaml`: 設定から再現可能な実験条件を生成する。 +- セッションメモ: 条件、操作者、位置、照明、問題をUTF-8 YAMLで保存する。 +- `quality_issues.csv`, `quality_report.md`: 欠損、条件不足、有効率、評価不能率、Pose欠損、カメラメモ、反応時間、plotsを検査する。 +- `pilot_report.md`: Phase 8結果を記述的に整理する。 +- `midterm_summary.md`: 発表スライドへ転記しやすいMarkdownを作る。 + +## CLIとGUI + +`protocol`, `quality-check`, `pilot-report`, `presentation-summary`はカメラやYOLOを初期化しない。GUIのQuality & Reportsタブでも別スレッドから同じ処理を呼び出す。 + +## 品質判定の限界 + +quality checkはログ品質の目安であり、研究上の妥当性を保証しない。少数サンプルの有意差や因果関係を断定しない。照明、通行方向、カメラ位置、ID switchが結果へ影響する。 + +## Phase 11 + +パイロット実験を収集し、品質Issueに基づいてカメラ位置・閾値を調整し、有効解析人数と除外理由を確認する。 diff --git a/docs/phase11_specification.md b/docs/phase11_specification.md new file mode 100644 index 0000000..8b9c15e --- /dev/null +++ b/docs/phase11_specification.md @@ -0,0 +1,56 @@ +# Phase 11 パイロット実験診断仕様 + +## 目的 + +Phase 7統合ログとPhase 8解析結果を用い、本実験へ進めるデータ品質かを診断する。新しい認識アルゴリズムではなく、実データに基づく運用・品質・設定改善支援である。 + +## 入力 + +- `*_person_analysis_table.csv` +- `*_session_summary.csv` +- Phase 8解析ディレクトリ +- Phase 10品質Issue + +顔向き評価不能と「評価可能だが反応なし」を混同しない。`valid_for_voice_analysis=false`を反応なしへ含めない。 + +## 診断 + +Prompt/Controlの総数、有効解析人数、反応率、有効音声解析率、not_evaluable率、no_pose_estimated率、条件人数比、セッション品質、camera_position_noteを確認する。 + +- `ready`: 両条件が目標人数以上で、各品質閾値、人数バランス、カメラ記録を満たす。 +- `needs_minor_adjustment`: 最低人数はあるが目標未達、または条件バランスに軽微な問題がある。 +- `needs_major_adjustment`: 有効率、評価不能率、Pose取得率、カメラ位置記録に重大な問題がある。 +- `insufficient_data`: 条件欠損、最低人数未満、空データ。 +- `error`: 必須列破損等で診断不能。 + +## 改善提案 + +ルールを明示し、カメラ位置、顔処理、閾値・response window確認、サンプル数、条件バランス、ログ・プロトコルをhigh/medium/lowで提案する。自動的に設定値を書き換えない。 + +## セッション比較 + +セッション単位で有効率、反応率、評価不能率、Pose未推定率、反応時間、カメラ位置メモを集計し、good/caution/poorに分類する。 + +## 変更履歴 + +`CalibrationHistory`は変更前後、理由、操作者、関連report IDをUTF-8 BOM CSVへ追記する。同じchange_idは拒否する。 + +## 出力 + +- `pilot_diagnostic_report.md` +- `pilot_diagnostic_metrics.csv` +- `improvement_recommendations.csv` +- `session_quality_summary.csv` +- `calibration_history.csv` + +## CLI・GUI + +`pilot-diagnostics`, `calibration-add`, `calibration-list`はカメラ、YOLO、MediaPipe、音声を初期化しない。GUI Diagnosticsタブも同じ処理をワーカースレッドで実行する。 + +## 制約 + +readyは研究デザインの妥当性を保証しない。少数サンプルの有意差は不安定であり、高い反応率も音声との因果関係を証明しない。照明、通行方向、混雑、カメラ位置、ID switchの影響を別途確認する。 + +## Phase 12 + +改善履歴を反映して本実験データを収集し、Prompt/Control双方のセッション数・有効人数を確保し、最終解析と報告へ進む。 diff --git a/docs/phase1_specification.md b/docs/phase1_specification.md new file mode 100644 index 0000000..c270d87 --- /dev/null +++ b/docs/phase1_specification.md @@ -0,0 +1,7 @@ +# Phase 1 仕様概要 + +Webカメラまたは動画からフレームを取得し、Ultralytics YOLO(初期値 `yolo11n.pt`)で `person` のみを検出する。各フレームにはBounding Box、信頼度、フレーム内人数、評価領域内人数、FPS、番号、時刻、評価領域、仮想トリガーラインを描画できる。 + +評価領域は normalized/pixel 座標に対応し、人物の下辺中央点が領域内かを判定する。領域外の検出も破棄しない。CSVには人物ごとに1行を保存し、設定により空フレームも記録する。動画保存は既定で無効である。 + +Phase 1では追跡、人物ID、通過・ユニーク人数集計、顔ランドマーク、Head Pose、振り向き判定、音声制御、統計解析を行わない。Phase 2では `list[Detection]` をByteTrackベースの追跡器へ渡す予定である。 diff --git a/docs/phase2_specification.md b/docs/phase2_specification.md new file mode 100644 index 0000000..64bf950 --- /dev/null +++ b/docs/phase2_specification.md @@ -0,0 +1,69 @@ +# Phase 2 人物追跡仕様 + +## 1. 目的 + +フレーム単位のYOLO人物検出を追跡し、1実行内の一時ID、軌跡、領域イベント、トリガー通過、方向、人物単位summaryを生成する。顔推定・振り向き判定・音声制御は対象外である。 + +## 2. 構成と追跡方式 + +```text +PersonDetector -> list[Detection] -> PersonTracker -> list[TrackedPerson] + | + v + TrackManager + events / states / counters +``` + +Ultralytics同梱の `BYTETracker` を使用する。既存依存とバージョンを揃えられ、高信頼検出と低信頼検出の二段階関連付けが遮蔽に有効なためである。内部のResults・トラック型は `tracker.py` の入力アダプタと出力正規化から外へ出さない。 + +## 3. DetectionからTrackedPersonへの変換 + +Detectionのxyxy、信頼度、class IDをByteTrack入力へ変換する。出力BBoxを画像内へクリップし、不正Boxとperson以外を除外する。公開IDは整数で、外部tracker IDを実行内で単調増加するIDへ写像する。確定状態、age、missedを独自dataclassへ格納する。 + +## 4. ByteTrack設定 + +高・低信頼閾値、新規track閾値、match閾値、buffer、最低確定フレーム、最大miss、score融合をYAMLで設定する。追跡有効時は `skip_frames=0` を要求し、推論を飛ばした検出を再利用しない。 + +## 5. TrackManager + +人物ごとに初回・最終時刻、表示・missフレーム、上限付き軌跡、確定状態、領域・交差イベントを保持する。最大missを超えるとlostとfinalizedを生成し、動画終端、Ctrl+C、通常終了時は全active trackを同じ経路で最終化する。 + +## 6. 評価領域 + +足元点を使用する。初回が領域内なら即時進入とする。それ以外の進入と退出は設定数の連続フレームで確定し、同一種類のイベントを重複生成しない。 + +## 7. トリガーライン + +縦線はleft/right、横線はtop/bottomの反対側への移動を検出する。確定track、最低点数、デッドバンド、最小変位を要求し、既定ではIDごとに1回だけ記録する。 + +## 8. 移動方向 + +トリガーラインを通過済みで通過方向がある場合、観測済みの `trigger_crossing_direction` を最終的な `movement_direction` として最優先する。これにより、通過後に停止した人物がsummaryでstationaryへ上書きされることを防ぐ。通過方向がない場合のみ、直近 `direction_window_points` の最初と最後の足元点を比較する。支配的な軸と符号から4方向を決め、十分な変位がない場合だけstationary、点不足はunknownとする。 + +## 9. 人数定義 + +- Active Tracks: activeかつ確定 +- Total Unique Tracks: 実行中に一度でも確定したID +- Region Visitors: 領域進入済み確定ID +- Trigger Crossings: ライン通過済み確定ID +- Completed Passers: 最終化済みで最低表示時間・領域・交差条件を満たすID + +## 10. CSV + +Phase 1検出CSVに加え、tracking_frames(人物・フレーム単位)、events(イベント単位、metadata JSON)、track_summary(最終人物単位)を `data/logs/tracking` へUTF-8 BOM付きで保存する。summaryはtrack IDごとに重複防止する。 + +## 11. 例外と停止 + +初期化失敗はTrackerError、状態管理はTrackManagementError、CSVはTrackingLogErrorの体系を用意する。単一フレームのtracker更新失敗は記録して空出力とし、次フレームを継続する。停止時は最終化、ログ、tracker、camera、writer、windowの順で確実に解放する。一時停止中は取得・追跡・miss・ログ・時刻を進めない。 + +## 12. テスト + +外部モデル不要でdataclass、縦横交差、デッドバンド、重複防止、進入退出ヒステリシス、stale/finalize、方向、集計妥当性、3種CSV、BOM、JSONを検証する。ByteTrack実映像性能は手動評価対象とする。 + +## 13. 受入・手動評価 + +同一人物IDの維持、複数ID、遮蔽、進入・退出・交差の一回性、方向、終了summary、カウンタ分離を確認する。シナリオは左右通過、2人並行・交差、遮蔽、ライン停止・往復、浅い領域進入、画面端、再入場、30分運転、CSV整合である。ID switch、fragmentation、誤track、見逃し、実数対システム数、交差誤り、平均・最低FPSを記録する。 + +## 14. Phase 3への引き継ぎ + +`list[TrackedPerson]` のtrack ID、BBox、confidence、領域状態と `TrackState.trajectory` をFaceProcessorへ渡し、`HeadPoseResult(track_id, pitch, yaw, roll)` と対応付ける。追跡IDは顔認証ではなく、再起動・再入場・遮蔽で変わり得る一時IDである。 diff --git a/docs/phase3_specification.md b/docs/phase3_specification.md new file mode 100644 index 0000000..f3ca2de --- /dev/null +++ b/docs/phase3_specification.md @@ -0,0 +1,72 @@ +# Phase 3 顔向き推定仕様 + +## 推奨依存環境 + +MediaPipeとNumPy/OpenCVの要件衝突を避けるため、Phase 3の推奨・固定環境は `mediapipe==0.10.21`、`numpy==1.26.4`、`opencv-contrib-python==4.11.0.86` の組み合わせとする。`opencv-contrib-python` が通常の `cv2` APIも提供するため、`opencv-python` は同じ仮想環境へ導入しない。既存環境に両方が存在する場合は、関連する4パッケージをアンインストールしてから `requirements.txt` を再インストールする。 + +## 1. 目的と構成 + +Phase 2の `TrackedPerson` ごとに顔ランドマークと近似Head Poseを取得し、track ID、frame、時刻に対応付ける。Phase 4の基準Yaw・振り向き判定の入力基盤であり、本Phaseでは判定・音声・反応時間を扱わない。 + +```text +TrackedPerson -> FaceROI -> FaceProcessor(MediaPipe) -> FaceLandmark + | + v + HeadPoseEstimator(solvePnP) + | + v + HeadPoseResult -> FaceTrackManager -> CSV / display +``` + +## 2. FaceROI + +人物Box上端から `roi_height_ratio`(既定0.45)までを候補とし、候補の幅・高さを `roi_expand_ratio` だけ拡張して画像内へクリップする。最小寸法、確定track、評価領域条件で除外する。面積の大きい領域内人物を優先し、1フレームの最大処理人数を制限する。 + +## 3. MediaPipe Face Mesh + +各ROIをBGRからRGBへ変換してFace Meshへ渡す。複数顔の場合はROI中心に最も近い顔を採用する。正規化されたROI座標をフレーム全体のpixel座標へ変換し、MediaPipe型を外へ漏らさない。例外や未検出はtrack単位のfailure resultに変換する。 + +## 4. 使用ランドマークとHead Pose + +MediaPipe index 1(nose tip)、152(chin)、33/263(左右目尻)、61/291(左右口角)を使う。顔の上下・左右へ広く分布し、一般的な6点solvePnP近似と対応しやすいためである。簡易3Dモデルの単位は任意で、相対形状が重要である。モデルのY軸はOpenCV画像座標と同じ下向きを正とし、正面顔が約180°回転として解かれる座標系不一致を避ける。 + +OpenCV `solvePnP` と `Rodrigues` で回転行列を求め、X/Y/Z回転をPitch/Yaw/Rollとしてdegreeで返す。本実装上、正のPitchはX軸正回転、正のYawはY軸正回転、正のRollはZ軸正回転である。ただし画像座標、カメラ設置、鏡像反転によって直感的な左右・上下と符号が一致しない場合がある。 + +顔平面の軸反転によるsolvePnPの等価解では、直立した正面顔でもRollが±180°付近になることがある。本研究では通常の直立した通行人を対象とするため、Rollを軸方向として `[-90°, 90°)` の等価表現へ正規化する。例えば -170°は10°、170°は-10°として扱う。上下逆さの顔を区別する用途にはこの正規化を使用できない。 + +## 5. カメラ行列の近似と制約 + +焦点距離をframe width、主点を画像中心、歪み係数をゼロとする。カメラキャリブレーションを行わないため絶対角度には系統誤差があり、同一条件下の変化量を優先する。Phase 4前に実機で符号と基準値を確認する。 + +## 6. 平滑化と頻度制御 + +推定成功値をtrack ID別EMA(既定alpha 0.4)で平滑化し、失敗時は更新しない。`process_every_n_frames=N` はNフレームごとに処理し、間引きフレームはface CSVへ書かず画面では直近値を使う。`max_persons_per_frame` でCPU負荷を制御する。 + +## 7. 状態とログ + +`FaceTrackManager` は処理対象フレーム、顔検出・pose成功数、成功率、最新角度、Yaw最小・最大・平均、上限付き時系列を保持する。 + +- `face_frames.csv`: frame、track ID、成否、角度、landmark数、ROI、失敗理由 +- `face_summary.csv`: track別成功率、Yaw統計、最終角度、有効性 + +UTF-8 BOM付きで、summaryはtrack IDごとに一度だけ出力する。 + +## 8. 表示 + +track付近へYaw/Pitch/RollまたはFace NG、FaceROIを表示する。全ランドマーク表示は負荷を考慮して既定false。左上にface enabled、Face OK、Pose OKを表示する。無効時もPhase 1・2描画を維持する。 + +## 9. 例外・無効状態 + +`roi_too_small`、`face_not_detected`、`insufficient_landmarks`、`solvepnp_failed`、`angle_out_of_range`、`mediapipe_error` を結果で区別する。真横付近の近似誤差を許容する既定上限は120°とする。範囲外でも計算済みraw角度はCSVと画面へ残し、符号・閾値調整に利用する。face無効・処理間引きは結果とCSV行を生成しない。tracker無効時はtrack IDを安定管理できないためfaceを自動無効化し、実行ログに警告する。フレーム単位の失敗は検出・追跡を停止させない。 + +## 10. テスト + +実モデルなしでROI比率・拡張・クリップ・除外、モデル型、成功率とYaw統計、カメラ行列、点不足、Euler変換、角度範囲、EMA用状態、CSV/BOM/失敗行/summary重複を検証する。MediaPipeは遅延importし、標準pytestは未導入環境でも実推論を要求しない。 + +## 11. 実機確認 + +正面、右、左、上、下、首傾斜について角度と符号を記録する。通行中track IDとの対応、顔なし人物、複数人、最大処理人数、face CSV、CPU平均・最低FPSを確認する。右・左Yawが逆方向へ変化すること、上下Pitch、傾斜Roll、正面安定性を重点評価する。 + +## 12. Phase 4への引き継ぎ + +安定提供する値はtrack ID、frame、timestamp、face/pose成否、Pitch/Yaw/Roll、failure reason、顔検出率、pose推定率である。Phase 4はrecent historyから基準Yaw、変化量、振り向きレベル、開始・最大・継続時間、品質除外を算出する。 diff --git a/docs/phase4_specification.md b/docs/phase4_specification.md new file mode 100644 index 0000000..8c31cc2 --- /dev/null +++ b/docs/phase4_specification.md @@ -0,0 +1,72 @@ +# Phase 4 振り向き判定仕様 + +## 1. Phase 4の目的 + +track ID付きHead PoseのYaw時系列から、サイネージ方向への顔向き変化、継続時間、強度、評価可否を算出する。音声提示や声掛け後反応はPhase 5の対象である。 + +## 2. システム構成 + +```text +HeadPoseResult -> TurnDetector -> TurnFrameResult / TurnEvent / TurnTrackState +TrackManager finalize ---------> TurnTrackManager -> summary +``` + +## 3. HeadPoseResultからTurnFrameResultへの変換 + +有効な `HeadPoseResult` をtrack ID別の状態へ入力し、baseline、現在Yaw、raw delta、サイネージ方向delta、candidate、confirmed、levelを持つ `TurnFrameResult` へ変換する。顔処理を間引いたフレームではTurn更新もframeログも行わない。 + +## 4. サイネージ方向Yawの定義 + +`positive` は `current - baseline`、`negative` は `baseline - current` をサイネージ方向deltaとし、サイネージへ向く変化を正に統一する。カメラ・鏡像設定ごとに実機で符号を確認する。 + +## 5. 基準Yaw取得 + +既定 `first_valid` はwindow内の最初の有効Yawを最低3点集め中央値とする。`before_trigger` はトリガー通過前だけ、`region_entry_window` は領域進入後の指定windowだけ収集する。中央値で瞬間ノイズを抑える。Phase 5で声掛け前windowを追加可能な状態構造とする。 + +## 6. 振り向きレベル分類 + +10/20/35/50°をsubtle/weak/medium/strong境界とする。weak以上を主判定とし、subtleは補助反応として保持する。最大delta、発生frame/time、最強levelをtrack単位で保持する。 + +## 7. 継続時間条件 + +weak以上は0.30秒、subtleは0.20秒の継続を要求する。elapsed timeで測り、0.30秒以内のpose gapは継続、超過時はcandidateを終了する。 + +## 8. 顔検出不能ケースの扱い + +顔未検出は「振り向きなし」ではない。no face、no pose、baselineなし、sample不足、顔・pose成功率不足をsummaryの除外理由として区別し、`not_evaluable` とする。評価可能で未確定の場合のみ `evaluable_no_turn` とする。 + +## 9. face_appeared補助イベント + +一定時間顔/poseが得られなかった後の有効poseは `face_appeared` 補助イベントとするが、単独ではconfirmedにしない。 + +## 10. TurnDetector状態管理 + +TurnDetectorは上限付きYaw/baseline履歴、candidate開始、最終確認、最大delta、最強level、成功率を管理する。イベントはbaseline acquired、face appeared、candidate started、confirmed、level changed、lost、track finalized。confirmedイベントとsummaryはtrack ID単位で重複防止する。 + +## 11. TurnLogger CSV仕様 + +- `turn_frames.csv`: frameごとのbaseline、Yaw、delta、candidate、confirmed、level、失敗理由 +- `turn_events.csv`: 状態遷移とUTF-8 JSON metadata +- `turn_summary.csv`: 人物単位の成功率、baseline、最大delta、時刻、level、評価可否 + +すべてUTF-8 BOM付きで、summaryはtrack ID単位に重複を防ぐ。event metadataは `ensure_ascii=False` のJSONで保存する。 + +## 12. 画面表示 + +人物付近にTurn levelとdelta、左上に評価可能数、confirmed、subtle、weak+を表示する。評価不能は `Turn:N/A`。一時停止中は状態・時刻・CSVを更新しない。 + +## 13. 例外処理 + +tracker、face、head poseのいずれかが無効ならTurnを自動無効化してPhase 1〜3を継続する。顔・pose失敗は評価不能結果として扱い、追跡処理を停止しない。ログI/Oエラーは `TurnLogError` として既存例外体系へ統合する。 + +## 14. テスト + +純粋な合成HeadPoseResultで符号、全境界、中央値baseline、継続、短/長gap、最大delta、イベント一回性、face appeared、評価不能、全CSV/BOM/JSON/summary重複を検証する。実モデル例外は既存FaceProcessorで結果化され、Turnは評価不能として扱う。 + +## 15. 実機確認手順 + +静止状態で正面・サイネージ・反対方向を向き、符号を確定する。通過では非注視、軽い/明確/瞬間反応、最初から注視、左右両方向、複数人、face appearedを試す。signage方向、baseline、current、delta、level、confirmed、顔・pose率、除外理由を記録する。 + +## 16. Phase 5への引き継ぎ + +安定インターフェースはtrack ID、baseline/current/delta、level、confirmed、turn start/confirmed time、最大delta、face appeared、分析可否、除外理由である。Phase 5は音声提示時刻を追加し、提示前baselineと提示後response latencyを計算する。 diff --git a/docs/phase5_specification.md b/docs/phase5_specification.md new file mode 100644 index 0000000..f086e03 --- /dev/null +++ b/docs/phase5_specification.md @@ -0,0 +1,89 @@ +# Phase 5 音声声掛け制御仕様 + +## 1. Phase 5の目的 + +一時track IDに対して再現可能な位置で音声提示またはpseudo promptを行い、その後の振り向き観測を保存する。統計的因果推論は対象外とする。 + +## 2. システム構成 + +`TrackEvent + TrackedPerson + TurnFrameResult -> VoicePromptController -> AudioPlayer / VoiceLogger` と接続する。外部pygame型はAudioPlayer内だけで扱う。 + +## 3. AudioPlayer + +pygameを遅延importし、Soundを非同期再生する。disabled/noop、ファイル欠落、初期化・再生失敗はFalseとして継続する。overlapはskip(既定)、restart、allowを選べる。 + +## 4. VoicePromptController + +track別eligibility、prompt実行、response window、反応、最終summaryとイベント重複防止を管理する。 + +## 5. VoicePromptMode + +`prompt`、`control`、`disabled`を独自Enum・文字列で表し、CSVにも同じ値を保存する。 + +## 6. prompt / control / disabled + +promptは音声再生成功時を開始点とする。controlは無音のpseudo promptを同じ開始点として記録する。disabledはcontrollerとvoice CSVを生成しない。 + +## 7. 声掛け条件 + +trigger line crossing(既定)、region entry、fixed position、first confirmed trackを選べる。 + +## 8. trigger_line_crossingの仕様 + +Phase 2の `trigger_crossed` eventを使用する。一定位置で発生し、実験条件を揃えやすいため既定とする。 + +## 9. fixed_x_positionの仕様 + +foot x/yがpixel閾値以上(increasing)または以下(decreasing)で候補となる。one-prompt制約で連続発火を防ぐ。 + +## 10. 声掛け対象条件 + +confirmed、領域内、非stale、最低track age、未prompt、未turnを設定に応じて要求する。skip理由もdecision/eventへ残す。 + +## 11. 重複声掛け防止 + +既定では同じtrack IDのprompt attemptまたはpseudo prompt後は再実行しない。track IDは個人識別ではなく、ID switch時は別trackになり得る。 + +## 12. 全体クールダウン + +成功promptまたはpseudo promptから既定3秒は別trackへの開始を抑止する。残り時間を画面表示する。 + +## 13. 反応観測ウィンドウ + +開始後3秒以内のweak以上の確定turnをresponseとし、elapsed time差をreaction timeとする。期限超過時はexpired eventを一度出す。 + +## 14. Phase 4 Turnとの接続 + +delta最大値、最終level、confirmed時刻をtrack IDで関連付ける。subtleとface appearedは補助情報であり主responseではない。 + +## 15. response_detected判定 + +window内の `turn_confirmed` かつweak/medium/strongを必要とする。声掛け後の観測であって因果関係の断定ではない。 + +## 16. controlモードの解析上の意味 + +同位置相当からの自然振り向きを観測し、prompt条件との比較開始点を揃える。音声は一切再生しない。 + +## 17. VoiceLogger CSV仕様 + +decisions、events、summaryをUTF-8 BOMで保存する。metadataは `ensure_ascii=False` JSON、summaryはtrack ID単位で重複排除する。 + +## 18. 画面表示 + +人物付近へwaiting/played/control/failed、左上へmode、prompt数、response数、cooldownを表示する。 + +## 19. 例外処理 + +音声欠落・pygame失敗はアプリを停止しない。tracker無効時はVoiceを無効化する。Turn無効時もprompt可能だが分析は `turn_disabled` とする。 + +## 20. テスト + +実音声を鳴らさずmock backend/playerでload、overlap、prompt/control/failure、重複、cooldown、response、expiry、CSV BOM/JSON/dedupを検証する。 + +## 21. 実機確認手順 + +音声を `assets/audio/prompt.wav` に置き、promptでライン通過・音量・cooldown・一回制限、controlで無音pseudo event、見る/見ない場合のresponse/expiry、欠落ファイル時の継続を確認する。音声内容・音量・再生機器を固定し、周囲とプライバシーへ配慮する。 + +## 22. Phase 6への引き継ぎ + +track ID、mode、eligibility、prompt/pseudo時刻、window、response、reaction time、response level、最大delta、分析可否・除外理由を安定して提供する。Phase 6は人物状態遷移と実験セッション管理を追加する。 diff --git a/docs/phase6_specification.md b/docs/phase6_specification.md new file mode 100644 index 0000000..dd8ce4d --- /dev/null +++ b/docs/phase6_specification.md @@ -0,0 +1,61 @@ +# Phase 6 人物状態管理仕様 + +## 1. Phase 6の目的 + +Phase 2〜5に分散する人物情報をtrack ID単位の実験状態と最終summaryへ統合する。個人識別は行わない。 + +## 2. システム構成 + +`Track / Face / Turn / Voice outputs -> PersonStateManager -> Snapshot / Event / Summary -> PersonStateLogger`。既存managerと既存CSVは維持する。 + +## 3. PersonStateManager + +各出力をtrack IDで対応付け、累積フラグ、現在status、遷移イベント、最終summaryを生成する。終了時は残trackを必ずfinalizeする。 + +## 4. PersonExperimentStatus + +new、tracked、inside_region、eligible、prompted、pseudo_prompted、observing_response、responded、no_response、completed、excluded、not_evaluable、lostを定義する。 + +## 5. PersonExclusionReason + +tracker/voice/turn disabled、no face/pose、baseline/sample/rate不足、not eligible、already turned、prompt/audio failure、short track、ID switch疑い、prompt前/観測中lost、outside region、unknownを統一語彙とする。 + +## 6. 状態遷移ルール + +通常はnew→tracked→inside→eligible→prompted/pseudo→observing→responded/no_response→completed。表示statusはexcluded、completed、lost、responded、no_response、observing、prompt、eligible、not evaluable、inside、tracked、newの優先順とする。responseフラグはlost後も失わない。 + +## 7. 除外理由の統一 + +Turn/Voiceのfinal reasonを共通Enumへ正規化する。最低duration、trigger要件、eligibility要件もfinalize時に評価する。not evaluableをexcludedへ含めるかは設定可能で、既定false。 + +## 8. Track / Face / Turn / Voiceとの連携 + +TrackEventから領域、line、lost、方向、HeadPoseResultからface/pose、Turnからlevel/confirmed/delta/validity、Voiceからeligibility、prompt、window、response、reaction timeを取り込む。 + +## 9. prompt/controlモードとの関係 + +promptはprompted、controlはpseudo_promptedとなるが、その後は共通のobserving/responded/no_response遷移を使う。 + +## 10. PersonStateLogger CSV仕様 + +frames、events、summaryの3 CSVをUTF-8 BOMで追加保存する。metadataは日本語保持JSON、summaryはtrack ID単位で重複排除する。 + +## 11. 画面表示 + +人物付近に短縮State、左上にtracked/eligible/prompted/observing/responded/excludedの現在数を表示する。 + +## 12. 例外処理 + +tracker無効時はPerson Stateを警告して無効化する。face/turn/voice無効時も人物追跡自体は継続し、対応値と統一理由へ反映する。ログI/Oは独自例外とする。 + +## 13. テスト + +実モデルなしでEnum、優先規則、除外、Track/Face/Turn/Voice統合、prompt/control遷移、finalize、CSV BOM/JSON/dedupを検証する。 + +## 14. 実機確認手順 + +promptではtracked→inside→eligible→prompted→observing→response/no response→completed、controlではpseudo経由の同型遷移を確認する。無効化時はperson-state CSVが生成されないことも確認する。 + +## 15. Phase 7への引き継ぎ + +track ID、status/previous status、event type、exclusion、voice mode、prompt/pseudo、response/reaction、turn level/confirmed、Turn/Voice分析可否を安定して提供し、Phase 7の時系列統合イベントへ渡す。 diff --git a/docs/phase7_specification.md b/docs/phase7_specification.md new file mode 100644 index 0000000..4648bc4 --- /dev/null +++ b/docs/phase7_specification.md @@ -0,0 +1,55 @@ +# Phase 7 統合イベント・解析テーブル仕様 + +## 1. Phase 7の目的 +既存ログを維持し、イベント、人物解析行、セッション集計、整合性issueをPhase 8向けに追加する。因果関係は断定しない。 + +## 2. システム構成 +Track/Turn/Voice/Person State event→UnifiedEventBuilder→Logger。PersonStateSummary→AnalysisTableBuilder→SessionSummaryBuilder/ConsistencyChecker。 + +## 3. UnifiedEvent +experiment/session/event ID、source、track/frame/time、人物・Voice・Turn・response・除外・severity・JSON metadataを固定列で持つ。 + +## 4. UnifiedEventBuilder +既存イベントをsource付きschemaへ変換し、session/frame/source/sequence形式の衝突しにくいIDを生成する。elapsed/frame/sourceで安定ソートし、important onlyにも対応する。 + +## 5. UnifiedEventLogger +4 CSVをUTF-8 BOMで保存し、event ID、track分析行、session summaryの重複を防ぐ。metadataは `ensure_ascii=False` JSON。 + +## 6. AnalysisTableBuilder +Person State summaryを中心にprompt/control/disabled共通の人物解析行を生成する。Turn falseとTurn評価不能を別列で保持する。 + +## 7. SessionSummaryBuilder +track数、除外、分析可能数、prompt/pseudo、response、reaction平均・中央値、Turn level、FPS、issue数を集計する。 + +## 8. LogConsistencyChecker +promptなしresponse、負reaction、turnなしresponse、windowなしprompt、不正pseudo mode、unknown condition、理由なしexcluded、重複summaryをwarning化し停止しない。 + +## 9. CSV仕様 +`unified_events`、`person_analysis_table`、`session_summary`、`log_consistency_issues`を `data/logs/unified` に追加する。既存CSVは置換しない。 + +## 10. response_rate定義 +`response_detected_count / valid_voice_analysis_tracks`。分母0では空値(None)。全trackや評価不能者を分母へ入れない。 + +## 11. 顔向き評価不能の扱い +`turn_confirmed=false AND valid_for_turn_analysis=true`は評価可能な非反応、`valid_for_turn_analysis=false`は評価不能として分離する。 + +## 12. prompt / control比較 +prompt condition、prompt played、pseudo prompt、window、response、reaction、Turn、validityを同一列で保存する。 + +## 13. camera_position_note +設定またはCLIから人物解析行とsession summaryへ保存する。カメラ位置、サイネージとの角度、顔可視性を記述する。 + +## 14. 既存ログとの関係 +Detection、Tracking、Face、Turn、Voice、Person Stateログはすべて維持する。face frameは既定でunified eventへ入れない。 + +## 15. 例外処理 +unified無効時はPhase 6相当。person state無効時は解析表を生成せずevent統合だけ可能。tracker無効時は人物解析不能の警告対象となる。 + +## 16. テスト +実モデルなしで4モデル、各source変換、stable sort/filter、prompt/control/disabled行、分母、平均/中央値、issue、4 CSV/BOM/JSON/dedupを検証する。 + +## 17. 実機確認手順 +prompt/control双方でcamera noteを指定し、該当イベント、condition、reaction、集計を確認する。正常時issueが重大でないこと、無効時にunified CSVが出ないことを確認する。 + +## 18. Phase 8への引き継ぎ +session ID、condition、prompt/pseudo、response/reaction、Turn level/delta、分析可否、status、excluded/reasonとsession response rateを安定して提供する。 diff --git a/docs/phase8_specification.md b/docs/phase8_specification.md new file mode 100644 index 0000000..ac9eea5 --- /dev/null +++ b/docs/phase8_specification.md @@ -0,0 +1,38 @@ +# Phase 8 統計解析仕様 + +## 1. Phase 8の目的 +Prompt/Controlの観測反応率、反応時間、Yaw変化を基本統計・検定・効果量で比較する。因果関係は断定しない。 +## 2. 入力データ仕様 +単一または再帰探索した複数の `person_analysis_table.csv`。session summaryもloaderで読込可能。 +## 3. 解析対象の定義 +主反応解析は `valid_for_voice_analysis=True` のみ。数値欠損は各metricから除く。 +## 4. prompt/control条件 +`prompt_condition`がprompt/controlの行を同一schemaで比較する。disabled/unknownは主比較外。 +## 5. response_rate定義 +response count / valid voice analysis count。分母0はNone。 +## 6. 顔向き評価不能の扱い +分析不能をno responseへ混ぜない。件数はcondition summaryと除外集計へ残す。 +## 7. 条件別summary +全件、valid、response/no response、率、除外、reaction/Yaw記述統計、Turn levelを出力する。 +## 8. 反応率検定 +autoは期待度数5未満でFisher、それ以外で2x2カイ二乗。率差、odds ratio、φを併記する。 +## 9. reaction_time_sec比較 +response detected行のみをMann–Whitney U(既定)またはWelch tで比較する。 +## 10. max_yaw_delta比較 +valid turn、valid voice、全non-nullの分母設定から選択し、同じ数値検定を行う。 +## 11. turn_level分布 +none、face appeared、subtle、weak、medium、strong、not evaluableを条件別集計する。 +## 12. 除外理由集計 +条件内全件を分母にreason別count/ratioを保存する。 +## 13. 出力CSV仕様 +condition、response test、numeric、Turn distribution、exclusion、run summaryの6表をUTF-8 BOMで保存する。 +## 14. グラフ出力 +matplotlib Aggで反応率bar、reaction/Yaw box、Turn分布、除外理由を英語ラベルPNGへ保存する。空データでも停止しない。 +## 15. CLI +`python main.py analyze --input data/logs/unified --output data/analysis`。解析経路はApplicationをimportしない。 +## 16. テスト +読込・型変換・必須列、検定選択、空標本、分母、summary、CSV BOM、overwrite、Agg plotを合成データで検証する。 +## 17. 結果解釈の注意 +p値は因果を証明しない。小標本ではFisherを使い効果量を併記する。session ID、カメラ位置、trigger条件が異なるデータを無批判に混ぜない。 +## 18. Phase 9への引き継ぎ +6 CSVと5 PNGを安定した結果閲覧・ファイル選択GUIへ提供する。 diff --git a/docs/phase9_specification.md b/docs/phase9_specification.md new file mode 100644 index 0000000..3508af7 --- /dev/null +++ b/docs/phase9_specification.md @@ -0,0 +1,64 @@ +# Phase 9 GUI仕様 + +## 目的と構成 + +Phase 1〜8の実験実行、条件設定、映像確認、状態監視、ログ確認、統計解析をWindows向けローカルGUIから操作できるようにする。GUIは研究用プロトタイプであり、既存CLIを置き換えない。 + +`MainWindow`は表示、`ExperimentController`は既存`Application`のワーカースレッド実行、`AnalysisController`はPhase 8の`run_analysis_cli`実行を担当する。カメラ、YOLO、各Phaseの処理やロガーはGUIへ複製しない。 + +## MainWindowと各パネル + +- Experiment: camera/video、Prompt/Control/Disabled、音声、音量、顔処理頻度、Yaw方向、統合ログを設定する。 +- Preview: OpenCV BGR画像をRGBへ変換し、縦横比を維持して表示する。 +- Status: FPS、frame、active/total tracks、crossings、prompt/pseudo prompt/response、turn、ログ場所を表示する。 +- Logs: 開始・終了、解析状態、警告、例外を表示する。 +- Analysis: 入出力、再帰検索、グラフ、Fisher/カイ二乗、Mann–Whitney/Welch tを設定する。 + +GUI起動直後にはカメラとYOLOを初期化せず、Start後にだけ`Application`を生成する。 + +## ExperimentController + +GUI入力をdefault YAML、experiment YAMLの後に最優先上書きとしてマージする。Applicationの二重起動を拒否し、start/stop/pause/resumeを協調的に制御する。プレビューは`gui.preview_max_fps`で間引く。 + +## AnalysisController + +Phase 8解析を別スレッドで実行し、カメラやYOLOを初期化しない。完了時に生成ファイル一覧を返す。実験終了後の自動解析も選択できる。 + +## Application連携とRuntimeMetrics + +`Application`へ描画済みフレームと`RuntimeMetrics`の任意コールバックを追加した。CLI時は未指定なので従来動作を維持する。`RuntimeMetrics`はframe、elapsed time、FPS、追跡数、訪問・通過数、顔検出数、振り向き数、音声条件、prompt/pseudo/response数、人物状態内訳、ログ場所を保持する。 + +## スレッドと停止 + +映像処理と統計解析はGUIスレッド外で実行する。Widget更新はQt Signal/SlotでGUIスレッドに戻す。Stopは`Application.request_stop()`を呼び、既存finally処理がsummary確定、logger close、Face/Audio/Tracker close、Camera/VideoWriter release、OpenCV window破棄を行う。finalizeに数秒かかる場合がある。 + +## 入力検証 + +camera indexは0以上、volumeは0〜1、face interval/max personsは1以上、動画入力は既存ファイル必須、voice modeはprompt/control/disabled、Yaw方向はpositive/negativeとする。Promptで音声が未指定または存在しない場合は警告する。 + +## 結果を開く機能 + +Windowsでは`os.startfile`、macOS/Linuxでは標準ファイルマネージャーを使用する。解析結果一覧のダブルクリックで保存場所を開く。 + +## テスト + +GUI非依存モデル、入力検証、mock Applicationによる開始・停止・一時停止、mock解析runner、BGR→RGB変換をテストする。PySide6がない環境ではQt依存テストのみskipするため、カメラを起動しない。 + +## 手動確認 + +1. `python main.py gui`で起動し、Start前にカメラLEDとYOLOロードが始まらないこと。 +2. Promptで音声を選択し、通過、prompt count、response count、Stop後の統合ログを確認する。 +3. Controlで音声が鳴らずpseudo prompt countが増えることを確認する。 +4. 動画入力、pause/resume、プレビュー、状態表示を確認する。 +5. Analysisから`data/logs/unified`を解析し、CSVとplotsを生成・表示できることを確認する。 + +## 既知の制約 + +- 実験前にCLIでも実機動作を確認する。 +- CPU環境ではプレビューによりFPSが低下する可能性があり、表示FPSと処理FPSは一致しないことがある。 +- GUIはID switch、顔認証、個人識別を解決しない。 +- 統計結果は因果関係を直接証明しない。 + +## Phase 10への引き継ぎ + +`GuiExperimentRequest`、`GuiAnalysisRequest`、`RuntimeMetrics`、Controllerのstart/stop/pause/resumeと完了コールバックを安定インターフェースとして、実験プロトコル、条件ローテーション、チェックリスト、品質確認を追加できる。 diff --git a/docs/pilot_experiment_plan.md b/docs/pilot_experiment_plan.md new file mode 100644 index 0000000..750b1ac --- /dev/null +++ b/docs/pilot_experiment_plan.md @@ -0,0 +1,10 @@ +# パイロット実験計画 + +目的は、本実験前にカメラ位置、顔向き取得率、音声提示、ログ品質を確認することである。通行人の観測であり、顔認証や個人識別は行わない。 + +- 場所、サイネージ、カメラ位置を固定する。 +- Promptはトリガー時に音声を提示する。 +- Controlは同じトリガー時刻を記録し、音声を提示しない。 +- 目安として各条件で有効解析人数20人以上を収集する。本実験の正式なサンプルサイズ設計ではない。 +- track、条件、反応、反応時間、Yaw、除外理由、照明、通行方向、異常を記録する。 +- カメラ位置変更、音声異常、ログ欠損時は中止して記録する。 diff --git a/main.py b/main.py new file mode 100644 index 0000000..a6cec37 --- /dev/null +++ b/main.py @@ -0,0 +1,247 @@ +"""Command-line entry point for pedestrian detection and tracking.""" + +from __future__ import annotations + +import argparse +import logging +import sys +from dataclasses import replace +from pathlib import Path +from typing import Any + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="YOLO + tracking + head-pose + turn + voice-prompt research system", + epilog="Desktop GUI: python main.py gui | Offline analysis: python main.py analyze", + ) + parser.add_argument("--config", type=Path, default=Path("config/experiment.yaml")) + parser.add_argument("--input-type", choices=("camera", "video")) + parser.add_argument("--camera-id", type=int) + parser.add_argument("--video", type=str) + parser.add_argument("--model", type=str) + parser.add_argument("--device", type=str) + parser.add_argument("--confidence", type=float) + parser.add_argument("--iou", type=float) + parser.add_argument("--image-size", type=int) + parser.add_argument("--save-video", action="store_true", default=None) + parser.add_argument("--no-display", action="store_true", default=None) + parser.add_argument("--debug", action="store_true", default=None) + parser.add_argument("--tracker", choices=("bytetrack",)) + parser.add_argument("--disable-tracker", action="store_true", default=None) + parser.add_argument("--track-buffer", type=int) + parser.add_argument("--match-threshold", type=float) + trajectory_group = parser.add_mutually_exclusive_group() + trajectory_group.add_argument("--show-trajectories", action="store_true", default=None) + trajectory_group.add_argument("--no-trajectories", action="store_true", default=None) + parser.add_argument("--disable-face", action="store_true", default=None) + parser.add_argument("--face-every", type=int) + parser.add_argument("--max-face-persons", type=int) + parser.add_argument("--no-head-pose", action="store_true", default=None) + parser.add_argument("--show-face-landmarks", action="store_true", default=None) + parser.add_argument("--no-face-roi", action="store_true", default=None) + parser.add_argument("--disable-turn", action="store_true", default=None) + parser.add_argument("--signage-yaw-direction", choices=("positive", "negative")) + parser.add_argument("--turn-weak-threshold", type=float) + parser.add_argument("--turn-medium-threshold", type=float) + parser.add_argument("--turn-strong-threshold", type=float) + parser.add_argument("--turn-min-duration", type=float) + parser.add_argument("--no-turn-frame-log", action="store_true", default=None) + parser.add_argument("--disable-voice", action="store_true", default=None) + parser.add_argument("--voice-mode", choices=("prompt", "control", "disabled")) + parser.add_argument("--audio-file", type=str) + parser.add_argument("--voice-trigger-strategy", choices=("trigger_line_crossing", "region_entry", "fixed_x_position", "first_confirmed_track")) + parser.add_argument("--voice-response-window", type=float) + parser.add_argument("--voice-cooldown", type=float) + parser.add_argument("--voice-volume", type=float) + parser.add_argument("--no-voice-decision-log", action="store_true", default=None) + parser.add_argument("--disable-person-state", action="store_true", default=None) + parser.add_argument("--no-person-state-frame-log", action="store_true", default=None) + parser.add_argument("--person-state-min-duration", type=float) + parser.add_argument("--disable-unified-logging", action="store_true", default=None) + parser.add_argument("--no-unified-events", action="store_true", default=None) + parser.add_argument("--no-analysis-table", action="store_true", default=None) + parser.add_argument("--no-session-summary", action="store_true", default=None) + parser.add_argument("--camera-position-note", type=str) + return parser + + +def build_analysis_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="main.py analyze", description="Offline prompt/control statistical analysis") + parser.add_argument("--config", type=Path, default=Path("config/experiment.yaml")) + parser.add_argument("--input", type=Path, default=Path("data/logs/unified")) + parser.add_argument("--output", type=Path, default=Path("data/analysis")) + parser.add_argument("--no-analysis-plots", action="store_true") + parser.add_argument("--analysis-recursive", action="store_true", default=None) + parser.add_argument("--analysis-response-test", choices=("auto", "chi_square", "fisher")) + parser.add_argument("--analysis-numeric-test", choices=("mannwhitney", "ttest")) + return parser + + +def build_gui_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="main.py gui", description="Phase 10 desktop experiment and quality GUI") + parser.add_argument("--config", type=Path, default=Path("config/experiment.yaml")) + return parser + +def build_operation_parser(command:str)->argparse.ArgumentParser: + descriptions={"protocol":"Generate the Phase 10 experiment protocol","quality-check":"Check Phase 7/8 log quality","pilot-report":"Generate a pilot experiment report","presentation-summary":"Generate a midterm presentation summary"} + parser=argparse.ArgumentParser(prog=f"main.py {command}",description=descriptions[command]);parser.add_argument("--config",type=Path,default=Path("config/experiment.yaml")) + if command=="protocol":parser.add_argument("--output",type=Path,default=Path("docs/experiment_protocol.md"));parser.add_argument("--yaml-output",type=Path,default=Path("data/experiment_notes/experiment_protocol.yaml")) + elif command=="quality-check":parser.add_argument("--logs",type=Path,default=Path("data/logs/unified"));parser.add_argument("--analysis",type=Path,default=Path("data/analysis"));parser.add_argument("--output",type=Path,default=Path("data/analysis")) + else:parser.add_argument("--analysis",type=Path,default=Path("data/analysis"));parser.add_argument("--logs",type=Path,default=Path("data/logs/unified"));parser.add_argument("--output",type=Path,required=False) + return parser +def build_phase11_parser(command:str)->argparse.ArgumentParser: + p=argparse.ArgumentParser(prog=f"main.py {command}",description="Phase 11 pilot diagnostics and calibration history");p.add_argument("--config",type=Path,default=Path("config/experiment.yaml")) + if command=="pilot-diagnostics":p.add_argument("--analysis",type=Path,default=Path("data/analysis"));p.add_argument("--logs",type=Path,default=Path("data/logs/unified"));p.add_argument("--output",type=Path,default=Path("data/analysis")) + elif command=="calibration-add":p.add_argument("--path",type=Path);p.add_argument("--operator",default="unknown");p.add_argument("--category",required=True);p.add_argument("--before",required=True);p.add_argument("--after",required=True);p.add_argument("--reason",required=True);p.add_argument("--report-id");p.add_argument("--notes",default="") + else:p.add_argument("--path",type=Path) + return p + + +def cli_overrides(args: argparse.Namespace) -> dict[str, Any]: + sections: dict[str, dict[str, Any]] = { + "input": {}, "detector": {}, "tracker": {}, "face": {}, "head_pose": {}, + "turn_detection": {}, "turn_logging": {}, "audio": {}, "voice_prompt": {}, "voice_logging": {}, + "output": {}, "display": {}, "debug": {}, + "person_state": {}, "person_state_logging": {}, + "unified_logging": {}, + } + mapping = { + "input_type": ("input", "type"), "camera_id": ("input", "camera_id"), "video": ("input", "video_path"), + "model": ("detector", "model_path"), "device": ("detector", "device"), + "confidence": ("detector", "confidence_threshold"), "iou": ("detector", "iou_threshold"), + "image_size": ("detector", "image_size"), "save_video": ("output", "save_video"), + "debug": ("debug", "enabled"), + "tracker": ("tracker", "type"), "track_buffer": ("tracker", "track_buffer_frames"), + "match_threshold": ("tracker", "match_thresh"), + "face_every": ("face", "process_every_n_frames"), + "max_face_persons": ("face", "max_persons_per_frame"), + "signage_yaw_direction": ("turn_detection", "signage_yaw_direction"), + "turn_weak_threshold": ("turn_detection", "weak_threshold_deg"), + "turn_medium_threshold": ("turn_detection", "medium_threshold_deg"), + "turn_strong_threshold": ("turn_detection", "strong_threshold_deg"), + "turn_min_duration": ("turn_detection", "min_turn_duration_sec"), + "voice_mode": ("voice_prompt", "mode"), "audio_file": ("audio", "file_path"), + "voice_trigger_strategy": ("voice_prompt", "trigger_strategy"), + "voice_response_window": ("voice_prompt", "response_window_sec"), + "voice_cooldown": ("voice_prompt", "global_cooldown_sec"), + "voice_volume": ("audio", "volume"), + "person_state_min_duration": ("person_state", "min_track_duration_sec"), + "camera_position_note": ("unified_logging", "camera_position_note"), + } + for argument, (section, key) in mapping.items(): + value = getattr(args, argument) + if value is not None: + sections[section][key] = value + if args.no_display is not None: + sections["display"]["enabled"] = not args.no_display + if args.disable_tracker is not None: + sections["tracker"]["enabled"] = not args.disable_tracker + if args.show_trajectories is not None: + sections["display"]["show_trajectories"] = True + if args.no_trajectories is not None: + sections["display"]["show_trajectories"] = False + if args.disable_face is not None: + sections["face"]["enabled"] = False + if args.no_head_pose is not None: + sections["head_pose"]["enabled"] = False + if args.show_face_landmarks is not None: + sections["display"]["show_face_landmarks"] = True + if args.no_face_roi is not None: + sections["display"]["show_face_roi"] = False + if args.disable_turn is not None: + sections["turn_detection"]["enabled"] = False + if args.no_turn_frame_log is not None: + sections["turn_logging"]["log_frame_turns"] = False + if args.disable_voice is not None: + sections["voice_prompt"].update({"enabled": False, "mode": "disabled"}) + if args.no_voice_decision_log is not None: + sections["voice_logging"]["log_decisions"] = False + if args.disable_person_state is not None: + sections["person_state"]["enabled"] = False + if args.no_person_state_frame_log is not None: + sections["person_state_logging"]["log_frame_states"] = False + if args.disable_unified_logging is not None: sections["unified_logging"]["enabled"] = False + if args.no_unified_events is not None: sections["unified_logging"]["log_unified_events"] = False + if args.no_analysis_table is not None: sections["unified_logging"]["log_person_analysis_table"] = False + if args.no_session_summary is not None: sections["unified_logging"]["log_session_summary"] = False + return {key: value for key, value in sections.items() if value} + + +def main(argv: list[str] | None = None) -> int: + actual = list(sys.argv[1:] if argv is None else argv) + if actual and actual[0] in {"pilot-diagnostics","calibration-add","calibration-list"}: + from datetime import datetime + command=actual[0];args=build_phase11_parser(command).parse_args(actual[1:]);from src.config_loader import load_config;from src.models import CalibrationChangeRecord;from src.phase11_cli import add_calibration,list_calibrations,run_diagnostics + try: + c=load_config("config/default.yaml",args.config) + if command=="pilot-diagnostics":outputs=run_diagnostics(c,args.analysis,args.logs,args.output);[print(x) for x in outputs] + elif command=="calibration-add": + record=CalibrationChangeRecord(f"change_{datetime.now():%Y%m%d_%H%M%S_%f}",datetime.now().astimezone().isoformat(timespec="seconds"),args.operator,args.category,args.before,args.after,args.reason,args.report_id,args.notes);print(add_calibration(c,record,args.path)) + else: + for r in list_calibrations(c,args.path):print(f"{r.created_at}\t{r.category}\t{r.before_value} -> {r.after_value}\t{r.reason}") + return 0 + except Exception as exc:print(f"{command} error: {exc}",file=sys.stderr);return 1 + if actual and actual[0] in {"protocol","quality-check","pilot-report","presentation-summary"}: + command=actual[0];args=build_operation_parser(command).parse_args(actual[1:]) + from src.config_loader import load_config + from src.phase10_cli import generate_protocol,run_pilot_report,run_presentation,run_quality + try: + config=load_config(Path("config/default.yaml"),args.config) + if command=="protocol":outputs=generate_protocol(config,args.output,args.yaml_output) + elif command=="quality-check":outputs=run_quality(config,args.logs,args.analysis,args.output) + elif command=="pilot-report":outputs=[run_pilot_report(config,args.analysis,args.output or Path(config.pilot_report.output_markdown),args.logs)] + else:outputs=[run_presentation(config,args.analysis,args.output or Path(config.presentation_summary.output_markdown),args.logs)] + for output in outputs:print(output) + return 0 + except Exception as exc:print(f"{command} error: {exc}",file=sys.stderr);return 1 + if actual and actual[0] == "gui": + args = build_gui_parser().parse_args(actual[1:]) + try: + from src.gui.gui_app import run_gui + return run_gui(args.config) + except (RuntimeError, ImportError) as exc: + print(f"GUI error: {exc}", file=sys.stderr) + return 1 + if actual and actual[0] == "analyze": + logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s") + args = build_analysis_parser().parse_args(actual[1:]) + from src.analysis_cli import run_analysis_cli + from src.config_loader import load_config + from src.exceptions import ConfigurationError + try: + config = load_config(Path("config/default.yaml"), args.config) + settings = replace(config.analysis, input_directory=str(args.input), output_directory=str(args.output), + generate_plots=False if args.no_analysis_plots else config.analysis.generate_plots, + recursive=args.analysis_recursive if args.analysis_recursive is not None else config.analysis.recursive, + response_rate_test=args.analysis_response_test or config.analysis.response_rate_test, + numeric_test=args.analysis_numeric_test or config.analysis.numeric_test) + run_analysis_cli(settings, args.input, args.output) + return 0 + except (ConfigurationError, FileNotFoundError, RuntimeError) as exc: + print(f"Analysis error: {exc}", file=sys.stderr) + return 1 + args = build_parser().parse_args(actual) + from src.application import Application + from src.config_loader import load_config + from src.exceptions import CameraOpenError, ConfigurationError, FrameReadError, ModelLoadError + + try: + config = load_config(Path("config/default.yaml"), args.config, cli_overrides(args)) + Application(config).run() + return 0 + except ConfigurationError as exc: + print(f"Configuration error: {exc}", file=sys.stderr) + return 1 + except (CameraOpenError, FrameReadError) as exc: + print(f"Input error: {exc}", file=sys.stderr) + return 2 + except ModelLoadError as exc: + print(f"Model error: {exc}", file=sys.stderr) + return 3 + except Exception as exc: + print(f"Unexpected error: {exc}", file=sys.stderr) + return 10 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/models/.gitkeep b/models/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/models/.gitkeep @@ -0,0 +1 @@ + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bbae8d1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +opencv-contrib-python==4.11.0.86 +ultralytics>=8.3,<9 +numpy==1.26.4 +PyYAML>=6.0,<7 +pytest>=8.0,<9 +mediapipe==0.10.21 +pygame>=2.6,<3 +pandas>=2.1,<3 +scipy>=1.11,<2 +matplotlib>=3.7,<4 +PySide6>=6.7,<7 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..fb69fe3 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,3 @@ +"""Phase 1-5 pedestrian detection, tracking, pose, turn, and voice system.""" + +__version__ = "0.11.0" diff --git a/src/analysis_cli.py b/src/analysis_cli.py new file mode 100644 index 0000000..fe741c7 --- /dev/null +++ b/src/analysis_cli.py @@ -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 diff --git a/src/analysis_loader.py b/src/analysis_loader.py new file mode 100644 index 0000000..b5d88f6 --- /dev/null +++ b/src/analysis_loader.py @@ -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 diff --git a/src/analysis_plots.py b/src/analysis_plots.py new file mode 100644 index 0000000..19dc912 --- /dev/null +++ b/src/analysis_plots.py @@ -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 diff --git a/src/analysis_reporter.py b/src/analysis_reporter.py new file mode 100644 index 0000000..1368fd6 --- /dev/null +++ b/src/analysis_reporter.py @@ -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 + diff --git a/src/analysis_table_builder.py b/src/analysis_table_builder.py new file mode 100644 index 0000000..8a232ea --- /dev/null +++ b/src/analysis_table_builder.py @@ -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] diff --git a/src/application.py b/src/application.py new file mode 100644 index 0000000..ca36c86 --- /dev/null +++ b/src/application.py @@ -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") diff --git a/src/audio_player.py b/src/audio_player.py new file mode 100644 index 0000000..78864d6 --- /dev/null +++ b/src/audio_player.py @@ -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 + diff --git a/src/calibration_history.py b/src/calibration_history.py new file mode 100644 index 0000000..01d10b6 --- /dev/null +++ b/src/calibration_history.py @@ -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 diff --git a/src/camera.py b/src/camera.py new file mode 100644 index 0000000..3347191 --- /dev/null +++ b/src/camera.py @@ -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() diff --git a/src/config_loader.py b/src/config_loader.py new file mode 100644 index 0000000..8eefc19 --- /dev/null +++ b/src/config_loader.py @@ -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 diff --git a/src/crossing_detector.py b/src/crossing_detector.py new file mode 100644 index 0000000..db7b60f --- /dev/null +++ b/src/crossing_detector.py @@ -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() diff --git a/src/detection_logger.py b/src/detection_logger.py new file mode 100644 index 0000000..5aca6e2 --- /dev/null +++ b/src/detection_logger.py @@ -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() diff --git a/src/detector.py b/src/detector.py new file mode 100644 index 0000000..82a5c02 --- /dev/null +++ b/src/detector.py @@ -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 diff --git a/src/exceptions.py b/src/exceptions.py new file mode 100644 index 0000000..0bd1534 --- /dev/null +++ b/src/exceptions.py @@ -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.""" diff --git a/src/experiment_notes.py b/src/experiment_notes.py new file mode 100644 index 0000000..7bfe096 --- /dev/null +++ b/src/experiment_notes.py @@ -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"))) diff --git a/src/experiment_protocol.py b/src/experiment_protocol.py new file mode 100644 index 0000000..8d4aeb8 --- /dev/null +++ b/src/experiment_protocol.py @@ -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 diff --git a/src/face_geometry.py b/src/face_geometry.py new file mode 100644 index 0000000..f6e7c01 --- /dev/null +++ b/src/face_geometry.py @@ -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) diff --git a/src/face_logger.py b/src/face_logger.py new file mode 100644 index 0000000..a8970b9 --- /dev/null +++ b/src/face_logger.py @@ -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() diff --git a/src/face_processor.py b/src/face_processor.py new file mode 100644 index 0000000..97c7e2b --- /dev/null +++ b/src/face_processor.py @@ -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() diff --git a/src/fps_counter.py b/src/fps_counter.py new file mode 100644 index 0000000..ed2c1df --- /dev/null +++ b/src/fps_counter.py @@ -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 diff --git a/src/gui/__init__.py b/src/gui/__init__.py new file mode 100644 index 0000000..46ea394 --- /dev/null +++ b/src/gui/__init__.py @@ -0,0 +1,5 @@ +"""Phase 9 desktop GUI package.""" + +from .gui_models import GuiAnalysisRequest, GuiExperimentRequest + +__all__ = ["GuiAnalysisRequest", "GuiExperimentRequest"] diff --git a/src/gui/analysis_controller.py b/src/gui/analysis_controller.py new file mode 100644 index 0000000..19e7c02 --- /dev/null +++ b/src/gui/analysis_controller.py @@ -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 diff --git a/src/gui/analysis_panel.py b/src/gui/analysis_panel.py new file mode 100644 index 0000000..1a36ac5 --- /dev/null +++ b/src/gui/analysis_panel.py @@ -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]) diff --git a/src/gui/config_panel.py b/src/gui/config_panel.py new file mode 100644 index 0000000..c273524 --- /dev/null +++ b/src/gui/config_panel.py @@ -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) diff --git a/src/gui/diagnostics_panel.py b/src/gui/diagnostics_panel.py new file mode 100644 index 0000000..ffc01bc --- /dev/null +++ b/src/gui/diagnostics_panel.py @@ -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") diff --git a/src/gui/experiment_controller.py b/src/gui/experiment_controller.py new file mode 100644 index 0000000..9797856 --- /dev/null +++ b/src/gui/experiment_controller.py @@ -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 diff --git a/src/gui/gui_app.py b/src/gui/gui_app.py new file mode 100644 index 0000000..58f4e87 --- /dev/null +++ b/src/gui/gui_app.py @@ -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() diff --git a/src/gui/gui_models.py b/src/gui/gui_models.py new file mode 100644 index 0000000..44ef454 --- /dev/null +++ b/src/gui/gui_models.py @@ -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 diff --git a/src/gui/log_panel.py b/src/gui/log_panel.py new file mode 100644 index 0000000..1a86d0d --- /dev/null +++ b/src/gui/log_panel.py @@ -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) diff --git a/src/gui/main_window.py b/src/gui/main_window.py new file mode 100644 index 0000000..be9aa9a --- /dev/null +++ b/src/gui/main_window.py @@ -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() diff --git a/src/gui/operations_controller.py b/src/gui/operations_controller.py new file mode 100644 index 0000000..23bfd3d --- /dev/null +++ b/src/gui/operations_controller.py @@ -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 diff --git a/src/gui/protocol_panel.py b/src/gui/protocol_panel.py new file mode 100644 index 0000000..ccaa8dc --- /dev/null +++ b/src/gui/protocol_panel.py @@ -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.")) diff --git a/src/gui/quality_panel.py b/src/gui/quality_panel.py new file mode 100644 index 0000000..ef9a680 --- /dev/null +++ b/src/gui/quality_panel.py @@ -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]) diff --git a/src/gui/status_panel.py b/src/gui/status_panel.py new file mode 100644 index 0000000..fdd6b50 --- /dev/null +++ b/src/gui/status_panel.py @@ -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) diff --git a/src/gui/video_widget.py b/src/gui/video_widget.py new file mode 100644 index 0000000..c704188 --- /dev/null +++ b/src/gui/video_widget.py @@ -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)) diff --git a/src/head_pose.py b/src/head_pose.py new file mode 100644 index 0000000..343a85d --- /dev/null +++ b/src/head_pose.py @@ -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) diff --git a/src/improvement_recommender.py b/src/improvement_recommender.py new file mode 100644 index 0000000..eb2bcf5 --- /dev/null +++ b/src/improvement_recommender.py @@ -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 diff --git a/src/log_consistency_checker.py b/src/log_consistency_checker.py new file mode 100644 index 0000000..5019256 --- /dev/null +++ b/src/log_consistency_checker.py @@ -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, {})) diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..abe694b --- /dev/null +++ b/src/models.py @@ -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="" diff --git a/src/person_state_logger.py b/src/person_state_logger.py new file mode 100644 index 0000000..6c086d1 --- /dev/null +++ b/src/person_state_logger.py @@ -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() diff --git a/src/person_state_manager.py b/src/person_state_manager.py new file mode 100644 index 0000000..a218108 --- /dev/null +++ b/src/person_state_manager.py @@ -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, {}) diff --git a/src/person_state_rules.py b/src/person_state_rules.py new file mode 100644 index 0000000..215c217 --- /dev/null +++ b/src/person_state_rules.py @@ -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) + diff --git a/src/phase10_cli.py b/src/phase10_cli.py new file mode 100644 index 0000000..1a196d5 --- /dev/null +++ b/src/phase10_cli.py @@ -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) diff --git a/src/phase11_cli.py b/src/phase11_cli.py new file mode 100644 index 0000000..280b725 --- /dev/null +++ b/src/phase11_cli.py @@ -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() diff --git a/src/pilot_diagnostics.py b/src/pilot_diagnostics.py new file mode 100644 index 0000000..96d3a0f --- /dev/null +++ b/src/pilot_diagnostics.py @@ -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.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)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 diff --git a/src/pilot_session_reporter.py b/src/pilot_session_reporter.py new file mode 100644 index 0000000..4f01684 --- /dev/null +++ b/src/pilot_session_reporter.py @@ -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 diff --git a/src/presentation_summary_builder.py b/src/presentation_summary_builder.py new file mode 100644 index 0000000..83b8d7b --- /dev/null +++ b/src/presentation_summary_builder.py @@ -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 diff --git a/src/quality_checker.py b/src/quality_checker.py new file mode 100644 index 0000000..56d63ce --- /dev/null +++ b/src/quality_checker.py @@ -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/totalself.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 diff --git a/src/session_comparator.py b/src/session_comparator.py new file mode 100644 index 0000000..40d204f --- /dev/null +++ b/src/session_comparator.py @@ -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 diff --git a/src/session_summary_builder.py b/src/session_summary_builder.py new file mode 100644 index 0000000..33d43f3 --- /dev/null +++ b/src/session_summary_builder.py @@ -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") diff --git a/src/statistical_analyzer.py b/src/statistical_analyzer.py new file mode 100644 index 0000000..0031a73 --- /dev/null +++ b/src/statistical_analyzer.py @@ -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 diff --git a/src/statistical_tests.py b/src/statistical_tests.py new file mode 100644 index 0000000..0b9bc22 --- /dev/null +++ b/src/statistical_tests.py @@ -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. diff --git a/src/track_manager.py b/src/track_manager.py new file mode 100644 index 0000000..943f1a9 --- /dev/null +++ b/src/track_manager.py @@ -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() diff --git a/src/tracker.py b/src/tracker.py new file mode 100644 index 0000000..e68df4a --- /dev/null +++ b/src/tracker.py @@ -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" diff --git a/src/tracking_logger.py b/src/tracking_logger.py new file mode 100644 index 0000000..1e96986 --- /dev/null +++ b/src/tracking_logger.py @@ -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() diff --git a/src/turn_detector.py b/src/turn_detector.py new file mode 100644 index 0000000..8c1d66c --- /dev/null +++ b/src/turn_detector.py @@ -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 {})) diff --git a/src/turn_logger.py b/src/turn_logger.py new file mode 100644 index 0000000..e67c37e --- /dev/null +++ b/src/turn_logger.py @@ -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() diff --git a/src/turn_rules.py b/src/turn_rules.py new file mode 100644 index 0000000..92ad02b --- /dev/null +++ b/src/turn_rules.py @@ -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 diff --git a/src/turn_track_manager.py b/src/turn_track_manager.py new file mode 100644 index 0000000..3e9a950 --- /dev/null +++ b/src/turn_track_manager.py @@ -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 diff --git a/src/unified_event_builder.py b/src/unified_event_builder.py new file mode 100644 index 0000000..bbd09f1 --- /dev/null +++ b/src/unified_event_builder.py @@ -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)) + diff --git a/src/unified_event_logger.py b/src/unified_event_logger.py new file mode 100644 index 0000000..7a3a412 --- /dev/null +++ b/src/unified_event_logger.py @@ -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() diff --git a/src/visualizer.py b/src/visualizer.py new file mode 100644 index 0000000..067377b --- /dev/null +++ b/src/visualizer.py @@ -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 diff --git a/src/voice_logger.py b/src/voice_logger.py new file mode 100644 index 0000000..4dc4a1c --- /dev/null +++ b/src/voice_logger.py @@ -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() diff --git a/src/voice_prompt_controller.py b/src/voice_prompt_controller.py new file mode 100644 index 0000000..43ac094 --- /dev/null +++ b/src/voice_prompt_controller.py @@ -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 {})) diff --git a/src/voice_rules.py b/src/voice_rules.py new file mode 100644 index 0000000..a00de84 --- /dev/null +++ b/src/voice_rules.py @@ -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" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e0310a0 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests.""" diff --git a/tests/fixtures/synthetic_person_analysis_table.csv b/tests/fixtures/synthetic_person_analysis_table.csv new file mode 100644 index 0000000..07c417f --- /dev/null +++ b/tests/fixtures/synthetic_person_analysis_table.csv @@ -0,0 +1,4 @@ +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 +exp,s1,1,prompt,prompt,True,False,t,1.0,True,True,0.8,weak,True,weak,25.0,True,True,0.8,0.7,True,True,completed,True,False,none,3.0,True,left_to_right,left_to_right,test, +exp,s2,2,control,control,False,True,t,1.0,True,False,,none,False,none,5.0,True,True,0.7,0.6,True,True,completed,True,False,none,3.0,True,left_to_right,left_to_right,test, +exp,s3,3,prompt,prompt,True,False,t,1.0,True,False,,,False,not_evaluable,,False,False,0.0,0.0,False,False,not_evaluable,True,False,no_pose_estimated,3.0,True,left_to_right,left_to_right,test, diff --git a/tests/test_analysis_cli.py b/tests/test_analysis_cli.py new file mode 100644 index 0000000..cea2c89 --- /dev/null +++ b/tests/test_analysis_cli.py @@ -0,0 +1,5 @@ +from main import build_analysis_parser + +def test_analysis_parser() -> None: + args=build_analysis_parser().parse_args(["--input","in","--output","out","--no-analysis-plots","--analysis-response-test","fisher"]) + assert str(args.input)=="in" and args.no_analysis_plots and args.analysis_response_test=="fisher" diff --git a/tests/test_analysis_loader.py b/tests/test_analysis_loader.py new file mode 100644 index 0000000..fe7cfea --- /dev/null +++ b/tests/test_analysis_loader.py @@ -0,0 +1,13 @@ +from pathlib import Path +import pytest +pd=pytest.importorskip("pandas") +from src.analysis_loader import AnalysisLoader +from src.config_loader import AnalysisSettings +from src.exceptions import ConfigurationError + +CFG=AnalysisSettings(True,"in","out",True,True,True,True,"weak","auto","mannwhitney","valid_turn_analysis",False,True,.05) +def test_load_find_convert_validate(tmp_path:Path)->None: + row={"experiment_id":"e","session_id":"s","track_id":"1","prompt_condition":"prompt","response_detected":"True","valid_for_voice_analysis":"False","valid_for_turn_analysis":"False","turn_confirmed":"False","turn_level":"not_evaluable","reaction_time_sec":"","max_yaw_delta_toward_signage":"","excluded":"False","exclusion_reason":"none"} + for i in range(2):pd.DataFrame([row]).to_csv(tmp_path/f"{i}_person_analysis_table.csv",index=False,encoding="utf-8-sig") + l=AnalysisLoader(CFG);paths=l.find_person_analysis_tables(tmp_path);df=l.load_person_analysis_tables(paths);assert len(df)==2 and bool(df.response_detected.iloc[0]) and not bool(df.valid_for_voice_analysis.iloc[0]) + with pytest.raises(ConfigurationError):l.validate_person_analysis_columns(pd.DataFrame({"x":[]})) diff --git a/tests/test_analysis_models.py b/tests/test_analysis_models.py new file mode 100644 index 0000000..e8cc45e --- /dev/null +++ b/tests/test_analysis_models.py @@ -0,0 +1,6 @@ +from src.models import AnalysisConditionSummary,AnalysisRunSummary + +def test_analysis_models() -> None: + condition=AnalysisConditionSummary("prompt",1,1,1,0,1.,0,0,None,None,None,None,None,None,0,1,0,0,1,0) + run=AnalysisRunSummary("a",1,1,1,0,1,0,"t","out","note") + assert condition.response_rate==1. and run.input_files_count==1 diff --git a/tests/test_analysis_plots.py b/tests/test_analysis_plots.py new file mode 100644 index 0000000..39ad22d --- /dev/null +++ b/tests/test_analysis_plots.py @@ -0,0 +1,11 @@ +from pathlib import Path +import pytest +pd=pytest.importorskip("pandas");pytest.importorskip("matplotlib") +from src.analysis_plots import AnalysisPlots +from src.config_loader import AnalysisSettings +from src.statistical_analyzer import StatisticalAnalyzer + +def test_five_plots(tmp_path:Path)->None: + c=AnalysisSettings(True,"in",str(tmp_path),True,True,True,True,"weak","auto","mannwhitney","valid_turn_analysis",True,True,.05) + df=pd.DataFrame([{"prompt_condition":"prompt","valid_for_voice_analysis":True,"response_detected":True,"reaction_time_sec":1.,"max_yaw_delta_toward_signage":20.,"turn_level":"weak","excluded":False,"valid_for_turn_analysis":True,"exclusion_reason":"none"},{"prompt_condition":"control","valid_for_voice_analysis":True,"response_detected":False,"reaction_time_sec":None,"max_yaw_delta_toward_signage":5.,"turn_level":"none","excluded":False,"valid_for_turn_analysis":True,"exclusion_reason":"none"}]) + assert len(AnalysisPlots(c).generate(df,StatisticalAnalyzer(c).run_analysis(df)))==5 diff --git a/tests/test_analysis_reporter.py b/tests/test_analysis_reporter.py new file mode 100644 index 0000000..c59dd3b --- /dev/null +++ b/tests/test_analysis_reporter.py @@ -0,0 +1,12 @@ +from pathlib import Path +from src.analysis_reporter import AnalysisReporter +from src.config_loader import AnalysisSettings +from src.models import AnalysisResult,AnalysisRunSummary,ResponseRateTestResult + +def cfg(path:Path,overwrite:bool=True)->AnalysisSettings:return AnalysisSettings(True,"in",str(path),True,True,True,True,"weak","auto","mannwhitney","valid_turn_analysis",False,overwrite,.05) +def empty(path:Path)->AnalysisResult:return AnalysisResult([],ResponseRateTestResult("none",0,0,0,0,None,None,None,None,None,"none",None,None,"empty"),[],[],[],AnalysisRunSummary("a",0,0,0,0,0,0,"t",str(path),"日本語")) +def test_six_bom_csvs_and_overwrite(tmp_path:Path)->None: + paths=AnalysisReporter(cfg(tmp_path)).write(empty(tmp_path));assert len(paths)==6 and all(p.read_bytes().startswith(b"\xef\xbb\xbf") for p in paths) + try:AnalysisReporter(cfg(tmp_path,False)).write(empty(tmp_path));assert False + except FileExistsError:pass + diff --git a/tests/test_analysis_table_builder.py b/tests/test_analysis_table_builder.py new file mode 100644 index 0000000..e07d346 --- /dev/null +++ b/tests/test_analysis_table_builder.py @@ -0,0 +1,16 @@ +from src.analysis_table_builder import AnalysisTableBuilder +from src.config_loader import UnifiedLogSettings +from src.models import PersonStateSummary + +CFG=UnifiedLogSettings(True,"x",True,True,True,True,30,True,True,True,True,False,False,"camera",True) + +def test_prompt_control_disabled_records() -> None: + b=AnalysisTableBuilder(CFG,"sid") + for mode in ("prompt","control",None): + s=PersonStateSummary("exp",1,voice_mode=mode,prompt_played=mode=="prompt",pseudo_prompt=mode=="control", + response_detected=True,reaction_time_sec=.8,turn_confirmed=True,turn_level="weak", + max_yaw_delta_toward_signage=25.,valid_for_turn_analysis=False,exclusion_reason="no_pose_estimated") + r=b.build_record(s) + assert r.prompt_condition==(mode or "disabled") and r.camera_position_note=="camera" + assert r.response_detected and r.reaction_time_sec==.8 and r.valid_for_turn_analysis is False + diff --git a/tests/test_application_source_regression.py b/tests/test_application_source_regression.py new file mode 100644 index 0000000..381135f --- /dev/null +++ b/tests/test_application_source_regression.py @@ -0,0 +1,10 @@ +"""Source-level guard for a GUI callback regression that occurred before first frame.""" +from pathlib import Path + + +def test_runtime_callback_does_not_reference_screenshot_path() -> None: + source = (Path(__file__).parents[1] / "src" / "application.py").read_text(encoding="utf-8") + callback_body = source.split("def _emit_runtime_update", 1)[1] + assert "Saved screenshot" not in callback_body + screenshot_body = source.split("def _save_screenshot", 1)[1].split("def _emit_runtime_update", 1)[0] + assert 'LOGGER.info("Saved screenshot: %s", path)' in screenshot_body diff --git a/tests/test_audio_player.py b/tests/test_audio_player.py new file mode 100644 index 0000000..a3f8b36 --- /dev/null +++ b/tests/test_audio_player.py @@ -0,0 +1,55 @@ +from pathlib import Path +from unittest.mock import Mock + +from src.audio_player import AudioPlayer +from src.config_loader import AudioSettings + + +def config(**values: object) -> AudioSettings: + base = dict(enabled=True, backend="pygame", file_path="prompt.wav", volume=.7, overlap_policy="skip") + base.update(values) + return AudioSettings(**base) + + +def test_disabled_and_missing_file_are_safe(tmp_path: Path) -> None: + assert AudioPlayer(config(enabled=False)).play() is False + player = AudioPlayer(config()) + player.load(tmp_path / "missing.wav") + assert player.play() is False + + +def test_mock_backend_load_play_volume_and_close(tmp_path: Path) -> None: + path = tmp_path / "prompt.wav" + path.write_bytes(b"test") + channel = Mock() + channel.get_busy.return_value = False + sound = Mock() + sound.play.return_value = channel + mixer = Mock() + mixer.get_init.return_value = True + mixer.Sound.return_value = sound + backend = Mock(mixer=mixer) + player = AudioPlayer(config(), backend) + player.load(path) + assert player.play() + sound.set_volume.assert_called_once_with(.7) + player.close() + player.close() + + +def test_overlap_skip_and_restart(tmp_path: Path) -> None: + path = tmp_path / "prompt.wav" + path.write_bytes(b"x") + for policy, expected in (("skip", 1), ("restart", 2)): + channel = Mock() + channel.get_busy.return_value = True + sound = Mock(play=Mock(return_value=channel)) + mixer = Mock() + mixer.get_init.return_value = True + mixer.Sound.return_value = sound + player = AudioPlayer(config(overlap_policy=policy), Mock(mixer=mixer)) + player.load(path) + assert player.play() + player.play() + assert sound.play.call_count == expected + diff --git a/tests/test_calibration_history.py b/tests/test_calibration_history.py new file mode 100644 index 0000000..d4deb6a --- /dev/null +++ b/tests/test_calibration_history.py @@ -0,0 +1,6 @@ +from pathlib import Path +from src.calibration_history import CalibrationHistory +from src.models import CalibrationChangeRecord +def test_calibration_roundtrip_bom(tmp_path:Path)->None: + h=CalibrationHistory(tmp_path/"h.csv");r=CalibrationChangeRecord("id1","now","学生","camera_position","横","正面","改善");h.append(r);assert h.path.read_bytes().startswith(b"\xef\xbb\xbf");assert h.load()[0].after_value=="正面" +def test_empty_history(tmp_path:Path)->None:assert CalibrationHistory(tmp_path/"none.csv").load()==[] diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py new file mode 100644 index 0000000..a49df23 --- /dev/null +++ b/tests/test_config_loader.py @@ -0,0 +1,79 @@ +from pathlib import Path + +import pytest +import yaml + +from src.config_loader import load_config +from src.exceptions import ConfigurationError + + +DEFAULT = Path(__file__).parents[1] / "config" / "default.yaml" + + +def write_yaml(tmp_path: Path, values: dict[str, object]) -> Path: + path = tmp_path / "experiment.yaml" + path.write_text(yaml.safe_dump(values), encoding="utf-8") + return path + + +def test_recursive_merge_and_experiment_precedence(tmp_path: Path) -> None: + path = write_yaml(tmp_path, {"detector": {"confidence_threshold": 0.8}, "experiment": {"location": "lab"}}) + config = load_config(DEFAULT, path) + assert config.detector.confidence_threshold == 0.8 + assert config.detector.iou_threshold == 0.5 + assert config.experiment.location == "lab" + assert config.experiment.experiment_id == "phase1_default" + + +def test_cli_override_is_last(tmp_path: Path) -> None: + path = write_yaml(tmp_path, {"detector": {"confidence_threshold": 0.6}}) + config = load_config(DEFAULT, path, {"detector": {"confidence_threshold": 0.7}}) + assert config.detector.confidence_threshold == 0.7 + + +@pytest.mark.parametrize("override", [ + {"detector": {"confidence_threshold": 1.1}}, + {"evaluation_region": {"x1": -0.1}}, + {"evaluation_region": {"x1": 0.9, "x2": 0.8}}, + {"input": {"type": "video", "video_path": None}}, + {"tracker": {"match_thresh": 1.1}}, + {"crossing": {"minimum_track_points": 1}}, + {"performance": {"skip_frames": 1}}, + {"face": {"process_every_n_frames": 0}}, + {"face": {"roi_height_ratio": 1.1}}, + {"face": {"min_detection_confidence": -0.1}}, + {"head_pose": {"ema_alpha": 0.0}}, + {"head_pose": {"max_abs_angle": 0}}, + {"face_logging": {"flush_interval_frames": 0}}, + {"turn_detection": {"signage_yaw_direction": "sideways"}}, + {"turn_detection": {"baseline_strategy": "unknown"}}, + {"turn_detection": {"baseline_min_samples": 0}}, + {"turn_detection": {"subtle_threshold_deg": 25, "weak_threshold_deg": 20}}, + {"turn_detection": {"min_pose_estimation_rate_for_evaluation": 1.1}}, + {"turn_logging": {"flush_interval_frames": 0}}, + {"audio": {"backend": "unknown"}}, + {"audio": {"volume": 1.1}}, + {"audio": {"overlap_policy": "mix"}}, + {"voice_prompt": {"mode": "random"}}, + {"voice_prompt": {"trigger_strategy": "unknown"}}, + {"voice_prompt": {"response_window_sec": 0}}, + {"voice_prompt": {"fixed_position_axis": "z"}}, + {"voice_logging": {"flush_interval_frames": 0}}, + {"person_state": {"min_track_duration_sec": -1}}, + {"person_state_logging": {"flush_interval_frames": 0}}, + {"unified_logging": {"flush_interval_frames": 0}}, + {"analysis": {"alpha": 1.0}}, + {"analysis": {"response_rate_test": "bad"}}, + {"gui": {"preview_max_fps": 0}}, + {"gui": {"enabled": "yes"}}, + {"pilot_diagnostics": {"min_valid_voice_analysis_rate": 1.1}}, + {"pilot_diagnostics": {"max_condition_valid_count_ratio": 0.5}}, +]) +def test_invalid_config(tmp_path: Path, override: dict[str, object]) -> None: + with pytest.raises(ConfigurationError): + load_config(DEFAULT, write_yaml(tmp_path, override)) + + +def test_skip_frames_allowed_when_tracker_disabled(tmp_path: Path) -> None: + path = write_yaml(tmp_path, {"tracker": {"enabled": False}, "performance": {"skip_frames": 2}}) + assert load_config(DEFAULT, path).performance.skip_frames == 2 diff --git a/tests/test_crossing_detector.py b/tests/test_crossing_detector.py new file mode 100644 index 0000000..5190669 --- /dev/null +++ b/tests/test_crossing_detector.py @@ -0,0 +1,40 @@ +from src.config_loader import CrossingSettings +from src.crossing_detector import CrossingDetector +from src.models import BoundingBox, PixelTriggerLine, TrackPoint + + +def settings(**overrides: object) -> CrossingSettings: + values = dict(enabled=True, count_once_per_track=True, deadband_pixels=3, + minimum_displacement_pixels=10, minimum_track_points=3, require_confirmed_track=True) + values.update(overrides) + return CrossingSettings(**values) # type: ignore[arg-type] + + +def points(values: list[float], vertical: bool = True) -> list[TrackPoint]: + result = [] + for frame, value in enumerate(values, 1): + x, y = (value, 20.0) if vertical else (20.0, value) + result.append(TrackPoint(float(frame), str(frame), frame, x, y, x, y, BoundingBox(0, 0, 2, 2))) + return result + + +def test_vertical_directions_and_no_crossing() -> None: + detector = CrossingDetector(settings(), PixelTriggerLine("vertical", 50)) + assert detector.detect(1, points([30, 45, 60]), True) == "left_to_right" + assert detector.detect(2, points([70, 55, 40]), True) == "right_to_left" + assert detector.detect(3, points([20, 30, 40]), True) is None + + +def test_deadband_displacement_confirmation_and_once() -> None: + detector = CrossingDetector(settings(), PixelTriggerLine("vertical", 50)) + assert detector.detect(1, points([46, 49, 54]), True) is None + assert CrossingDetector(settings(minimum_displacement_pixels=30), PixelTriggerLine("vertical", 50)).detect(2, points([40, 50, 60]), True) is None + assert detector.detect(4, points([30, 45, 60]), False) is None + assert detector.detect(4, points([30, 45, 60]), True) == "left_to_right" + assert detector.detect(4, points([60, 50, 30]), True) is None + + +def test_horizontal_directions() -> None: + detector = CrossingDetector(settings(), PixelTriggerLine("horizontal", 50)) + assert detector.detect(1, points([30, 45, 60], vertical=False), True) == "top_to_bottom" + assert detector.detect(2, points([70, 55, 40], vertical=False), True) == "bottom_to_top" diff --git a/tests/test_detector.py b/tests/test_detector.py new file mode 100644 index 0000000..af673bd --- /dev/null +++ b/tests/test_detector.py @@ -0,0 +1,69 @@ +import sys +from types import SimpleNamespace +from typing import Any + +# The detector only needs NumPy for annotations; keep this unit test independent +# from binary packages and real image/model execution. +_inserted_numpy_stub = "numpy" not in sys.modules +if _inserted_numpy_stub: + sys.modules["numpy"] = SimpleNamespace( + ndarray=object, + isscalar=lambda value: isinstance(value, (int, float)), + ) + +from src.config_loader import DetectorSettings +from src.detector import PersonDetector + +if _inserted_numpy_stub: + sys.modules.pop("numpy", None) + + +class FakeModel: + def __init__(self) -> None: + self.kwargs: dict[str, Any] = {} + + def predict(self, **kwargs: Any) -> list[Any]: + self.kwargs = kwargs + return [] + + +class FakeFrame: + shape = (10, 10, 3) + + +def make_settings(half_precision: bool) -> DetectorSettings: + return DetectorSettings( + model_path="unused.pt", + device="auto", + confidence_threshold=0.4, + iou_threshold=0.5, + image_size=640, + person_class_id=0, + max_detections=30, + half_precision=half_precision, + use_agnostic_nms=False, + ) + + +def run_detection(half_precision: bool, effective_half: bool) -> dict[str, Any]: + detector = PersonDetector(make_settings(half_precision)) + model = FakeModel() + detector._model = model + detector._device_name = "cuda" if effective_half else "cpu" + detector._half = effective_half + detector.detect(FakeFrame()) # type: ignore[arg-type] + return model.kwargs + + +def test_cpu_does_not_pass_half_when_disabled() -> None: + assert "half" not in run_detection(half_precision=False, effective_half=False) + + +def test_cpu_does_not_pass_half_when_requested_but_unavailable() -> None: + assert "half" not in run_detection(half_precision=True, effective_half=False) + + +def test_cuda_passes_half_only_when_effectively_enabled() -> None: + kwargs = run_detection(half_precision=True, effective_half=True) + assert kwargs["half"] is True + assert "quantize" not in kwargs diff --git a/tests/test_experiment_notes.py b/tests/test_experiment_notes.py new file mode 100644 index 0000000..f499851 --- /dev/null +++ b/tests/test_experiment_notes.py @@ -0,0 +1,6 @@ +from pathlib import Path +from src.experiment_notes import ExperimentNotes +from src.models import ExperimentSessionNote +def test_note_utf8_roundtrip(tmp_path:Path)->None: + n=ExperimentSessionNote("n1",None,"e1","2026-08-09","prompt","学生","研究室","サイネージ正面",120.0,"水平","右側","左から右","昼",None,"こんにちは",.8,"config.yaml",None,None,10,None,"なし","日本語メモ") + store=ExperimentNotes();path=store.save(n,tmp_path/"note.yaml");loaded=store.load(path);assert loaded.camera_position_note=="サイネージ正面" and loaded.condition=="prompt" diff --git a/tests/test_experiment_protocol.py b/tests/test_experiment_protocol.py new file mode 100644 index 0000000..18e758a --- /dev/null +++ b/tests/test_experiment_protocol.py @@ -0,0 +1,7 @@ +from pathlib import Path +import yaml +from src.config_loader import load_config +from src.experiment_protocol import ExperimentProtocolBuilder +def test_protocol_exports(tmp_path:Path)->None: + c=load_config("config/default.yaml",None);b=ExperimentProtocolBuilder(c.to_dict());p=b.build_default_protocol();assert p.voice_volume==c.audio.volume and p.camera_position_description==c.unified_logging.camera_position_note + md=b.export_markdown(p,tmp_path/"p.md");yp=b.export_yaml(p,tmp_path/"p.yaml");assert "voice_volume" in md.read_text(encoding="utf-8");assert yaml.safe_load(yp.read_text(encoding="utf-8"))["privacy_note"] diff --git a/tests/test_face_geometry.py b/tests/test_face_geometry.py new file mode 100644 index 0000000..5ff6542 --- /dev/null +++ b/tests/test_face_geometry.py @@ -0,0 +1,36 @@ +from src.config_loader import FaceSettings +from src.face_geometry import create_face_roi +from src.models import BoundingBox, TrackedPerson + + +def settings(**overrides: object) -> FaceSettings: + values = dict(enabled=True, backend="mediapipe", process_every_n_frames=1, max_persons_per_frame=3, + require_confirmed_track=True, process_only_inside_region=False, roi_height_ratio=.5, + roi_expand_ratio=.1, min_roi_width=10, min_roi_height=10, static_image_mode=False, + max_num_faces=1, refine_landmarks=True, min_detection_confidence=.5, + min_tracking_confidence=.5) + values.update(overrides) + return FaceSettings(**values) # type: ignore[arg-type] + + +def person(bbox: BoundingBox, confirmed: bool = True, inside: bool = True) -> TrackedPerson: + return TrackedPerson(1, bbox, .9, 0, "person", bbox.center_x, bbox.center_y, bbox.foot_x, bbox.foot_y, + inside, confirmed, 3, 0) + + +def test_roi_ratio_expansion_and_clipping() -> None: + roi = create_face_roi(person(BoundingBox(10, 10, 110, 210)), 200, 300, settings()) + assert roi is not None + assert (roi.x1, roi.x2) == (0, 120) + assert roi.y1 == 0 + assert roi.y2 == 120 + clipped = create_face_roi(person(BoundingBox(0, 0, 50, 100)), 60, 80, settings(roi_height_ratio=1.0)) + assert clipped and clipped.x1 == 0 and clipped.y1 == 0 and clipped.y2 == 80 + + +def test_small_unconfirmed_and_outside_are_excluded() -> None: + assert create_face_roi(person(BoundingBox(0, 0, 20, 20)), 100, 100, + settings(min_roi_width=40, min_roi_height=40)) is None + assert create_face_roi(person(BoundingBox(0, 0, 80, 100), confirmed=False), 100, 100, settings()) is None + assert create_face_roi(person(BoundingBox(0, 0, 80, 100), inside=False), 100, 100, + settings(process_only_inside_region=True)) is None diff --git a/tests/test_face_logger.py b/tests/test_face_logger.py new file mode 100644 index 0000000..4de823a --- /dev/null +++ b/tests/test_face_logger.py @@ -0,0 +1,32 @@ +import csv +from pathlib import Path + +from src.config_loader import FaceLoggingSettings +from src.face_logger import FaceLogger +from src.models import FaceTrackState, HeadPoseResult + + +def test_face_logs_success_failure_bom_and_summary_deduplication(tmp_path: Path) -> None: + logger = FaceLogger(FaceLoggingSettings(True, True, True, 1, str(tmp_path)), "exp", "20260101_000000") + logger.open() + success = HeadPoseResult(1, 1, "now", .1, True, True, 1.0, -2.0, 3.0, 468, None, .9) + failure = HeadPoseResult(1, 2, "later", .2, False, False, None, None, None, 0, None, None, "face_not_detected") + logger.write_results([success, failure]) + state = FaceTrackState(1, total_frames=2, face_detected_frames=1, pose_estimated_frames=1, + last_pitch=1.0, last_yaw=-2.0, last_roll=3.0, min_yaw=-2.0, + max_yaw=-2.0, yaw_sum=-2.0, last_result=failure, finalized=True) + logger.write_summary(state) + logger.write_summary(state) + logger.close() + logger.close() + paths = list(tmp_path.glob("*.csv")) + assert len(paths) == 2 + assert all(path.read_bytes().startswith(b"\xef\xbb\xbf") for path in paths) + frame_path = next(path for path in paths if "frames" in path.name) + with frame_path.open(encoding="utf-8-sig", newline="") as stream: + rows = list(csv.DictReader(stream)) + assert len(rows) == 2 and rows[1]["failure_reason"] == "face_not_detected" + assert "yaw" in rows[0] + summary_path = next(path for path in paths if "summary" in path.name) + with summary_path.open(encoding="utf-8-sig", newline="") as stream: + assert len(list(csv.DictReader(stream))) == 1 diff --git a/tests/test_face_models.py b/tests/test_face_models.py new file mode 100644 index 0000000..294295c --- /dev/null +++ b/tests/test_face_models.py @@ -0,0 +1,42 @@ +from collections import deque + +import pytest + +from src.face_processor import FaceTrackManager +from src.models import BoundingBox, FaceROI, HeadPoseResult + + +def result(track_id: int, yaw: float | None, detected: bool = True) -> HeadPoseResult: + success = yaw is not None + return HeadPoseResult(track_id, 1, "now", .1, detected, success, + 1.0 if success else None, yaw, 2.0 if success else None, + 6 if detected else 0, None, .9 if detected else None, + "" if success else "face_not_detected") + + +def test_face_roi_properties() -> None: + bbox = BoundingBox(10, 20, 110, 220) + roi = FaceROI(1, 5, 10, 115, 100, bbox, .8) + assert (roi.width, roi.height) == (110, 90) + assert (roi.center_x, roi.center_y) == (60.0, 55.0) + + +def test_head_pose_result_success_and_failure_validation() -> None: + assert result(1, -12.0).pose_estimated + assert not result(1, None, False).face_detected + with pytest.raises(ValueError): + HeadPoseResult(1, 1, "now", 0, True, True, None, 1.0, 2.0, 6, None, .5) + + +def test_face_track_statistics_and_history_limit() -> None: + manager = FaceTrackManager(2) + manager.update([result(1, -10.0), result(1, 20.0), result(1, None, False)]) + state = manager.get_state(1) + assert state is not None + assert state.total_frames == 3 + assert state.face_detection_rate == pytest.approx(2 / 3) + assert state.pose_estimation_rate == pytest.approx(2 / 3) + assert (state.min_yaw, state.max_yaw, state.mean_yaw) == (-10.0, 20.0, 5.0) + assert len(state.history) == 2 + finalized = manager.finalize_track(1) + assert finalized and finalized.finalized diff --git a/tests/test_fps_counter.py b/tests/test_fps_counter.py new file mode 100644 index 0000000..1b690e9 --- /dev/null +++ b/tests/test_fps_counter.py @@ -0,0 +1,30 @@ +import pytest + +from src.fps_counter import FPSCounter + + +def test_initial_and_tick_and_average(monkeypatch: pytest.MonkeyPatch) -> None: + times = iter([1.0, 1.5, 1.75]) + monkeypatch.setattr("src.fps_counter.time.perf_counter", lambda: next(times)) + counter = FPSCounter(2) + assert counter.current_fps == counter.average_fps == 0.0 + assert counter.tick() == 0.0 + assert counter.tick() == pytest.approx(2.0) + assert counter.tick() == pytest.approx(4.0) + assert counter.average_fps == pytest.approx(3.0) + + +def test_reset(monkeypatch: pytest.MonkeyPatch) -> None: + times = iter([1.0, 2.0]) + monkeypatch.setattr("src.fps_counter.time.perf_counter", lambda: next(times)) + counter = FPSCounter(3) + counter.tick() + counter.tick() + counter.reset() + assert counter.current_fps == counter.average_fps == 0.0 + assert counter.sample_count == 0 + + +def test_invalid_window() -> None: + with pytest.raises(ValueError): + FPSCounter(0) diff --git a/tests/test_gui_analysis_controller.py b/tests/test_gui_analysis_controller.py new file mode 100644 index 0000000..0029d26 --- /dev/null +++ b/tests/test_gui_analysis_controller.py @@ -0,0 +1,17 @@ +import time +from pathlib import Path +import pytest +from src.gui.analysis_controller import AnalysisController +from src.gui.gui_models import GuiAnalysisRequest +def test_analysis_runner_and_callback(tmp_path:Path)->None: + expected=[tmp_path/"condition_summary.csv"];finished=[] + c=AnalysisController(lambda settings,i,o:expected);c.set_finished_callback(finished.extend);c.run_analysis(GuiAnalysisRequest(output_directory=str(tmp_path)));time.sleep(.05) + assert finished==expected +def test_analysis_double_run_guard()->None: + def slow(s,i,o):time.sleep(.1);return [] + c=AnalysisController(slow);c.run_analysis(GuiAnalysisRequest()) + with pytest.raises(RuntimeError):c.run_analysis(GuiAnalysisRequest()) +def test_analysis_error_callback()->None: + errors=[] + def fail(s,i,o):raise RuntimeError("broken") + c=AnalysisController(fail);c.set_error_callback(errors.append);c.run_analysis(GuiAnalysisRequest());time.sleep(.05);assert errors==["broken"] diff --git a/tests/test_gui_config_panel.py b/tests/test_gui_config_panel.py new file mode 100644 index 0000000..50ae45b --- /dev/null +++ b/tests/test_gui_config_panel.py @@ -0,0 +1,16 @@ +from pathlib import Path +from src.gui.config_panel import experiment_overrides,validate_experiment_request +from src.gui.gui_models import GuiExperimentRequest +CONFIG=str(Path(__file__).parents[1]/"config"/"experiment.yaml") +def request(**values):return GuiExperimentRequest(config_path=CONFIG,**values) +def test_invalid_numeric_fields()->None: + assert not validate_experiment_request(request(camera_index=-1)).valid + assert not validate_experiment_request(request(voice_volume=1.1)).valid + assert not validate_experiment_request(request(face_every=0)).valid + assert not validate_experiment_request(request(max_face_persons=0)).valid +def test_video_requires_existing_path()->None:assert not validate_experiment_request(request(input_type="video")).valid +def test_prompt_without_audio_is_warning()->None: + result=validate_experiment_request(request(voice_mode="prompt"));assert result.valid and result.warnings +def test_valid_request_and_overrides()->None: + r=request(voice_mode="control",signage_yaw_direction="negative");assert validate_experiment_request(r).valid + values=experiment_overrides(r);assert values["voice_prompt"]["mode"]=="control" and values["turn_detection"]["signage_yaw_direction"]=="negative" diff --git a/tests/test_gui_experiment_controller.py b/tests/test_gui_experiment_controller.py new file mode 100644 index 0000000..549f864 --- /dev/null +++ b/tests/test_gui_experiment_controller.py @@ -0,0 +1,21 @@ +import time +from pathlib import Path +import pytest +from src.gui.experiment_controller import ExperimentController +from src.gui.gui_models import GuiExperimentRequest +CONFIG=str(Path(__file__).parents[1]/"config"/"experiment.yaml") +class FakeApp: + instances=[] + def __init__(self,config,**kwargs):self.stop=False;self.paused=False;self.kwargs=kwargs;FakeApp.instances.append(self) + def run(self): + while not self.stop:time.sleep(.005) + def request_stop(self):self.stop=True + def request_pause(self):self.paused=True + def request_resume(self):self.paused=False +def test_controller_start_stop_pause_without_camera()->None: + c=ExperimentController(FakeApp);c.start_experiment(GuiExperimentRequest(config_path=CONFIG));time.sleep(.02) + assert c.is_running();c.pause_experiment();assert FakeApp.instances[-1].paused;c.resume_experiment();assert not FakeApp.instances[-1].paused;c.stop_experiment();time.sleep(.03);assert not c.is_running() +def test_controller_prevents_double_start()->None: + c=ExperimentController(FakeApp);c.start_experiment(GuiExperimentRequest(config_path=CONFIG)); + with pytest.raises(RuntimeError):c.start_experiment(GuiExperimentRequest(config_path=CONFIG)) + c.stop_experiment() diff --git a/tests/test_gui_models.py b/tests/test_gui_models.py new file mode 100644 index 0000000..f83496f --- /dev/null +++ b/tests/test_gui_models.py @@ -0,0 +1,8 @@ +from src.gui.gui_models import GuiAnalysisRequest,GuiExperimentRequest +def test_gui_request_defaults()->None: + e=GuiExperimentRequest();a=GuiAnalysisRequest() + assert e.input_type=="camera" and e.voice_mode=="disabled" and e.camera_position_note=="" + assert a.recursive and a.response_test=="auto" +def test_gui_request_preserves_values()->None: + e=GuiExperimentRequest(audio_file="x.wav",camera_position_note="front",voice_mode="prompt") + assert e.audio_file=="x.wav" and e.camera_position_note=="front" diff --git a/tests/test_head_pose.py b/tests/test_head_pose.py new file mode 100644 index 0000000..afa3de0 --- /dev/null +++ b/tests/test_head_pose.py @@ -0,0 +1,52 @@ +import math + +from src.config_loader import HeadPoseSettings +from src.head_pose import HeadPoseEstimator +from src.models import FaceLandmark + + +def settings(max_angle: float = 90.0) -> HeadPoseSettings: + return HeadPoseSettings(True, "solvepnp", True, "ema", .4, max_angle) + + +def landmarks() -> list[FaceLandmark]: + return [FaceLandmark(index, float(index), float(index + 1), 0.0) + for index in HeadPoseEstimator.LANDMARK_INDICES] + + +def test_missing_landmarks_and_input_builders() -> None: + estimator = HeadPoseEstimator(settings()) + assert estimator.estimate([], 640, 480).failure_reason == "insufficient_landmarks" + assert len(estimator.build_image_points(landmarks()) or []) == 6 + matrix = estimator.build_camera_matrix(640, 480) + assert matrix == [[640.0, 0.0, 320.0], [0.0, 640.0, 240.0], [0.0, 0.0, 1.0]] + + +def test_euler_conversion_returns_floats() -> None: + angles = HeadPoseEstimator.rotation_matrix_to_euler([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) + assert all(isinstance(value, float) for value in angles) + assert angles == (0.0, -0.0, 0.0) + + +def test_angle_range_validation() -> None: + estimator = HeadPoseEstimator(settings(45.0)) + assert estimator.result_from_angles((10.0, 20.0, 5.0)).success + failed = estimator.result_from_angles((10.0, 60.0, 5.0)) + assert failed.failure_reason == "angle_out_of_range" + assert (failed.pitch, failed.yaw, failed.roll) == (10.0, 60.0, 5.0) + assert estimator.result_from_angles((math.nan, 0.0, 0.0)).failure_reason == "angle_out_of_range" + + +def test_face_model_uses_image_coordinate_y_direction() -> None: + _, chin, left_eye, right_eye, left_mouth, right_mouth = HeadPoseEstimator.MODEL_POINTS + assert chin[1] > 0 + assert left_eye[1] < 0 and right_eye[1] < 0 + assert left_mouth[1] > 0 and right_mouth[1] > 0 + + +def test_roll_near_180_is_canonicalized_for_upright_face() -> None: + estimator = HeadPoseEstimator(settings()) + assert estimator.normalize_angles((2.0, -5.0, -170.0)) == (2.0, -5.0, 10.0) + assert estimator.normalize_angles((2.0, -5.0, 170.0)) == (2.0, -5.0, -10.0) + result = estimator.result_from_angles((2.0, -5.0, -170.0)) + assert result.success and result.roll == 10.0 diff --git a/tests/test_improvement_recommender.py b/tests/test_improvement_recommender.py new file mode 100644 index 0000000..1b1dd79 --- /dev/null +++ b/tests/test_improvement_recommender.py @@ -0,0 +1,4 @@ +from src.improvement_recommender import ImprovementRecommender +from src.models import ConditionBalanceDiagnostic +def test_recommendation_categories()->None: + b=ConditionBalanceDiagnostic(10,2,8,1,.5,.2,7,8.,False,"bad","add");cats={x.category for x in ImprovementRecommender().recommend(.3,.6,.7,b,8,1,True)};assert {"camera_position","face_detection","condition_balance","sample_size"}<=cats diff --git a/tests/test_log_consistency_checker.py b/tests/test_log_consistency_checker.py new file mode 100644 index 0000000..9c41c30 --- /dev/null +++ b/tests/test_log_consistency_checker.py @@ -0,0 +1,8 @@ +from src.log_consistency_checker import LogConsistencyChecker +from src.models import PersonAnalysisRecord + +def test_detects_nonfatal_issues() -> None: + r=PersonAnalysisRecord("e","s",1,"prompt","unknown",response_detected=True,reaction_time_sec=-1.,excluded=True,exclusion_reason="none",valid_for_voice_analysis=True) + kinds={x.issue_type for x in LogConsistencyChecker().check([r,r])} + assert {"missing_prompt_for_response","invalid_reaction_time","response_without_turn","duplicate_summary"}<=kinds + diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..f15f65f --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,24 @@ +import pytest + +from src.models import BoundingBox, PixelRegion + + +def test_bounding_box_properties() -> None: + box = BoundingBox(10, 20, 30, 50) + assert (box.width, box.height) == (20, 30) + assert (box.center_x, box.center_y) == (20, 35) + assert (box.foot_x, box.foot_y) == (20, 50) + assert box.area == 600 + + +def test_pixel_region_contains_including_boundary() -> None: + region = PixelRegion(10, 20, 30, 40) + assert region.contains(10, 20) + assert region.contains(30, 40) + assert not region.contains(31, 40) + + +@pytest.mark.parametrize("coords", [(1, 0, 1, 2), (0, 2, 1, 2), (2, 0, 1, 2)]) +def test_invalid_bounding_box(coords: tuple[int, int, int, int]) -> None: + with pytest.raises(ValueError): + BoundingBox(*coords) diff --git a/tests/test_person_state_logger.py b/tests/test_person_state_logger.py new file mode 100644 index 0000000..1ec07f4 --- /dev/null +++ b/tests/test_person_state_logger.py @@ -0,0 +1,19 @@ +import csv +from pathlib import Path + +from src.config_loader import PersonStateLoggingSettings +from src.models import PersonStateEvent, PersonStateSnapshot, PersonStateSummary +from src.person_state_logger import PersonStateLogger + + +def test_person_state_csvs_bom_json_and_dedup(tmp_path: Path) -> None: + logger = PersonStateLogger(PersonStateLoggingSettings(True, True, True, True, 1, str(tmp_path)), "exp", "stamp") + logger.open() + logger.write_snapshots([PersonStateSnapshot(1, 1, "t", 1., "tracked", metadata={"日本語": "状態"})]) + logger.write_events([PersonStateEvent("status_changed", 1, 1, "t", 1., "new", "tracked", metadata={"理由": "追跡"})]) + summary = PersonStateSummary("exp", 1, final_status="completed", completed=True) + logger.write_summary(summary); logger.write_summary(summary); logger.close(); logger.close() + assert all(path.read_bytes().startswith(b"\xef\xbb\xbf") for path in tmp_path.glob("*.csv")) + assert "日本語" in (tmp_path / "stamp_exp_person_state_frames.csv").read_text(encoding="utf-8-sig") + with (tmp_path / "stamp_exp_person_state_summary.csv").open(encoding="utf-8-sig", newline="") as stream: + assert len(list(csv.DictReader(stream))) == 1 diff --git a/tests/test_person_state_manager.py b/tests/test_person_state_manager.py new file mode 100644 index 0000000..89657f0 --- /dev/null +++ b/tests/test_person_state_manager.py @@ -0,0 +1,56 @@ +from src.config_loader import PersonStateSettings +from src.models import (BoundingBox, FrameInfo, HeadPoseResult, TrackEvent, TrackedPerson, TurnFrameResult, + VoicePromptDecision, VoicePromptEvent, VoicePromptTrackState) +from src.person_state_manager import PersonStateManager + + +CFG = PersonStateSettings(True, 0., False, False, False, True, True) + + +def info(frame: int, elapsed: float) -> FrameInfo: + return FrameInfo(frame, f"t{frame}", elapsed, 100, 100, 30.) + + +def person() -> TrackedPerson: + return TrackedPerson(1, BoundingBox(1, 1, 20, 40), .9, 0, "person", 10, 20, 10, 40, True, True, 10, 0) + + +def face() -> HeadPoseResult: + return HeadPoseResult(1, 1, "t1", 1., True, True, 0., 10., 0., 6, None, .9) + + +def turn() -> TurnFrameResult: + return TurnFrameResult(1, 1, "t1", 1., True, True, True, 0., 25., 25., 25., "positive", True, True, "weak", .4) + + +def test_integrates_track_face_turn_voice_and_response() -> None: + manager = PersonStateManager(CFG, "exp") + track_events = [TrackEvent("region_entered", 1, 1, "t1", 1., 10, 40), + TrackEvent("trigger_crossed", 1, 1, "t1", 1., 10, 40, "left_to_right")] + decision = VoicePromptDecision(1, 1, "t1", 1., "prompt", True, True, True, False, "prompt_played", "a.wav") + voice_events = [VoicePromptEvent("response_window_started", 1, 1, "t1", 1., "prompt", True), + VoicePromptEvent("response_detected", 1, 1, "t1", 1.5, "prompt", True, + turn_confirmed=True, turn_level="weak", reaction_time_sec=.5)] + voice_state = VoicePromptTrackState(1, "prompt", eligible=True, prompt_played=True, + response_window_started=True, response_detected=True, + reaction_time_sec=.5, response_turn_level="weak") + snapshots, events = manager.update([person()], track_events, [face()], [turn()], None, + [decision], voice_events, {1: voice_state}, info(1, 1.5)) + state = snapshots[0] + assert state.status == "responded" and state.trigger_crossed + assert state.face_detected and state.pose_estimated and state.turn_confirmed + assert state.prompt_played and state.response_detected and state.reaction_time_sec == .5 + assert any(event.event_type == "status_changed" for event in events) + summary, _ = manager.finalize_track(1, info(2, 2.), voice_state=voice_state) + assert summary and summary.trigger_crossed and summary.response_detected + + +def test_control_no_response_and_finalize_all() -> None: + manager = PersonStateManager(CFG, "exp") + decision = VoicePromptDecision(1, 1, "t1", 1., "control", True, True, False, True, "pseudo") + events = [VoicePromptEvent("response_window_expired", 1, 2, "t2", 2., "control", pseudo_prompt=True)] + snapshots, _ = manager.update([person()], [], [], [], None, [decision], events, None, info(2, 2.)) + assert snapshots[0].status == "no_response" and snapshots[0].pseudo_prompt + summaries, _ = manager.finalize_all(info(3, 3.)) + assert len(summaries) == 1 + diff --git a/tests/test_person_state_models.py b/tests/test_person_state_models.py new file mode 100644 index 0000000..2d6e8a6 --- /dev/null +++ b/tests/test_person_state_models.py @@ -0,0 +1,13 @@ +from src.models import (PersonExclusionReason, PersonExperimentStatus, PersonStateEvent, + PersonStateSnapshot, PersonStateSummary) + + +def test_enums_and_models() -> None: + assert PersonExperimentStatus.RESPONDED.value == "responded" + assert PersonExclusionReason.NO_FACE_DETECTED.value == "no_face_detected" + snapshot = PersonStateSnapshot(1, 2, "t", 1., "tracked", metadata={"日本語": True}) + event = PersonStateEvent("status_changed", 1, 2, "t", 1., "new", "tracked") + summary = PersonStateSummary("exp", 1) + assert snapshot.metadata["日本語"] and event.new_status == "tracked" + assert summary.completed is False and summary.exclusion_reason == "none" + diff --git a/tests/test_person_state_rules.py b/tests/test_person_state_rules.py new file mode 100644 index 0000000..db601dc --- /dev/null +++ b/tests/test_person_state_rules.py @@ -0,0 +1,27 @@ +from src.config_loader import PersonStateSettings +from src.person_state_rules import choose_status, final_exclusion_reason, normalize_exclusion_reason + + +CFG = PersonStateSettings(True, .5, False, True, False, True, True) + + +def test_status_priority_chain() -> None: + assert choose_status(tracked=True) == "tracked" + assert choose_status(tracked=True, inside_region=True) == "inside_region" + assert choose_status(eligible=True) == "eligible" + assert choose_status(prompt_played=True) == "prompted" + assert choose_status(pseudo_prompt=True) == "pseudo_prompted" + assert choose_status(observing=True) == "observing_response" + assert choose_status(responded=True, lost=True) == "lost" + assert choose_status(responded=True) == "responded" + assert choose_status(no_response=True) == "no_response" + assert choose_status(finalized=True) == "completed" + assert choose_status(excluded=True, finalized=True) == "excluded" + + +def test_exclusion_rules() -> None: + assert final_exclusion_reason(CFG, .1, True, True, None, None, False, True, False) == "track_too_short" + assert final_exclusion_reason(CFG, 1., True, False, None, None, False, False, False) == "not_prompt_eligible" + assert final_exclusion_reason(CFG, 1., True, True, None, "prompt_failed", False, True, False) == "prompt_failed" + assert normalize_exclusion_reason("no_turn_state") == "response_not_evaluable" + diff --git a/tests/test_phase11_cli.py b/tests/test_phase11_cli.py new file mode 100644 index 0000000..517102f --- /dev/null +++ b/tests/test_phase11_cli.py @@ -0,0 +1,9 @@ +from pathlib import Path +from main import build_phase11_parser +from src.config_loader import load_config +from src.phase11_cli import run_diagnostics +def test_phase11_parsers()->None: + assert build_phase11_parser("pilot-diagnostics").parse_args([]).output==Path("data/analysis") + assert build_phase11_parser("calibration-add").parse_args(["--category","camera_position","--before","a","--after","b","--reason","r"]).category=="camera_position" +def test_diagnostics_runner_is_camera_free(tmp_path:Path)->None: + outputs=run_diagnostics(load_config("config/default.yaml",None),tmp_path,tmp_path/"logs",tmp_path/"out");assert len(outputs)==4 diff --git a/tests/test_pilot_diagnostics.py b/tests/test_pilot_diagnostics.py new file mode 100644 index 0000000..1759cf9 --- /dev/null +++ b/tests/test_pilot_diagnostics.py @@ -0,0 +1,20 @@ +import csv +from pathlib import Path +from src.config_loader import load_config +from src.pilot_diagnostics import PilotDiagnostics +FIELDS=["session_id","prompt_condition","valid_for_voice_analysis","response_detected","turn_level","pose_estimated_ever","camera_position_note","reaction_time_sec"] +def write(root:Path,rows): + root.mkdir();p=root/"x_person_analysis_table.csv" + with p.open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=FIELDS);w.writeheader();w.writerows(rows) +def test_empty_is_insufficient_and_exports(tmp_path:Path)->None: + d=PilotDiagnostics(load_config("config/default.yaml",None).pilot_diagnostics);r=d.run(tmp_path,tmp_path/"none");assert r.overall_status=="insufficient_data";paths=d.export(r,tmp_path/"out");assert len(paths)==4 and paths[1].read_bytes().startswith(b"\xef\xbb\xbf") +def test_ready_with_target_counts(tmp_path:Path)->None: + rows=[] + for cond in ("prompt","control"): + for i in range(20):rows.append({"session_id":cond,"prompt_condition":cond,"valid_for_voice_analysis":"true","response_detected":"false","turn_level":"none","pose_estimated_ever":"true","camera_position_note":"front","reaction_time_sec":""}) + logs=tmp_path/"logs";write(logs,rows);r=PilotDiagnostics(load_config("config/default.yaml",None).pilot_diagnostics).run(tmp_path,logs);assert r.overall_status=="ready" and r.ready_for_main_experiment +def test_high_not_evaluable_needs_major(tmp_path:Path)->None: + rows=[] + for cond in ("prompt","control"): + for i in range(5):rows.append({"session_id":cond,"prompt_condition":cond,"valid_for_voice_analysis":"true","response_detected":"false","turn_level":"not_evaluable","pose_estimated_ever":"false","camera_position_note":"front","reaction_time_sec":""}) + logs=tmp_path/"logs";write(logs,rows);assert PilotDiagnostics(load_config("config/default.yaml",None).pilot_diagnostics).run(tmp_path,logs).overall_status=="needs_major_adjustment" diff --git a/tests/test_pilot_session_reporter.py b/tests/test_pilot_session_reporter.py new file mode 100644 index 0000000..800ad45 --- /dev/null +++ b/tests/test_pilot_session_reporter.py @@ -0,0 +1,8 @@ +import csv +from pathlib import Path +from src.pilot_session_reporter import PilotSessionReporter +def test_report_reads_phase8_and_exports(tmp_path:Path)->None: + fields=["condition","total_records","valid_voice_analysis_count","response_rate","mean_reaction_time_sec"] + with (tmp_path/"condition_summary.csv").open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=fields);w.writeheader();w.writerows([{"condition":"prompt","total_records":10,"valid_voice_analysis_count":8,"response_rate":.5,"mean_reaction_time_sec":1.2},{"condition":"control","total_records":10,"valid_voice_analysis_count":7,"response_rate":.2,"mean_reaction_time_sec":1.4}]) + r=PilotSessionReporter();report=r.build_report(tmp_path);assert report.rate_difference==.3;assert "因果関係" in r.export_markdown(report,tmp_path/"pilot.md").read_text(encoding="utf-8") +def test_empty_report_is_generated(tmp_path:Path)->None:assert PilotSessionReporter().build_report(tmp_path).total_tracks==0 diff --git a/tests/test_presentation_summary_builder.py b/tests/test_presentation_summary_builder.py new file mode 100644 index 0000000..063500e --- /dev/null +++ b/tests/test_presentation_summary_builder.py @@ -0,0 +1,5 @@ +from pathlib import Path +from src.models import PilotSessionReport +from src.presentation_summary_builder import PresentationSummaryBuilder +def test_midterm_summary_contains_required_sections(tmp_path:Path)->None: + r=PilotSessionReport("r","now","in","out",0,0,0,0,0,None,None,None,None,None,None,"find","quality","next");path=PresentationSummaryBuilder().build(r,tmp_path,tmp_path/"midterm.md");text=path.read_text(encoding="utf-8");assert "研究目的" in text and "実装済み" in text and "反応率" in text and "因果関係" in text diff --git a/tests/test_quality_checker.py b/tests/test_quality_checker.py new file mode 100644 index 0000000..5d9f67d --- /dev/null +++ b/tests/test_quality_checker.py @@ -0,0 +1,12 @@ +import csv +from pathlib import Path +from src.config_loader import load_config +from src.quality_checker import QualityChecker +def write_table(path:Path,rows:list[dict])->None: + path.parent.mkdir(parents=True);fields=["prompt_condition","valid_for_voice_analysis","turn_level","pose_estimated_ever","camera_position_note","reaction_time_sec","response_detected"] + with path.open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=fields);w.writeheader();w.writerows(rows) +def test_missing_files_and_exports(tmp_path:Path)->None: + q=QualityChecker(load_config("config/default.yaml",None).quality_check);issues=q.check(tmp_path/"logs",tmp_path/"analysis");assert any(i.issue_type=="missing_file" for i in issues) + assert q.export_csv(issues,tmp_path/"quality_issues.csv").read_bytes().startswith(b"\xef\xbb\xbf");assert "ログ品質" in q.export_markdown(issues,tmp_path/"quality.md").read_text(encoding="utf-8") +def test_quality_rates_conditions_and_negative_time(tmp_path:Path)->None: + table=tmp_path/"logs"/"x_person_analysis_table.csv";write_table(table,[{"prompt_condition":"prompt","valid_for_voice_analysis":"false","turn_level":"not_evaluable","pose_estimated_ever":"false","camera_position_note":"","reaction_time_sec":"-1","response_detected":"true"}]);q=QualityChecker(load_config("config/default.yaml",None).quality_check);types={i.issue_type for i in q.check(tmp_path/"logs",tmp_path/"analysis")};assert {"no_control_records","low_valid_voice_analysis_rate","high_not_evaluable_rate","high_no_pose_rate","missing_camera_position_note","negative_reaction_time"}<=types diff --git a/tests/test_session_comparator.py b/tests/test_session_comparator.py new file mode 100644 index 0000000..6783e49 --- /dev/null +++ b/tests/test_session_comparator.py @@ -0,0 +1,3 @@ +from src.session_comparator import SessionComparator +def test_session_rates_and_camera_missing()->None: + rows=[{"session_id":"s","prompt_condition":"prompt","valid_for_voice_analysis":"true","response_detected":"true","turn_level":"weak","pose_estimated_ever":"true","reaction_time_sec":"1","camera_position_note":"front"},{"session_id":"s","prompt_condition":"prompt","valid_for_voice_analysis":"false","response_detected":"false","turn_level":"not_evaluable","pose_estimated_ever":"false"}];s=SessionComparator().summarize(rows)[0];assert s.valid_voice_analysis_rate==.5 and s.not_evaluable_rate==.5 and s.no_pose_estimated_rate==.5 diff --git a/tests/test_session_summary_builder.py b/tests/test_session_summary_builder.py new file mode 100644 index 0000000..42a9d8e --- /dev/null +++ b/tests/test_session_summary_builder.py @@ -0,0 +1,16 @@ +from src.config_loader import UnifiedLogSettings +from src.models import PersonAnalysisRecord +from src.session_summary_builder import SessionSummaryBuilder + +CFG=UnifiedLogSettings(True,"x",True,True,True,True,30,True,True,True,True,False,False,"cam",True) +def test_rates_exclude_invalid() -> None: + records=[PersonAnalysisRecord("e","s",1,"prompt","prompt",True,response_detected=True,reaction_time_sec=1.,turn_confirmed=True,turn_level="medium",valid_for_turn_analysis=True,valid_for_voice_analysis=True,completed=True), + PersonAnalysisRecord("e","s",2,"prompt","prompt",True,response_detected=False,turn_level="subtle",valid_for_turn_analysis=False,valid_for_voice_analysis=False,excluded=True)] + out=SessionSummaryBuilder(CFG,"e","s","start").build(records,"end",10.,"prompt",20.) + assert out.total_tracks==2 and out.valid_voice_analysis_tracks==1 and out.response_rate==1. + assert out.mean_reaction_time_sec==out.median_reaction_time_sec==1. + assert out.not_evaluable_count==1 and out.medium_or_higher_count==1 and out.subtle_count==1 +def test_zero_denominator() -> None: + out=SessionSummaryBuilder(CFG,"e","s","start").build([],"end",0.,None) + assert out.response_rate is None + diff --git a/tests/test_statistical_analyzer.py b/tests/test_statistical_analyzer.py new file mode 100644 index 0000000..b34147a --- /dev/null +++ b/tests/test_statistical_analyzer.py @@ -0,0 +1,11 @@ +import pytest +pd=pytest.importorskip("pandas") +from src.config_loader import AnalysisSettings +from src.statistical_analyzer import StatisticalAnalyzer + +CFG=AnalysisSettings(True,"in","out",True,True,True,True,"weak","auto","mannwhitney","valid_turn_analysis",False,True,.05) +def test_valid_denominator_summaries_and_run()->None: + df=pd.DataFrame([{"prompt_condition":"prompt","valid_for_voice_analysis":True,"response_detected":True,"reaction_time_sec":1.,"max_yaw_delta_toward_signage":30.,"turn_level":"weak","excluded":False,"valid_for_turn_analysis":True,"exclusion_reason":"none"},{"prompt_condition":"prompt","valid_for_voice_analysis":False,"response_detected":False,"reaction_time_sec":None,"max_yaw_delta_toward_signage":None,"turn_level":None,"excluded":True,"valid_for_turn_analysis":False,"exclusion_reason":"no_pose_estimated"},{"prompt_condition":"control","valid_for_voice_analysis":True,"response_detected":False,"reaction_time_sec":None,"max_yaw_delta_toward_signage":5.,"turn_level":"none","excluded":False,"valid_for_turn_analysis":True,"exclusion_reason":"none"}]) + a=StatisticalAnalyzer(CFG);s=a.summarize_conditions(df);assert s[0].valid_voice_analysis_count==1 and s[0].response_rate==1 and s[1].response_rate==0 + result=a.run_analysis(df,1);assert result.run_summary.total_records==3 and result.exclusion_summaries[0].count==1 + diff --git a/tests/test_statistical_tests.py b/tests/test_statistical_tests.py new file mode 100644 index 0000000..9f0aef4 --- /dev/null +++ b/tests/test_statistical_tests.py @@ -0,0 +1,11 @@ +from src.statistical_tests import compare_numeric,compare_response_counts + +def test_response_auto_fisher_and_rates() -> None: + r=compare_response_counts(1,3,0,3,"auto");assert r.test_name=="fisher" and r.rate_difference==1/3 +def test_response_auto_chi_and_phi() -> None: + r=compare_response_counts(30,50,15,50,"auto");assert r.test_name=="chi_square" and r.p_value is not None and r.effect_size_name=="phi" +def test_numeric_and_empty() -> None: + r=compare_numeric([1.,2.,3.],[3.,4.,5.],"reaction_time_sec");assert r.prompt_count==3 and r.effect_size_name=="rank_biserial" + assert compare_numeric([],[],"yaw").p_value is None + assert compare_response_counts(0,0,0,0).p_value is None + diff --git a/tests/test_track_manager.py b/tests/test_track_manager.py new file mode 100644 index 0000000..73d5fc8 --- /dev/null +++ b/tests/test_track_manager.py @@ -0,0 +1,94 @@ +from src.config_loader import CrossingSettings, TrackManagementSettings +from src.models import BoundingBox, FrameInfo, PixelRegion, PixelTriggerLine, TrackedPerson +from src.track_manager import TrackManager + + +def management(**overrides: object) -> TrackManagementSettings: + values = dict(trajectory_max_points=20, direction_window_points=5, minimum_direction_displacement_pixels=5, + region_entry_confirm_frames=2, region_exit_confirm_frames=3, + minimum_visible_frames_for_count=3, minimum_duration_sec_for_count=0.1, + require_region_entry_for_count=True, require_trigger_crossing_for_count=True) + values.update(overrides) + return TrackManagementSettings(**values) # type: ignore[arg-type] + + +def crossing() -> CrossingSettings: + return CrossingSettings(True, True, 2, 5, 3, True) + + +def info(frame: int) -> FrameInfo: + return FrameInfo(frame, f"2026-01-01T00:00:{frame:02d}", frame * 0.1, 100, 100, 20.0) + + +def person(track_id: int, frame: int, x: int, y: int, confirmed: bool = True) -> TrackedPerson: + bbox = BoundingBox(max(0, x - 5), max(0, y - 20), min(99, x + 5), min(99, y)) + return TrackedPerson(track_id, bbox, 0.9, 0, "person", x, y - 10, x, y, False, confirmed, frame, 0) + + +def manager(**overrides: object) -> TrackManager: + return TrackManager(management(**overrides), PixelRegion(20, 20, 80, 80), PixelTriggerLine("vertical", 50), crossing(), 2) + + +def test_create_initial_entry_trajectory_and_unique_count() -> None: + value = manager() + events = value.update([person(1, 1, 30, 60)], info(1)) + assert [event.event_type for event in events] == ["track_created", "region_entered"] + value.update([person(1, 2, 40, 60)], info(2)) + assert len(value.get_state(1).trajectory) == 2 # type: ignore[union-attr] + assert value.total_unique_tracks == value.region_visitors == 1 + + +def test_entry_exit_hysteresis_and_no_duplicates() -> None: + value = manager() + value.update([person(1, 1, 10, 60)], info(1)) + assert not value.update([person(1, 2, 30, 60)], info(2)) + assert [e.event_type for e in value.update([person(1, 3, 35, 60)], info(3))] == ["region_entered"] + assert "region_exited" not in [e.event_type for e in value.update([person(1, 4, 90, 60)], info(4))] + assert "region_exited" not in [e.event_type for e in value.update([person(1, 5, 90, 60)], info(5))] + assert "region_exited" in [e.event_type for e in value.update([person(1, 6, 90, 60)], info(6))] + assert "region_exited" not in [e.event_type for e in value.update([person(1, 7, 90, 60)], info(7))] + + +def test_crossing_direction_finalization_and_valid_count() -> None: + value = manager() + all_events = [] + for frame, x in enumerate((30, 45, 60), 1): + all_events.extend(value.update([person(1, frame, x, 60)], info(frame))) + assert sum(event.event_type == "trigger_crossed" for event in all_events) == 1 + state = value.get_state(1) + assert state and state.movement_direction == "left_to_right" + events = value.finalize_all(info(4)) + assert events[0].event_type == "track_finalized" + assert value.get_finalized_states()[0].valid_for_counting + assert value.completed_passers == 1 + + +def test_stale_track_and_short_track_invalid() -> None: + value = manager(minimum_visible_frames_for_count=5) + value.update([person(1, 1, 30, 60)], info(1)) + value.update([], info(2)) + value.update([], info(3)) + value.update([], info(4)) + events = value.finalize_stale_tracks(info(4)) + assert [event.event_type for event in events] == ["track_lost", "track_finalized"] + assert value.get_state(1).invalid_reason == "insufficient_visible_frames" # type: ignore[union-attr] + + +def test_crossing_direction_wins_when_track_stops_before_finalization() -> None: + """Reproduce a right-to-left crossing followed by stationary tail points.""" + value = manager(direction_window_points=3, minimum_direction_displacement_pixels=5) + all_events = [] + positions = (70, 55, 40, 40, 40, 40) + for frame, x in enumerate(positions, 1): + all_events.extend(value.update([person(1, frame, x, 60)], info(frame))) + + crossing_event = next(event for event in all_events if event.event_type == "trigger_crossed") + assert crossing_event.direction == "right_to_left" + state = value.get_state(1) + assert state and state.trigger_crossing_direction == "right_to_left" + assert state.movement_direction == "right_to_left" + + finalized_event = value.finalize_all(info(7))[0] + finalized_state = value.get_finalized_states()[0] + assert finalized_event.direction == "right_to_left" + assert finalized_state.movement_direction == "right_to_left" diff --git a/tests/test_tracker.py b/tests/test_tracker.py new file mode 100644 index 0000000..8aa90ff --- /dev/null +++ b/tests/test_tracker.py @@ -0,0 +1,58 @@ +import sys +from argparse import Namespace +from types import SimpleNamespace + +_inserted_numpy_stub = "numpy" not in sys.modules +if _inserted_numpy_stub: + sys.modules["numpy"] = SimpleNamespace(ndarray=object) + +from src.config_loader import TrackerSettings +from src.tracker import PersonTracker + +if _inserted_numpy_stub: + sys.modules.pop("numpy", None) + + +def settings() -> TrackerSettings: + return TrackerSettings(False, "bytetrack", .5, .1, .6, .8, 30, 2, 30, True) + + +def test_external_rows_are_normalized_and_ids_are_stable() -> None: + tracker = PersonTracker(settings()) + rows = [[-2, 1, 20, 30, 7, .9, 0, 0]] + first = tracker._normalize(rows, 100, 100) + second = tracker._normalize(rows, 100, 100) + assert first[0].track_id == second[0].track_id + assert first[0].bbox.x1 == 0 + assert not first[0].is_confirmed + assert second[0].is_confirmed + + +def test_empty_non_person_and_invalid_boxes_are_filtered() -> None: + tracker = PersonTracker(settings()) + assert tracker._normalize([], 100, 100) == [] + rows = [[1, 1, 1, 20, 1, .9, 0, 0], [1, 1, 20, 20, 2, .9, 3, 0]] + assert tracker._normalize(rows, 100, 100) == [] + + +def test_reset_does_not_reuse_public_ids() -> None: + tracker = PersonTracker(settings()) + first = tracker._normalize([[1, 1, 20, 20, 1, .9, 0, 0]], 100, 100)[0].track_id + tracker.reset() + second = tracker._normalize([[1, 1, 20, 20, 1, .9, 0, 0]], 100, 100)[0].track_id + assert second > first + + +def test_backend_constructor_supports_new_and_old_ultralytics_apis() -> None: + class NewBackend: + def __init__(self, args: Namespace) -> None: + self.args = args + + class OldBackend: + def __init__(self, args: Namespace, frame_rate: int) -> None: + self.args, self.frame_rate = args, frame_rate + + args = Namespace(track_buffer=30) + assert PersonTracker._create_backend(NewBackend, args, 25).args is args + old = PersonTracker._create_backend(OldBackend, args, 25) + assert old.args is args and old.frame_rate == 25 diff --git a/tests/test_tracker_models.py b/tests/test_tracker_models.py new file mode 100644 index 0000000..96bccd3 --- /dev/null +++ b/tests/test_tracker_models.py @@ -0,0 +1,33 @@ +from collections import deque + +import pytest + +from src.models import BoundingBox, TrackEvent, TrackPoint, TrackedPerson, TrackState + + +def point(frame: int, x: float) -> TrackPoint: + return TrackPoint(float(frame), f"2026-01-01T00:00:0{frame}", frame, x, 10.0, x, 20.0, BoundingBox(0, 0, 10, 20)) + + +def test_tracked_person_and_track_point() -> None: + bbox = BoundingBox(1, 2, 11, 22) + person = TrackedPerson(3, bbox, 0.9, 0, "person", 6.0, 12.0, 6.0, 22.0, True, True, 4, 0) + assert person.track_id == 3 + assert person.foot_y == 22.0 + assert point(1, 5).frame_number == 1 + + +def test_track_state_trajectory_is_bounded() -> None: + state = TrackState(1, "a", "a", 1, 1, trajectory=deque(maxlen=2)) + state.trajectory.extend((point(1, 1), point(2, 2), point(3, 3))) + assert [item.frame_number for item in state.trajectory] == [2, 3] + assert not state.finalized + + +def test_track_event_and_invalid_values() -> None: + event = TrackEvent("track_created", 1, 1, "now", 0.0, 1.0, 2.0, metadata={"日本語": "値"}) + assert event.metadata["日本語"] == "値" + with pytest.raises(ValueError): + TrackEvent("bad", 1, 1, "now", 0.0, 0.0, 0.0) + with pytest.raises(ValueError): + TrackedPerson(-1, BoundingBox(0, 0, 2, 2), 0.5, 0, "person", 1, 1, 1, 2, False, False, 0, 0) diff --git a/tests/test_tracking_logger.py b/tests/test_tracking_logger.py new file mode 100644 index 0000000..7c743b5 --- /dev/null +++ b/tests/test_tracking_logger.py @@ -0,0 +1,38 @@ +import csv +import json +from collections import deque +from pathlib import Path + +from src.config_loader import TrackingLoggingSettings +from src.models import BoundingBox, FrameInfo, TrackEvent, TrackPoint, TrackedPerson, TrackState +from src.tracking_logger import TrackingLogger + + +def test_all_tracking_csv_logs_and_duplicate_summary(tmp_path: Path) -> None: + config = TrackingLoggingSettings(True, True, True, True, 1, str(tmp_path)) + logger = TrackingLogger(config, "exp", "20260101_000000") + logger.open() + bbox = BoundingBox(1, 2, 11, 22) + tracked = TrackedPerson(1, bbox, .9, 0, "person", 6, 12, 6, 22, True, True, 3, 0) + point = TrackPoint(.1, "now", 1, 6, 12, 6, 22, bbox) + state = TrackState(1, "now", "later", 1, 2, entered_evaluation_region=True, trigger_crossed=True, + movement_direction="left_to_right", total_visible_frames=3, trajectory=deque([point]), + finalized=True, is_confirmed=True, first_seen_monotonic=.1, last_seen_monotonic=.5, + valid_for_counting=True, invalid_reason="") + logger.write_frame_tracks(FrameInfo(1, "now", .1, 100, 100, 20), [tracked], {1: state}) + event = TrackEvent("trigger_crossed", 1, 1, "now", .1, 6, 22, "left_to_right", {"注記": "通過"}) + logger.write_events([event]) + logger.write_summary(state) + logger.write_summary(state) + logger.close() + logger.close() + paths = list(tmp_path.glob("*.csv")) + assert len(paths) == 3 + assert all(path.read_bytes().startswith(b"\xef\xbb\xbf") for path in paths) + summary = next(path for path in paths if "summary" in path.name) + with summary.open(encoding="utf-8-sig", newline="") as stream: + assert len(list(csv.DictReader(stream))) == 1 + events = next(path for path in paths if "events" in path.name) + with events.open(encoding="utf-8-sig", newline="") as stream: + row = next(csv.DictReader(stream)) + assert json.loads(row["metadata_json"])["注記"] == "通過" diff --git a/tests/test_turn_detector.py b/tests/test_turn_detector.py new file mode 100644 index 0000000..abc3776 --- /dev/null +++ b/tests/test_turn_detector.py @@ -0,0 +1,99 @@ +from src.config_loader import TurnDetectionSettings +from src.models import FrameInfo, HeadPoseResult +from src.turn_detector import TurnDetector + + +def config(**overrides: object) -> TurnDetectionSettings: + values = dict(enabled=True, signage_yaw_direction="positive", baseline_strategy="first_valid", + baseline_window_sec=.8, baseline_min_samples=3, subtle_threshold_deg=10, + weak_threshold_deg=20, medium_threshold_deg=35, strong_threshold_deg=50, + min_subtle_duration_sec=.2, min_turn_duration_sec=.3, max_pose_gap_sec=.3, + min_pose_samples_for_evaluation=5, min_face_detection_rate_for_evaluation=.2, + min_pose_estimation_rate_for_evaluation=.2, face_appearance_enabled=True, + face_missing_before_appearance_sec=.3, weak_or_higher_is_turn=True) + values.update(overrides) + return TurnDetectionSettings(**values) # type: ignore[arg-type] + + +def info(frame: int, elapsed: float) -> FrameInfo: + return FrameInfo(frame, f"t{frame}", elapsed, 100, 100, 20) + + +def pose(frame: int, elapsed: float, yaw: float | None, face: bool = True) -> HeadPoseResult: + success = yaw is not None + return HeadPoseResult(1, frame, f"t{frame}", elapsed, face, success, + 0 if success else None, yaw, 0 if success else None, + 6 if face else 0, None, .9 if face else None, + "" if success else ("solvepnp_failed" if face else "face_not_detected")) + + +def feed(detector: TurnDetector, samples: list[tuple[float, float | None, bool]]) -> tuple[list, list]: + results, events = [], [] + for frame, (elapsed, yaw, face) in enumerate(samples, 1): + frame_results, new_events = detector.update([pose(frame, elapsed, yaw, face)], info(frame, elapsed)) + results.extend(frame_results) + events.extend(new_events) + return results, events + + +def test_first_valid_baseline_and_threshold_levels() -> None: + detector = TurnDetector(config()) + results, events = feed(detector, [(0, 0, True), (.1, 2, True), (.2, -1, True), (.3, 9, True), (.4, 11, True)]) + state = detector.get_state(1) + assert state and state.baseline_acquired and state.baseline_yaw == 0 + assert state.baseline_sample_count == 3 + assert sum(event.event_type == "baseline_acquired" for event in events) == 1 + assert results[-2].turn_level == "none" + assert results[-1].turn_level == "subtle" + + +def test_baseline_requires_minimum_samples() -> None: + detector = TurnDetector(config(baseline_min_samples=3)) + results, _ = feed(detector, [(0, 0, True), (.1, 1, True)]) + assert not detector.get_state(1).baseline_acquired # type: ignore[union-attr] + assert results[-1].failure_reason == "baseline_not_acquired" + + +def test_weak_duration_confirms_once_and_tracks_max_delta() -> None: + detector = TurnDetector(config()) + _, events = feed(detector, [(0, 0, True), (.1, 0, True), (.2, 0, True), + (.3, 25, True), (.45, 30, True), (.61, 40, True), (.8, 45, True)]) + state = detector.get_state(1) + assert state and state.turn_confirmed and state.max_yaw_delta_toward_signage == 45 + assert state.strongest_level == "medium" + assert sum(event.event_type == "turn_confirmed" for event in events) == 1 + + +def test_short_candidate_and_pose_gap_behaviour() -> None: + detector = TurnDetector(config()) + results, _ = feed(detector, [(0, 0, True), (.1, 0, True), (.2, 0, True), + (.3, 25, True), (.4, 25, True), (.5, None, False), (.61, 25, True)]) + assert results[3].turn_candidate and not results[3].turn_confirmed + assert detector.get_state(1).turn_confirmed # type: ignore[union-attr] + detector2 = TurnDetector(config()) + _, events = feed(detector2, [(0, 0, True), (.1, 0, True), (.2, 0, True), + (.3, 25, True), (.4, None, False), (.8, None, False)]) + assert any(event.event_type == "turn_lost" for event in events) + + +def test_face_appeared_and_finalize_classifications() -> None: + detector = TurnDetector(config(min_pose_samples_for_evaluation=3)) + _, events = feed(detector, [(0, None, False), (.4, 0, True), (.5, 0, True), (.6, 0, True)]) + state = detector.get_state(1) + assert state and state.face_appeared_after_missing + assert any(event.event_type == "face_appeared" for event in events) + final, final_events = detector.finalize_track(1, info(5, .7)) + assert final and final.valid_for_turn_analysis and final.exclusion_reason == "evaluable_no_turn" + assert final_events[0].event_type == "turn_track_finalized" + + no_pose = TurnDetector(config()) + no_pose.update([pose(1, 0, None, True)], info(1, 0)) + final, _ = no_pose.finalize_track(1, info(2, .1)) + assert final and not final.evaluable and final.exclusion_reason == "no_pose_estimated" + + +def test_baseline_not_acquired_finalize() -> None: + detector = TurnDetector(config(baseline_min_samples=3)) + feed(detector, [(0, 0, True), (.1, 1, True)]) + final, _ = detector.finalize_track(1, info(3, .2)) + assert final and final.exclusion_reason == "baseline_not_acquired" diff --git a/tests/test_turn_logger.py b/tests/test_turn_logger.py new file mode 100644 index 0000000..8bcb159 --- /dev/null +++ b/tests/test_turn_logger.py @@ -0,0 +1,30 @@ +import csv +import json +from pathlib import Path + +from src.config_loader import TurnLoggingSettings +from src.models import TurnEvent, TurnFrameResult, TurnTrackState +from src.turn_logger import TurnLogger + + +def test_three_turn_logs_bom_json_and_summary_deduplication(tmp_path: Path) -> None: + logger = TurnLogger(TurnLoggingSettings(True, True, True, True, 1, str(tmp_path)), "exp", "stamp") + logger.open() + frame = TurnFrameResult(1, 2, "now", .2, True, True, True, 0, 25, 25, 25, + "positive", True, True, "weak", .3) + event = TurnEvent("turn_confirmed", 1, 2, "now", .2, 25, 25, "weak", {"注記": "反応"}) + state = TurnTrackState(1, total_frames=5, pose_frames=5, face_detected_frames=5, + baseline_yaw=0, baseline_acquired=True, turn_confirmed=True, + turn_level="weak", evaluable=True, valid_for_turn_analysis=True, + exclusion_reason="turn_detected", finalized=True) + logger.write_frames([frame]) + logger.write_events([event]) + logger.write_summary(state) + logger.write_summary(state) + logger.close(); logger.close() + paths = list(tmp_path.glob("*.csv")) + assert len(paths) == 3 and all(path.read_bytes().startswith(b"\xef\xbb\xbf") for path in paths) + with next(path for path in paths if "events" in path.name).open(encoding="utf-8-sig", newline="") as stream: + assert json.loads(next(csv.DictReader(stream))["metadata_json"])["注記"] == "反応" + with next(path for path in paths if "summary" in path.name).open(encoding="utf-8-sig", newline="") as stream: + assert len(list(csv.DictReader(stream))) == 1 diff --git a/tests/test_turn_models.py b/tests/test_turn_models.py new file mode 100644 index 0000000..72c86eb --- /dev/null +++ b/tests/test_turn_models.py @@ -0,0 +1,24 @@ +import pytest + +from src.models import TurnEvent, TurnFrameResult, TurnLevel, TurnTrackState + + +def test_turn_frame_success_failure_and_levels() -> None: + result = TurnFrameResult(1, 2, "now", .2, True, True, True, 0, 25, 25, 25, + "positive", True, False, TurnLevel.WEAK.value, .1) + assert result.yaw_delta_toward_signage == 25 + failure = TurnFrameResult(1, 3, "later", .3, False, False, False, None, None, None, None, + "positive", False, False, "not_evaluable", 0, "no_face") + assert failure.turn_level == "not_evaluable" + with pytest.raises(ValueError): + TurnFrameResult(1, 1, "x", 0, True, True, False, None, 0, None, None, + "bad", False, False, "none", 0) + + +def test_turn_state_rates_and_event() -> None: + state = TurnTrackState(1, total_frames=4, pose_frames=2, face_detected_frames=3) + assert state.face_detection_rate == .75 + assert state.pose_estimation_rate == .5 + assert state.turn_level == "not_evaluable" + event = TurnEvent("turn_confirmed", 1, 2, "now", .2, 20, 20, "weak", {"注記": "確認"}) + assert event.metadata["注記"] == "確認" diff --git a/tests/test_turn_rules.py b/tests/test_turn_rules.py new file mode 100644 index 0000000..4f35aef --- /dev/null +++ b/tests/test_turn_rules.py @@ -0,0 +1,24 @@ +import pytest + +from src.config_loader import TurnDetectionSettings +from src.turn_rules import classify_turn_level, yaw_delta_toward_signage + + +def config() -> TurnDetectionSettings: + return TurnDetectionSettings(True, "positive", "first_valid", .8, 3, 10, 20, 35, 50, + .2, .3, .3, 5, .2, .2, True, .3, True) + + +def test_signage_delta_directions() -> None: + assert yaw_delta_toward_signage(30, 10, "positive") == (20, 20) + assert yaw_delta_toward_signage(-10, 10, "negative") == (-20, 20) + with pytest.raises(ValueError): + yaw_delta_toward_signage(1, 0, "bad") + + +@pytest.mark.parametrize(("delta", "level"), [ + (9.99, "none"), (10, "subtle"), (19.99, "subtle"), (20, "weak"), + (34.99, "weak"), (35, "medium"), (49.99, "medium"), (50, "strong"), +]) +def test_level_boundaries(delta: float, level: str) -> None: + assert classify_turn_level(delta, config()) == level diff --git a/tests/test_turn_track_manager.py b/tests/test_turn_track_manager.py new file mode 100644 index 0000000..05caa48 --- /dev/null +++ b/tests/test_turn_track_manager.py @@ -0,0 +1,18 @@ +from src.config_loader import TurnDetectionSettings +from src.models import FrameInfo, TrackEvent +from src.turn_detector import TurnDetector +from src.turn_track_manager import TurnTrackManager + + +def test_track_finalization_creates_not_evaluable_summary() -> None: + config = TurnDetectionSettings(True, "positive", "first_valid", .8, 3, 10, 20, 35, 50, + .2, .3, .3, 5, .2, .2, True, .3, True) + detector = TurnDetector(config) + detector.register_tracks([7]) + manager = TurnTrackManager(detector) + info = FrameInfo(10, "now", 1.0, 100, 100, 20) + track_event = TrackEvent("track_finalized", 7, 10, "now", 1.0, 0, 0) + states, events = manager.finalize_from_track_events([track_event], info) + assert states[0].turn_level == "not_evaluable" + assert states[0].exclusion_reason == "no_face_detected" + assert events[0].event_type == "turn_track_finalized" diff --git a/tests/test_unified_event_builder.py b/tests/test_unified_event_builder.py new file mode 100644 index 0000000..6bb7031 --- /dev/null +++ b/tests/test_unified_event_builder.py @@ -0,0 +1,22 @@ +from src.config_loader import UnifiedLogSettings +from src.models import FrameInfo, PersonStateEvent, TrackEvent, TurnEvent, VoicePromptEvent +from src.unified_event_builder import UnifiedEventBuilder + +def cfg(important:bool=False)->UnifiedLogSettings: + return UnifiedLogSettings(True,"x",True,True,True,True,30,True,True,True,True,False,important,"",True) + +def test_converts_sources_and_sorts() -> None: + b=UnifiedEventBuilder(cfg(),"exp","session"); f=FrameInfo(2,"t",2.,10,10,1.) + items=[] + items+=b.from_track_events([TrackEvent("trigger_crossed",1,2,"t",2.,1,1)],f) + items+=b.from_turn_events([TurnEvent("turn_confirmed",1,2,"t",2.,1.,20.,"weak")],f) + items+=b.from_voice_events([VoicePromptEvent("prompt_played",1,2,"t",2.,"prompt",True)],f) + items+=b.from_person_state_events([PersonStateEvent("responded",1,2,"t",2.,"observing","responded")],f) + assert {x.source for x in items}=={"tracking","turn","voice","person_state"} + assert len({x.event_id for x in items})==4 + assert b.stable_sort(items)==sorted(items,key=lambda x:(x.elapsed_time_sec,x.frame_number,x.source,x.event_id)) + +def test_important_only_filters() -> None: + b=UnifiedEventBuilder(cfg(True),"e","s");f=FrameInfo(1,"t",1.,1,1,1.) + assert not b.from_track_events([TrackEvent("track_created",1,1,"t",1.,1,1)],f) + diff --git a/tests/test_unified_event_logger.py b/tests/test_unified_event_logger.py new file mode 100644 index 0000000..524d1ee --- /dev/null +++ b/tests/test_unified_event_logger.py @@ -0,0 +1,13 @@ +from pathlib import Path +from src.config_loader import UnifiedLogSettings +from src.models import UnifiedEvent,PersonAnalysisRecord,SessionSummary,LogConsistencyIssue +from src.unified_event_logger import UnifiedEventLogger + +def test_four_csvs_bom_json_dedup(tmp_path:Path)->None: + c=UnifiedLogSettings(True,str(tmp_path),True,True,True,True,1,True,True,True,True,False,False,"",True) + l=UnifiedEventLogger(c,"exp","sid","stamp");l.open() + e=UnifiedEvent("exp","sid","id","system","x",None,1,"t",1.,metadata={"日本語":"値"}) + l.write_events([e,e]);r=PersonAnalysisRecord("exp","sid",1);l.write_analysis([r,r]);s=SessionSummary("exp","sid","t");l.write_session(s);l.write_session(s);l.write_issues([LogConsistencyIssue("unknown","warning",1,None,"m",{"日":"本"})]);l.close();l.close() + assert len(list(tmp_path.glob("*.csv")))==4 + assert all(p.read_bytes().startswith(b"\xef\xbb\xbf") for p in tmp_path.glob("*.csv")) + assert "日本語" in (tmp_path/"stamp_exp_unified_events.csv").read_text(encoding="utf-8-sig") diff --git a/tests/test_unified_event_models.py b/tests/test_unified_event_models.py new file mode 100644 index 0000000..c799cb8 --- /dev/null +++ b/tests/test_unified_event_models.py @@ -0,0 +1,11 @@ +import pytest +from src.models import UnifiedEvent, PersonAnalysisRecord, SessionSummary, LogConsistencyIssue + +def test_models_and_validation() -> None: + event=UnifiedEvent("e","s","id","tracking","created",1,1,"t",0.,metadata={"日本語":True}) + assert event.metadata["日本語"] + assert PersonAnalysisRecord("e","s",1).prompt_condition=="unknown" + assert SessionSummary("e","s","t").total_tracks==0 + assert LogConsistencyIssue("unknown","warning",None,None,"m").severity=="warning" + with pytest.raises(ValueError): UnifiedEvent("e","s","id","bad","x",None,0,"t",0.) + diff --git a/tests/test_video_widget.py b/tests/test_video_widget.py new file mode 100644 index 0000000..563e788 --- /dev/null +++ b/tests/test_video_widget.py @@ -0,0 +1,6 @@ +import pytest +pytest.importorskip("PySide6") +np=pytest.importorskip("numpy") +from src.gui.video_widget import bgr_to_rgb +def test_bgr_to_rgb()->None: + value=np.array([[[1,2,3]]],dtype=np.uint8);assert bgr_to_rgb(value).tolist()==[[[3,2,1]]] diff --git a/tests/test_voice_logger.py b/tests/test_voice_logger.py new file mode 100644 index 0000000..f3aebcd --- /dev/null +++ b/tests/test_voice_logger.py @@ -0,0 +1,26 @@ +import csv +from pathlib import Path + +from src.config_loader import VoiceLoggingSettings +from src.models import VoicePromptDecision, VoicePromptEvent, VoicePromptTrackState +from src.voice_logger import VoiceLogger + + +def test_voice_csvs_bom_json_control_and_dedup(tmp_path: Path) -> None: + config = VoiceLoggingSettings(True, True, True, True, 1, str(tmp_path)) + logger = VoiceLogger(config, "exp", "stamp") + logger.open() + logger.write_decisions([VoicePromptDecision(1, 2, "t", 1., "control", True, True, False, True, "pseudo", None)]) + logger.write_events([VoicePromptEvent("pseudo_prompt_triggered", 1, 2, "t", 1., "control", + pseudo_prompt=True, metadata={"日本語": "声掛けなし"})]) + state = VoicePromptTrackState(1, "control", eligible=True, pseudo_prompt=True, finalized=True) + logger.write_summary(state) + logger.write_summary(state) + logger.close() + logger.close() + for path in tmp_path.glob("*.csv"): + assert path.read_bytes().startswith(b"\xef\xbb\xbf") + event_text = (tmp_path / "stamp_exp_voice_events.csv").read_text(encoding="utf-8-sig") + assert "日本語" in event_text and "声掛けなし" in event_text + with (tmp_path / "stamp_exp_voice_summary.csv").open(encoding="utf-8-sig", newline="") as stream: + assert len(list(csv.DictReader(stream))) == 1 diff --git a/tests/test_voice_prompt_controller.py b/tests/test_voice_prompt_controller.py new file mode 100644 index 0000000..6077a35 --- /dev/null +++ b/tests/test_voice_prompt_controller.py @@ -0,0 +1,73 @@ +from unittest.mock import Mock + +from src.config_loader import VoicePromptSettings +from src.models import BoundingBox, FrameInfo, TrackEvent, TrackedPerson, TurnFrameResult +from src.voice_prompt_controller import VoicePromptController + + +def cfg(mode: str = "prompt", **kw: object) -> VoicePromptSettings: + values = dict(enabled=True, mode=mode, trigger_strategy="trigger_line_crossing", require_confirmed_track=True, + require_inside_region=True, skip_if_already_turned=True, one_prompt_per_track=True, + global_cooldown_sec=3., minimum_track_age_sec=0., response_window_sec=3., + fixed_position_axis="x", fixed_position_value=640., fixed_position_direction="decreasing") + values.update(kw) + return VoicePromptSettings(**values) + + +def info(frame: int, elapsed: float) -> FrameInfo: + return FrameInfo(frame, f"t{frame}", elapsed, 100, 100, 30.) + + +def person(track_id: int = 1) -> TrackedPerson: + return TrackedPerson(track_id, BoundingBox(1, 1, 20, 40), .9, 0, "person", 10, 20, 10, 40, True, True, 10, 0) + + +def crossing(track_id: int = 1) -> TrackEvent: + return TrackEvent("trigger_crossed", track_id, 1, "t", 1., 10, 40) + + +def turn(frame: int, elapsed: float, confirmed: bool = True) -> TurnFrameResult: + return TurnFrameResult(1, frame, f"t{frame}", elapsed, True, True, True, 0., 25., 25., 25., "positive", + True, confirmed, "weak", .4, "") + + +def test_prompt_success_duplicate_and_response() -> None: + audio = Mock(audio_file="a.wav") + audio.play.return_value = True + controller = VoicePromptController(cfg(), audio) + decisions, events = controller.update([person()], [crossing()], [], info(1, 1.)) + assert decisions[0].prompt_played + assert {e.event_type for e in events} >= {"prompt_played", "response_window_started"} + controller.update([person()], [crossing()], [], info(2, 1.1)) + assert audio.play.call_count == 1 + _, events = controller.update([person()], [], [turn(3, 1.8)], info(3, 1.8)) + response = next(e for e in events if e.event_type == "response_detected") + assert response.reaction_time_sec == .8 + + +def test_prompt_failure_control_and_expiration() -> None: + failed = Mock(audio_file="missing.wav") + failed.play.return_value = False + controller = VoicePromptController(cfg(), failed) + _, events = controller.update([person()], [crossing()], [], info(1, 1.)) + assert "prompt_failed" in {event.event_type for event in events} + control_audio = Mock(audio_file="a.wav") + control = VoicePromptController(cfg("control", response_window_sec=1.), control_audio) + decisions, events = control.update([person()], [crossing()], [], info(1, 1.)) + assert decisions[0].pseudo_prompt and not decisions[0].prompt_played + control_audio.play.assert_not_called() + _, events = control.update([person()], [], [], info(2, 2.1)) + assert "response_window_expired" in {event.event_type for event in events} + + +def test_cooldown_and_finalize_all() -> None: + audio = Mock(audio_file="a.wav") + audio.play.return_value = True + controller = VoicePromptController(cfg(), audio) + controller.update([person(1)], [crossing(1)], [], info(1, 1.)) + decisions, _ = controller.update([person(2)], [crossing(2)], [], info(2, 2.)) + assert decisions[0].reason == "global_cooldown" + states, events = controller.finalize_all(info(3, 5.)) + assert len(states) == 2 and all(state.finalized for state in states) + assert all(event.event_type == "voice_track_finalized" for event in events) + diff --git a/tests/test_voice_rules.py b/tests/test_voice_rules.py new file mode 100644 index 0000000..9978210 --- /dev/null +++ b/tests/test_voice_rules.py @@ -0,0 +1,37 @@ +from src.config_loader import VoicePromptSettings +from src.models import BoundingBox, TrackEvent, TrackedPerson, VoicePromptTrackState +from src.voice_rules import eligibility_reason, trigger_matches + + +def cfg(strategy: str = "trigger_line_crossing", **kw: object) -> VoicePromptSettings: + values = dict(enabled=True, mode="prompt", trigger_strategy=strategy, require_confirmed_track=True, + require_inside_region=True, skip_if_already_turned=True, one_prompt_per_track=True, + global_cooldown_sec=3., minimum_track_age_sec=0., response_window_sec=3., + fixed_position_axis="x", fixed_position_value=50., fixed_position_direction="increasing") + values.update(kw) + return VoicePromptSettings(**values) + + +def person(x: float = 60, confirmed: bool = True, inside: bool = True) -> TrackedPerson: + return TrackedPerson(1, BoundingBox(40, 10, 80, 100), .9, 0, "person", 60, 55, x, 100, + inside, confirmed, 10, 0) + + +def event(kind: str) -> TrackEvent: + return TrackEvent(kind, 1, 1, "t", 1., 60, 100) + + +def test_event_and_position_triggers() -> None: + assert trigger_matches(cfg(), person(), [event("trigger_crossed")]) + assert trigger_matches(cfg("region_entry"), person(), [event("region_entered")]) + assert trigger_matches(cfg("fixed_x_position"), person(), []) + + +def test_eligibility_guards() -> None: + state = VoicePromptTrackState(1, "prompt") + assert eligibility_reason(cfg(), person(confirmed=False), state, None, 0) == "unconfirmed_track" + assert eligibility_reason(cfg(), person(inside=False), state, None, 0) == "outside_region" + assert eligibility_reason(cfg(), person(), state, None, 1) == "global_cooldown" + state.prompt_attempted = True + assert eligibility_reason(cfg(), person(), state, None, 0) == "already_prompted" +