main
KoyoNoguchi 1 month ago
commit c5bcb95d74
  1. 30
      .gitignore
  2. 442
      README.md
  3. 1
      assets/audio/.gitkeep
  4. 302
      config/default.yaml
  5. 104
      config/experiment.yaml
  6. 22
      config/logging.yaml
  7. 9
      config/presets/pilot_control.yaml
  8. 7
      config/presets/pilot_disabled.yaml
  9. 9
      config/presets/pilot_prompt.yaml
  10. 1
      data/analysis/.gitkeep
  11. 1
      data/analysis/plots/.gitkeep
  12. BIN
      data/analysis/plots/exclusion_reason_counts.png
  13. BIN
      data/analysis/plots/max_yaw_delta_by_condition.png
  14. BIN
      data/analysis/plots/reaction_time_by_condition.png
  15. BIN
      data/analysis/plots/response_rate_by_condition.png
  16. BIN
      data/analysis/plots/turn_level_distribution.png
  17. 1
      data/calibration_history/.gitkeep
  18. 1
      data/experiment_notes/.gitkeep
  19. 1
      data/gui/.gitkeep
  20. 1
      data/input/.gitkeep
  21. 1
      data/logs/detections/.gitkeep
  22. 1
      data/logs/face/.gitkeep
  23. 1
      data/logs/person_state/.gitkeep
  24. 1
      data/logs/runtime/.gitkeep
  25. 1
      data/logs/tracking/.gitkeep
  26. 1
      data/logs/turn/.gitkeep
  27. 1
      data/logs/unified/.gitkeep
  28. 1
      data/logs/voice/.gitkeep
  29. 1
      data/output/screenshots/.gitkeep
  30. 1
      data/output/videos/.gitkeep
  31. 23
      docs/experiment_checklist.md
  32. 27
      docs/experiment_environment.md
  33. 26
      docs/experiment_protocol.md
  34. 16
      docs/midterm_presentation_results_template.md
  35. 29
      docs/phase10_specification.md
  36. 56
      docs/phase11_specification.md
  37. 7
      docs/phase1_specification.md
  38. 69
      docs/phase2_specification.md
  39. 72
      docs/phase3_specification.md
  40. 72
      docs/phase4_specification.md
  41. 89
      docs/phase5_specification.md
  42. 61
      docs/phase6_specification.md
  43. 55
      docs/phase7_specification.md
  44. 38
      docs/phase8_specification.md
  45. 64
      docs/phase9_specification.md
  46. 10
      docs/pilot_experiment_plan.md
  47. 247
      main.py
  48. 1
      models/.gitkeep
  49. 11
      requirements.txt
  50. 3
      src/__init__.py
  51. 17
      src/analysis_cli.py
  52. 45
      src/analysis_loader.py
  53. 33
      src/analysis_plots.py
  54. 23
      src/analysis_reporter.py
  55. 39
      src/analysis_table_builder.py
  56. 569
      src/application.py
  57. 88
      src/audio_player.py
  58. 18
      src/calibration_history.py
  59. 78
      src/camera.py
  60. 729
      src/config_loader.py
  61. 47
      src/crossing_detector.py
  62. 71
      src/detection_logger.py
  63. 90
      src/detector.py
  64. 61
      src/exceptions.py
  65. 11
      src/experiment_notes.py
  66. 18
      src/experiment_protocol.py
  67. 23
      src/face_geometry.py
  68. 113
      src/face_logger.py
  69. 192
      src/face_processor.py
  70. 44
      src/fps_counter.py
  71. 5
      src/gui/__init__.py
  72. 32
      src/gui/analysis_controller.py
  73. 24
      src/gui/analysis_panel.py
  74. 40
      src/gui/config_panel.py
  75. 9
      src/gui/diagnostics_panel.py
  76. 67
      src/gui/experiment_controller.py
  77. 11
      src/gui/gui_app.py
  78. 39
      src/gui/gui_models.py
  79. 5
      src/gui/log_panel.py
  80. 79
      src/gui/main_window.py
  81. 26
      src/gui/operations_controller.py
  82. 4
      src/gui/protocol_panel.py
  83. 12
      src/gui/quality_panel.py
  84. 13
      src/gui/status_panel.py
  85. 25
      src/gui/video_widget.py
  86. 100
      src/head_pose.py
  87. 14
      src/improvement_recommender.py
  88. 35
      src/log_consistency_checker.py
  89. 877
      src/models.py
  90. 96
      src/person_state_logger.py
  91. 308
      src/person_state_manager.py
  92. 73
      src/person_state_rules.py
  93. 18
      src/phase10_cli.py
  94. 9
      src/phase11_cli.py
  95. 42
      src/pilot_diagnostics.py
  96. 22
      src/pilot_session_reporter.py
  97. 10
      src/presentation_summary_builder.py
  98. 45
      src/quality_checker.py
  99. 16
      src/session_comparator.py
  100. 38
      src/session_summary_builder.py
  101. Some files were not shown because too many files have changed in this diff Show More

30
.gitignore vendored

@ -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

@ -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_<experiment_id>_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`を参照してください。

@ -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

@ -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

@ -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]

@ -0,0 +1,9 @@
voice_prompt:
enabled: true
mode: control
audio:
enabled: false
unified_logging:
camera_position_note: "Prompt条件と同じ位置を記入する"
analysis:
generate_plots: true

@ -0,0 +1,7 @@
voice_prompt:
enabled: false
mode: disabled
audio:
enabled: false
unified_logging:
camera_position_note: "動作確認時の位置を記入する"

@ -0,0 +1,9 @@
voice_prompt:
enabled: true
mode: prompt
audio:
enabled: true
unified_logging:
camera_position_note: "実験前にカメラ位置を記入する"
analysis:
generate_plots: true

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@ -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を生成した

@ -0,0 +1,27 @@
# 実験環境記録テンプレート
| 項目 | 記録内容 |
|---|---|
| 実験日 | |
| 実験場所 | |
| カメラ製品名 | |
| カメラ設置高さ | |
| カメラ角度 | |
| 解像度 | |
| FPS | |
| サイネージ位置 | |
| 評価領域 | |
| トリガーライン | |
| 照明条件 | |
| 逆光の有無 | |
| 通行方向 | |
| PC仕様 | |
| CPU | |
| GPU | |
| OS | |
| Pythonバージョン | |
| 使用モデル | |
| confidence threshold | |
| image size | |
| 平均FPS | |
| 備考 | |

@ -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**: カメラ位置、照明、通行方向をセッションごとに固定・記録する

@ -0,0 +1,16 @@
# 中間発表結果テンプレート
## 研究目的
## システム構成
## 実装済み機能
## 実験条件
## パイロット結果
- Prompt/Control有効解析人数
- 反応率と反応率差
- 反応時間
- 最大Yaw変化
## 除外理由とログ品質
## 現時点の課題
## 今後の予定
## 注意
少数サンプルの有意差や因果関係を断定しない。顔未検出を「見ていない」と扱わない。

@ -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に基づいてカメラ位置・閾値を調整し、有効解析人数と除外理由を確認する。

@ -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双方のセッション数・有効人数を確保し、最終解析と報告へ進む。

@ -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ベースの追跡器へ渡す予定である。

@ -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である。

@ -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、変化量、振り向きレベル、開始・最大・継続時間、品質除外を算出する。

@ -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を計算する。

@ -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は人物状態遷移と実験セッション管理を追加する。

@ -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の時系列統合イベントへ渡す。

@ -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を安定して提供する。

@ -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へ提供する。

@ -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と完了コールバックを安定インターフェースとして、実験プロトコル、条件ローテーション、チェックリスト、品質確認を追加できる。

@ -0,0 +1,10 @@
# パイロット実験計画
目的は、本実験前にカメラ位置、顔向き取得率、音声提示、ログ品質を確認することである。通行人の観測であり、顔認証や個人識別は行わない。
- 場所、サイネージ、カメラ位置を固定する。
- Promptはトリガー時に音声を提示する。
- Controlは同じトリガー時刻を記録し、音声を提示しない。
- 目安として各条件で有効解析人数20人以上を収集する。本実験の正式なサンプルサイズ設計ではない。
- track、条件、反応、反応時間、Yaw、除外理由、照明、通行方向、異常を記録する。
- カメラ位置変更、音声異常、ログ欠損時は中止して記録する。

@ -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())

@ -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

@ -0,0 +1,3 @@
"""Phase 1-5 pedestrian detection, tracking, pose, turn, and voice system."""
__version__ = "0.11.0"

@ -0,0 +1,17 @@
"""Offline Phase 8 analysis command that never imports camera/application code."""
from __future__ import annotations
import logging
from pathlib import Path
from .analysis_loader import AnalysisLoader
from .analysis_plots import AnalysisPlots
from .analysis_reporter import AnalysisReporter
from .config_loader import AnalysisSettings
from .statistical_analyzer import StatisticalAnalyzer
LOGGER=logging.getLogger(__name__)
def run_analysis_cli(config:AnalysisSettings,input_path:Path,output_path:Path)->list[Path]:
settings=AnalysisSettings(config.enabled,str(input_path),str(output_path),config.recursive,config.include_prompt_condition,config.include_control_condition,config.valid_voice_analysis_only,config.response_level_threshold,config.response_rate_test,config.numeric_test,config.yaw_metric_denominator,config.generate_plots,config.overwrite_outputs,config.alpha)
loader=AnalysisLoader(settings);paths=loader.find_person_analysis_tables(input_path,settings.recursive) if input_path.is_dir() else [input_path]
df=loader.load_person_analysis_tables(paths);result=StatisticalAnalyzer(settings).run_analysis(df,len(paths));outputs=AnalysisReporter(settings).write(result);outputs+=AnalysisPlots(settings).generate(df,result)
for path in outputs:LOGGER.info("Analysis output: %s",path)
return outputs

@ -0,0 +1,45 @@
"""Validated multi-session Phase 7 CSV loading."""
from __future__ import annotations
from pathlib import Path
from .config_loader import AnalysisSettings
from .exceptions import ConfigurationError
class AnalysisLoader:
REQUIRED={"experiment_id","session_id","track_id","prompt_condition","response_detected",
"valid_for_voice_analysis","valid_for_turn_analysis","turn_confirmed","turn_level",
"reaction_time_sec","max_yaw_delta_toward_signage","excluded","exclusion_reason"}
BOOLS={"prompt_played","pseudo_prompt","response_window_started","response_detected","turn_confirmed","face_detected_ever","pose_estimated_ever","valid_for_turn_analysis","valid_for_voice_analysis","completed","excluded","trigger_crossed"}
NUMBERS={"track_id","prompt_elapsed_time_sec","reaction_time_sec","max_yaw_delta_toward_signage","face_detection_rate","pose_estimation_rate","track_duration_sec"}
def __init__(self,config:AnalysisSettings)->None:self.config=config
def find_person_analysis_tables(self,directory:Path,recursive:bool=True)->list[Path]:
return sorted(directory.rglob("*_person_analysis_table.csv") if recursive else directory.glob("*_person_analysis_table.csv"))
def load_person_analysis_tables(self,paths:list[Path]):
pd=self._pandas()
if not paths:raise ConfigurationError("No person_analysis_table.csv files found")
frames=[]
for path in paths:
try: frames.append(pd.read_csv(path,encoding="utf-8-sig"))
except Exception as exc:raise ConfigurationError(f"Failed to read {path}: {exc}") from exc
df=pd.concat(frames,ignore_index=True);self.validate_person_analysis_columns(df)
for col in self.BOOLS & set(df.columns):df[col]=df[col].map(self._bool_value).astype("boolean")
for col in self.NUMBERS & set(df.columns):df[col]=pd.to_numeric(df[col],errors="coerce")
conditions=set(df["prompt_condition"].dropna().astype(str))
if not conditions<={"prompt","control","disabled","unknown"}:raise ConfigurationError(f"Invalid prompt_condition values: {sorted(conditions)}")
return df
def load_session_summaries(self,paths:list[Path]):
pd=self._pandas();return pd.concat([pd.read_csv(p,encoding="utf-8-sig") for p in paths],ignore_index=True) if paths else pd.DataFrame()
def validate_person_analysis_columns(self,df:object)->None:
missing=self.REQUIRED-set(df.columns)
if missing:raise ConfigurationError(f"Missing person analysis columns: {sorted(missing)}")
@staticmethod
def _bool_value(value:object):
if value is None:return None
text=str(value).strip().lower()
if text in {"true","1","yes"}:return True
if text in {"false","0","no"}:return False
return None
@staticmethod
def _pandas():
try:import pandas as pd
except ImportError as exc:raise ConfigurationError("Phase 8 requires pandas; run pip install -r requirements.txt") from exc
return pd

@ -0,0 +1,33 @@
"""Minimal matplotlib plots for exploratory Phase 8 review."""
from __future__ import annotations
import logging
from pathlib import Path
from .config_loader import AnalysisSettings
LOGGER=logging.getLogger(__name__)
class AnalysisPlots:
"""Generate five independent PNG files with an optional Agg backend."""
def __init__(self,config:AnalysisSettings)->None:self.config=config;self.directory=Path(config.output_directory)/"plots"
def generate(self,df,result)->list[Path]:
if not self.config.generate_plots:return []
try:
import matplotlib;matplotlib.use("Agg",force=True);import matplotlib.pyplot as plt
except ImportError as exc:raise RuntimeError("Phase 8 plots require matplotlib") from exc
self.directory.mkdir(parents=True,exist_ok=True);paths=[]
if df.empty: LOGGER.warning("Analysis plot input is empty; placeholder plots will be generated")
def save(name,title,x,y,ylabel):
fig,ax=plt.subplots();ax.bar(x,y);ax.set_title(title);ax.set_ylabel(ylabel);fig.tight_layout();path=self.directory/name;fig.savefig(path);plt.close(fig);paths.append(path)
summaries=result.condition_summaries;save("response_rate_by_condition.png","Response Rate",[x.condition for x in summaries],[x.response_rate or 0 for x in summaries],"Rate")
for metric,name,title in (("reaction_time_sec","reaction_time_by_condition.png","Reaction Time"),("max_yaw_delta_toward_signage","max_yaw_delta_by_condition.png","Max Yaw Delta")):
groups=[df[df.prompt_condition==c][metric].dropna().tolist() for c in ("prompt","control")];fig,ax=plt.subplots()
# Matplotlib 3.9 renamed ``labels`` to ``tick_labels`` and newer
# releases no longer accept the old keyword. Use the new name
# first while retaining compatibility with older supported builds.
try:
ax.boxplot(groups,tick_labels=["prompt","control"])
except TypeError:
ax.boxplot(groups,labels=["prompt","control"])
ax.set_title(title);fig.tight_layout();path=self.directory/name;fig.savefig(path);plt.close(fig);paths.append(path)
levels=["none","subtle","weak","medium","strong","not_evaluable"];p=df[df.prompt_condition=="prompt"].turn_level.fillna("not_evaluable");c=df[df.prompt_condition=="control"].turn_level.fillna("not_evaluable");fig,ax=plt.subplots();x=range(len(levels));ax.bar([i-.2 for i in x],[(p==v).sum() for v in levels],.4,label="prompt");ax.bar([i+.2 for i in x],[(c==v).sum() for v in levels],.4,label="control");ax.set_xticks(list(x),levels,rotation=30);ax.legend();fig.tight_layout();path=self.directory/"turn_level_distribution.png";fig.savefig(path);plt.close(fig);paths.append(path)
exclusions=df[df.excluded==True].exclusion_reason.fillna("unknown").value_counts();save("exclusion_reason_counts.png","Exclusion Reasons",list(exclusions.index) or ["none"],list(exclusions.values) or [0],"Count")
return paths

@ -0,0 +1,23 @@
"""UTF-8 BOM Phase 8 result CSV reporting."""
from __future__ import annotations
import csv
from dataclasses import asdict
from pathlib import Path
from .config_loader import AnalysisSettings
from .models import AnalysisResult
class AnalysisReporter:
"""Write six fixed-schema result tables, including empty tables."""
FILES={"condition_summary":("condition_summary.csv",["condition","total_records","valid_voice_analysis_count","response_detected_count","no_response_count","response_rate","excluded_count","not_evaluable_count","mean_reaction_time_sec","median_reaction_time_sec","std_reaction_time_sec","mean_max_yaw_delta","median_max_yaw_delta","std_max_yaw_delta","subtle_count","weak_count","medium_count","strong_count","weak_or_higher_count","medium_or_higher_count"]),"response_rate_test":("response_rate_test.csv",["test_name","prompt_success","prompt_total","control_success","control_total","prompt_rate","control_rate","rate_difference","statistic","p_value","effect_size_name","effect_size","odds_ratio","method_note"]),"numeric_comparisons":("numeric_comparisons.csv",["metric_name","test_name","prompt_count","control_count","prompt_mean","control_mean","prompt_median","control_median","mean_difference","statistic","p_value","effect_size_name","effect_size","method_note"]),"turn_levels":("turn_level_distribution.csv",["condition","none_count","face_appeared_count","subtle_count","weak_count","medium_count","strong_count","not_evaluable_count","total_count"]),"exclusions":("exclusion_summary.csv",["condition","exclusion_reason","count","ratio"]),"run":("analysis_run_summary.csv",["analysis_id","input_files_count","total_records","prompt_records","control_records","valid_prompt_records","valid_control_records","generated_at","output_directory","notes"])}
def __init__(self,config:AnalysisSettings)->None:self.config=config;self.output=Path(config.output_directory)
def write(self,result:AnalysisResult)->list[Path]:
self.output.mkdir(parents=True,exist_ok=True);mapping={"condition_summary":result.condition_summaries,"response_rate_test":[result.response_rate_test],"numeric_comparisons":result.numeric_comparisons,"turn_levels":result.turn_level_distributions,"exclusions":result.exclusion_summaries,"run":[result.run_summary]};paths=[]
for key,items in mapping.items():
name,fields=self.FILES[key];path=self.output/name
if path.exists() and not self.config.overwrite_outputs:raise FileExistsError(path)
with path.open("w",encoding="utf-8-sig",newline="") as f:
writer=csv.DictWriter(f,fieldnames=fields);writer.writeheader()
for item in items:writer.writerow(asdict(item))
paths.append(path)
return paths

@ -0,0 +1,39 @@
"""Build Phase 8-ready rows from integrated person summaries."""
from __future__ import annotations
from .config_loader import UnifiedLogSettings
from .models import (PersonAnalysisRecord, PersonStateSummary, TurnTrackState,
VoicePromptTrackState)
class AnalysisTableBuilder:
"""Convert integrated person summaries to one Phase 8-ready row each."""
def __init__(self, config: UnifiedLogSettings, session_id: str) -> None:
self.config, self.session_id = config, session_id
def build_record(
self, person_summary: PersonStateSummary, turn_state: TurnTrackState | None = None,
voice_state: VoicePromptTrackState | None = None, face_state: object | None = None,
track_summary: object | None = None,
) -> PersonAnalysisRecord:
del turn_state, voice_state, face_state, track_summary
mode = person_summary.voice_mode or "disabled"
condition = mode if mode in {"prompt", "control", "disabled"} else "unknown"
return PersonAnalysisRecord(person_summary.experiment_id, self.session_id, person_summary.track_id,
mode, condition, person_summary.prompt_played, person_summary.pseudo_prompt,
person_summary.prompt_time, person_summary.prompt_elapsed_time_sec,
person_summary.response_window_started, person_summary.response_detected,
person_summary.reaction_time_sec, person_summary.response_turn_level,
person_summary.turn_confirmed, person_summary.turn_level,
person_summary.max_yaw_delta_toward_signage, person_summary.face_detected_ever,
person_summary.pose_estimated_ever, person_summary.face_detection_rate,
person_summary.pose_estimation_rate, person_summary.valid_for_turn_analysis,
person_summary.valid_for_voice_analysis, person_summary.final_status, person_summary.completed,
person_summary.excluded, person_summary.exclusion_reason, person_summary.duration_sec,
person_summary.trigger_crossed, person_summary.trigger_crossing_direction,
person_summary.movement_direction, self.config.camera_position_note or None, person_summary.notes)
def build_records(self, person_summaries: list[PersonStateSummary], **_states: object) -> list[PersonAnalysisRecord]:
return [self.build_record(item) for item in person_summaries]

@ -0,0 +1,569 @@
"""Top-level Phase 1-4 computer-vision research orchestration."""
from __future__ import annotations
import logging
import logging.config
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Callable
import cv2
import numpy as np
import yaml
from .camera import Camera
from .analysis_table_builder import AnalysisTableBuilder
from .audio_player import AudioPlayer
from .config_loader import AppConfig
from .detection_logger import DetectionLogger
from .detector import PersonDetector
from .exceptions import VideoWriterError
from .fps_counter import FPSCounter
from .face_logger import FaceLogger
from .face_processor import FaceProcessor, FaceTrackManager
from .models import Detection, FrameInfo, RuntimeMetrics, TrackedPerson
from .person_state_logger import PersonStateLogger
from .person_state_manager import PersonStateManager
from .log_consistency_checker import LogConsistencyChecker
from .session_summary_builder import SessionSummaryBuilder
from .track_manager import TrackManager
from .tracker import PersonTracker
from .tracking_logger import TrackingLogger
from .turn_detector import TurnDetector
from .turn_logger import TurnLogger
from .turn_track_manager import TurnTrackManager
from .visualizer import Visualizer
from .unified_event_builder import UnifiedEventBuilder
from .unified_event_logger import UnifiedEventLogger
from .voice_logger import VoiceLogger
from .voice_prompt_controller import VoicePromptController
LOGGER = logging.getLogger(__name__)
class Application:
"""Own application resources and execute the frame-processing loop."""
def __init__(
self,
config: AppConfig,
logging_config_path: Path = Path("config/logging.yaml"),
on_frame_rendered: Callable[[np.ndarray, RuntimeMetrics], None] | None = None,
on_status_updated: Callable[[RuntimeMetrics], None] | None = None,
) -> None:
self.config = config
self.logging_config_path = logging_config_path
self._run_stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self._writer: cv2.VideoWriter | None = None
self._detection_logger: DetectionLogger | None = None
self._tracking_logger: TrackingLogger | None = None
self._face_logger: FaceLogger | None = None
self._turn_logger: TurnLogger | None = None
self._voice_logger: VoiceLogger | None = None
self._person_state_logger: PersonStateLogger | None = None
self._unified_logger: UnifiedEventLogger | None = None
self._stop_requested = threading.Event()
self._pause_requested = threading.Event()
self._frame_callback = on_frame_rendered
self._status_callback = on_status_updated
def request_stop(self) -> None:
"""Request a cooperative stop; resource finalization remains in ``run``."""
self._stop_requested.set()
def request_pause(self) -> None:
"""Pause processing without advancing frames or loggers."""
self._pause_requested.set()
def request_resume(self) -> None:
"""Resume processing after a GUI-requested pause."""
self._pause_requested.clear()
def set_frame_callback(self, callback: Callable[[np.ndarray, RuntimeMetrics], None] | None) -> None:
self._frame_callback = callback
def set_status_callback(self, callback: Callable[[RuntimeMetrics], None] | None) -> None:
self._status_callback = callback
def _prepare(self) -> None:
for directory in (
self.config.logging.runtime_log_directory, self.config.logging.detection_log_directory,
self.config.output.video_directory, self.config.output.screenshot_directory,
self.config.tracking_logging.directory,
self.config.face_logging.directory,
self.config.turn_logging.directory,
self.config.voice_logging.directory,
self.config.person_state_logging.directory,
self.config.unified_logging.directory,
):
Path(directory).mkdir(parents=True, exist_ok=True)
with self.logging_config_path.open("r", encoding="utf-8") as stream:
logging_settings = yaml.safe_load(stream)
if self.config.logging.runtime_log_enabled:
runtime_file = Path(self.config.logging.runtime_log_directory) / f"{self._run_stamp}_{self.config.experiment.experiment_id}.log"
logging_settings["handlers"]["file"]["filename"] = str(runtime_file)
else:
logging_settings["root"]["handlers"] = ["console"]
logging.config.dictConfig(logging_settings)
merged_path = Path(self.config.logging.runtime_log_directory) / f"{self._run_stamp}_{self.config.experiment.experiment_id}_config.yaml"
with merged_path.open("w", encoding="utf-8") as stream:
yaml.safe_dump(self.config.to_dict(), stream, allow_unicode=True, sort_keys=False)
LOGGER.info("Final configuration:\n%s", yaml.safe_dump(self.config.to_dict(), allow_unicode=True, sort_keys=False))
def _open_writer(self, width: int, height: int, source_fps: float) -> None:
if not self.config.output.save_video:
return
path = Path(self.config.output.video_directory) / f"{self._run_stamp}_{self.config.experiment.experiment_id}.mp4"
fps = source_fps if source_fps > 0 else (self.config.input.fps if self.config.input.fps > 0 else 30.0)
codec = self.config.output.video_codec
if len(codec) != 4:
raise VideoWriterError("output.video_codec must contain exactly four characters")
self._writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*codec), fps, (width, height))
if not self._writer.isOpened():
self._writer.release()
self._writer = None
raise VideoWriterError(f"Could not open output video: {path}")
LOGGER.info("Saving annotated video to %s", path)
def run(self) -> None:
self._prepare()
camera = Camera(self.config.input)
detector = PersonDetector(self.config.detector)
tracker: PersonTracker | None = None
track_manager: TrackManager | None = None
face_processor: FaceProcessor | None = None
face_manager: FaceTrackManager | None = None
turn_detector: TurnDetector | None = None
turn_manager: TurnTrackManager | None = None
audio_player: AudioPlayer | None = None
voice_controller: VoicePromptController | None = None
person_state_manager: PersonStateManager | None = None
unified_builder: UnifiedEventBuilder | None = None
analysis_builder: AnalysisTableBuilder | None = None
fps = FPSCounter(self.config.performance.fps_average_window)
visualizer = Visualizer(self.config.display, self.config.performance.warn_fps_threshold, self.config.performance.fps_average_window)
frame_number, processed_frames = 0, 0
start = time.perf_counter()
session_started_at = datetime.now().astimezone().isoformat(timespec="milliseconds")
paused = False
pause_started: float | None = None
paused_total = 0.0
debug = self.config.debug.enabled
display_frame = None
last_info: FrameInfo | None = None
session_id = f"{self._run_stamp}_{self.config.experiment.experiment_id}"
analysis_person_summaries = []
session_started_written = False
try:
camera.open()
detector.load_model()
tracker = PersonTracker(self.config.tracker, camera.get_actual_fps())
face_effective = self.config.face.enabled and self.config.tracker.enabled
if self.config.face.enabled and not self.config.tracker.enabled:
LOGGER.warning("Face processing is disabled because tracker.enabled is false")
if face_effective:
face_processor = FaceProcessor(self.config.face, self.config.head_pose)
face_manager = FaceTrackManager(self.config.track_management.trajectory_max_points)
turn_effective = (self.config.turn_detection.enabled and self.config.tracker.enabled
and self.config.face.enabled and self.config.head_pose.enabled)
if self.config.turn_detection.enabled and not turn_effective:
LOGGER.warning("Turn detection is disabled because tracker, face, or head_pose is disabled")
if turn_effective:
turn_detector = TurnDetector(self.config.turn_detection, self.config.track_management.trajectory_max_points)
turn_manager = TurnTrackManager(turn_detector)
voice_effective = (self.config.voice_prompt.enabled and self.config.voice_prompt.mode != "disabled"
and self.config.tracker.enabled)
if self.config.voice_prompt.enabled and not self.config.tracker.enabled:
LOGGER.warning("Voice prompting is disabled because tracker.enabled is false")
if voice_effective:
audio_player = AudioPlayer(self.config.audio)
if self.config.voice_prompt.mode == "prompt":
audio_player.load(Path(self.config.audio.file_path))
source_fps = camera.get_actual_fps() or self.config.input.fps
voice_controller = VoicePromptController(self.config.voice_prompt, audio_player, turn_effective, source_fps)
person_state_effective = self.config.person_state.enabled and self.config.tracker.enabled
if self.config.person_state.enabled and not self.config.tracker.enabled:
LOGGER.warning("Person state management is disabled because tracker.enabled is false")
if person_state_effective:
person_state_manager = PersonStateManager(
self.config.person_state, self.config.experiment.experiment_id, turn_effective, voice_effective,
)
if self.config.unified_logging.enabled:
unified_builder = UnifiedEventBuilder(self.config.unified_logging, self.config.experiment.experiment_id, session_id)
analysis_builder = AnalysisTableBuilder(self.config.unified_logging, session_id)
detection_path = Path(self.config.logging.detection_log_directory) / f"{self._run_stamp}_{self.config.experiment.experiment_id}_detections.csv"
if self.config.logging.detection_log_enabled:
self._detection_logger = DetectionLogger(self.config.logging, self.config.experiment.experiment_id, detection_path)
self._detection_logger.open()
if self.config.tracking_logging.enabled:
self._tracking_logger = TrackingLogger(
self.config.tracking_logging, self.config.experiment.experiment_id, self._run_stamp,
)
self._tracking_logger.open()
if face_effective and self.config.face_logging.enabled:
self._face_logger = FaceLogger(
self.config.face_logging, self.config.experiment.experiment_id, self._run_stamp,
)
self._face_logger.open()
if turn_effective and self.config.turn_logging.enabled:
self._turn_logger = TurnLogger(
self.config.turn_logging, self.config.experiment.experiment_id, self._run_stamp,
)
self._turn_logger.open()
if voice_effective and self.config.voice_logging.enabled:
self._voice_logger = VoiceLogger(
self.config.voice_logging, self.config.experiment.experiment_id, self._run_stamp,
)
self._voice_logger.open()
if person_state_effective and self.config.person_state_logging.enabled:
self._person_state_logger = PersonStateLogger(
self.config.person_state_logging, self.config.experiment.experiment_id, self._run_stamp,
)
self._person_state_logger.open()
if self.config.unified_logging.enabled:
self._unified_logger = UnifiedEventLogger(
self.config.unified_logging, self.config.experiment.experiment_id, session_id, self._run_stamp,
)
self._unified_logger.open()
self._open_writer(camera.get_actual_width(), camera.get_actual_height(), camera.get_actual_fps())
while True:
if self._stop_requested.is_set():
LOGGER.info("Stop requested")
break
if self._pause_requested.is_set() and not paused:
paused = True
pause_started = time.perf_counter()
if paused:
if not self._pause_requested.is_set() and not self.config.display.enabled:
paused = False
if pause_started is not None:
paused_total += time.perf_counter() - pause_started
pause_started = None
fps.reset()
continue
key = cv2.waitKey(30) & 0xFF if self.config.display.enabled else -1
if key in (ord(self.config.application.exit_key), 27):
break
if key == ord(self.config.application.pause_key):
paused = False
self._pause_requested.clear()
if pause_started is not None:
paused_total += time.perf_counter() - pause_started
pause_started = None
fps.reset()
elif key == ord("d"):
debug = not debug
continue
frame = camera.read()
if frame is None:
if self.config.input.type == "video":
LOGGER.info("Reached end of video")
break
continue
frame_number += 1
if self.config.input.horizontal_flip:
frame = cv2.flip(frame, 1)
height, width = frame.shape[:2]
region = self.config.evaluation_region.to_pixels(width, height)
trigger_line = self.config.trigger_line.to_pixels(width, height)
if track_manager is None:
track_manager = TrackManager(
self.config.track_management, region, trigger_line, self.config.crossing,
self.config.tracker.max_missed_frames,
)
should_infer = (frame_number - 1) % (self.config.performance.skip_frames + 1) == 0
detections: list[Detection] = detector.detect(frame, region) if should_infer else []
pre_info = FrameInfo(frame_number, datetime.now().astimezone().isoformat(timespec="milliseconds"),
time.perf_counter() - start - paused_total, width, height, fps.average_fps)
tracked = tracker.update(detections, frame, pre_info) if tracker is not None else []
fps.tick()
measured = fps.average_fps
now = datetime.now().astimezone()
info = FrameInfo(frame_number, now.isoformat(timespec="milliseconds"), time.perf_counter() - start - paused_total, width, height, measured)
events = track_manager.update(tracked, info) if track_manager is not None else []
if track_manager is not None:
events.extend(track_manager.finalize_stale_tracks(info))
states = {state.track_id: state for state in track_manager.get_active_states()} if track_manager else {}
face_results = face_processor.process(frame, tracked, info) if face_processor is not None else []
if face_manager is not None:
face_manager.update(face_results)
if turn_detector is not None:
turn_detector.register_tracks([person.track_id for person in tracked])
turn_results, turn_events = turn_detector.update(face_results, info, states)
else:
turn_results, turn_events = [], []
active_turn_states = turn_detector.get_active_states() if turn_detector is not None else {}
if voice_controller is not None:
voice_decisions, voice_events = voice_controller.update(
tracked, events, turn_results, info, active_turn_states,
)
else:
voice_decisions, voice_events = [], []
active_voice_states = voice_controller.get_active_states() if voice_controller is not None else {}
if person_state_manager is not None:
person_snapshots, person_events = person_state_manager.update(
tracked, events, face_results, turn_results, active_turn_states,
voice_decisions, voice_events, active_voice_states, info,
)
else:
person_snapshots, person_events = [], []
if self._detection_logger is not None and self.config.logging.log_every_frame:
self._detection_logger.log_frame(info, detections)
if self._tracking_logger is not None:
self._tracking_logger.write_frame_tracks(info, tracked, states)
self._tracking_logger.write_events(events)
for event in events:
if event.event_type == "track_finalized" and track_manager is not None:
state = track_manager.get_state(event.track_id)
if state is not None:
self._tracking_logger.write_summary(state)
if self._face_logger is not None:
self._face_logger.write_results(face_results)
if face_manager is not None:
for event in events:
if event.event_type == "track_finalized":
face_state = face_manager.finalize_track(event.track_id)
if face_state is not None and self._face_logger is not None:
self._face_logger.write_summary(face_state)
finalized_turn_states = []
if turn_manager is not None:
finalized_turn_states, finalized_turn_events = turn_manager.finalize_from_track_events(events, info)
turn_events.extend(finalized_turn_events)
if self._turn_logger is not None:
self._turn_logger.write_frames(turn_results)
self._turn_logger.write_events(turn_events)
for turn_state in finalized_turn_states:
self._turn_logger.write_summary(turn_state)
finalized_voice_states = []
if voice_controller is not None:
for event in events:
if event.event_type == "track_finalized":
turn_state = turn_detector.get_state(event.track_id) if turn_detector else None
voice_state, final_voice_events = voice_controller.finalize_track(event.track_id, info, turn_state)
if voice_state is not None:
finalized_voice_states.append(voice_state)
voice_events.extend(final_voice_events)
if self._voice_logger is not None:
self._voice_logger.write_decisions(voice_decisions)
self._voice_logger.write_events(voice_events)
for voice_state in finalized_voice_states:
self._voice_logger.write_summary(voice_state)
finalized_person_summaries = []
if person_state_manager is not None:
for event in events:
if event.event_type == "track_finalized":
summary, final_person_events = person_state_manager.finalize_track(
event.track_id, info,
track_manager.get_state(event.track_id) if track_manager else None,
face_manager.get_state(event.track_id) if face_manager else None,
turn_detector.get_state(event.track_id) if turn_detector else None,
voice_controller.get_state(event.track_id) if voice_controller else None,
)
if summary is not None: finalized_person_summaries.append(summary)
person_events.extend(final_person_events)
if self._person_state_logger is not None:
self._person_state_logger.write_snapshots(person_snapshots)
self._person_state_logger.write_events(person_events)
for summary in finalized_person_summaries: self._person_state_logger.write_summary(summary)
analysis_person_summaries.extend(finalized_person_summaries)
if unified_builder is not None and self._unified_logger is not None:
unified_events = []
if not session_started_written:
unified_events.append(unified_builder.build_session_event("session_started", info,
{"camera_position_note": self.config.unified_logging.camera_position_note}))
session_started_written = True
unified_events.extend(unified_builder.from_track_events(events, info))
unified_events.extend(unified_builder.from_turn_events(turn_events, info))
unified_events.extend(unified_builder.from_voice_events(voice_events, info))
unified_events.extend(unified_builder.from_person_state_events(person_events, info))
self._unified_logger.write_events(unified_builder.stable_sort(unified_events))
counters = self._tracking_counters(track_manager)
latest_faces = face_manager.get_latest_results() if face_manager is not None else {}
active_person_states = person_state_manager.get_active_states() if person_state_manager is not None else {}
display_frame = visualizer.draw(
frame, detections, info, region, self.config.trigger_line, detector.device_name, debug=debug,
tracked_persons=tracked, track_states=states, tracking_counters=counters,
tracker_name=tracker.tracker_name if tracker else "disabled",
face_results=latest_faces, face_enabled=face_processor is not None,
turn_states=active_turn_states, turn_enabled=turn_detector is not None,
voice_states=active_voice_states, voice_mode=self.config.voice_prompt.mode if voice_controller else "disabled",
voice_cooldown=voice_controller.cooldown_remaining(info.elapsed_time_sec) if voice_controller else 0.0,
person_states=active_person_states, person_state_enabled=person_state_manager is not None,
)
state_counts: dict[str, int] = {}
for state in active_person_states.values():
status = getattr(state, "status", "unknown")
key_name = getattr(status, "value", str(status))
state_counts[key_name] = state_counts.get(key_name, 0) + 1
voice_values = list(active_voice_states.values())
metrics = RuntimeMetrics(
frame_number=frame_number, elapsed_time_sec=info.elapsed_time_sec, fps=measured,
active_tracks=counters["active"] if counters else 0,
total_tracks=counters["unique"] if counters else 0,
region_visitors=counters["visitors"] if counters else 0,
trigger_crossings=counters["crossings"] if counters else 0,
face_detected_tracks=sum(1 for value in latest_faces.values() if value.face_detected),
turn_confirmed_count=sum(1 for value in active_turn_states.values() if value.turn_confirmed),
voice_mode=self.config.voice_prompt.mode if voice_controller else "disabled",
prompt_played_count=sum(bool(getattr(value, "prompt_played", False)) for value in voice_values),
pseudo_prompt_count=sum(bool(getattr(value, "pseudo_prompt", False)) for value in voice_values),
response_detected_count=sum(bool(getattr(value, "response_detected", False)) for value in voice_values),
person_state_counts=state_counts,
current_log_directory=self.config.unified_logging.directory if self.config.unified_logging.enabled else self.config.logging.runtime_log_directory,
)
self._emit_runtime_update(display_frame, metrics)
last_info = info
if self._writer is not None:
self._writer.write(display_frame)
processed_frames += 1
if self.config.display.enabled:
shown = display_frame
if self.config.display.resize_scale != 1.0:
shown = cv2.resize(shown, None, fx=self.config.display.resize_scale, fy=self.config.display.resize_scale)
cv2.imshow(self.config.application.window_name, shown)
key = cv2.waitKey(1) & 0xFF
if key in (ord(self.config.application.exit_key), 27):
break
if key == ord(self.config.application.pause_key):
paused = True
pause_started = time.perf_counter()
display_frame = visualizer.draw(
frame, detections, info, region, self.config.trigger_line, detector.device_name,
paused=True, debug=debug, tracked_persons=tracked, track_states=states,
tracking_counters=counters, tracker_name=tracker.tracker_name if tracker else "disabled",
face_results=latest_faces, face_enabled=face_processor is not None,
turn_states=active_turn_states, turn_enabled=turn_detector is not None,
voice_states=active_voice_states, voice_mode=self.config.voice_prompt.mode if voice_controller else "disabled",
voice_cooldown=voice_controller.cooldown_remaining(info.elapsed_time_sec) if voice_controller else 0.0,
person_states=active_person_states, person_state_enabled=person_state_manager is not None,
)
cv2.imshow(self.config.application.window_name, display_frame)
elif key == ord(self.config.application.screenshot_key):
self._save_screenshot(display_frame, frame_number)
elif key == ord("d"):
debug = not debug
elif key == ord("r"):
fps.reset()
except KeyboardInterrupt:
LOGGER.info("Stopped by user")
finally:
if track_manager is not None and last_info is not None:
final_events = track_manager.finalize_all(last_info)
if self._tracking_logger is not None:
self._tracking_logger.write_events(final_events)
for state in track_manager.get_finalized_states():
self._tracking_logger.write_summary(state)
if face_manager is not None:
face_manager.finalize_all()
if self._face_logger is not None:
for event in final_events:
face_state = face_manager.get_state(event.track_id)
if face_state is not None:
self._face_logger.write_summary(face_state)
final_turn_states, remaining_states, final_turn_events, remaining_events = [], [], [], []
if turn_manager is not None:
final_turn_states, final_turn_events = turn_manager.finalize_from_track_events(final_events, last_info)
remaining_states, remaining_events = turn_detector.finalize_all(last_info) if turn_detector else ([], [])
if self._turn_logger is not None:
self._turn_logger.write_events(final_turn_events + remaining_events)
for turn_state in final_turn_states + remaining_states:
self._turn_logger.write_summary(turn_state)
if voice_controller is not None:
known_turn_states = {state.track_id: state for state in (final_turn_states + remaining_states)}
voice_states, voice_events = voice_controller.finalize_all(last_info, known_turn_states)
if self._voice_logger is not None:
self._voice_logger.write_events(voice_events)
for voice_state in voice_states:
self._voice_logger.write_summary(voice_state)
else:
voice_states, voice_events = [], []
if person_state_manager is not None:
final_turn_map = {state.track_id: state for state in final_turn_states + remaining_states}
final_voice_map = {state.track_id: state for state in voice_states} if voice_controller is not None else {}
person_summaries, person_events = person_state_manager.finalize_all(last_info, final_turn_map, final_voice_map)
if self._person_state_logger is not None:
self._person_state_logger.write_events(person_events)
for summary in person_summaries: self._person_state_logger.write_summary(summary)
analysis_person_summaries.extend(person_summaries)
else:
person_summaries, person_events = [], []
if unified_builder is not None and self._unified_logger is not None:
final_unified = []
final_unified.extend(unified_builder.from_track_events(final_events, last_info))
final_unified.extend(unified_builder.from_turn_events(final_turn_events + remaining_events, last_info))
final_unified.extend(unified_builder.from_voice_events(voice_events, last_info))
final_unified.extend(unified_builder.from_person_state_events(person_events, last_info))
final_unified.append(unified_builder.build_session_event("session_ended", last_info))
self._unified_logger.write_events(unified_builder.stable_sort(final_unified))
records = analysis_builder.build_records(analysis_person_summaries) if analysis_builder else []
issues = LogConsistencyChecker().check(records) if self.config.unified_logging.run_consistency_checks else []
average_fps = processed_frames / last_info.elapsed_time_sec if last_info.elapsed_time_sec > 0 else None
session_summary = SessionSummaryBuilder(
self.config.unified_logging, self.config.experiment.experiment_id, session_id,
session_started_at,
).build(records, last_info.timestamp_iso, last_info.elapsed_time_sec,
self.config.voice_prompt.mode, average_fps, issues)
self._unified_logger.write_analysis(records)
self._unified_logger.write_issues(issues)
self._unified_logger.write_session(session_summary)
LOGGER.info("Session summary: tracks=%d valid_voice=%d responses=%d response_rate=%s issues=%d",
session_summary.total_tracks, session_summary.valid_voice_analysis_tracks,
session_summary.response_detected_count, session_summary.response_rate, len(issues))
if self._unified_logger is not None:
self._unified_logger.close()
if self._person_state_logger is not None:
self._person_state_logger.close()
if self._voice_logger is not None:
self._voice_logger.close()
if self._turn_logger is not None:
self._turn_logger.close()
if self._face_logger is not None:
self._face_logger.close()
if face_processor is not None:
face_processor.close()
if audio_player is not None:
audio_player.close()
if self._tracking_logger is not None:
self._tracking_logger.close()
if self._detection_logger is not None:
self._detection_logger.close()
if tracker is not None:
tracker.close()
camera.release()
if self._writer is not None:
self._writer.release()
cv2.destroyAllWindows()
elapsed = time.perf_counter() - start
LOGGER.info("Run summary: frames=%d elapsed=%.2fs average=%.2f FPS", processed_frames, elapsed, processed_frames / elapsed if elapsed else 0.0)
@staticmethod
def _tracking_counters(manager: TrackManager | None) -> dict[str, int] | None:
if manager is None:
return None
return {
"active": manager.active_track_count, "unique": manager.total_unique_tracks,
"visitors": manager.region_visitors, "crossings": manager.trigger_crossings,
"completed": manager.completed_passers,
}
def _save_screenshot(self, frame: np.ndarray, frame_number: int) -> None:
now = datetime.now()
stamp = now.strftime("%Y%m%d_%H%M%S_") + f"{now.microsecond // 1000:03d}"
path = Path(self.config.output.screenshot_directory) / f"{stamp}_frame_{frame_number}.jpg"
if not cv2.imwrite(str(path), frame):
LOGGER.error("Failed to save screenshot: %s", path)
else:
LOGGER.info("Saved screenshot: %s", path)
def _emit_runtime_update(self, frame: np.ndarray, metrics: RuntimeMetrics) -> None:
"""Deliver copied GUI data without allowing callback failures to stop a run."""
try:
if self._frame_callback is not None:
self._frame_callback(frame.copy(), metrics)
if self._status_callback is not None:
self._status_callback(metrics)
except Exception:
LOGGER.exception("Runtime callback failed")

@ -0,0 +1,88 @@
"""Fault-tolerant pygame audio playback isolated from the application."""
from __future__ import annotations
import importlib
import logging
from pathlib import Path
from typing import Any
from .config_loader import AudioSettings
LOGGER = logging.getLogger(__name__)
class AudioPlayer:
"""Load and play one prompt sound without exposing pygame types."""
def __init__(self, config: AudioSettings, backend: Any | None = None) -> None:
self.config = config
self._backend = backend
self._sound: Any | None = None
self._channel: Any | None = None
self._loaded_path: Path | None = None
self._closed = False
@property
def audio_file(self) -> str | None:
return str(self._loaded_path) if self._loaded_path else self.config.file_path or None
def load(self, audio_path: Path) -> None:
if not self.config.enabled or self.config.backend == "noop":
return
if not audio_path.is_file():
LOGGER.warning("Audio prompt file does not exist: %s", audio_path)
return
try:
backend = self._backend or importlib.import_module("pygame")
self._backend = backend
if not backend.mixer.get_init():
backend.mixer.init()
self._sound = backend.mixer.Sound(str(audio_path))
self._sound.set_volume(self.config.volume)
self._loaded_path = audio_path
except Exception as exc: # pygame raises backend-specific exceptions
LOGGER.error("Failed to load audio prompt %s: %s", audio_path, exc)
self._sound = None
def play(self) -> bool:
if not self.config.enabled or self._sound is None or self._closed:
return False
try:
if self.is_playing():
if self.config.overlap_policy == "skip":
return False
if self.config.overlap_policy == "restart":
self.stop()
self._channel = self._sound.play()
return self._channel is not None
except Exception as exc:
LOGGER.error("Failed to play audio prompt: %s", exc)
return False
def stop(self) -> None:
try:
if self._channel is not None:
self._channel.stop()
except Exception as exc:
LOGGER.warning("Failed to stop audio prompt: %s", exc)
finally:
self._channel = None
def is_playing(self) -> bool:
try:
return bool(self._channel is not None and self._channel.get_busy())
except Exception:
return False
def close(self) -> None:
if self._closed:
return
self.stop()
try:
if self._backend is not None and self._backend.mixer.get_init():
self._backend.mixer.quit()
except Exception as exc:
LOGGER.warning("Failed to close audio backend: %s", exc)
self._closed = True

@ -0,0 +1,18 @@
"""Append-only UTF-8 BOM calibration change history."""
from __future__ import annotations
import csv
from dataclasses import asdict
from pathlib import Path
from .models import CalibrationChangeRecord
class CalibrationHistory:
FIELDS=["change_id","created_at","operator","category","before_value","after_value","reason","related_report_id","notes"]
def __init__(self,path:Path)->None:self.path=path
def load(self)->list[CalibrationChangeRecord]:
if not self.path.exists():return []
with self.path.open(encoding="utf-8-sig",newline="") as f:return [CalibrationChangeRecord(**r) for r in csv.DictReader(f)]
def append(self,record:CalibrationChangeRecord)->Path:
records=self.load()
if any(x.change_id==record.change_id for x in records):raise ValueError("duplicate change_id")
self.path.parent.mkdir(parents=True,exist_ok=True);exists=self.path.exists()
with self.path.open("a",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=self.FIELDS);w.writeheader() if not exists else None;w.writerow(asdict(record))
return self.path

@ -0,0 +1,78 @@
"""OpenCV camera and video input abstraction."""
from pathlib import Path
import cv2
import numpy as np
from .config_loader import InputSettings
from .exceptions import CameraOpenError, FrameReadError
class Camera:
"""Read frames from a camera device or video file."""
def __init__(self, config: InputSettings) -> None:
self.config = config
self._capture: cv2.VideoCapture | None = None
self._consecutive_failures = 0
def open(self) -> None:
backend_map = {"auto": cv2.CAP_ANY, "dshow": cv2.CAP_DSHOW, "msmf": cv2.CAP_MSMF, "v4l2": cv2.CAP_V4L2}
source: int | str
if self.config.type == "camera":
source = self.config.camera_id
else:
path = Path(self.config.video_path or "")
if not path.is_file():
raise CameraOpenError(f"Video file does not exist: {path}")
source = str(path)
self._capture = cv2.VideoCapture(source, backend_map[self.config.backend])
if not self._capture.isOpened():
self.release()
raise CameraOpenError(f"Could not open {self.config.type} input: {source}")
if self.config.type == "camera":
self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, self.config.width)
self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, self.config.height)
self._capture.set(cv2.CAP_PROP_FPS, self.config.fps)
def read(self) -> np.ndarray | None:
if self._capture is None or not self._capture.isOpened():
raise CameraOpenError("Input is not open")
ok, frame = self._capture.read()
if ok and frame is not None:
self._consecutive_failures = 0
return frame
if self.config.type == "video":
return None
self._consecutive_failures += 1
if self._consecutive_failures >= self.config.max_consecutive_read_failures:
raise FrameReadError(f"Camera read failed {self._consecutive_failures} consecutive times")
return None
def is_opened(self) -> bool:
return self._capture is not None and self._capture.isOpened()
def _property(self, property_id: int) -> float:
return float(self._capture.get(property_id)) if self._capture is not None else 0.0
def get_actual_width(self) -> int:
return round(self._property(cv2.CAP_PROP_FRAME_WIDTH))
def get_actual_height(self) -> int:
return round(self._property(cv2.CAP_PROP_FRAME_HEIGHT))
def get_actual_fps(self) -> float:
return self._property(cv2.CAP_PROP_FPS)
def release(self) -> None:
if self._capture is not None:
self._capture.release()
self._capture = None
def __enter__(self) -> "Camera":
self.open()
return self
def __exit__(self, *_args: object) -> None:
self.release()

@ -0,0 +1,729 @@
"""YAML configuration loading, recursive merging, and validation."""
from __future__ import annotations
from dataclasses import asdict, dataclass, fields
from pathlib import Path
from typing import Any, Mapping, TypeVar
import yaml
from .exceptions import ConfigurationError
from .models import PixelRegion, PixelTriggerLine
@dataclass(frozen=True, slots=True)
class ApplicationSettings:
name: str
version: str
window_name: str
exit_key: str
screenshot_key: str
pause_key: str
@dataclass(frozen=True, slots=True)
class ExperimentSettings:
experiment_id: str
location: str
operator: str
notes: str
@dataclass(frozen=True, slots=True)
class InputSettings:
type: str
camera_id: int
video_path: str | None
width: int
height: int
fps: float
backend: str
horizontal_flip: bool
max_consecutive_read_failures: int
@dataclass(frozen=True, slots=True)
class DetectorSettings:
model_path: str
device: str
confidence_threshold: float
iou_threshold: float
image_size: int
person_class_id: int
max_detections: int
half_precision: bool
use_agnostic_nms: bool
@dataclass(frozen=True, slots=True)
class DisplaySettings:
enabled: bool
show_bounding_boxes: bool
show_confidence: bool
show_detection_count: bool
show_fps: bool
show_timestamp: bool
show_frame_number: bool
show_evaluation_region: bool
show_trigger_line: bool
show_track_ids: bool
show_trajectories: bool
show_movement_direction: bool
show_tracking_counters: bool
trajectory_max_points: int
show_face_roi: bool
show_head_pose: bool
show_face_landmarks: bool
show_face_counters: bool
show_turn_status: bool
show_yaw_delta: bool
show_turn_counters: bool
show_voice_status: bool
show_voice_counters: bool
show_person_state: bool
show_person_state_counters: bool
resize_scale: float
@dataclass(frozen=True, slots=True)
class EvaluationRegionSettings:
enabled: bool
coordinate_mode: str
x1: float
y1: float
x2: float
y2: float
def to_pixels(self, width: int, height: int) -> PixelRegion | None:
if not self.enabled:
return None
if self.coordinate_mode == "normalized":
return PixelRegion(
round(self.x1 * (width - 1)), round(self.y1 * (height - 1)),
round(self.x2 * (width - 1)), round(self.y2 * (height - 1)),
)
return PixelRegion(round(self.x1), round(self.y1), round(self.x2), round(self.y2))
@dataclass(frozen=True, slots=True)
class TriggerLineSettings:
enabled: bool
orientation: str
coordinate_mode: str
position: float
def pixel_position(self, width: int, height: int) -> int | None:
if not self.enabled:
return None
extent = width - 1 if self.orientation == "vertical" else height - 1
return round(self.position * extent) if self.coordinate_mode == "normalized" else round(self.position)
def to_pixels(self, width: int, height: int) -> PixelTriggerLine | None:
position = self.pixel_position(width, height)
return PixelTriggerLine(self.orientation, position) if position is not None else None
@dataclass(frozen=True, slots=True)
class TrackerSettings:
enabled: bool
type: str
track_high_thresh: float
track_low_thresh: float
new_track_thresh: float
match_thresh: float
track_buffer_frames: int
min_confirmed_frames: int
max_missed_frames: int
fuse_score: bool
@dataclass(frozen=True, slots=True)
class TrackManagementSettings:
trajectory_max_points: int
direction_window_points: int
minimum_direction_displacement_pixels: float
region_entry_confirm_frames: int
region_exit_confirm_frames: int
minimum_visible_frames_for_count: int
minimum_duration_sec_for_count: float
require_region_entry_for_count: bool
require_trigger_crossing_for_count: bool
@dataclass(frozen=True, slots=True)
class CrossingSettings:
enabled: bool
count_once_per_track: bool
deadband_pixels: float
minimum_displacement_pixels: float
minimum_track_points: int
require_confirmed_track: bool
@dataclass(frozen=True, slots=True)
class TrackingLoggingSettings:
enabled: bool
log_frame_tracks: bool
log_events: bool
log_summaries: bool
flush_interval_frames: int
directory: str
@dataclass(frozen=True, slots=True)
class FaceSettings:
enabled: bool
backend: str
process_every_n_frames: int
max_persons_per_frame: int
require_confirmed_track: bool
process_only_inside_region: bool
roi_height_ratio: float
roi_expand_ratio: float
min_roi_width: int
min_roi_height: int
static_image_mode: bool
max_num_faces: int
refine_landmarks: bool
min_detection_confidence: float
min_tracking_confidence: float
@dataclass(frozen=True, slots=True)
class HeadPoseSettings:
enabled: bool
method: str
smoothing_enabled: bool
smoothing_method: str
ema_alpha: float
max_abs_angle: float
@dataclass(frozen=True, slots=True)
class FaceLoggingSettings:
enabled: bool
log_frame_faces: bool
log_summaries: bool
flush_interval_frames: int
directory: str
@dataclass(frozen=True, slots=True)
class TurnDetectionSettings:
enabled: bool
signage_yaw_direction: str
baseline_strategy: str
baseline_window_sec: float
baseline_min_samples: int
subtle_threshold_deg: float
weak_threshold_deg: float
medium_threshold_deg: float
strong_threshold_deg: float
min_subtle_duration_sec: float
min_turn_duration_sec: float
max_pose_gap_sec: float
min_pose_samples_for_evaluation: int
min_face_detection_rate_for_evaluation: float
min_pose_estimation_rate_for_evaluation: float
face_appearance_enabled: bool
face_missing_before_appearance_sec: float
weak_or_higher_is_turn: bool
@dataclass(frozen=True, slots=True)
class TurnLoggingSettings:
enabled: bool
log_frame_turns: bool
log_events: bool
log_summaries: bool
flush_interval_frames: int
directory: str
@dataclass(frozen=True, slots=True)
class AudioSettings:
enabled: bool
backend: str
file_path: str
volume: float
overlap_policy: str
@dataclass(frozen=True, slots=True)
class VoicePromptSettings:
enabled: bool
mode: str
trigger_strategy: str
require_confirmed_track: bool
require_inside_region: bool
skip_if_already_turned: bool
one_prompt_per_track: bool
global_cooldown_sec: float
minimum_track_age_sec: float
response_window_sec: float
fixed_position_axis: str
fixed_position_value: float
fixed_position_direction: str
@dataclass(frozen=True, slots=True)
class VoiceLoggingSettings:
enabled: bool
log_decisions: bool
log_events: bool
log_summaries: bool
flush_interval_frames: int
directory: str
@dataclass(frozen=True, slots=True)
class PersonStateSettings:
enabled: bool
min_track_duration_sec: float
require_trigger_crossing_for_completion: bool
require_voice_eligibility_for_voice_analysis: bool
mark_not_evaluable_as_excluded: bool
finalize_on_track_lost: bool
keep_completed_state_after_response: bool
@dataclass(frozen=True, slots=True)
class PersonStateLoggingSettings:
enabled: bool
log_frame_states: bool
log_events: bool
log_summaries: bool
flush_interval_frames: int
directory: str
@dataclass(frozen=True, slots=True)
class UnifiedLogSettings:
enabled: bool
directory: str
log_unified_events: bool
log_person_analysis_table: bool
log_session_summary: bool
log_consistency_issues: bool
flush_interval_frames: int
include_tracking_events: bool
include_turn_events: bool
include_voice_events: bool
include_person_state_events: bool
include_face_events: bool
important_events_only: bool
camera_position_note: str
run_consistency_checks: bool
@dataclass(frozen=True, slots=True)
class AnalysisSettings:
enabled: bool
input_directory: str
output_directory: str
recursive: bool
include_prompt_condition: bool
include_control_condition: bool
valid_voice_analysis_only: bool
response_level_threshold: str
response_rate_test: str
numeric_test: str
yaw_metric_denominator: str
generate_plots: bool
overwrite_outputs: bool
alpha: float
@dataclass(frozen=True, slots=True)
class GuiSettings:
enabled: bool
default_config_path: str
default_analysis_input: str
default_analysis_output: str
preview_max_fps: float
remember_last_paths: bool
show_opencv_window_when_gui: bool
auto_run_analysis_after_experiment: bool
@dataclass(frozen=True, slots=True)
class ExperimentProtocolSettings:
default_output_markdown: str; default_output_yaml: str; voice_prompt_text: str
privacy_note: str; planned_session_duration_min: float
@dataclass(frozen=True, slots=True)
class QualityCheckSettings:
enabled: bool; min_valid_voice_analysis_rate: float; max_not_evaluable_rate: float
max_no_pose_rate: float; require_prompt_and_control: bool
require_camera_position_note: bool; require_plots: bool
@dataclass(frozen=True, slots=True)
class PilotReportSettings:
enabled: bool; output_markdown: str
@dataclass(frozen=True, slots=True)
class PresentationSummarySettings:
enabled: bool; output_markdown: str
@dataclass(frozen=True,slots=True)
class PilotDiagnosticsSettings:
enabled:bool;target_valid_records_per_condition:int;min_valid_records_per_condition:int
min_valid_voice_analysis_rate:float;max_not_evaluable_rate:float;max_no_pose_estimated_rate:float
max_condition_valid_count_ratio:float;min_sessions_per_condition:int;require_camera_position_note:bool
ready_requires_prompt_and_control:bool;output_report:str;output_metrics_csv:str
output_recommendations_csv:str;output_session_quality_csv:str
@dataclass(frozen=True,slots=True)
class CalibrationHistorySettings:
enabled:bool;path:str
@dataclass(frozen=True, slots=True)
class DetectionLoggingSettings:
runtime_log_enabled: bool
detection_log_enabled: bool
log_every_frame: bool
log_empty_frames: bool
flush_interval_frames: int
runtime_log_directory: str
detection_log_directory: str
@dataclass(frozen=True, slots=True)
class OutputSettings:
save_video: bool
video_directory: str
video_codec: str
screenshot_directory: str
@dataclass(frozen=True, slots=True)
class PerformanceSettings:
skip_frames: int
fps_average_window: int
warn_fps_threshold: float
@dataclass(frozen=True, slots=True)
class DebugSettings:
enabled: bool
show_raw_model_output: bool
print_detections: bool
@dataclass(frozen=True, slots=True)
class AppConfig:
application: ApplicationSettings
experiment: ExperimentSettings
input: InputSettings
detector: DetectorSettings
tracker: TrackerSettings
track_management: TrackManagementSettings
crossing: CrossingSettings
tracking_logging: TrackingLoggingSettings
face: FaceSettings
head_pose: HeadPoseSettings
face_logging: FaceLoggingSettings
turn_detection: TurnDetectionSettings
turn_logging: TurnLoggingSettings
audio: AudioSettings
voice_prompt: VoicePromptSettings
voice_logging: VoiceLoggingSettings
person_state: PersonStateSettings
person_state_logging: PersonStateLoggingSettings
unified_logging: UnifiedLogSettings
analysis: AnalysisSettings
gui: GuiSettings
experiment_protocol: ExperimentProtocolSettings
quality_check: QualityCheckSettings
pilot_report: PilotReportSettings
presentation_summary: PresentationSummarySettings
pilot_diagnostics:PilotDiagnosticsSettings
calibration_history:CalibrationHistorySettings
display: DisplaySettings
evaluation_region: EvaluationRegionSettings
trigger_line: TriggerLineSettings
logging: DetectionLoggingSettings
output: OutputSettings
performance: PerformanceSettings
debug: DebugSettings
def to_dict(self) -> dict[str, Any]:
return asdict(self)
T = TypeVar("T")
def recursive_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
result = dict(base)
for key, value in override.items():
if isinstance(value, Mapping) and isinstance(result.get(key), Mapping):
result[key] = recursive_merge(result[key], value)
else:
result[key] = value
return result
def _read_yaml(path: Path) -> dict[str, Any]:
try:
with path.open("r", encoding="utf-8") as stream:
value = yaml.safe_load(stream) or {}
except (OSError, yaml.YAMLError) as exc:
raise ConfigurationError(f"Failed to read configuration {path}: {exc}") from exc
if not isinstance(value, dict):
raise ConfigurationError(f"Configuration root must be a mapping: {path}")
return value
def _make(cls: type[T], values: Any, section: str) -> T:
if not isinstance(values, Mapping):
raise ConfigurationError(f"'{section}' must be a mapping")
expected = {field.name for field in fields(cls)}
missing = expected - values.keys()
unknown = values.keys() - expected
if missing:
raise ConfigurationError(f"Missing settings in '{section}': {sorted(missing)}")
if unknown:
raise ConfigurationError(f"Unknown settings in '{section}': {sorted(unknown)}")
try:
return cls(**values)
except (TypeError, ValueError) as exc:
raise ConfigurationError(f"Invalid '{section}' settings: {exc}") from exc
def _validate(config: AppConfig) -> None:
i, d, e, t = config.input, config.detector, config.evaluation_region, config.trigger_line
if i.type not in {"camera", "video"}:
raise ConfigurationError("input.type must be camera or video")
if i.type == "video" and not i.video_path:
raise ConfigurationError("input.video_path is required for video input")
if i.width <= 0 or i.height <= 0 or i.fps <= 0 or i.max_consecutive_read_failures <= 0:
raise ConfigurationError("input dimensions, fps, and read failure limit must be positive")
if i.backend not in {"auto", "dshow", "msmf", "v4l2"}:
raise ConfigurationError("input.backend must be auto, dshow, msmf, or v4l2")
if not 0.0 <= d.confidence_threshold <= 1.0 or not 0.0 <= d.iou_threshold <= 1.0:
raise ConfigurationError("detector confidence and IoU thresholds must be in [0, 1]")
if d.image_size <= 0 or d.max_detections <= 0 or d.person_class_id < 0:
raise ConfigurationError("detector image_size/max_detections must be positive and class ID non-negative")
if config.performance.fps_average_window <= 0 or config.performance.skip_frames < 0:
raise ConfigurationError("performance window must be positive and skip_frames non-negative")
if config.display.resize_scale <= 0:
raise ConfigurationError("display.resize_scale must be positive")
if config.display.trajectory_max_points <= 0:
raise ConfigurationError("display.trajectory_max_points must be positive")
if config.logging.flush_interval_frames <= 0:
raise ConfigurationError("logging.flush_interval_frames must be positive")
for name in ("exit_key", "screenshot_key", "pause_key"):
if len(getattr(config.application, name)) != 1:
raise ConfigurationError(f"application.{name} must be one character")
if e.coordinate_mode not in {"normalized", "pixel"} or t.coordinate_mode not in {"normalized", "pixel"}:
raise ConfigurationError("coordinate_mode must be normalized or pixel")
if not (e.x1 < e.x2 and e.y1 < e.y2):
raise ConfigurationError("evaluation region requires x1 < x2 and y1 < y2")
if e.coordinate_mode == "normalized" and any(not 0.0 <= v <= 1.0 for v in (e.x1, e.y1, e.x2, e.y2)):
raise ConfigurationError("normalized evaluation coordinates must be in [0, 1]")
if t.orientation not in {"vertical", "horizontal"}:
raise ConfigurationError("trigger_line.orientation must be vertical or horizontal")
if t.position < 0 or (t.coordinate_mode == "normalized" and t.position > 1.0):
raise ConfigurationError("trigger_line.position is outside its valid range")
tracker = config.tracker
thresholds = (tracker.track_high_thresh, tracker.track_low_thresh, tracker.new_track_thresh, tracker.match_thresh)
if tracker.type != "bytetrack":
raise ConfigurationError("tracker.type must be bytetrack")
if any(not 0.0 <= value <= 1.0 for value in thresholds):
raise ConfigurationError("tracker thresholds must be in [0, 1]")
if tracker.track_low_thresh > tracker.track_high_thresh:
raise ConfigurationError("tracker.track_low_thresh must not exceed track_high_thresh")
if min(tracker.track_buffer_frames, tracker.min_confirmed_frames, tracker.max_missed_frames) <= 0:
raise ConfigurationError("tracker frame counts must be positive")
management = config.track_management
counts = (management.trajectory_max_points, management.direction_window_points, management.region_entry_confirm_frames,
management.region_exit_confirm_frames, management.minimum_visible_frames_for_count)
if min(counts) <= 0 or management.minimum_direction_displacement_pixels < 0 or management.minimum_duration_sec_for_count < 0:
raise ConfigurationError("track_management counts must be positive and thresholds non-negative")
crossing = config.crossing
if crossing.deadband_pixels < 0 or crossing.minimum_displacement_pixels < 0 or crossing.minimum_track_points < 2:
raise ConfigurationError("crossing distances must be non-negative and minimum_track_points >= 2")
if config.tracking_logging.flush_interval_frames <= 0:
raise ConfigurationError("tracking_logging.flush_interval_frames must be positive")
if tracker.enabled and config.performance.skip_frames > 0:
raise ConfigurationError("performance.skip_frames must be 0 while tracking is enabled")
face = config.face
if not isinstance(face.enabled, bool) or face.backend != "mediapipe":
raise ConfigurationError("face.enabled must be bool and face.backend must be mediapipe")
if face.process_every_n_frames <= 0 or face.max_persons_per_frame <= 0 or face.max_num_faces <= 0:
raise ConfigurationError("face processing intervals and counts must be positive")
if not 0.0 < face.roi_height_ratio <= 1.0 or face.roi_expand_ratio < 0:
raise ConfigurationError("face ROI ratios are outside their valid range")
if face.min_roi_width <= 0 or face.min_roi_height <= 0:
raise ConfigurationError("face minimum ROI dimensions must be positive")
if not 0.0 <= face.min_detection_confidence <= 1.0 or not 0.0 <= face.min_tracking_confidence <= 1.0:
raise ConfigurationError("face confidence thresholds must be in [0, 1]")
pose = config.head_pose
if pose.method != "solvepnp" or pose.smoothing_method != "ema":
raise ConfigurationError("head_pose method must be solvepnp and smoothing_method must be ema")
if not 0.0 < pose.ema_alpha <= 1.0 or pose.max_abs_angle <= 0:
raise ConfigurationError("head_pose EMA alpha and maximum angle are invalid")
if config.face_logging.flush_interval_frames <= 0:
raise ConfigurationError("face_logging.flush_interval_frames must be positive")
display_bools = (config.display.show_face_roi, config.display.show_head_pose,
config.display.show_face_landmarks, config.display.show_face_counters)
if any(not isinstance(value, bool) for value in display_bools):
raise ConfigurationError("face display settings must be boolean")
turn = config.turn_detection
if not isinstance(turn.enabled, bool) or turn.signage_yaw_direction not in {"positive", "negative"}:
raise ConfigurationError("turn enabled/direction settings are invalid")
if turn.baseline_strategy not in {"first_valid", "before_trigger", "region_entry_window"}:
raise ConfigurationError("invalid turn baseline_strategy")
if turn.baseline_window_sec <= 0 or turn.baseline_min_samples <= 0:
raise ConfigurationError("turn baseline window and sample count must be positive")
thresholds = (turn.subtle_threshold_deg, turn.weak_threshold_deg,
turn.medium_threshold_deg, turn.strong_threshold_deg)
if any(value <= 0 for value in thresholds) or not all(a < b for a, b in zip(thresholds, thresholds[1:])):
raise ConfigurationError("turn thresholds must be positive and strictly increasing")
if min(turn.min_subtle_duration_sec, turn.min_turn_duration_sec, turn.max_pose_gap_sec,
turn.face_missing_before_appearance_sec) < 0:
raise ConfigurationError("turn durations must be non-negative")
if turn.min_pose_samples_for_evaluation <= 0:
raise ConfigurationError("turn minimum pose samples must be positive")
rates = (turn.min_face_detection_rate_for_evaluation, turn.min_pose_estimation_rate_for_evaluation)
if any(not 0.0 <= value <= 1.0 for value in rates):
raise ConfigurationError("turn evaluation rates must be in [0, 1]")
if config.turn_logging.flush_interval_frames <= 0:
raise ConfigurationError("turn_logging.flush_interval_frames must be positive")
turn_display = (config.display.show_turn_status, config.display.show_yaw_delta, config.display.show_turn_counters)
if any(not isinstance(value, bool) for value in turn_display):
raise ConfigurationError("turn display settings must be boolean")
audio = config.audio
if not isinstance(audio.enabled, bool) or audio.backend not in {"pygame", "noop"}:
raise ConfigurationError("audio.enabled/backend is invalid")
if (not isinstance(audio.file_path, str) or not isinstance(audio.volume, (int, float))
or not 0.0 <= audio.volume <= 1.0):
raise ConfigurationError("audio file_path/volume is invalid")
if audio.overlap_policy not in {"skip", "restart", "allow"}:
raise ConfigurationError("invalid audio overlap_policy")
voice = config.voice_prompt
if not isinstance(voice.enabled, bool) or voice.mode not in {"prompt", "control", "disabled"}:
raise ConfigurationError("voice enabled/mode is invalid")
if voice.trigger_strategy not in {"trigger_line_crossing", "region_entry", "fixed_x_position", "first_confirmed_track"}:
raise ConfigurationError("invalid voice trigger_strategy")
bools = (voice.require_confirmed_track, voice.require_inside_region, voice.skip_if_already_turned,
voice.one_prompt_per_track)
if any(not isinstance(value, bool) for value in bools):
raise ConfigurationError("voice eligibility settings must be boolean")
durations = (voice.global_cooldown_sec, voice.minimum_track_age_sec, voice.response_window_sec)
if any(not isinstance(value, (int, float)) for value in durations) or min(durations[:2]) < 0 or durations[2] <= 0:
raise ConfigurationError("voice durations are invalid")
if voice.fixed_position_axis not in {"x", "y"} or voice.fixed_position_direction not in {"increasing", "decreasing"}:
raise ConfigurationError("voice fixed-position settings are invalid")
vlog = config.voice_logging
if (not isinstance(vlog.enabled, bool) or not isinstance(vlog.directory, str)
or vlog.flush_interval_frames <= 0
or any(not isinstance(value, bool) for value in (vlog.log_decisions, vlog.log_events, vlog.log_summaries))):
raise ConfigurationError("voice_logging settings are invalid")
if any(not isinstance(value, bool) for value in (config.display.show_voice_status, config.display.show_voice_counters)):
raise ConfigurationError("voice display settings must be boolean")
person = config.person_state
person_bools = (person.enabled, person.require_trigger_crossing_for_completion,
person.require_voice_eligibility_for_voice_analysis, person.mark_not_evaluable_as_excluded,
person.finalize_on_track_lost, person.keep_completed_state_after_response)
if (any(not isinstance(value, bool) for value in person_bools)
or not isinstance(person.min_track_duration_sec, (int, float))
or person.min_track_duration_sec < 0):
raise ConfigurationError("person_state settings are invalid")
plog = config.person_state_logging
if (not isinstance(plog.enabled, bool) or not isinstance(plog.directory, str)
or plog.flush_interval_frames <= 0
or any(not isinstance(value, bool) for value in (plog.log_frame_states, plog.log_events, plog.log_summaries))):
raise ConfigurationError("person_state_logging settings are invalid")
if any(not isinstance(value, bool) for value in (config.display.show_person_state,
config.display.show_person_state_counters)):
raise ConfigurationError("person-state display settings must be boolean")
unified = config.unified_logging
unified_bools = (unified.enabled, unified.log_unified_events, unified.log_person_analysis_table,
unified.log_session_summary, unified.log_consistency_issues, unified.include_tracking_events,
unified.include_turn_events, unified.include_voice_events, unified.include_person_state_events,
unified.include_face_events, unified.important_events_only, unified.run_consistency_checks)
if (any(not isinstance(value, bool) for value in unified_bools) or not isinstance(unified.directory, str)
or not isinstance(unified.camera_position_note, str)
or not isinstance(unified.flush_interval_frames, int) or unified.flush_interval_frames <= 0):
raise ConfigurationError("unified_logging settings are invalid")
analysis = config.analysis
analysis_bools = (analysis.enabled, analysis.recursive, analysis.include_prompt_condition,
analysis.include_control_condition, analysis.valid_voice_analysis_only,
analysis.generate_plots, analysis.overwrite_outputs)
if any(not isinstance(value, bool) for value in analysis_bools): raise ConfigurationError("analysis flags must be boolean")
if not isinstance(analysis.input_directory, str) or not isinstance(analysis.output_directory, str): raise ConfigurationError("analysis directories must be strings")
if analysis.response_level_threshold not in {"subtle","weak","medium","strong"}: raise ConfigurationError("invalid analysis response threshold")
if analysis.response_rate_test not in {"auto","chi_square","fisher"}: raise ConfigurationError("invalid response_rate_test")
if analysis.numeric_test not in {"mannwhitney","ttest"}: raise ConfigurationError("invalid numeric_test")
if analysis.yaw_metric_denominator not in {"valid_turn_analysis","valid_voice_analysis","all_non_null"}: raise ConfigurationError("invalid yaw denominator")
if not isinstance(analysis.alpha,(int,float)) or not 0 < analysis.alpha < 1: raise ConfigurationError("analysis alpha must be in (0,1)")
gui = config.gui
if (not isinstance(gui.enabled, bool)
or not isinstance(gui.default_config_path, str)
or not isinstance(gui.default_analysis_input, str)
or not isinstance(gui.default_analysis_output, str)
or not isinstance(gui.preview_max_fps, (int, float))
or gui.preview_max_fps <= 0
or not isinstance(gui.remember_last_paths, bool)
or not isinstance(gui.show_opencv_window_when_gui, bool)
or not isinstance(gui.auto_run_analysis_after_experiment, bool)):
raise ConfigurationError("gui settings are invalid")
protocol=config.experiment_protocol
if not all(isinstance(v,str) for v in (protocol.default_output_markdown,protocol.default_output_yaml,protocol.voice_prompt_text,protocol.privacy_note)) or protocol.planned_session_duration_min<=0: raise ConfigurationError("experiment_protocol settings are invalid")
quality=config.quality_check
if any(not 0<=v<=1 for v in (quality.min_valid_voice_analysis_rate,quality.max_not_evaluable_rate,quality.max_no_pose_rate)) or any(not isinstance(v,bool) for v in (quality.enabled,quality.require_prompt_and_control,quality.require_camera_position_note,quality.require_plots)): raise ConfigurationError("quality_check settings are invalid")
pd=config.pilot_diagnostics
if (not isinstance(pd.enabled,bool) or min(pd.target_valid_records_per_condition,pd.min_valid_records_per_condition,pd.min_sessions_per_condition)<=0 or pd.min_valid_records_per_condition>pd.target_valid_records_per_condition or any(not 0<=v<=1 for v in (pd.min_valid_voice_analysis_rate,pd.max_not_evaluable_rate,pd.max_no_pose_estimated_rate)) or pd.max_condition_valid_count_ratio<1 or any(not isinstance(v,bool) for v in (pd.require_camera_position_note,pd.ready_requires_prompt_and_control))):raise ConfigurationError("pilot_diagnostics settings are invalid")
if not isinstance(config.calibration_history.enabled,bool) or not isinstance(config.calibration_history.path,str):raise ConfigurationError("calibration_history settings are invalid")
def load_config(
default_path: Path | str = Path("config/default.yaml"),
experiment_path: Path | str | None = Path("config/experiment.yaml"),
cli_overrides: Mapping[str, Any] | None = None,
) -> AppConfig:
"""Load default, experiment, then CLI overrides using recursive merging."""
merged = _read_yaml(Path(default_path))
if experiment_path is not None:
merged = recursive_merge(merged, _read_yaml(Path(experiment_path)))
if cli_overrides:
merged = recursive_merge(merged, cli_overrides)
config = AppConfig(
application=_make(ApplicationSettings, merged.get("application"), "application"),
experiment=_make(ExperimentSettings, merged.get("experiment"), "experiment"),
input=_make(InputSettings, merged.get("input"), "input"),
detector=_make(DetectorSettings, merged.get("detector"), "detector"),
tracker=_make(TrackerSettings, merged.get("tracker"), "tracker"),
track_management=_make(TrackManagementSettings, merged.get("track_management"), "track_management"),
crossing=_make(CrossingSettings, merged.get("crossing"), "crossing"),
tracking_logging=_make(TrackingLoggingSettings, merged.get("tracking_logging"), "tracking_logging"),
face=_make(FaceSettings, merged.get("face"), "face"),
head_pose=_make(HeadPoseSettings, merged.get("head_pose"), "head_pose"),
face_logging=_make(FaceLoggingSettings, merged.get("face_logging"), "face_logging"),
turn_detection=_make(TurnDetectionSettings, merged.get("turn_detection"), "turn_detection"),
turn_logging=_make(TurnLoggingSettings, merged.get("turn_logging"), "turn_logging"),
audio=_make(AudioSettings, merged.get("audio"), "audio"),
voice_prompt=_make(VoicePromptSettings, merged.get("voice_prompt"), "voice_prompt"),
voice_logging=_make(VoiceLoggingSettings, merged.get("voice_logging"), "voice_logging"),
person_state=_make(PersonStateSettings, merged.get("person_state"), "person_state"),
person_state_logging=_make(PersonStateLoggingSettings, merged.get("person_state_logging"), "person_state_logging"),
unified_logging=_make(UnifiedLogSettings, merged.get("unified_logging"), "unified_logging"),
analysis=_make(AnalysisSettings, merged.get("analysis"), "analysis"),
gui=_make(GuiSettings, merged.get("gui"), "gui"),
experiment_protocol=_make(ExperimentProtocolSettings,merged.get("experiment_protocol"),"experiment_protocol"),
quality_check=_make(QualityCheckSettings,merged.get("quality_check"),"quality_check"),
pilot_report=_make(PilotReportSettings,merged.get("pilot_report"),"pilot_report"),
presentation_summary=_make(PresentationSummarySettings,merged.get("presentation_summary"),"presentation_summary"),
pilot_diagnostics=_make(PilotDiagnosticsSettings,merged.get("pilot_diagnostics"),"pilot_diagnostics"),
calibration_history=_make(CalibrationHistorySettings,merged.get("calibration_history"),"calibration_history"),
display=_make(DisplaySettings, merged.get("display"), "display"),
evaluation_region=_make(EvaluationRegionSettings, merged.get("evaluation_region"), "evaluation_region"),
trigger_line=_make(TriggerLineSettings, merged.get("trigger_line"), "trigger_line"),
logging=_make(DetectionLoggingSettings, merged.get("logging"), "logging"),
output=_make(OutputSettings, merged.get("output"), "output"),
performance=_make(PerformanceSettings, merged.get("performance"), "performance"),
debug=_make(DebugSettings, merged.get("debug"), "debug"),
)
_validate(config)
return config

@ -0,0 +1,47 @@
"""Trigger-line crossing detection independent of tracking libraries."""
from .config_loader import CrossingSettings
from .models import PixelTriggerLine, TrackPoint
class CrossingDetector:
"""Detect robust directional crossings with deadband and deduplication."""
def __init__(self, config: CrossingSettings, trigger_line: PixelTriggerLine | None) -> None:
self.config = config
self.trigger_line = trigger_line
self._crossed_ids: set[int] = set()
def detect(self, track_id: int, trajectory: list[TrackPoint], is_confirmed: bool) -> str | None:
if not self.config.enabled or self.trigger_line is None:
return None
if self.config.require_confirmed_track and not is_confirmed:
return None
if len(trajectory) < self.config.minimum_track_points:
return None
if self.config.count_once_per_track and track_id in self._crossed_ids:
return None
current = trajectory[-1]
if self.trigger_line.orientation == "vertical":
values = [point.foot_x for point in trajectory]
forward, backward = "left_to_right", "right_to_left"
else:
values = [point.foot_y for point in trajectory]
forward, backward = "top_to_bottom", "bottom_to_top"
line, deadband = self.trigger_line.position, self.config.deadband_pixels
after = values[-1]
direction: str | None = None
if after >= line + deadband:
before = next((value for value in reversed(values[:-1]) if value < line - deadband), None)
if before is not None and after - before >= self.config.minimum_displacement_pixels:
direction = forward
elif after <= line - deadband:
before = next((value for value in reversed(values[:-1]) if value > line + deadband), None)
if before is not None and before - after >= self.config.minimum_displacement_pixels:
direction = backward
if direction is not None and self.config.count_once_per_track:
self._crossed_ids.add(track_id)
return direction
def reset(self) -> None:
self._crossed_ids.clear()

@ -0,0 +1,71 @@
"""Streaming CSV detection logger."""
import csv
from pathlib import Path
from typing import TextIO
from .config_loader import DetectionLoggingSettings
from .models import Detection, FrameInfo
class DetectionLogger:
"""Write one CSV row per detection, with optional empty-frame rows."""
FIELDNAMES = [
"experiment_id", "frame_number", "timestamp", "elapsed_time_sec", "frame_width", "frame_height",
"measured_fps", "detection_index", "class_id", "class_name", "confidence", "bbox_x1", "bbox_y1",
"bbox_x2", "bbox_y2", "bbox_width", "bbox_height", "center_x", "center_y", "foot_x", "foot_y",
"inside_evaluation_region",
]
def __init__(self, config: DetectionLoggingSettings, experiment_id: str, path: Path) -> None:
self.config = config
self.experiment_id = experiment_id
self.path = path
self._file: TextIO | None = None
self._writer: csv.DictWriter[str] | None = None
self._frames_since_flush = 0
def open(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self._file = self.path.open("w", encoding="utf-8-sig", newline="")
self._writer = csv.DictWriter(self._file, fieldnames=self.FIELDNAMES)
self._writer.writeheader()
def log_frame(self, info: FrameInfo, detections: list[Detection]) -> None:
if self._writer is None:
return
base = {
"experiment_id": self.experiment_id, "frame_number": info.frame_number, "timestamp": info.timestamp_iso,
"elapsed_time_sec": f"{info.elapsed_time_sec:.6f}", "frame_width": info.width, "frame_height": info.height,
"measured_fps": f"{info.measured_fps:.3f}",
}
if detections:
for index, detection in enumerate(detections):
b = detection.bbox
self._writer.writerow(base | {
"detection_index": index, "class_id": detection.class_id, "class_name": detection.class_name,
"confidence": f"{detection.confidence:.6f}", "bbox_x1": b.x1, "bbox_y1": b.y1,
"bbox_x2": b.x2, "bbox_y2": b.y2, "bbox_width": b.width, "bbox_height": b.height,
"center_x": b.center_x, "center_y": b.center_y, "foot_x": b.foot_x, "foot_y": b.foot_y,
"inside_evaluation_region": detection.inside_evaluation_region,
})
elif self.config.log_empty_frames:
self._writer.writerow(base)
self._frames_since_flush += 1
if self._frames_since_flush >= self.config.flush_interval_frames:
self._file.flush()
self._frames_since_flush = 0
def close(self) -> None:
if self._file is not None:
self._file.close()
self._file = None
self._writer = None
def __enter__(self) -> "DetectionLogger":
self.open()
return self
def __exit__(self, *_args: object) -> None:
self.close()

@ -0,0 +1,90 @@
"""Ultralytics YOLO adapter that exposes application domain models."""
import logging
from typing import Any
import numpy as np
from .config_loader import DetectorSettings
from .exceptions import ModelLoadError
from .models import BoundingBox, Detection, PixelRegion
LOGGER = logging.getLogger(__name__)
class PersonDetector:
"""Detect people and isolate the application from Ultralytics result types."""
def __init__(self, config: DetectorSettings) -> None:
self.config = config
self._model: Any = None
self._device_name = "cpu"
self._half = False
self._half_warning_emitted = False
def load_model(self) -> None:
try:
import torch
from ultralytics import YOLO
cuda_available = torch.cuda.is_available()
self._device_name = ("cuda" if cuda_available else "cpu") if self.config.device == "auto" else self.config.device
cuda_device_selected = self._device_name.lower().startswith("cuda") or self._device_name.isdigit()
self._half = self.config.half_precision and cuda_available and cuda_device_selected
if self.config.half_precision and not self._half and not self._half_warning_emitted:
LOGGER.warning("half_precision is disabled because the selected device is not CUDA")
self._half_warning_emitted = True
self._model = YOLO(self.config.model_path)
except Exception as exc:
raise ModelLoadError(f"Failed to load YOLO model '{self.config.model_path}': {exc}") from exc
def detect(self, frame: np.ndarray, evaluation_region: PixelRegion | None = None) -> list[Detection]:
if self._model is None:
raise ModelLoadError("Model has not been loaded")
height, width = frame.shape[:2]
try:
predict_kwargs: dict[str, Any] = {
"source": frame,
"conf": self.config.confidence_threshold,
"iou": self.config.iou_threshold,
"imgsz": self.config.image_size,
"classes": [self.config.person_class_id],
"max_det": self.config.max_detections,
"agnostic_nms": self.config.use_agnostic_nms,
"device": self._device_name,
"verbose": False,
}
# Ultralytics emits a deprecation warning whenever the ``half`` key
# is supplied, even when its value is False. Only request FP16 when
# it is explicitly enabled on CUDA; regular CPU inference needs
# neither ``half`` nor ``quantize``.
if self._half:
predict_kwargs["half"] = True
results = self._model.predict(**predict_kwargs)
except Exception:
LOGGER.exception("Inference failed for one frame")
return []
detections: list[Detection] = []
for result in results:
boxes = getattr(result, "boxes", None)
if boxes is None:
continue
for box in boxes:
class_id = int(box.cls.item())
if class_id != self.config.person_class_id:
continue
raw = box.xyxy[0].tolist()
x1 = max(0, min(width - 1, round(raw[0])))
y1 = max(0, min(height - 1, round(raw[1])))
x2 = max(0, min(width - 1, round(raw[2])))
y2 = max(0, min(height - 1, round(raw[3])))
if x1 >= x2 or y1 >= y2:
continue
bbox = BoundingBox(x1, y1, x2, y2)
inside = evaluation_region.contains(bbox.foot_x, bbox.foot_y) if evaluation_region else False
detections.append(Detection(class_id, "person", float(box.conf.item()), bbox, inside))
return detections
@property
def device_name(self) -> str:
return self._device_name

@ -0,0 +1,61 @@
"""Application-specific exception hierarchy."""
class ApplicationError(Exception):
"""Base class for expected application errors."""
class ConfigurationError(ApplicationError):
"""Raised when configuration is missing or invalid."""
class CameraOpenError(ApplicationError):
"""Raised when an input source cannot be opened."""
class FrameReadError(ApplicationError):
"""Raised after repeated camera frame read failures."""
class ModelLoadError(ApplicationError):
"""Raised when the YOLO model cannot be loaded."""
class VideoWriterError(ApplicationError):
"""Raised when an output video writer cannot be opened."""
class TrackerError(ApplicationError):
"""Raised when the external tracker cannot be initialized or updated."""
class TrackManagementError(ApplicationError):
"""Raised when accumulated track state is inconsistent."""
class TrackingLogError(ApplicationError):
"""Raised when tracking CSV files cannot be written."""
class FaceProcessingError(ApplicationError):
"""Raised when the face backend cannot be initialized."""
class FaceLogError(ApplicationError):
"""Raised when face CSV logs cannot be opened or written."""
class TurnLogError(ApplicationError):
"""Raised when turn-analysis CSV files cannot be opened or written."""
class VoiceLogError(ApplicationError):
"""Raised when voice-prompt CSV files cannot be opened or written."""
class PersonStateLogError(ApplicationError):
"""Raised when integrated person-state CSV files cannot be written."""
class UnifiedLogError(ApplicationError):
"""Raised when Phase 7 unified CSV files cannot be written."""

@ -0,0 +1,11 @@
"""UTF-8 YAML persistence for one experiment session note."""
from __future__ import annotations
from dataclasses import asdict
from pathlib import Path
import yaml
from .models import ExperimentSessionNote
class ExperimentNotes:
def save(self,note:ExperimentSessionNote,output_path:Path)->Path:
output_path.parent.mkdir(parents=True,exist_ok=True);output_path.write_text(yaml.safe_dump(asdict(note),allow_unicode=True,sort_keys=False),encoding="utf-8");return output_path
def load(self,path:Path)->ExperimentSessionNote:
return ExperimentSessionNote(**yaml.safe_load(path.read_text(encoding="utf-8")))

@ -0,0 +1,18 @@
"""Build and export a reproducible research experiment protocol."""
from __future__ import annotations
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from typing import Any
import yaml
from .models import ExperimentProtocol
class ExperimentProtocolBuilder:
def __init__(self,config:dict[str,Any])->None:self.config=config
def build_default_protocol(self)->ExperimentProtocol:
c=self.config;p=c["experiment_protocol"]
return ExperimentProtocol(f"protocol_{datetime.now():%Y%m%d}","デジタルサイネージ声掛けパイロット実験",c["application"]["version"],datetime.now().astimezone().isoformat(timespec="seconds"),"音声提示と通行人の顔向き反応を観測する",c["experiment"]["location"],"デジタルサイネージ",c["unified_logging"]["camera_position_note"],None,"要記録","要記録","要記録",p["voice_prompt_text"],c["audio"]["file_path"],c["audio"]["volume"],c["voice_prompt"]["trigger_strategy"],f"{c['trigger_line']['orientation']} {c['trigger_line']['position']}",c["voice_prompt"]["response_window_sec"],c["turn_detection"]["weak_threshold_deg"],c["face"]["process_every_n_frames"],"トリガー時に音声を提示する","同じトリガー時刻を記録し音声は提示しない",p["privacy_note"],"カメラ位置、照明、通行方向をセッションごとに固定・記録する")
def export_markdown(self,protocol:ExperimentProtocol,output_path:Path)->Path:
output_path.parent.mkdir(parents=True,exist_ok=True);d=asdict(protocol);lines=[f"# {protocol.title}",""]+[f"- **{k}**: {v}" for k,v in d.items()];output_path.write_text("\n".join(lines)+"\n",encoding="utf-8");return output_path
def export_yaml(self,protocol:ExperimentProtocol,output_path:Path)->Path:
output_path.parent.mkdir(parents=True,exist_ok=True);output_path.write_text(yaml.safe_dump(asdict(protocol),allow_unicode=True,sort_keys=False),encoding="utf-8");return output_path

@ -0,0 +1,23 @@
"""Pure geometry helpers for deriving face candidate regions."""
from .config_loader import FaceSettings
from .models import FaceROI, TrackedPerson
def create_face_roi(person: TrackedPerson, frame_width: int, frame_height: int, config: FaceSettings) -> FaceROI | None:
"""Create a clipped upper-body ROI or return None when the track is ineligible."""
if config.require_confirmed_track and not person.is_confirmed:
return None
if config.process_only_inside_region and not person.inside_evaluation_region:
return None
bbox = person.bbox
candidate_height = bbox.height * config.roi_height_ratio
expand_x = bbox.width * config.roi_expand_ratio
expand_y = candidate_height * config.roi_expand_ratio
x1 = max(0, round(bbox.x1 - expand_x))
y1 = max(0, round(bbox.y1 - expand_y))
x2 = min(frame_width, round(bbox.x2 + expand_x))
y2 = min(frame_height, round(bbox.y1 + candidate_height + expand_y))
if x2 - x1 < config.min_roi_width or y2 - y1 < config.min_roi_height:
return None
return FaceROI(person.track_id, x1, y1, x2, y2, bbox, person.confidence)

@ -0,0 +1,113 @@
"""UTF-8 BOM frame and summary logs for Phase 3 face processing."""
from __future__ import annotations
import csv
from datetime import datetime
from pathlib import Path
from typing import TextIO
from .config_loader import FaceLoggingSettings
from .exceptions import FaceLogError
from .models import FaceTrackState, HeadPoseResult
class FaceLogger:
"""Write per-frame head pose and deduplicated per-track face summaries."""
FRAME_FIELDS = ["experiment_id", "frame_number", "timestamp", "elapsed_time_sec", "track_id", "face_detected",
"pose_estimated", "pitch", "yaw", "roll", "landmarks_count", "roi_x1", "roi_y1", "roi_x2",
"roi_y2", "roi_width", "roi_height", "confidence", "failure_reason"]
SUMMARY_FIELDS = ["experiment_id", "track_id", "total_frames", "face_detected_frames", "pose_estimated_frames",
"face_detection_rate", "pose_estimation_rate", "min_yaw", "max_yaw", "mean_yaw", "last_pitch",
"last_yaw", "last_roll", "valid_face_track", "invalid_reason"]
def __init__(self, config: FaceLoggingSettings, experiment_id: str, run_stamp: str | None = None) -> None:
self.config = config
self.experiment_id = experiment_id
self.run_stamp = run_stamp or datetime.now().strftime("%Y%m%d_%H%M%S")
self.directory = Path(config.directory)
self._files: dict[str, TextIO] = {}
self._writers: dict[str, csv.DictWriter[str]] = {}
self._summary_ids: set[int] = set()
self._frames_since_flush = 0
def open(self) -> None:
if not self.config.enabled:
return
self.directory.mkdir(parents=True, exist_ok=True)
try:
self._open("frames", "face_frames", self.FRAME_FIELDS, self.config.log_frame_faces)
self._open("summary", "face_summary", self.SUMMARY_FIELDS, self.config.log_summaries)
except OSError as exc:
self.close()
raise FaceLogError(f"Failed to open face logs: {exc}") from exc
def _open(self, key: str, suffix: str, fields: list[str], enabled: bool) -> None:
if not enabled:
return
path = self.directory / f"{self.run_stamp}_{self.experiment_id}_{suffix}.csv"
stream = path.open("w", encoding="utf-8-sig", newline="")
writer = csv.DictWriter(stream, fieldnames=fields)
writer.writeheader()
self._files[key], self._writers[key] = stream, writer
def write_results(self, results: list[HeadPoseResult]) -> None:
writer = self._writers.get("frames")
if writer is None:
return
for result in results:
roi = result.roi
writer.writerow({
"experiment_id": self.experiment_id, "frame_number": result.frame_number,
"timestamp": result.timestamp_iso, "elapsed_time_sec": f"{result.elapsed_time_sec:.6f}",
"track_id": result.track_id, "face_detected": result.face_detected,
"pose_estimated": result.pose_estimated,
"pitch": self._number(result.pitch), "yaw": self._number(result.yaw), "roll": self._number(result.roll),
"landmarks_count": result.landmarks_count, "roi_x1": roi.x1 if roi else "", "roi_y1": roi.y1 if roi else "",
"roi_x2": roi.x2 if roi else "", "roi_y2": roi.y2 if roi else "",
"roi_width": roi.width if roi else "", "roi_height": roi.height if roi else "",
"confidence": self._number(result.confidence), "failure_reason": result.failure_reason,
})
self._frames_since_flush += 1
if self._frames_since_flush >= self.config.flush_interval_frames:
self.flush()
def write_summary(self, state: FaceTrackState) -> None:
writer = self._writers.get("summary")
if writer is None or state.track_id in self._summary_ids:
return
valid = state.pose_estimated_frames > 0
writer.writerow({
"experiment_id": self.experiment_id, "track_id": state.track_id, "total_frames": state.total_frames,
"face_detected_frames": state.face_detected_frames, "pose_estimated_frames": state.pose_estimated_frames,
"face_detection_rate": f"{state.face_detection_rate:.6f}",
"pose_estimation_rate": f"{state.pose_estimation_rate:.6f}", "min_yaw": self._number(state.min_yaw),
"max_yaw": self._number(state.max_yaw), "mean_yaw": self._number(state.mean_yaw),
"last_pitch": self._number(state.last_pitch), "last_yaw": self._number(state.last_yaw),
"last_roll": self._number(state.last_roll), "valid_face_track": valid,
"invalid_reason": "" if valid else "no_pose_estimation",
})
self._summary_ids.add(state.track_id)
@staticmethod
def _number(value: float | None) -> str:
return f"{value:.6f}" if value is not None else ""
def flush(self) -> None:
for stream in self._files.values():
stream.flush()
self._frames_since_flush = 0
def close(self) -> None:
for stream in self._files.values():
stream.close()
self._files.clear()
self._writers.clear()
def __enter__(self) -> "FaceLogger":
self.open()
return self
def __exit__(self, *_args: object) -> None:
self.close()

@ -0,0 +1,192 @@
"""MediaPipe face processing and per-track face statistics."""
from __future__ import annotations
import logging
from collections import deque
from typing import Any
from .config_loader import FaceSettings, HeadPoseSettings
from .exceptions import FaceProcessingError
from .face_geometry import create_face_roi
from .head_pose import HeadPoseEstimator
from .models import FaceLandmark, FaceTrackState, FrameInfo, HeadPoseResult, TrackedPerson
LOGGER = logging.getLogger(__name__)
class FaceProcessor:
"""Run MediaPipe Face Mesh per tracked-person ROI and estimate head pose."""
def __init__(self, config: FaceSettings, head_pose_config: HeadPoseSettings) -> None:
self.config = config
self.head_pose_config = head_pose_config
self.estimator = HeadPoseEstimator(head_pose_config)
self._mesh: Any = None
self._smoothed: dict[int, tuple[float, float, float]] = {}
if config.enabled:
self._initialize()
def _initialize(self) -> None:
try:
import mediapipe as mp
self._mesh = mp.solutions.face_mesh.FaceMesh(
static_image_mode=self.config.static_image_mode,
max_num_faces=self.config.max_num_faces,
refine_landmarks=self.config.refine_landmarks,
min_detection_confidence=self.config.min_detection_confidence,
min_tracking_confidence=self.config.min_tracking_confidence,
)
except Exception as exc:
raise FaceProcessingError(f"Failed to initialize MediaPipe Face Mesh: {exc}") from exc
def process(
self, frame: Any, tracked_persons: list[TrackedPerson], frame_info: FrameInfo,
) -> list[HeadPoseResult]:
if not self.config.enabled or self._mesh is None:
return []
if (frame_info.frame_number - 1) % self.config.process_every_n_frames != 0:
return []
eligible = [person for person in tracked_persons if self._eligible(person)]
eligible.sort(key=lambda person: (person.inside_evaluation_region, person.bbox.area), reverse=True)
results: list[HeadPoseResult] = []
for person in eligible[:self.config.max_persons_per_frame]:
results.append(self._process_person(frame, person, frame_info))
return results
def _eligible(self, person: TrackedPerson) -> bool:
if self.config.require_confirmed_track and not person.is_confirmed:
return False
if self.config.process_only_inside_region and not person.inside_evaluation_region:
return False
return True
def _process_person(self, frame: Any, person: TrackedPerson, info: FrameInfo) -> HeadPoseResult:
height, width = frame.shape[:2]
roi = create_face_roi(person, width, height, self.config)
if roi is None:
return self._failure(person.track_id, info, "roi_too_small")
try:
image = frame[roi.y1:roi.y2, roi.x1:roi.x2]
import cv2
output = self._mesh.process(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
faces = getattr(output, "multi_face_landmarks", None) or []
if not faces:
return self._failure(person.track_id, info, "face_not_detected", roi)
selected = min(faces, key=self._face_center_distance)
landmarks = self._convert_landmarks(selected.landmark, roi)
except Exception:
LOGGER.exception("MediaPipe failed for track %d on frame %d", person.track_id, info.frame_number)
return self._failure(person.track_id, info, "mediapipe_error", roi)
estimation = self.estimator.estimate(landmarks, width, height)
if not estimation.success:
return HeadPoseResult(
person.track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec,
True, False, estimation.pitch, estimation.yaw, estimation.roll, len(landmarks), roi, roi.confidence,
estimation.failure_reason, tuple(landmarks),
)
pitch, yaw, roll = self._smooth(person.track_id, estimation.pitch, estimation.yaw, estimation.roll)
return HeadPoseResult(
person.track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec,
True, True, pitch, yaw, roll, len(landmarks), roi, roi.confidence, "", tuple(landmarks),
)
@staticmethod
def _face_center_distance(face: Any) -> float:
landmarks = face.landmark
center_x = sum(point.x for point in landmarks) / len(landmarks)
center_y = sum(point.y for point in landmarks) / len(landmarks)
return (center_x - 0.5) ** 2 + (center_y - 0.5) ** 2
@staticmethod
def _convert_landmarks(raw_landmarks: Any, roi: Any) -> list[FaceLandmark]:
return [
FaceLandmark(
index=index, x=roi.x1 + float(point.x) * roi.width,
y=roi.y1 + float(point.y) * roi.height, z=float(point.z) * roi.width,
visibility=float(point.visibility) if hasattr(point, "visibility") else None,
)
for index, point in enumerate(raw_landmarks)
]
def _smooth(
self, track_id: int, pitch: float | None, yaw: float | None, roll: float | None,
) -> tuple[float, float, float]:
assert pitch is not None and yaw is not None and roll is not None
current = (pitch, yaw, roll)
if not self.head_pose_config.smoothing_enabled or track_id not in self._smoothed:
smoothed = current
else:
alpha = self.head_pose_config.ema_alpha
previous = self._smoothed[track_id]
smoothed = tuple(alpha * value + (1.0 - alpha) * old for value, old in zip(current, previous))
self._smoothed[track_id] = smoothed
return smoothed
@staticmethod
def _failure(track_id: int, info: FrameInfo, reason: str, roi: Any = None) -> HeadPoseResult:
return HeadPoseResult(track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec,
False, False, None, None, None, 0, roi, None, reason)
def reset(self) -> None:
self._smoothed.clear()
def close(self) -> None:
if self._mesh is not None:
self._mesh.close()
self._mesh = None
self._smoothed.clear()
class FaceTrackManager:
"""Accumulate face success rates, yaw statistics, and recent results by track ID."""
def __init__(self, max_history_points: int) -> None:
if max_history_points <= 0:
raise ValueError("max_history_points must be positive")
self.max_history_points = max_history_points
self._states: dict[int, FaceTrackState] = {}
self._finalized: dict[int, FaceTrackState] = {}
def update(self, results: list[HeadPoseResult]) -> None:
for result in results:
state = self._states.get(result.track_id)
if state is None:
state = FaceTrackState(result.track_id, history=deque(maxlen=self.max_history_points))
self._states[result.track_id] = state
state.total_frames += 1
state.face_detected_frames += int(result.face_detected)
state.pose_estimated_frames += int(result.pose_estimated)
state.last_result = result
state.history.append(result)
if result.pose_estimated and result.yaw is not None:
state.last_pitch, state.last_yaw, state.last_roll = result.pitch, result.yaw, result.roll
state.min_yaw = result.yaw if state.min_yaw is None else min(state.min_yaw, result.yaw)
state.max_yaw = result.yaw if state.max_yaw is None else max(state.max_yaw, result.yaw)
state.yaw_sum += result.yaw
def get_state(self, track_id: int) -> FaceTrackState | None:
return self._states.get(track_id) or self._finalized.get(track_id)
def get_latest_result(self, track_id: int) -> HeadPoseResult | None:
state = self.get_state(track_id)
return state.last_result if state else None
def get_latest_results(self) -> dict[int, HeadPoseResult]:
return {track_id: state.last_result for track_id, state in self._states.items() if state.last_result is not None}
def finalize_track(self, track_id: int) -> FaceTrackState | None:
state = self._states.pop(track_id, None)
if state is not None:
state.finalized = True
self._finalized[track_id] = state
return state
def finalize_all(self) -> list[FaceTrackState]:
return [state for track_id in list(self._states) if (state := self.finalize_track(track_id)) is not None]
def reset(self) -> None:
self._states.clear()
self._finalized.clear()

@ -0,0 +1,44 @@
"""Frame-rate measurement."""
import time
from collections import deque
class FPSCounter:
"""Calculate instantaneous and moving-average FPS."""
def __init__(self, average_window: int) -> None:
if average_window <= 0:
raise ValueError("average_window must be positive")
self._samples: deque[float] = deque(maxlen=average_window)
self._last_time: float | None = None
self._current_fps = 0.0
def tick(self) -> float:
now = time.perf_counter()
if self._last_time is None:
self._last_time = now
return 0.0
elapsed = now - self._last_time
self._last_time = now
self._current_fps = 1.0 / elapsed if elapsed > 0.0 else 0.0
if self._current_fps > 0.0:
self._samples.append(self._current_fps)
return self._current_fps
@property
def current_fps(self) -> float:
return self._current_fps
@property
def average_fps(self) -> float:
return sum(self._samples) / len(self._samples) if self._samples else 0.0
@property
def sample_count(self) -> int:
return len(self._samples)
def reset(self) -> None:
self._samples.clear()
self._last_time = None
self._current_fps = 0.0

@ -0,0 +1,5 @@
"""Phase 9 desktop GUI package."""
from .gui_models import GuiAnalysisRequest, GuiExperimentRequest
__all__ = ["GuiAnalysisRequest", "GuiExperimentRequest"]

@ -0,0 +1,32 @@
"""Threaded Phase 8 analysis controller; it never initializes camera or YOLO."""
from __future__ import annotations
import threading
from dataclasses import replace
from pathlib import Path
from typing import Callable
from ..analysis_cli import run_analysis_cli
from ..config_loader import AnalysisSettings, load_config
from .gui_models import GuiAnalysisRequest
class AnalysisController:
def __init__(self, runner: Callable[[AnalysisSettings, Path, Path], list[Path]] = run_analysis_cli) -> None:
self._runner=runner; self._thread: threading.Thread|None=None
self._finished_callback: Callable[[list[Path]],None]|None=None; self._error_callback: Callable[[str],None]|None=None
def run_analysis(self, request: GuiAnalysisRequest, config_path: str = "config/experiment.yaml") -> None:
if self.is_running(): raise RuntimeError("Analysis is already running")
if request.response_test not in {"auto","chi_square","fisher"}: raise ValueError("Invalid response test")
if request.numeric_test not in {"mannwhitney","ttest"}: raise ValueError("Invalid numeric test")
config=load_config(Path("config/default.yaml"),Path(config_path))
settings=replace(config.analysis,input_directory=request.input_directory,output_directory=request.output_directory,
recursive=request.recursive,generate_plots=request.generate_plots,
response_rate_test=request.response_test,numeric_test=request.numeric_test)
self._thread=threading.Thread(target=self._worker,args=(settings,),name="analysis-worker",daemon=True);self._thread.start()
def _worker(self, settings: AnalysisSettings) -> None:
try:
paths=self._runner(settings,Path(settings.input_directory),Path(settings.output_directory))
if self._finished_callback:self._finished_callback(paths)
except Exception as exc:
if self._error_callback:self._error_callback(str(exc))
def is_running(self)->bool:return self._thread is not None and self._thread.is_alive()
def set_finished_callback(self,callback:Callable[[list[Path]],None])->None:self._finished_callback=callback
def set_error_callback(self,callback:Callable[[str],None])->None:self._error_callback=callback

@ -0,0 +1,24 @@
"""Phase 8 analysis controls and output list."""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QCheckBox,QComboBox,QFileDialog,QFormLayout,QHBoxLayout,QLineEdit,QListWidget,QPushButton,QVBoxLayout,QWidget
from .gui_models import GuiAnalysisRequest
class AnalysisPanel(QWidget):
run_requested=Signal(object);open_requested=Signal(str)
def __init__(self,input_dir:str,output_dir:str)->None:
super().__init__();layout=QVBoxLayout(self);form=QFormLayout();self.input=QLineEdit(input_dir);self.output=QLineEdit(output_dir)
form.addRow("Input directory",self._path_row(self.input,False));form.addRow("Output directory",self._path_row(self.output,False))
self.recursive=QCheckBox();self.recursive.setChecked(True);self.plots=QCheckBox();self.plots.setChecked(True)
self.response=QComboBox();self.response.addItems(["auto","chi_square","fisher"]);self.numeric=QComboBox();self.numeric.addItems(["mannwhitney","ttest"])
form.addRow("Recursive",self.recursive);form.addRow("Generate plots",self.plots);form.addRow("Response test",self.response);form.addRow("Numeric test",self.numeric);layout.addLayout(form)
run=QPushButton("Run analysis");run.clicked.connect(self._emit);layout.addWidget(run);self.files=QListWidget();self.files.itemDoubleClicked.connect(lambda i:self.open_requested.emit(i.text()));layout.addWidget(self.files)
def _path_row(self,line:QLineEdit,file:bool)->QWidget:
widget=QWidget();row=QHBoxLayout(widget);row.setContentsMargins(0,0,0,0);row.addWidget(line);button=QPushButton("Browse")
button.clicked.connect(lambda:self._browse(line));row.addWidget(button);return widget
def _browse(self,line:QLineEdit)->None:
value=QFileDialog.getExistingDirectory(self,"Select directory",line.text());
if value:line.setText(value)
def _emit(self)->None:self.run_requested.emit(GuiAnalysisRequest(self.input.text(),self.output.text(),self.recursive.isChecked(),self.plots.isChecked(),self.response.currentText(),self.numeric.currentText()))
def set_outputs(self,paths:list[Path])->None:self.files.clear();self.files.addItems([str(p) for p in paths])

@ -0,0 +1,40 @@
"""Validation and override conversion for GUI experiment fields."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from .gui_models import GuiExperimentRequest, GuiValidationResult
def validate_experiment_request(request: GuiExperimentRequest) -> GuiValidationResult:
errors: list[str] = []; warnings: list[str] = []
if request.input_type not in {"camera", "video"}: errors.append("Input type must be camera or video")
if request.camera_index < 0: errors.append("Camera index must be zero or greater")
if request.input_type == "video" and (not request.video_path or not Path(request.video_path).is_file()): errors.append("Select an existing video file")
if request.voice_mode not in {"prompt", "control", "disabled"}: errors.append("Voice mode is invalid")
if not 0.0 <= request.voice_volume <= 1.0: errors.append("Volume must be between 0.0 and 1.0")
if request.face_every is not None and request.face_every < 1: errors.append("Face interval must be at least 1")
if request.max_face_persons is not None and request.max_face_persons < 1: errors.append("Maximum face persons must be at least 1")
if request.signage_yaw_direction not in {None, "positive", "negative"}: errors.append("Signage yaw direction is invalid")
if request.voice_mode == "prompt" and (not request.audio_file or not Path(request.audio_file).is_file()): warnings.append("Prompt mode has no existing audio file")
if not Path(request.config_path).is_file(): errors.append("Configuration file does not exist")
return GuiValidationResult(tuple(errors), tuple(warnings))
def experiment_overrides(request: GuiExperimentRequest) -> dict[str, Any]:
values: dict[str, Any] = {
"input": {"type": request.input_type, "camera_id": request.camera_index,
"video_path": request.video_path if request.input_type == "video" else None},
"display": {"enabled": request.show_opencv_window},
"voice_prompt": {"enabled": request.voice_mode != "disabled", "mode": request.voice_mode},
"audio": {"volume": request.voice_volume},
"unified_logging": {"enabled": request.enable_unified_logging,
"camera_position_note": request.camera_position_note},
}
if request.audio_file: values["audio"]["file_path"] = request.audio_file
if request.face_every is not None: values.setdefault("face", {})["process_every_n_frames"] = request.face_every
if request.max_face_persons is not None: values.setdefault("face", {})["max_persons_per_frame"] = request.max_face_persons
if request.signage_yaw_direction: values.setdefault("turn_detection", {})["signage_yaw_direction"] = request.signage_yaw_direction
return values
class ConfigPanelLogic:
"""Compatibility facade used by tests and a future richer config editor."""
validate = staticmethod(validate_experiment_request)
to_overrides = staticmethod(experiment_overrides)

@ -0,0 +1,9 @@
"""Phase 11 pilot diagnostic controls."""
from pathlib import Path
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QFormLayout,QLineEdit,QListWidget,QPushButton,QVBoxLayout,QWidget
class DiagnosticsPanel(QWidget):
run_requested=Signal(str,str,str);open_requested=Signal(str)
def __init__(self)->None:
super().__init__();layout=QVBoxLayout(self);form=QFormLayout();self.analysis=QLineEdit("data/analysis");self.logs=QLineEdit("data/logs/unified");self.output=QLineEdit("data/analysis");form.addRow("Analysis",self.analysis);form.addRow("Logs",self.logs);form.addRow("Output",self.output);layout.addLayout(form);b=QPushButton("Run Pilot Diagnostics");b.clicked.connect(lambda:self.run_requested.emit(self.analysis.text(),self.logs.text(),self.output.text()));layout.addWidget(b);self.status=QLineEdit();self.status.setReadOnly(True);layout.addWidget(self.status);self.files=QListWidget();self.files.itemDoubleClicked.connect(lambda i:self.open_requested.emit(i.text()));layout.addWidget(self.files)
def set_outputs(self,paths:list[Path])->None:self.files.clear();self.files.addItems([str(p) for p in paths]);self.status.setText("Diagnostics completed")

@ -0,0 +1,67 @@
"""Threaded controller for running the existing Application from a GUI."""
from __future__ import annotations
import threading
import time
from pathlib import Path
from typing import Any, Callable, Protocol
from ..config_loader import load_config
from ..models import RuntimeMetrics
from .config_panel import experiment_overrides, validate_experiment_request
from .gui_models import GuiExperimentRequest
class ApplicationLike(Protocol):
def run(self) -> None: ...
def request_stop(self) -> None: ...
def request_pause(self) -> None: ...
def request_resume(self) -> None: ...
class ExperimentController:
"""Own one worker thread and cooperatively control an Application instance."""
def __init__(self, application_factory: Callable[..., ApplicationLike] | None = None, preview_max_fps: float = 15.0) -> None:
self._factory = application_factory
self._application: ApplicationLike | None = None; self._thread: threading.Thread | None = None
self._frame_callback: Callable[[Any, RuntimeMetrics], None] | None = None
self._status_callback: Callable[[RuntimeMetrics], None] | None = None
self._error_callback: Callable[[str], None] | None = None
self._finished_callback: Callable[[], None] | None = None
self._preview_interval = 1.0 / preview_max_fps
self._last_preview_time = 0.0
def start_experiment(self, request: GuiExperimentRequest) -> None:
if self.is_running(): raise RuntimeError("An experiment is already running")
validation = validate_experiment_request(request)
if not validation.valid: raise ValueError("; ".join(validation.errors))
config = load_config(Path("config/default.yaml"), Path(request.config_path), experiment_overrides(request))
if self._factory is None:
from ..application import Application
factory: Callable[..., ApplicationLike] = Application
else: factory = self._factory
self._application = factory(config, on_frame_rendered=self._deliver_frame, on_status_updated=self._status_callback)
self._thread = threading.Thread(target=self._run_worker, name="experiment-worker", daemon=True)
self._thread.start()
def _run_worker(self) -> None:
try:
assert self._application is not None; self._application.run()
except Exception as exc:
if self._error_callback: self._error_callback(str(exc))
finally:
if self._finished_callback: self._finished_callback()
def _deliver_frame(self, frame: Any, metrics: RuntimeMetrics) -> None:
now = time.monotonic()
if self._frame_callback is not None and now - self._last_preview_time >= self._preview_interval:
self._last_preview_time = now
self._frame_callback(frame, metrics)
def stop_experiment(self) -> None:
if self._application is not None: self._application.request_stop()
def pause_experiment(self) -> None:
if self._application is not None: self._application.request_pause()
def resume_experiment(self) -> None:
if self._application is not None: self._application.request_resume()
def is_running(self) -> bool: return self._thread is not None and self._thread.is_alive()
def set_frame_callback(self, callback: Callable[[Any, RuntimeMetrics], None]) -> None: self._frame_callback = callback
def set_status_callback(self, callback: Callable[[RuntimeMetrics], None]) -> None: self._status_callback = callback
def set_error_callback(self, callback: Callable[[str], None]) -> None: self._error_callback = callback
def set_finished_callback(self, callback: Callable[[], None]) -> None: self._finished_callback = callback

@ -0,0 +1,11 @@
"""PySide6 application bootstrap with lazy dependency loading."""
from __future__ import annotations
import sys
from pathlib import Path
def run_gui(config_path:Path=Path("config/experiment.yaml"))->int:
try:from PySide6.QtWidgets import QApplication
except ImportError as exc:raise RuntimeError("Phase 9 GUI requires PySide6. Run: pip install -r requirements.txt") from exc
from ..config_loader import load_config
from .main_window import MainWindow
config=load_config(Path("config/default.yaml"),config_path);app=QApplication.instance() or QApplication(sys.argv);window=MainWindow(config.gui);window.show();return app.exec()

@ -0,0 +1,39 @@
"""GUI request models kept independent from PySide6 for unit testing."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class GuiExperimentRequest:
config_path: str = "config/experiment.yaml"
input_type: str = "camera"
camera_index: int = 0
video_path: str | None = None
voice_mode: str = "disabled"
audio_file: str | None = None
voice_volume: float = 0.8
camera_position_note: str = ""
face_every: int | None = None
max_face_persons: int | None = None
signage_yaw_direction: str | None = None
enable_unified_logging: bool = True
enable_analysis_after_run: bool = False
analysis_output_directory: str = "data/analysis"
show_opencv_window: bool = False
@dataclass(frozen=True, slots=True)
class GuiAnalysisRequest:
input_directory: str = "data/logs/unified"
output_directory: str = "data/analysis"
recursive: bool = True
generate_plots: bool = True
response_test: str = "auto"
numeric_test: str = "mannwhitney"
@dataclass(frozen=True, slots=True)
class GuiValidationResult:
errors: tuple[str, ...] = ()
warnings: tuple[str, ...] = ()
@property
def valid(self) -> bool:
return not self.errors

@ -0,0 +1,5 @@
"""GUI log and message view."""
from PySide6.QtWidgets import QPlainTextEdit
class LogPanel(QPlainTextEdit):
def __init__(self)->None:super().__init__();self.setReadOnly(True);self.setPlaceholderText("Runtime messages")
def add_message(self,message:str)->None:self.appendPlainText(message)

@ -0,0 +1,79 @@
"""Phase 9 main desktop window."""
from __future__ import annotations
import os,sys
from pathlib import Path
from PySide6.QtCore import QObject,Signal,Slot
from PySide6.QtWidgets import (QCheckBox,QComboBox,QDoubleSpinBox,QFileDialog,QFormLayout,QHBoxLayout,QLineEdit,QMainWindow,QMessageBox,QPushButton,QSpinBox,QTabWidget,QVBoxLayout,QWidget)
from .analysis_controller import AnalysisController
from .analysis_panel import AnalysisPanel
from .experiment_controller import ExperimentController
from .gui_models import GuiExperimentRequest
from .log_panel import LogPanel
from .operations_controller import OperationsController
from .diagnostics_panel import DiagnosticsPanel
from .quality_panel import QualityPanel
from .status_panel import StatusPanel
from .video_widget import VideoWidget
def open_path_in_file_manager(path:Path)->bool:
target=path if path.is_dir() else path.parent
try:
if sys.platform.startswith("win"):os.startfile(str(target)) # type: ignore[attr-defined]
elif sys.platform=="darwin":__import__("subprocess").Popen(["open",str(target)])
else:__import__("subprocess").Popen(["xdg-open",str(target)])
return True
except OSError:return False
class _Bridge(QObject):
frame=Signal(object,object);status=Signal(object);error=Signal(str);finished=Signal();analysis_finished=Signal(object);operation_finished=Signal(object)
class MainWindow(QMainWindow):
def __init__(self,gui_settings)->None:
super().__init__();self.setWindowTitle("Pedestrian Signage Research System - Phase 10");self.resize(1200,800);self.bridge=_Bridge();self.experiment=ExperimentController(preview_max_fps=gui_settings.preview_max_fps);self.analysis=AnalysisController();self.operations=OperationsController();self._paused=False;self._last_request=None
self.experiment.set_frame_callback(lambda f,m:self.bridge.frame.emit(f,m));self.experiment.set_status_callback(lambda m:self.bridge.status.emit(m));self.experiment.set_error_callback(self.bridge.error.emit);self.experiment.set_finished_callback(self.bridge.finished.emit)
self.analysis.set_finished_callback(self.bridge.analysis_finished.emit);self.analysis.set_error_callback(self.bridge.error.emit)
self.operations.set_finished_callback(self.bridge.operation_finished.emit);self.operations.set_error_callback(self.bridge.error.emit)
tabs=QTabWidget();tabs.addTab(self._experiment_tab(gui_settings),"Experiment");self.analysis_panel=AnalysisPanel(gui_settings.default_analysis_input,gui_settings.default_analysis_output);tabs.addTab(self.analysis_panel,"Analysis");self.quality_panel=QualityPanel();tabs.addTab(self.quality_panel,"Quality & Reports");self.diagnostics_panel=DiagnosticsPanel();tabs.addTab(self.diagnostics_panel,"Diagnostics");self.setCentralWidget(tabs)
self.bridge.frame.connect(lambda f,m:self.preview.set_frame(f));self.bridge.status.connect(self.status.update_metrics);self.bridge.error.connect(self._error);self.bridge.finished.connect(self._finished);self.bridge.analysis_finished.connect(self._analysis_finished);self.bridge.operation_finished.connect(self._operation_finished);self.analysis_panel.run_requested.connect(self._run_analysis);self.analysis_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)));self.quality_panel.operation_requested.connect(self._run_operation);self.quality_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)));self.diagnostics_panel.run_requested.connect(lambda a,l,o:self._run_operation("diagnostics",l,a,o));self.diagnostics_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)))
def _experiment_tab(self,s)->QWidget:
root=QWidget();layout=QHBoxLayout(root);left=QWidget();form=QFormLayout(left);self.config=QLineEdit(s.default_config_path);form.addRow("Config",self._file_row(self.config,"yaml"));self.input_type=QComboBox();self.input_type.addItems(["camera","video"]);form.addRow("Input",self.input_type);self.camera=QSpinBox();self.camera.setMinimum(0);form.addRow("Camera index",self.camera);self.video=QLineEdit();form.addRow("Video",self._file_row(self.video,"video"));self.voice=QComboBox();self.voice.addItems(["prompt","control","disabled"]);form.addRow("Voice mode",self.voice);self.audio=QLineEdit();form.addRow("Audio",self._file_row(self.audio,"audio"));self.volume=QDoubleSpinBox();self.volume.setRange(0,1);self.volume.setSingleStep(.1);self.volume.setValue(.8);form.addRow("Volume",self.volume);self.note=QLineEdit();form.addRow("Camera position note",self.note);self.yaw=QComboBox();self.yaw.addItems(["positive","negative"]);form.addRow("Signage yaw",self.yaw);self.face_every=QSpinBox();self.face_every.setMinimum(1);self.face_every.setValue(1);form.addRow("Face every",self.face_every);self.max_faces=QSpinBox();self.max_faces.setMinimum(1);self.max_faces.setValue(3);form.addRow("Max face persons",self.max_faces);self.unified=QCheckBox();self.unified.setChecked(True);form.addRow("Unified logging",self.unified)
buttons=QWidget();row=QHBoxLayout(buttons);self.start=QPushButton("Start");self.stop=QPushButton("Stop");self.pause=QPushButton("Pause");row.addWidget(self.start);row.addWidget(self.stop);row.addWidget(self.pause);form.addRow(buttons);self.start.clicked.connect(self._start);self.stop.clicked.connect(self.experiment.stop_experiment);self.pause.clicked.connect(self._pause)
self.auto_analysis=QCheckBox();self.auto_analysis.setChecked(s.auto_run_analysis_after_experiment);form.addRow("Analyze after run",self.auto_analysis)
right=QWidget();r=QVBoxLayout(right);self.preview=VideoWidget();self.status=StatusPanel();self.logs=LogPanel();r.addWidget(self.preview,3);r.addWidget(self.status);r.addWidget(self.logs,1);layout.addWidget(left);layout.addWidget(right,1);return root
def _file_row(self,line:QLineEdit,kind:str)->QWidget:
w=QWidget();row=QHBoxLayout(w);row.setContentsMargins(0,0,0,0);row.addWidget(line);b=QPushButton("Browse");b.clicked.connect(lambda:self._browse_file(line,kind));row.addWidget(b);return w
def _browse_file(self,line:QLineEdit,kind:str)->None:
filters={"yaml":"YAML (*.yaml *.yml)","video":"Video (*.mp4 *.avi *.mov)","audio":"Audio (*.wav *.mp3)"};path,_=QFileDialog.getOpenFileName(self,"Select file",line.text(),filters[kind]);
if path:line.setText(path)
def _request(self)->GuiExperimentRequest:return GuiExperimentRequest(self.config.text(),self.input_type.currentText(),self.camera.value(),self.video.text() or None,self.voice.currentText(),self.audio.text() or None,self.volume.value(),self.note.text(),self.face_every.value(),self.max_faces.value(),self.yaw.currentText(),self.unified.isChecked(),self.auto_analysis.isChecked(),self.analysis_panel.output.text() if hasattr(self,"analysis_panel") else "data/analysis",False)
@Slot()
def _start(self)->None:
try:self._last_request=self._request();self.experiment.start_experiment(self._last_request);self.logs.add_message("Experiment started");self.start.setEnabled(False)
except Exception as exc:self._error(str(exc))
@Slot()
def _pause(self)->None:
self._paused=not self._paused
if self._paused:self.experiment.pause_experiment();self.pause.setText("Resume")
else:self.experiment.resume_experiment();self.pause.setText("Pause")
@Slot(str)
def _error(self,message:str)->None:self.logs.add_message("ERROR: "+message);QMessageBox.critical(self,"Error",message)
@Slot()
def _finished(self)->None:
self.start.setEnabled(True);self._paused=False;self.pause.setText("Pause");self.logs.add_message("Experiment finished")
if self._last_request is not None and self._last_request.enable_analysis_after_run:
self.analysis_panel._emit()
@Slot(object)
def _run_analysis(self,request)->None:
try:self.analysis.run_analysis(request,self.config.text());self.logs.add_message("Analysis started")
except Exception as exc:self._error(str(exc))
@Slot(object)
def _analysis_finished(self,paths)->None:self.analysis_panel.set_outputs(paths);self.logs.add_message(f"Analysis finished: {len(paths)} files")
@Slot(str,str,str,str)
def _run_operation(self,operation,logs,analysis,output)->None:
try:self.operations.run(operation,Path(self.config.text()),Path(logs),Path(analysis),Path(output));self.logs.add_message(f"Phase 10 operation started: {operation}")
except Exception as exc:self._error(str(exc))
@Slot(object)
def _operation_finished(self,paths)->None:self.quality_panel.set_outputs(paths);self.diagnostics_panel.set_outputs(paths);self.logs.add_message(f"Offline operation outputs: {len(paths)} files")
def closeEvent(self,event)->None: # type: ignore[no-untyped-def]
if self.experiment.is_running():self.experiment.stop_experiment()
event.accept()

@ -0,0 +1,26 @@
"""Background Phase 10 operation controller."""
from __future__ import annotations
import threading
from pathlib import Path
from typing import Callable
from ..config_loader import load_config
from ..phase10_cli import generate_protocol,run_pilot_report,run_presentation,run_quality
from ..phase11_cli import run_diagnostics
class OperationsController:
def __init__(self)->None:self._thread:threading.Thread|None=None;self._finished:Callable[[list[Path]],None]|None=None;self._error:Callable[[str],None]|None=None
def run(self,operation:str,config_path:Path,logs:Path,analysis:Path,output:Path)->None:
if self._thread and self._thread.is_alive():raise RuntimeError("An operation is already running")
self._thread=threading.Thread(target=self._worker,args=(operation,config_path,logs,analysis,output),daemon=True,name="phase10-worker");self._thread.start()
def _worker(self,operation:str,config_path:Path,logs:Path,analysis:Path,output:Path)->None:
try:
c=load_config("config/default.yaml",config_path)
if operation=="diagnostics":paths=run_diagnostics(c,analysis,logs,output)
elif operation=="quality":paths=run_quality(c,logs,analysis,output)
elif operation=="pilot":paths=[run_pilot_report(c,analysis,output/"pilot_report.md",logs)]
elif operation=="presentation":paths=[run_presentation(c,analysis,output/"midterm_summary.md",logs)]
else:paths=generate_protocol(c,output/"experiment_protocol.md",output/"experiment_protocol.yaml")
if self._finished:self._finished(paths)
except Exception as exc:
if self._error:self._error(str(exc))
def set_finished_callback(self,callback:Callable[[list[Path]],None])->None:self._finished=callback
def set_error_callback(self,callback:Callable[[str],None])->None:self._error=callback

@ -0,0 +1,4 @@
"""Small read-only pointer to Phase 10 protocol documentation."""
from PySide6.QtWidgets import QLabel,QVBoxLayout,QWidget
class ProtocolPanel(QWidget):
def __init__(self)->None:super().__init__();layout=QVBoxLayout(self);layout.addWidget(QLabel("Phase 10 protocol and checklist generation is available in the Quality tab."))

@ -0,0 +1,12 @@
"""Phase 10 quality and reporting controls."""
from pathlib import Path
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QFormLayout,QHBoxLayout,QLineEdit,QListWidget,QPushButton,QVBoxLayout,QWidget
class QualityPanel(QWidget):
operation_requested=Signal(str,str,str,str);open_requested=Signal(str)
def __init__(self)->None:
super().__init__();layout=QVBoxLayout(self);form=QFormLayout();self.logs=QLineEdit("data/logs/unified");self.analysis=QLineEdit("data/analysis");self.output=QLineEdit("data/analysis");form.addRow("Unified logs",self.logs);form.addRow("Analysis",self.analysis);form.addRow("Output",self.output);layout.addLayout(form);row=QHBoxLayout()
for text,op in (("Run quality check","quality"),("Generate pilot report","pilot"),("Generate midterm summary","presentation"),("Generate protocol","protocol")):
button=QPushButton(text);button.clicked.connect(lambda checked=False,value=op:self.operation_requested.emit(value,self.logs.text(),self.analysis.text(),self.output.text()));row.addWidget(button)
layout.addLayout(row);self.files=QListWidget();self.files.itemDoubleClicked.connect(lambda i:self.open_requested.emit(i.text()));layout.addWidget(self.files)
def set_outputs(self,paths:list[Path])->None:self.files.clear();self.files.addItems([str(p) for p in paths])

@ -0,0 +1,13 @@
"""Compact runtime metric display."""
from __future__ import annotations
from PySide6.QtWidgets import QFormLayout,QLabel,QWidget
from ..models import RuntimeMetrics
class StatusPanel(QWidget):
def __init__(self)->None:
super().__init__();layout=QFormLayout(self);self._labels={}
for key,label in (("fps","FPS"),("frame","Frame"),("active","Active tracks"),("total","Total tracks"),("crossings","Crossings"),("prompt","Prompts"),("pseudo","Pseudo prompts"),("response","Responses"),("turn","Turn confirmed"),("voice","Voice mode"),("logs","Log directory")):
value=QLabel("-");self._labels[key]=value;layout.addRow(label,value)
def update_metrics(self,m:RuntimeMetrics)->None:
values={"fps":f"{m.fps:.1f}" if m.fps is not None else "-","frame":str(m.frame_number),"active":str(m.active_tracks),"total":str(m.total_tracks),"crossings":str(m.trigger_crossings),"prompt":str(m.prompt_played_count),"pseudo":str(m.pseudo_prompt_count),"response":str(m.response_detected_count),"turn":str(m.turn_confirmed_count),"voice":m.voice_mode or "-","logs":m.current_log_directory or "-"}
for key,value in values.items():self._labels[key].setText(value)

@ -0,0 +1,25 @@
"""OpenCV preview widget with aspect-ratio preserving Qt rendering."""
from __future__ import annotations
import numpy as np
try:
from PySide6.QtCore import Qt
from PySide6.QtGui import QImage, QPixmap
from PySide6.QtWidgets import QLabel
except ImportError as exc: # pragma: no cover - exercised only without optional GUI dependency
raise ImportError("Phase 9 GUI requires PySide6. Install it with: pip install -r requirements.txt") from exc
def bgr_to_rgb(frame: np.ndarray) -> np.ndarray:
if frame.ndim != 3 or frame.shape[2] != 3: raise ValueError("Expected a BGR image with three channels")
return np.ascontiguousarray(frame[:, :, ::-1])
class VideoWidget(QLabel):
def __init__(self) -> None:
super().__init__("Preview starts after Start is pressed")
self.setAlignment(Qt.AlignmentFlag.AlignCenter); self.setMinimumSize(640,360); self._pixmap:QPixmap|None=None
def set_frame(self, frame: np.ndarray) -> None:
rgb=bgr_to_rgb(frame);h,w,c=rgb.shape
image=QImage(rgb.data,w,h,c*w,QImage.Format.Format_RGB888).copy();self._pixmap=QPixmap.fromImage(image);self._refresh()
def resizeEvent(self,event)->None: # type: ignore[no-untyped-def]
super().resizeEvent(event);self._refresh()
def _refresh(self)->None:
if self._pixmap is not None:self.setPixmap(self._pixmap.scaled(self.size(),Qt.AspectRatioMode.KeepAspectRatio,Qt.TransformationMode.SmoothTransformation))

@ -0,0 +1,100 @@
"""OpenCV solvePnP head-pose estimation with no MediaPipe dependency."""
from __future__ import annotations
import math
from .config_loader import HeadPoseSettings
from .models import FaceLandmark, HeadPoseEstimation
class HeadPoseEstimator:
"""Estimate approximate pitch, yaw, and roll from six facial landmarks."""
LANDMARK_INDICES = (1, 152, 33, 263, 61, 291)
# Arbitrary units: only relative shape matters. Y is positive downward to
# match OpenCV image coordinates; using a Y-up model can make a frontal
# face solve as an approximately 180-degree rotation.
MODEL_POINTS = (
(0.0, 0.0, 0.0), (0.0, 63.6, -12.5), (-43.3, -32.7, -26.0),
(43.3, -32.7, -26.0), (-28.9, 28.9, -24.1), (28.9, 28.9, -24.1),
)
def __init__(self, config: HeadPoseSettings) -> None:
self.config = config
def build_image_points(self, landmarks: list[FaceLandmark]) -> list[tuple[float, float]] | None:
by_index = {landmark.index: landmark for landmark in landmarks}
if any(index not in by_index for index in self.LANDMARK_INDICES):
return None
return [(by_index[index].x, by_index[index].y) for index in self.LANDMARK_INDICES]
@staticmethod
def build_camera_matrix(frame_width: int, frame_height: int) -> list[list[float]]:
focal = float(frame_width)
return [[focal, 0.0, frame_width / 2.0], [0.0, focal, frame_height / 2.0], [0.0, 0.0, 1.0]]
@staticmethod
def rotation_matrix_to_euler(rotation: list[list[float]]) -> tuple[float, float, float]:
"""Return X/Y/Z Euler rotations as pitch/yaw/roll in degrees."""
sy = math.sqrt(rotation[0][0] ** 2 + rotation[1][0] ** 2)
singular = sy < 1e-6
if not singular:
pitch = math.atan2(rotation[2][1], rotation[2][2])
yaw = math.atan2(-rotation[2][0], sy)
roll = math.atan2(rotation[1][0], rotation[0][0])
else:
pitch = math.atan2(-rotation[1][2], rotation[1][1])
yaw = math.atan2(-rotation[2][0], sy)
roll = 0.0
return tuple(float(math.degrees(value)) for value in (pitch, yaw, roll))
def estimate(self, landmarks: list[FaceLandmark], frame_width: int, frame_height: int) -> HeadPoseEstimation:
if not self.config.enabled:
return HeadPoseEstimation(False, failure_reason="head_pose_disabled")
image_points = self.build_image_points(landmarks)
if image_points is None:
return HeadPoseEstimation(False, failure_reason="insufficient_landmarks")
try:
import cv2
import numpy as np
model = np.asarray(self.MODEL_POINTS, dtype=np.float64)
image = np.asarray(image_points, dtype=np.float64)
camera = np.asarray(self.build_camera_matrix(frame_width, frame_height), dtype=np.float64)
distortion = np.zeros((4, 1), dtype=np.float64)
success, rotation_vector, translation_vector = cv2.solvePnP(
model, image, camera, distortion, flags=cv2.SOLVEPNP_ITERATIVE,
)
if not success:
return HeadPoseEstimation(False, failure_reason="solvepnp_failed")
rotation_matrix, _ = cv2.Rodrigues(rotation_vector)
angles = self.rotation_matrix_to_euler(rotation_matrix.tolist())
projected, _ = cv2.projectPoints(model, rotation_vector, translation_vector, camera, distortion)
error = float(np.sqrt(np.mean(np.sum((projected.reshape(-1, 2) - image) ** 2, axis=1))))
except Exception:
return HeadPoseEstimation(False, failure_reason="solvepnp_failed")
return self.result_from_angles(angles, error)
def result_from_angles(
self, angles: tuple[float, float, float], reprojection_error: float | None = None,
) -> HeadPoseEstimation:
"""Validate calculated angles and construct a normalized result."""
normalized = self.normalize_angles(angles)
if any(not math.isfinite(value) or abs(value) > self.config.max_abs_angle for value in normalized):
return HeadPoseEstimation(False, *normalized, reprojection_error=reprojection_error,
failure_reason="angle_out_of_range")
return HeadPoseEstimation(True, *normalized, reprojection_error=reprojection_error)
@staticmethod
def normalize_angles(angles: tuple[float, float, float]) -> tuple[float, float, float]:
"""Canonicalize roll for upright subjects while preserving pitch and yaw.
A planar face can be returned by solvePnP with its in-plane axes reversed,
producing roll near +/-180 degrees for an upright frontal face. Roll is an
axial orientation here, so the equivalent value in [-90, 90) is used.
"""
pitch, yaw, roll = angles
if math.isfinite(roll):
roll = (roll + 90.0) % 180.0 - 90.0
return float(pitch), float(yaw), float(roll)

@ -0,0 +1,14 @@
"""Rule-based, transparent recommendations for pilot experiment improvement."""
from __future__ import annotations
from .models import ConditionBalanceDiagnostic,ImprovementRecommendation
class ImprovementRecommender:
def recommend(self,valid_rate:float|None,not_eval:float|None,no_pose:float|None,balance:ConditionBalanceDiagnostic,prompt_valid:int,control_valid:int,camera_missing:bool=False)->list[ImprovementRecommendation]:
values=[]
def add(cat,pri,title,reason,action,metrics,effect):values.append(ImprovementRecommendation(f"rec_{len(values)+1:03d}",cat,pri,title,reason,action,metrics,effect))
if no_pose is not None and no_pose>.4:add("camera_position","high","顔が映るカメラ位置へ調整","no_pose_estimated率が高い","通行人の顔が正面〜斜め前から映る位置へカメラを移動する",{"no_pose_estimated_rate":no_pose},"Pose推定可能率の改善")
if not_eval is not None and not_eval>.4:add("face_detection","high","顔処理条件を改善","not_evaluable率が高い","カメラ位置、照明、face処理間隔、最大処理人数を確認する",{"not_evaluable_rate":not_eval},"評価可能率の改善")
if valid_rate is not None and valid_rate<.5:add("protocol","high","有効解析率を改善","valid_voice_analysis_rateが低い","追跡・顔向き・response windowの除外理由を確認する",{"valid_voice_analysis_rate":valid_rate},"有効解析人数の増加")
if not balance.balanced:add("condition_balance","high","Prompt/Control人数を揃える","条件間の有効人数差が大きい","同じ場所・時間帯で不足条件のセッションを追加する",{"ratio":balance.valid_count_ratio},"比較可能性の改善")
if min(prompt_valid,control_valid)<20:add("sample_size","medium","有効解析人数を追加","目標有効人数に未達","各条件20人以上を目安に追加収集する",{"prompt":prompt_valid,"control":control_valid},"推定の安定化")
if camera_missing:add("camera_position","high","カメラ位置を記録","camera_position_noteが欠損","位置、高さ、角度、サイネージとの関係を記録する",{},"再現性の確保")
return values

@ -0,0 +1,35 @@
"""Non-fatal consistency checks for Phase 7 analysis records."""
from __future__ import annotations
from .models import LogConsistencyIssue, PersonAnalysisRecord
class LogConsistencyChecker:
"""Report suspicious analysis combinations without stopping a run."""
def check(self, records: list[PersonAnalysisRecord]) -> list[LogConsistencyIssue]:
issues: list[LogConsistencyIssue] = []
seen: set[int] = set()
for item in records:
if item.track_id in seen: self._add(issues, "duplicate_summary", item, "Duplicate track summary")
seen.add(item.track_id)
if item.response_detected and not (item.prompt_played or item.pseudo_prompt):
self._add(issues, "missing_prompt_for_response", item, "Response has no prompt or pseudo prompt")
if item.reaction_time_sec is not None and item.reaction_time_sec < 0:
self._add(issues, "invalid_reaction_time", item, "Reaction time is negative")
if item.response_detected and not item.turn_confirmed:
self._add(issues, "response_without_turn", item, "Response has no confirmed turn")
if item.prompt_played and not item.response_window_started:
self._add(issues, "prompt_without_response_window", item, "Prompt has no response window")
if item.pseudo_prompt and item.voice_mode != "control":
self._add(issues, "inconsistent_voice_mode", item, "Pseudo prompt is not control mode")
if item.valid_for_voice_analysis and item.prompt_condition == "unknown":
self._add(issues, "inconsistent_voice_mode", item, "Valid record has unknown condition")
if item.excluded and item.exclusion_reason == "none":
self._add(issues, "unknown", item, "Excluded record has no reason")
return issues
@staticmethod
def _add(items: list[LogConsistencyIssue], kind: str, record: PersonAnalysisRecord, message: str) -> None:
items.append(LogConsistencyIssue(kind, "warning", record.track_id, None, message, {}))

@ -0,0 +1,877 @@
"""Domain models independent of OpenCV and Ultralytics."""
from collections import deque
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
@dataclass(frozen=True, slots=True)
class BoundingBox:
x1: int
y1: int
x2: int
y2: int
def __post_init__(self) -> None:
if self.x1 >= self.x2 or self.y1 >= self.y2:
raise ValueError("BoundingBox requires x1 < x2 and y1 < y2")
@property
def width(self) -> int:
return self.x2 - self.x1
@property
def height(self) -> int:
return self.y2 - self.y1
@property
def center_x(self) -> int:
return (self.x1 + self.x2) // 2
@property
def center_y(self) -> int:
return (self.y1 + self.y2) // 2
@property
def foot_x(self) -> int:
return self.center_x
@property
def foot_y(self) -> int:
return self.y2
@property
def area(self) -> int:
return self.width * self.height
@dataclass(slots=True)
class Detection:
class_id: int
class_name: str
confidence: float
bbox: BoundingBox
inside_evaluation_region: bool = False
@dataclass(frozen=True, slots=True)
class FrameInfo:
frame_number: int
timestamp_iso: str
elapsed_time_sec: float
width: int
height: int
measured_fps: float
@dataclass(frozen=True, slots=True)
class PixelRegion:
x1: int
y1: int
x2: int
y2: int
def __post_init__(self) -> None:
if self.x1 >= self.x2 or self.y1 >= self.y2:
raise ValueError("PixelRegion requires x1 < x2 and y1 < y2")
def contains(self, x: int, y: int) -> bool:
return self.x1 <= x <= self.x2 and self.y1 <= y <= self.y2
@dataclass(frozen=True, slots=True)
class PixelTriggerLine:
"""Trigger line in frame pixel coordinates."""
orientation: str
position: int
def __post_init__(self) -> None:
if self.orientation not in {"vertical", "horizontal"}:
raise ValueError("orientation must be vertical or horizontal")
if self.position < 0:
raise ValueError("position must be non-negative")
@dataclass(frozen=True, slots=True)
class TrackedPerson:
"""Library-independent snapshot of one tracker output."""
track_id: int
bbox: BoundingBox
confidence: float
class_id: int
class_name: str
center_x: float
center_y: float
foot_x: float
foot_y: float
inside_evaluation_region: bool
is_confirmed: bool
track_age_frames: int
missed_frames: int
def __post_init__(self) -> None:
if self.track_id < 0 or self.track_age_frames < 0 or self.missed_frames < 0:
raise ValueError("track identifiers and frame counters must be non-negative")
if not 0.0 <= self.confidence <= 1.0:
raise ValueError("confidence must be in [0, 1]")
@dataclass(frozen=True, slots=True)
class TrackPoint:
timestamp_monotonic: float
timestamp_iso: str
frame_number: int
center_x: float
center_y: float
foot_x: float
foot_y: float
bbox: BoundingBox
def __post_init__(self) -> None:
if self.timestamp_monotonic < 0 or self.frame_number < 0:
raise ValueError("track point time and frame must be non-negative")
@dataclass(slots=True)
class TrackState:
"""Research state accumulated for one temporary tracking ID."""
track_id: int
first_seen_time: str
last_seen_time: str
first_seen_frame: int
last_seen_frame: int
entered_evaluation_region: bool = False
entry_time: str | None = None
entry_frame: int | None = None
entry_monotonic: float | None = None
exited_evaluation_region: bool = False
exit_time: str | None = None
exit_frame: int | None = None
exit_monotonic: float | None = None
trigger_crossed: bool = False
trigger_crossing_time: str | None = None
trigger_crossing_frame: int | None = None
trigger_crossing_direction: str | None = None
movement_direction: str = "unknown"
total_visible_frames: int = 0
total_missed_frames: int = 0
max_missed_frames: int = 0
trajectory: deque[TrackPoint] = field(default_factory=deque)
finalized: bool = False
is_confirmed: bool = False
first_seen_monotonic: float = 0.0
last_seen_monotonic: float = 0.0
current_inside_region: bool = False
pending_entry_frames: int = 0
pending_exit_frames: int = 0
valid_for_counting: bool = False
invalid_reason: str = "not_finalized"
@property
def duration_sec(self) -> float:
return max(0.0, self.last_seen_monotonic - self.first_seen_monotonic)
@property
def dwell_time_sec(self) -> float:
if self.entry_monotonic is None:
return 0.0
end = self.exit_monotonic if self.exit_monotonic is not None else self.last_seen_monotonic
return max(0.0, end - self.entry_monotonic)
@dataclass(frozen=True, slots=True)
class TrackEvent:
event_type: str
track_id: int
frame_number: int
timestamp_iso: str
elapsed_time_sec: float
x: float
y: float
direction: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
valid = {"track_created", "region_entered", "trigger_crossed", "region_exited", "track_lost", "track_finalized"}
if self.event_type not in valid:
raise ValueError(f"Unsupported track event: {self.event_type}")
if self.track_id < 0 or self.frame_number < 0:
raise ValueError("event track ID and frame must be non-negative")
@dataclass(frozen=True, slots=True)
class FaceROI:
"""Frame-coordinate face candidate region derived from a person box."""
track_id: int
x1: int
y1: int
x2: int
y2: int
source_bbox: BoundingBox
confidence: float
def __post_init__(self) -> None:
if self.track_id < 0 or self.x1 >= self.x2 or self.y1 >= self.y2:
raise ValueError("FaceROI requires a non-negative track ID and valid coordinates")
if not 0.0 <= self.confidence <= 1.0:
raise ValueError("FaceROI confidence must be in [0, 1]")
@property
def width(self) -> int:
return self.x2 - self.x1
@property
def height(self) -> int:
return self.y2 - self.y1
@property
def center_x(self) -> float:
return (self.x1 + self.x2) / 2.0
@property
def center_y(self) -> float:
return (self.y1 + self.y2) / 2.0
@dataclass(frozen=True, slots=True)
class FaceLandmark:
index: int
x: float
y: float
z: float
visibility: float | None = None
def __post_init__(self) -> None:
if self.index < 0:
raise ValueError("landmark index must be non-negative")
@dataclass(frozen=True, slots=True)
class HeadPoseEstimation:
"""Geometry-only result returned by HeadPoseEstimator."""
success: bool
pitch: float | None = None
yaw: float | None = None
roll: float | None = None
reprojection_error: float | None = None
failure_reason: str = ""
@dataclass(frozen=True, slots=True)
class HeadPoseResult:
"""Head pose linked to a temporary tracking ID and frame."""
track_id: int
frame_number: int
timestamp_iso: str
elapsed_time_sec: float
face_detected: bool
pose_estimated: bool
pitch: float | None
yaw: float | None
roll: float | None
landmarks_count: int
roi: FaceROI | None
confidence: float | None
failure_reason: str = ""
landmarks: tuple[FaceLandmark, ...] = field(default_factory=tuple, repr=False)
def __post_init__(self) -> None:
if self.track_id < 0 or self.frame_number < 0 or self.landmarks_count < 0:
raise ValueError("head pose identifiers and counts must be non-negative")
if self.pose_estimated and any(value is None for value in (self.pitch, self.yaw, self.roll)):
raise ValueError("successful pose estimation requires pitch, yaw, and roll")
if self.confidence is not None and not 0.0 <= self.confidence <= 1.0:
raise ValueError("face confidence must be in [0, 1]")
@dataclass(slots=True)
class FaceTrackState:
"""Per-track face statistics and Phase 4-ready recent history."""
track_id: int
total_frames: int = 0
face_detected_frames: int = 0
pose_estimated_frames: int = 0
last_pitch: float | None = None
last_yaw: float | None = None
last_roll: float | None = None
min_yaw: float | None = None
max_yaw: float | None = None
yaw_sum: float = 0.0
last_result: HeadPoseResult | None = None
history: deque[HeadPoseResult] = field(default_factory=deque, repr=False)
finalized: bool = False
@property
def mean_yaw(self) -> float | None:
return self.yaw_sum / self.pose_estimated_frames if self.pose_estimated_frames else None
@property
def face_detection_rate(self) -> float:
return self.face_detected_frames / self.total_frames if self.total_frames else 0.0
@property
def pose_estimation_rate(self) -> float:
return self.pose_estimated_frames / self.total_frames if self.total_frames else 0.0
class TurnLevel(str, Enum):
NONE = "none"
FACE_APPEARED = "face_appeared"
SUBTLE = "subtle"
WEAK = "weak"
MEDIUM = "medium"
STRONG = "strong"
NOT_EVALUABLE = "not_evaluable"
@dataclass(frozen=True, slots=True)
class TurnFrameResult:
track_id: int
frame_number: int
timestamp_iso: str
elapsed_time_sec: float
face_detected: bool
pose_estimated: bool
baseline_available: bool
baseline_yaw: float | None
current_yaw: float | None
yaw_delta_raw: float | None
yaw_delta_toward_signage: float | None
signage_direction: str
turn_candidate: bool
turn_confirmed: bool
turn_level: str
continuous_duration_sec: float
failure_reason: str = ""
def __post_init__(self) -> None:
if self.track_id < 0 or self.frame_number < 0 or self.continuous_duration_sec < 0:
raise ValueError("turn frame identifiers and duration must be non-negative")
if self.signage_direction not in {"positive", "negative"}:
raise ValueError("invalid signage direction")
if self.turn_level not in {level.value for level in TurnLevel}:
raise ValueError("invalid turn level")
@dataclass(slots=True)
class TurnTrackState:
track_id: int
total_frames: int = 0
pose_frames: int = 0
face_detected_frames: int = 0
baseline_yaw: float | None = None
baseline_frame: int | None = None
baseline_time: str | None = None
baseline_sample_count: int = 0
baseline_acquired: bool = False
first_face_detected_frame: int | None = None
first_face_detected_time: str | None = None
face_appeared_after_missing: bool = False
max_yaw_delta_toward_signage: float | None = None
max_yaw_delta_frame: int | None = None
max_yaw_delta_time: str | None = None
turn_started: bool = False
turn_start_frame: int | None = None
turn_start_time: str | None = None
turn_confirmed: bool = False
turn_confirmed_frame: int | None = None
turn_confirmed_time: str | None = None
turn_level: str = TurnLevel.NOT_EVALUABLE.value
turn_duration_sec: float = 0.0
evaluable: bool = False
valid_for_turn_analysis: bool = False
exclusion_reason: str = "not_finalized"
finalized: bool = False
yaw_history: deque[tuple[float, float]] = field(default_factory=deque, repr=False)
baseline_samples: deque[tuple[float, float, int, str]] = field(default_factory=deque, repr=False)
candidate_start_elapsed: float | None = None
last_pose_elapsed: float | None = None
missing_started_elapsed: float | None = None
strongest_level: str = TurnLevel.NONE.value
emitted_events: set[str] = field(default_factory=set, repr=False)
@property
def face_detection_rate(self) -> float:
return self.face_detected_frames / self.total_frames if self.total_frames else 0.0
@property
def pose_estimation_rate(self) -> float:
return self.pose_frames / self.total_frames if self.total_frames else 0.0
@dataclass(frozen=True, slots=True)
class TurnEvent:
event_type: str
track_id: int
frame_number: int
timestamp_iso: str
elapsed_time_sec: float
yaw: float | None
yaw_delta_toward_signage: float | None
turn_level: str
metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
valid = {"baseline_acquired", "face_appeared", "turn_candidate_started", "turn_confirmed",
"turn_level_changed", "turn_lost", "turn_track_finalized"}
if self.event_type not in valid or self.track_id < 0 or self.frame_number < 0:
raise ValueError("invalid turn event")
class VoicePromptMode(str, Enum):
DISABLED = "disabled"
PROMPT = "prompt"
CONTROL = "control"
@dataclass(frozen=True, slots=True)
class VoicePromptDecision:
track_id: int
frame_number: int
timestamp_iso: str
elapsed_time_sec: float
mode: str
eligible: bool
should_prompt: bool
prompt_played: bool
pseudo_prompt: bool
reason: str
audio_file: str | None = None
@dataclass(frozen=True, slots=True)
class VoicePromptEvent:
event_type: str
track_id: int
frame_number: int
timestamp_iso: str
elapsed_time_sec: float
mode: str
prompt_played: bool = False
pseudo_prompt: bool = False
audio_file: str | None = None
turn_confirmed: bool = False
turn_level: str | None = None
reaction_time_sec: float | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class VoicePromptTrackState:
track_id: int
mode: str
eligible: bool = False
eligibility_frame: int | None = None
eligibility_time: str | None = None
prompt_attempted: bool = False
prompt_played: bool = False
prompt_failed: bool = False
pseudo_prompt: bool = False
prompt_frame: int | None = None
prompt_time: str | None = None
prompt_elapsed_time_sec: float | None = None
audio_file: str | None = None
response_window_started: bool = False
response_window_start_frame: int | None = None
response_window_start_time: str | None = None
response_window_end_time_sec: float | None = None
response_detected: bool = False
response_frame: int | None = None
response_time: str | None = None
reaction_time_sec: float | None = None
response_turn_level: str | None = None
max_yaw_delta_after_prompt: float | None = None
final_turn_level: str | None = None
valid_for_voice_analysis: bool = False
exclusion_reason: str = "not_finalized"
finalized: bool = False
response_window_expired: bool = False
class PersonExperimentStatus(str, Enum):
NEW = "new"
TRACKED = "tracked"
INSIDE_REGION = "inside_region"
ELIGIBLE = "eligible"
PROMPTED = "prompted"
PSEUDO_PROMPTED = "pseudo_prompted"
OBSERVING_RESPONSE = "observing_response"
RESPONDED = "responded"
NO_RESPONSE = "no_response"
COMPLETED = "completed"
EXCLUDED = "excluded"
NOT_EVALUABLE = "not_evaluable"
LOST = "lost"
class PersonExclusionReason(str, Enum):
NONE = "none"
TRACKER_DISABLED = "tracker_disabled"
VOICE_DISABLED = "voice_disabled"
TURN_DISABLED = "turn_disabled"
NO_POSE_ESTIMATED = "no_pose_estimated"
NO_FACE_DETECTED = "no_face_detected"
BASELINE_NOT_ACQUIRED = "baseline_not_acquired"
INSUFFICIENT_POSE_SAMPLES = "insufficient_pose_samples"
LOW_FACE_DETECTION_RATE = "low_face_detection_rate"
LOW_POSE_ESTIMATION_RATE = "low_pose_estimation_rate"
NOT_PROMPT_ELIGIBLE = "not_prompt_eligible"
ALREADY_TURNED_BEFORE_PROMPT = "already_turned_before_prompt"
PROMPT_FAILED = "prompt_failed"
AUDIO_MISSING = "audio_missing"
RESPONSE_NOT_EVALUABLE = "response_not_evaluable"
TRACK_TOO_SHORT = "track_too_short"
ID_SWITCH_SUSPECTED = "id_switch_suspected"
TRACK_LOST_BEFORE_PROMPT = "track_lost_before_prompt"
TRACK_LOST_DURING_RESPONSE_WINDOW = "track_lost_during_response_window"
OUTSIDE_REGION = "outside_region"
UNKNOWN = "unknown"
@dataclass(frozen=True, slots=True)
class PersonStateSnapshot:
track_id: int
frame_number: int
timestamp_iso: str
elapsed_time_sec: float
status: str
previous_status: str | None = None
inside_region: bool = False
track_confirmed: bool = False
track_lost: bool = False
trigger_crossed: bool = False
movement_direction: str | None = None
face_detected: bool | None = None
pose_estimated: bool | None = None
turn_level: str | None = None
turn_confirmed: bool = False
voice_mode: str | None = None
voice_eligible: bool = False
prompt_played: bool = False
pseudo_prompt: bool = False
response_window_started: bool = False
response_detected: bool = False
reaction_time_sec: float | None = None
valid_for_turn_analysis: bool | None = None
valid_for_voice_analysis: bool | None = None
exclusion_reason: str = PersonExclusionReason.NONE.value
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PersonStateEvent:
event_type: str
track_id: int
frame_number: int
timestamp_iso: str
elapsed_time_sec: float
previous_status: str | None
new_status: str
reason: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PersonStateSummary:
experiment_id: str
track_id: int
first_frame: int | None = None
last_frame: int | None = None
first_seen_time: str | None = None
last_seen_time: str | None = None
duration_sec: float | None = None
final_status: str = PersonExperimentStatus.COMPLETED.value
completed: bool = False
excluded: bool = False
exclusion_reason: str = PersonExclusionReason.NONE.value
inside_region_ever: bool = False
trigger_crossed: bool = False
trigger_crossing_direction: str | None = None
movement_direction: str | None = None
face_detected_ever: bool = False
pose_estimated_ever: bool = False
face_detection_rate: float | None = None
pose_estimation_rate: float | None = None
baseline_acquired: bool | None = None
turn_confirmed: bool = False
turn_level: str | None = None
max_yaw_delta_toward_signage: float | None = None
voice_mode: str | None = None
voice_eligible: bool = False
prompt_played: bool = False
pseudo_prompt: bool = False
prompt_time: str | None = None
prompt_elapsed_time_sec: float | None = None
response_window_started: bool = False
response_detected: bool = False
reaction_time_sec: float | None = None
response_turn_level: str | None = None
valid_for_turn_analysis: bool | None = None
valid_for_voice_analysis: bool | None = None
notes: str = ""
@dataclass(frozen=True, slots=True)
class UnifiedEvent:
experiment_id: str
session_id: str
event_id: str
source: str
event_type: str
track_id: int | None
frame_number: int
timestamp_iso: str
elapsed_time_sec: float
person_status: str | None = None
voice_mode: str | None = None
prompt_played: bool | None = None
pseudo_prompt: bool | None = None
turn_confirmed: bool | None = None
turn_level: str | None = None
response_detected: bool | None = None
reaction_time_sec: float | None = None
exclusion_reason: str | None = None
severity: str = "info"
metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.source not in {"tracking", "face", "turn", "voice", "person_state", "session", "system"}:
raise ValueError("invalid unified event source")
if self.severity not in {"info", "warning", "error"}:
raise ValueError("invalid unified event severity")
@dataclass(frozen=True, slots=True)
class PersonAnalysisRecord:
experiment_id: str
session_id: str
track_id: int
voice_mode: str | None = None
prompt_condition: str = "unknown"
prompt_played: bool = False
pseudo_prompt: bool = False
prompt_time: str | None = None
prompt_elapsed_time_sec: float | None = None
response_window_started: bool = False
response_detected: bool = False
reaction_time_sec: float | None = None
response_turn_level: str | None = None
turn_confirmed: bool = False
turn_level: str | None = None
max_yaw_delta_toward_signage: float | None = None
face_detected_ever: bool = False
pose_estimated_ever: bool = False
face_detection_rate: float | None = None
pose_estimation_rate: float | None = None
valid_for_turn_analysis: bool | None = None
valid_for_voice_analysis: bool | None = None
final_status: str = "completed"
completed: bool = False
excluded: bool = False
exclusion_reason: str = "none"
track_duration_sec: float | None = None
trigger_crossed: bool = False
trigger_crossing_direction: str | None = None
movement_direction: str | None = None
camera_position_note: str | None = None
notes: str = ""
@dataclass(frozen=True, slots=True)
class SessionSummary:
experiment_id: str
session_id: str
started_at: str
ended_at: str | None = None
duration_sec: float | None = None
voice_mode: str | None = None
total_tracks: int = 0
completed_tracks: int = 0
excluded_tracks: int = 0
evaluable_turn_tracks: int = 0
valid_voice_analysis_tracks: int = 0
prompt_played_count: int = 0
pseudo_prompt_count: int = 0
response_detected_count: int = 0
no_response_count: int = 0
not_evaluable_count: int = 0
response_rate: float | None = None
mean_reaction_time_sec: float | None = None
median_reaction_time_sec: float | None = None
weak_or_higher_count: int = 0
subtle_count: int = 0
medium_or_higher_count: int = 0
strong_count: int = 0
average_fps: float | None = None
camera_position_note: str | None = None
warnings_count: int = 0
errors_count: int = 0
notes: str = ""
@dataclass(frozen=True, slots=True)
class LogConsistencyIssue:
issue_type: str
severity: str
track_id: int | None
frame_number: int | None
message: str
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class AnalysisConditionSummary:
condition: str; total_records: int; valid_voice_analysis_count: int; response_detected_count: int
no_response_count: int; response_rate: float | None; excluded_count: int; not_evaluable_count: int
mean_reaction_time_sec: float | None; median_reaction_time_sec: float | None; std_reaction_time_sec: float | None
mean_max_yaw_delta: float | None; median_max_yaw_delta: float | None; std_max_yaw_delta: float | None
subtle_count: int; weak_count: int; medium_count: int; strong_count: int
weak_or_higher_count: int; medium_or_higher_count: int
@dataclass(frozen=True, slots=True)
class ResponseRateTestResult:
test_name: str; prompt_success: int; prompt_total: int; control_success: int; control_total: int
prompt_rate: float | None; control_rate: float | None; rate_difference: float | None
statistic: float | None; p_value: float | None; effect_size_name: str; effect_size: float | None
odds_ratio: float | None; method_note: str
@dataclass(frozen=True, slots=True)
class NumericComparisonResult:
metric_name: str; test_name: str; prompt_count: int; control_count: int
prompt_mean: float | None; control_mean: float | None; prompt_median: float | None; control_median: float | None
mean_difference: float | None; statistic: float | None; p_value: float | None
effect_size_name: str; effect_size: float | None; method_note: str
@dataclass(frozen=True, slots=True)
class TurnLevelDistributionResult:
condition: str; none_count: int; face_appeared_count: int; subtle_count: int; weak_count: int
medium_count: int; strong_count: int; not_evaluable_count: int; total_count: int
@dataclass(frozen=True, slots=True)
class ExclusionSummary:
condition: str; exclusion_reason: str; count: int; ratio: float | None
@dataclass(frozen=True, slots=True)
class AnalysisRunSummary:
analysis_id: str; input_files_count: int; total_records: int; prompt_records: int; control_records: int
valid_prompt_records: int; valid_control_records: int; generated_at: str; output_directory: str; notes: str
@dataclass(frozen=True, slots=True)
class AnalysisResult:
condition_summaries: list[AnalysisConditionSummary]
response_rate_test: ResponseRateTestResult
numeric_comparisons: list[NumericComparisonResult]
turn_level_distributions: list[TurnLevelDistributionResult]
exclusion_summaries: list[ExclusionSummary]
run_summary: AnalysisRunSummary
@dataclass(frozen=True, slots=True)
class RuntimeMetrics:
"""Small, GUI-safe snapshot of the current experiment state."""
frame_number: int = 0
elapsed_time_sec: float = 0.0
fps: float | None = None
active_tracks: int = 0
total_tracks: int = 0
region_visitors: int = 0
trigger_crossings: int = 0
face_detected_tracks: int = 0
turn_confirmed_count: int = 0
voice_mode: str | None = None
prompt_played_count: int = 0
pseudo_prompt_count: int = 0
response_detected_count: int = 0
person_state_counts: dict[str, int] = field(default_factory=dict)
current_log_directory: str | None = None
latest_message: str | None = None
@dataclass(frozen=True, slots=True)
class ExperimentProtocol:
protocol_id: str; title: str; version: str; created_at: str; purpose: str
target_location: str; signage_description: str; camera_position_description: str
camera_height_cm: float | None; camera_angle_description: str; walking_direction_description: str
lighting_condition: str; voice_prompt_text: str; voice_file_path: str; voice_volume: float
trigger_strategy: str; trigger_line_description: str; response_window_sec: float
turn_weak_threshold_deg: float; face_process_every_n_frames: int
prompt_condition_rule: str; control_condition_rule: str; privacy_note: str; notes: str
@dataclass(frozen=True, slots=True)
class ExperimentChecklistItem:
item_id: str; category: str; description: str; required: bool = True; checked: bool = False; note: str = ""
@dataclass(frozen=True, slots=True)
class ExperimentSessionNote:
session_note_id: str; session_id: str | None; experiment_id: str; date: str; condition: str
operator: str; location: str; camera_position_note: str; camera_height_cm: float | None
camera_angle_note: str; signage_position_note: str; walking_direction_note: str; lighting_note: str
audio_file: str | None; voice_prompt_text: str; voice_volume: float | None; config_path: str
started_at: str | None; ended_at: str | None; planned_duration_min: float | None
actual_duration_sec: float | None; issues: str; notes: str
@dataclass(frozen=True, slots=True)
class LogQualityIssue:
issue_type: str; severity: str; file_path: str | None; session_id: str | None
track_id: int | None; message: str; suggested_action: str; metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.severity not in {"info", "warning", "error"}: raise ValueError("invalid quality issue severity")
@dataclass(frozen=True, slots=True)
class PilotSessionReport:
report_id: str; generated_at: str; input_directory: str; output_directory: str
total_sessions: int; prompt_sessions: int; control_sessions: int; total_tracks: int
valid_voice_analysis_tracks: int; valid_voice_analysis_rate: float | None
response_rate_prompt: float | None; response_rate_control: float | None; rate_difference: float | None
mean_reaction_time_prompt: float | None; mean_reaction_time_control: float | None
high_level_findings: str; quality_notes: str; recommended_next_actions: str
@dataclass(frozen=True, slots=True)
class PilotDiagnosticMetric:
metric_name:str;value:float|int|str|None;threshold:float|int|str|None;passed:bool|None;severity:str;message:str;suggested_action:str
@dataclass(frozen=True, slots=True)
class ConditionBalanceDiagnostic:
prompt_total_records:int;control_total_records:int;prompt_valid_records:int;control_valid_records:int
prompt_response_rate:float|None;control_response_rate:float|None;valid_count_difference:int
valid_count_ratio:float|None;balanced:bool;message:str;suggested_action:str
@dataclass(frozen=True, slots=True)
class SessionQualitySummary:
session_id:str;condition:str;total_tracks:int;valid_voice_analysis_tracks:int;valid_voice_analysis_rate:float|None
response_detected_count:int;response_rate:float|None;not_evaluable_count:int;not_evaluable_rate:float|None
no_pose_estimated_count:int;no_pose_estimated_rate:float|None;mean_reaction_time_sec:float|None
median_reaction_time_sec:float|None;camera_position_note:str|None;quality_level:str;main_issue:str;suggested_action:str
@dataclass(frozen=True, slots=True)
class ImprovementRecommendation:
recommendation_id:str;category:str;priority:str;title:str;reason:str;suggested_action:str
related_metrics:dict[str,Any]=field(default_factory=dict);expected_effect:str=""
@dataclass(frozen=True, slots=True)
class PilotDiagnosticReport:
report_id:str;generated_at:str;input_analysis_dir:str;input_logs_dir:str|None;overall_status:str
ready_for_main_experiment:bool;total_records:int;prompt_valid_records:int;control_valid_records:int
prompt_response_rate:float|None;control_response_rate:float|None;valid_voice_analysis_rate:float|None
not_evaluable_rate:float|None;no_pose_estimated_rate:float|None;condition_balance:ConditionBalanceDiagnostic
session_summaries:list[SessionQualitySummary];metrics:list[PilotDiagnosticMetric]
recommendations:list[ImprovementRecommendation];notes:str
@dataclass(frozen=True, slots=True)
class CalibrationChangeRecord:
change_id:str;created_at:str;operator:str;category:str;before_value:str;after_value:str;reason:str
related_report_id:str|None=None;notes:str=""

@ -0,0 +1,96 @@
"""CSV persistence for integrated Phase 6 person state."""
from __future__ import annotations
import csv
import json
from datetime import datetime
from pathlib import Path
from typing import TextIO
from .config_loader import PersonStateLoggingSettings
from .exceptions import PersonStateLogError
from .models import PersonStateEvent, PersonStateSnapshot, PersonStateSummary
class PersonStateLogger:
"""Write snapshots, transitions, and deduplicated person summaries."""
FRAME_FIELDS = ["experiment_id", "frame_number", "timestamp", "elapsed_time_sec", "track_id", "status",
"previous_status", "inside_region", "track_confirmed", "track_lost", "trigger_crossed",
"movement_direction", "face_detected", "pose_estimated", "turn_level", "turn_confirmed",
"voice_mode", "voice_eligible", "prompt_played", "pseudo_prompt", "response_window_started",
"response_detected", "reaction_time_sec", "valid_for_turn_analysis", "valid_for_voice_analysis",
"exclusion_reason", "metadata_json"]
EVENT_FIELDS = ["experiment_id", "event_type", "track_id", "frame_number", "timestamp", "elapsed_time_sec",
"previous_status", "new_status", "reason", "metadata_json"]
SUMMARY_FIELDS = ["experiment_id", "track_id", "first_frame", "last_frame", "first_seen_time", "last_seen_time",
"duration_sec", "final_status", "completed", "excluded", "exclusion_reason", "inside_region_ever",
"trigger_crossed", "trigger_crossing_direction", "movement_direction", "face_detected_ever",
"pose_estimated_ever", "face_detection_rate", "pose_estimation_rate", "baseline_acquired",
"turn_confirmed", "turn_level", "max_yaw_delta_toward_signage", "voice_mode", "voice_eligible",
"prompt_played", "pseudo_prompt", "prompt_time", "prompt_elapsed_time_sec",
"response_window_started", "response_detected", "reaction_time_sec", "response_turn_level",
"valid_for_turn_analysis", "valid_for_voice_analysis", "notes"]
def __init__(self, config: PersonStateLoggingSettings, experiment_id: str, run_stamp: str | None = None) -> None:
self.config, self.experiment_id = config, experiment_id
self.run_stamp = run_stamp or datetime.now().strftime("%Y%m%d_%H%M%S")
self.directory = Path(config.directory)
self._files: dict[str, TextIO] = {}; self._writers: dict[str, csv.DictWriter[str]] = {}
self._summary_ids: set[int] = set(); self._frames = 0
def open(self) -> None:
if not self.config.enabled: return
self.directory.mkdir(parents=True, exist_ok=True)
try:
self._open("frames", "person_state_frames", self.FRAME_FIELDS, self.config.log_frame_states)
self._open("events", "person_state_events", self.EVENT_FIELDS, self.config.log_events)
self._open("summary", "person_state_summary", self.SUMMARY_FIELDS, self.config.log_summaries)
except OSError as exc:
self.close(); raise PersonStateLogError(f"Failed to open person-state logs: {exc}") from exc
def _open(self, key: str, suffix: str, fields: list[str], enabled: bool) -> None:
if not enabled: return
stream = (self.directory / f"{self.run_stamp}_{self.experiment_id}_{suffix}.csv").open("w", encoding="utf-8-sig", newline="")
writer = csv.DictWriter(stream, fieldnames=fields); writer.writeheader()
self._files[key], self._writers[key] = stream, writer
def write_snapshots(self, items: list[PersonStateSnapshot]) -> None:
writer = self._writers.get("frames")
if writer:
for item in items:
row = {field: getattr(item, field, "") for field in self.FRAME_FIELDS
if field not in {"experiment_id", "timestamp", "metadata_json"}}
row.update(experiment_id=self.experiment_id, timestamp=item.timestamp_iso,
metadata_json=json.dumps(item.metadata, ensure_ascii=False))
writer.writerow(row)
self._frames += 1
if self._frames >= self.config.flush_interval_frames: self.flush()
def write_events(self, items: list[PersonStateEvent]) -> None:
writer = self._writers.get("events")
if not writer: return
for item in items:
writer.writerow({"experiment_id": self.experiment_id, "event_type": item.event_type, "track_id": item.track_id,
"frame_number": item.frame_number, "timestamp": item.timestamp_iso,
"elapsed_time_sec": item.elapsed_time_sec, "previous_status": item.previous_status or "",
"new_status": item.new_status, "reason": item.reason,
"metadata_json": json.dumps(item.metadata, ensure_ascii=False)})
def write_summary(self, item: PersonStateSummary) -> None:
writer = self._writers.get("summary")
if not writer or item.track_id in self._summary_ids: return
writer.writerow({field: getattr(item, field) for field in self.SUMMARY_FIELDS})
self._summary_ids.add(item.track_id)
def flush(self) -> None:
for stream in self._files.values(): stream.flush()
self._frames = 0
def close(self) -> None:
for stream in self._files.values(): stream.close()
self._files.clear(); self._writers.clear()
def __enter__(self) -> "PersonStateLogger": self.open(); return self
def __exit__(self, *_args: object) -> None: self.close()

@ -0,0 +1,308 @@
"""Integration layer combining Track, Face, Turn, and Voice by track ID."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from .config_loader import PersonStateSettings
from .models import (FrameInfo, HeadPoseResult, PersonExperimentStatus, PersonExclusionReason,
PersonStateEvent, PersonStateSnapshot, PersonStateSummary, TrackEvent, TrackedPerson,
TurnFrameResult, TurnTrackState, VoicePromptDecision, VoicePromptEvent,
VoicePromptTrackState)
from .person_state_rules import choose_status, final_exclusion_reason
@dataclass(slots=True)
class _Record:
track_id: int
status: str = PersonExperimentStatus.NEW.value
first_frame: int | None = None
last_frame: int | None = None
first_time: str | None = None
last_time: str | None = None
first_elapsed: float | None = None
last_elapsed: float | None = None
inside: bool = False
inside_ever: bool = False
confirmed: bool = False
lost: bool = False
trigger_crossed: bool = False
trigger_direction: str | None = None
movement_direction: str | None = None
face_detected: bool | None = None
pose_estimated: bool | None = None
face_ever: bool = False
pose_ever: bool = False
turn_level: str | None = None
turn_confirmed: bool = False
turn_valid: bool | None = None
turn_reason: str | None = None
baseline_acquired: bool | None = None
max_delta: float | None = None
voice_mode: str | None = None
eligible: bool = False
prompt_played: bool = False
pseudo_prompt: bool = False
prompt_time: str | None = None
prompt_elapsed: float | None = None
observing: bool = False
window_started: bool = False
response: bool = False
no_response: bool = False
reaction: float | None = None
response_level: str | None = None
voice_valid: bool | None = None
voice_reason: str | None = None
exclusion: str = PersonExclusionReason.NONE.value
finalized: bool = False
emitted: set[tuple[str, str]] = field(default_factory=set)
class PersonStateManager:
"""Build research-facing person state without replacing source managers."""
def __init__(
self, config: PersonStateSettings, experiment_id: str = "",
turn_available: bool = True, voice_available: bool = True,
) -> None:
self.config, self.experiment_id = config, experiment_id
self.turn_available, self.voice_available = turn_available, voice_available
self._records: dict[int, _Record] = {}
self._snapshots: dict[int, PersonStateSnapshot] = {}
self._summaries: dict[int, PersonStateSummary] = {}
def update(
self, tracked_persons: list[TrackedPerson], track_events: list[TrackEvent],
face_results: list[HeadPoseResult], turn_results: list[TurnFrameResult],
turn_states: dict[int, TurnTrackState] | None, voice_decisions: list[VoicePromptDecision],
voice_events: list[VoicePromptEvent], voice_states: dict[int, VoicePromptTrackState] | None,
frame_info: FrameInfo,
) -> tuple[list[PersonStateSnapshot], list[PersonStateEvent]]:
if not self.config.enabled:
return [], []
people = {item.track_id: item for item in tracked_persons}
faces = {item.track_id: item for item in face_results}
turns = {item.track_id: item for item in turn_results}
decisions = {item.track_id: item for item in voice_decisions}
ids = set(people) | {e.track_id for e in track_events} | set(faces) | set(turns) | set(decisions)
ids |= {e.track_id for e in voice_events}
snapshots: list[PersonStateSnapshot] = []
events: list[PersonStateEvent] = []
for track_id in sorted(ids):
record = self._record(track_id, frame_info, events)
previous = record.status
self._apply_track(record, people.get(track_id), [e for e in track_events if e.track_id == track_id])
self._apply_face(record, faces.get(track_id))
self._apply_turn(record, turns.get(track_id), (turn_states or {}).get(track_id))
self._apply_voice(record, decisions.get(track_id), [e for e in voice_events if e.track_id == track_id],
(voice_states or {}).get(track_id))
record.last_frame, record.last_time, record.last_elapsed = frame_info.frame_number, frame_info.timestamp_iso, frame_info.elapsed_time_sec
record.status = choose_status(
lost=record.lost, responded=record.response, no_response=record.no_response,
observing=record.observing and not record.no_response, prompt_played=record.prompt_played,
pseudo_prompt=record.pseudo_prompt, eligible=record.eligible, inside_region=record.inside,
tracked=record.confirmed, not_evaluable=record.turn_valid is False,
)
if record.status != previous:
events.append(self._event("status_changed", record, frame_info, previous, record.status, "state_priority"))
event_name = {
"tracked": "person_tracked", "inside_region": "entered_region", "eligible": "became_eligible",
"prompted": "prompt_played", "pseudo_prompted": "pseudo_prompted",
"observing_response": "response_observation_started", "responded": "responded",
"no_response": "no_response", "not_evaluable": "became_not_evaluable",
"excluded": "excluded", "completed": "completed", "lost": "lost",
}.get(record.status)
signature = (event_name or "", "")
if event_name and signature not in record.emitted:
events.append(self._event(event_name, record, frame_info, previous, record.status, "source_state"))
record.emitted.add(signature)
source_kinds = {
"region_entered": "entered_region", "region_exited": "exited_region", "track_lost": "lost",
"prompt_eligible": "became_eligible", "prompt_played": "prompt_played",
"pseudo_prompt_triggered": "pseudo_prompted",
"response_window_started": "response_observation_started",
"response_detected": "responded", "response_window_expired": "no_response",
}
source_events: list[tuple[str, str]] = []
source_events.extend((event.event_type, "track_event") for event in track_events if event.track_id == track_id)
source_events.extend((event.event_type, "voice_event") for event in voice_events if event.track_id == track_id)
for source, reason in source_events:
kind = source_kinds.get(source)
signature = (kind or "", "")
if kind and signature not in record.emitted:
events.append(self._event(kind, record, frame_info, previous, record.status, reason))
record.emitted.add(signature)
snapshot = self._snapshot(record, frame_info, previous)
self._snapshots[track_id] = snapshot
snapshots.append(snapshot)
return snapshots, events
def _record(self, track_id: int, info: FrameInfo, events: list[PersonStateEvent]) -> _Record:
record = self._records.get(track_id)
if record is None:
record = _Record(track_id, first_frame=info.frame_number, first_time=info.timestamp_iso,
first_elapsed=info.elapsed_time_sec)
self._records[track_id] = record
events.append(self._event("person_created", record, info, None, record.status, "new_track"))
return record
@staticmethod
def _apply_track(record: _Record, person: TrackedPerson | None, events: list[TrackEvent]) -> None:
if person is not None:
record.confirmed = person.is_confirmed
record.inside = person.inside_evaluation_region
record.inside_ever |= record.inside
for event in events:
if event.event_type == "region_entered":
record.inside = record.inside_ever = True
elif event.event_type == "region_exited":
record.inside = False
elif event.event_type == "trigger_crossed":
record.trigger_crossed, record.trigger_direction = True, event.direction
elif event.event_type == "track_lost":
record.lost = True
record.movement_direction = event.direction or record.movement_direction
@staticmethod
def _apply_face(record: _Record, face: HeadPoseResult | None) -> None:
if face is not None:
record.face_detected, record.pose_estimated = face.face_detected, face.pose_estimated
record.face_ever |= face.face_detected
record.pose_ever |= face.pose_estimated
@staticmethod
def _apply_turn(record: _Record, result: TurnFrameResult | None, state: TurnTrackState | None) -> None:
if result is not None:
record.turn_level, record.turn_confirmed = result.turn_level, result.turn_confirmed
if result.yaw_delta_toward_signage is not None:
record.max_delta = result.yaw_delta_toward_signage if record.max_delta is None else max(record.max_delta, result.yaw_delta_toward_signage)
if state is not None:
record.turn_level, record.turn_confirmed = state.turn_level, state.turn_confirmed
record.baseline_acquired, record.max_delta = state.baseline_acquired, state.max_yaw_delta_toward_signage
if state.finalized:
record.turn_valid, record.turn_reason = state.valid_for_turn_analysis, state.exclusion_reason
@staticmethod
def _apply_voice(
record: _Record, decision: VoicePromptDecision | None, events: list[VoicePromptEvent],
state: VoicePromptTrackState | None,
) -> None:
if decision is not None:
record.voice_mode, record.eligible = decision.mode, record.eligible or decision.eligible
record.prompt_played |= decision.prompt_played
record.pseudo_prompt |= decision.pseudo_prompt
for event in events:
record.voice_mode = event.mode
if event.event_type == "prompt_eligible": record.eligible = True
if event.event_type == "prompt_played": record.prompt_played = True
if event.event_type == "pseudo_prompt_triggered": record.pseudo_prompt = True
if event.event_type == "response_window_started": record.observing = record.window_started = True
if event.event_type == "response_detected":
record.response, record.observing = True, False
record.reaction, record.response_level = event.reaction_time_sec, event.turn_level
if event.event_type == "response_window_expired":
record.no_response, record.observing = True, False
if state is not None:
record.voice_mode, record.eligible = state.mode, state.eligible
record.prompt_played, record.pseudo_prompt = state.prompt_played, state.pseudo_prompt
record.prompt_time, record.prompt_elapsed = state.prompt_time, state.prompt_elapsed_time_sec
record.observing = state.response_window_started and not state.response_detected and not state.response_window_expired
record.window_started |= state.response_window_started
record.response, record.no_response = state.response_detected, state.response_window_expired
record.reaction, record.response_level = state.reaction_time_sec, state.response_turn_level
if state.finalized:
record.voice_valid, record.voice_reason = state.valid_for_voice_analysis, state.exclusion_reason
def finalize_track(
self, track_id: int, frame_info: FrameInfo, track_summary: Any | None = None,
face_state: Any | None = None, turn_state: TurnTrackState | None = None,
voice_state: VoicePromptTrackState | None = None,
) -> tuple[PersonStateSummary | None, list[PersonStateEvent]]:
if not self.config.enabled or track_id in self._summaries:
return self._summaries.get(track_id), []
record = self._records.setdefault(track_id, _Record(track_id, first_frame=frame_info.frame_number,
first_time=frame_info.timestamp_iso,
first_elapsed=frame_info.elapsed_time_sec))
if track_summary is not None:
record.inside_ever = bool(getattr(track_summary, "entered_evaluation_region", record.inside_ever))
record.trigger_crossed = bool(getattr(track_summary, "trigger_crossed", record.trigger_crossed))
record.trigger_direction = getattr(track_summary, "trigger_crossing_direction", record.trigger_direction)
record.movement_direction = getattr(track_summary, "movement_direction", record.movement_direction)
if face_state is not None:
record.face_ever |= bool(getattr(face_state, "face_detected_frames", 0))
record.pose_ever |= bool(getattr(face_state, "pose_estimated_frames", 0))
self._apply_turn(record, None, turn_state)
self._apply_voice(record, None, [], voice_state)
record.last_frame, record.last_time, record.last_elapsed = frame_info.frame_number, frame_info.timestamp_iso, frame_info.elapsed_time_sec
duration = max(0.0, (record.last_elapsed or 0.0) - (record.first_elapsed or 0.0))
if not self.turn_available:
record.turn_valid, record.turn_reason = False, PersonExclusionReason.TURN_DISABLED.value
if not self.voice_available:
record.voice_valid, record.voice_reason = False, PersonExclusionReason.VOICE_DISABLED.value
reason = final_exclusion_reason(self.config, duration, record.trigger_crossed,
record.eligible or not self.voice_available,
record.turn_reason, record.voice_reason, record.lost,
record.prompt_played or record.pseudo_prompt, record.observing)
not_evaluable = record.turn_valid is False
excluded = reason != PersonExclusionReason.NONE.value and (not not_evaluable or self.config.mark_not_evaluable_as_excluded)
final_status = PersonExperimentStatus.EXCLUDED.value if excluded else (
PersonExperimentStatus.NOT_EVALUABLE.value if not_evaluable else PersonExperimentStatus.COMPLETED.value)
record.finalized, record.status, record.exclusion = True, final_status, reason
summary = self._summary(record, duration, face_state, turn_state, voice_state, excluded)
self._summaries[track_id] = summary
self._records.pop(track_id, None)
previous = self._snapshots.get(track_id).status if track_id in self._snapshots else None
events = [self._event("completed" if final_status == PersonExperimentStatus.COMPLETED.value else
("excluded" if final_status == PersonExperimentStatus.EXCLUDED.value else "became_not_evaluable"),
record, frame_info, previous, final_status, reason),
self._event("person_finalized", record, frame_info, previous, final_status, reason)]
return summary, events
def finalize_all(
self, frame_info: FrameInfo, turn_states: dict[int, TurnTrackState] | None = None,
voice_states: dict[int, VoicePromptTrackState] | None = None,
) -> tuple[list[PersonStateSummary], list[PersonStateEvent]]:
summaries: list[PersonStateSummary] = []
events: list[PersonStateEvent] = []
for track_id in list(self._records):
summary, new = self.finalize_track(track_id, frame_info, turn_state=(turn_states or {}).get(track_id),
voice_state=(voice_states or {}).get(track_id))
if summary is not None: summaries.append(summary)
events.extend(new)
return summaries, events
def get_state(self, track_id: int) -> PersonStateSnapshot | None:
return self._snapshots.get(track_id)
def get_active_states(self) -> dict[int, PersonStateSnapshot]:
return {key: value for key, value in self._snapshots.items() if key in self._records}
def get_summary_preview(self, track_id: int) -> PersonStateSummary | None:
return self._summaries.get(track_id)
def reset(self) -> None:
self._records.clear(); self._snapshots.clear(); self._summaries.clear()
@staticmethod
def _snapshot(r: _Record, info: FrameInfo, previous: str | None) -> PersonStateSnapshot:
return PersonStateSnapshot(r.track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec, r.status,
previous, r.inside, r.confirmed, r.lost, r.trigger_crossed, r.movement_direction,
r.face_detected, r.pose_estimated, r.turn_level, r.turn_confirmed, r.voice_mode,
r.eligible, r.prompt_played, r.pseudo_prompt, r.window_started, r.response, r.reaction,
r.turn_valid, r.voice_valid, r.exclusion, {})
def _summary(self, r: _Record, duration: float, face: Any, turn: TurnTrackState | None,
voice: VoicePromptTrackState | None, excluded: bool) -> PersonStateSummary:
return PersonStateSummary(self.experiment_id, r.track_id, r.first_frame, r.last_frame, r.first_time, r.last_time,
duration, r.status, True, excluded, r.exclusion, r.inside_ever, r.trigger_crossed, r.trigger_direction,
r.movement_direction, r.face_ever, r.pose_ever, getattr(face, "face_detection_rate", None),
getattr(face, "pose_estimation_rate", None), r.baseline_acquired, r.turn_confirmed, r.turn_level, r.max_delta,
r.voice_mode, r.eligible, r.prompt_played, r.pseudo_prompt, r.prompt_time, r.prompt_elapsed, r.window_started,
r.response, r.reaction, r.response_level, r.turn_valid, r.voice_valid,
"response observed" if r.response else "")
@staticmethod
def _event(kind: str, r: _Record, info: FrameInfo, previous: str | None, new: str, reason: str) -> PersonStateEvent:
return PersonStateEvent(kind, r.track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec,
previous, new, reason, {})

@ -0,0 +1,73 @@
"""Pure priority and exclusion rules for integrated person state."""
from __future__ import annotations
from .config_loader import PersonStateSettings
from .models import PersonExperimentStatus, PersonExclusionReason
def choose_status(
*, excluded: bool = False, finalized: bool = False, lost: bool = False,
responded: bool = False, no_response: bool = False, observing: bool = False,
prompt_played: bool = False, pseudo_prompt: bool = False, eligible: bool = False,
inside_region: bool = False, tracked: bool = False, not_evaluable: bool = False,
) -> str:
"""Choose the single display status using research-priority ordering."""
if excluded:
return PersonExperimentStatus.EXCLUDED.value
if finalized:
return PersonExperimentStatus.COMPLETED.value
if lost:
return PersonExperimentStatus.LOST.value
if responded:
return PersonExperimentStatus.RESPONDED.value
if no_response:
return PersonExperimentStatus.NO_RESPONSE.value
if observing:
return PersonExperimentStatus.OBSERVING_RESPONSE.value
if prompt_played:
return PersonExperimentStatus.PROMPTED.value
if pseudo_prompt:
return PersonExperimentStatus.PSEUDO_PROMPTED.value
if eligible:
return PersonExperimentStatus.ELIGIBLE.value
if not_evaluable:
return PersonExperimentStatus.NOT_EVALUABLE.value
if inside_region:
return PersonExperimentStatus.INSIDE_REGION.value
if tracked:
return PersonExperimentStatus.TRACKED.value
return PersonExperimentStatus.NEW.value
def normalize_exclusion_reason(reason: str | None) -> str:
"""Map module-specific final reasons to the unified vocabulary."""
if not reason or reason in {"none", "not_finalized", "evaluable_no_turn", "turn_detected",
"response_detected", "no_response"}:
return PersonExclusionReason.NONE.value
aliases = {"not_eligible": "not_prompt_eligible", "no_turn_state": "response_not_evaluable"}
value = aliases.get(reason, reason)
valid = {item.value for item in PersonExclusionReason}
return value if value in valid else PersonExclusionReason.UNKNOWN.value
def final_exclusion_reason(
config: PersonStateSettings, duration_sec: float, trigger_crossed: bool,
voice_eligible: bool, turn_reason: str | None, voice_reason: str | None,
lost: bool, prompt_started: bool, observing: bool,
) -> str:
if duration_sec < config.min_track_duration_sec:
return PersonExclusionReason.TRACK_TOO_SHORT.value
if config.require_trigger_crossing_for_completion and not trigger_crossed:
return PersonExclusionReason.OUTSIDE_REGION.value
if lost and observing:
return PersonExclusionReason.TRACK_LOST_DURING_RESPONSE_WINDOW.value
if lost and not prompt_started:
return PersonExclusionReason.TRACK_LOST_BEFORE_PROMPT.value
if config.require_voice_eligibility_for_voice_analysis and not voice_eligible:
return PersonExclusionReason.NOT_PROMPT_ELIGIBLE.value
voice = normalize_exclusion_reason(voice_reason)
if voice != PersonExclusionReason.NONE.value:
return voice
return normalize_exclusion_reason(turn_reason)

@ -0,0 +1,18 @@
"""Camera-free Phase 10 operation commands."""
from __future__ import annotations
from pathlib import Path
from .config_loader import AppConfig
from .experiment_protocol import ExperimentProtocolBuilder
from .pilot_session_reporter import PilotSessionReporter
from .presentation_summary_builder import PresentationSummaryBuilder
from .quality_checker import QualityChecker
def generate_protocol(config:AppConfig,markdown:Path,yaml_path:Path|None=None)->list[Path]:
builder=ExperimentProtocolBuilder(config.to_dict());protocol=builder.build_default_protocol();paths=[builder.export_markdown(protocol,markdown)]
if yaml_path:paths.append(builder.export_yaml(protocol,yaml_path))
return paths
def run_quality(config:AppConfig,logs:Path,analysis:Path,output:Path)->list[Path]:
checker=QualityChecker(config.quality_check);issues=checker.check(logs,analysis);return [checker.export_csv(issues,output/"quality_issues.csv"),checker.export_markdown(issues,output/"quality_report.md")]
def run_pilot_report(config:AppConfig,analysis:Path,output:Path,logs:Path=Path("data/logs/unified"))->Path:
issues=QualityChecker(config.quality_check).check(logs,analysis);reporter=PilotSessionReporter();return reporter.export_markdown(reporter.build_report(analysis,issues),output)
def run_presentation(config:AppConfig,analysis:Path,output:Path,logs:Path=Path("data/logs/unified"))->Path:
issues=QualityChecker(config.quality_check).check(logs,analysis);report=PilotSessionReporter().build_report(analysis,issues);return PresentationSummaryBuilder().build(report,analysis,output)

@ -0,0 +1,9 @@
"""Camera-free Phase 11 diagnostics and calibration commands."""
from pathlib import Path
from .calibration_history import CalibrationHistory
from .config_loader import AppConfig
from .models import CalibrationChangeRecord
from .pilot_diagnostics import PilotDiagnostics
def run_diagnostics(config:AppConfig,analysis:Path,logs:Path,output:Path)->list[Path]:return PilotDiagnostics(config.pilot_diagnostics).export(PilotDiagnostics(config.pilot_diagnostics).run(analysis,logs),output)
def add_calibration(config:AppConfig,record:CalibrationChangeRecord,path:Path|None=None)->Path:return CalibrationHistory(path or Path(config.calibration_history.path)).append(record)
def list_calibrations(config:AppConfig,path:Path|None=None)->list[CalibrationChangeRecord]:return CalibrationHistory(path or Path(config.calibration_history.path)).load()

@ -0,0 +1,42 @@
"""Pilot experiment readiness diagnostics and transparent artifact export."""
from __future__ import annotations
import csv,json
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from .config_loader import PilotDiagnosticsSettings
from .improvement_recommender import ImprovementRecommender
from .models import ConditionBalanceDiagnostic,PilotDiagnosticMetric,PilotDiagnosticReport
from .session_comparator import SessionComparator
def _b(v)->bool:return str(v).lower() in {"true","1","yes"}
class PilotDiagnostics:
def __init__(self,config:PilotDiagnosticsSettings)->None:self.config=config
def _rows(self,root:Path|None)->list[dict[str,str]]:
rows=[]
if root and root.exists():
for p in root.rglob("*person_analysis_table.csv"):
with p.open(encoding="utf-8-sig",newline="") as f:rows.extend(csv.DictReader(f))
return rows
def run(self,analysis_dir:Path,logs_dir:Path|None=None)->PilotDiagnosticReport:
rows=self._rows(logs_dir);total=len(rows);pr=[r for r in rows if r.get("prompt_condition")=="prompt"];cr=[r for r in rows if r.get("prompt_condition")=="control"];pv=sum(_b(r.get("valid_for_voice_analysis")) for r in pr);cv=sum(_b(r.get("valid_for_voice_analysis")) for r in cr);valid=pv+cv
def rate(items,key,predicate=lambda r:True):return sum(predicate(r) for r in items)/len(items) if items else None
pvr=[r for r in pr if _b(r.get("valid_for_voice_analysis"))];cvr=[r for r in cr if _b(r.get("valid_for_voice_analysis"))];p_resp=rate(pvr,"",lambda r:_b(r.get("response_detected")));c_resp=rate(cvr,"",lambda r:_b(r.get("response_detected")));ne=rate(rows,"",lambda r:r.get("turn_level")=="not_evaluable");np=rate(rows,"",lambda r:not _b(r.get("pose_estimated_ever")));vr=valid/total if total else None;ratio=max(pv,cv)/min(pv,cv) if min(pv,cv)>0 else None;balanced=ratio is not None and ratio<=self.config.max_condition_valid_count_ratio;balance=ConditionBalanceDiagnostic(len(pr),len(cr),pv,cv,p_resp,c_resp,abs(pv-cv),ratio,balanced,"条件人数は許容範囲" if balanced else "条件間の有効人数差が大きい","不足条件を同じ環境で追加収集する")
camera_missing=any(not r.get("camera_position_note","").strip() for r in rows);metrics=[self._metric("valid_voice_analysis_rate",vr,self.config.min_valid_voice_analysis_rate,vr is not None and vr>=self.config.min_valid_voice_analysis_rate,"有効音声解析率","除外理由を確認する"),self._metric("not_evaluable_rate",ne,self.config.max_not_evaluable_rate,ne is not None and ne<=self.config.max_not_evaluable_rate,"評価不能率","カメラ位置と顔処理を確認する"),self._metric("no_pose_estimated_rate",np,self.config.max_no_pose_estimated_rate,np is not None and np<=self.config.max_no_pose_estimated_rate,"Pose未推定率","顔が映る位置へ調整する")]
sessions=SessionComparator().summarize(rows);recs=ImprovementRecommender().recommend(vr,ne,np,balance,pv,cv,camera_missing)
if not rows or (self.config.ready_requires_prompt_and_control and (not pr or not cr)) or min(pv,cv)<self.config.min_valid_records_per_condition:status="insufficient_data"
elif (vr is not None and vr<self.config.min_valid_voice_analysis_rate) or (ne is not None and ne>self.config.max_not_evaluable_rate) or (np is not None and np>self.config.max_no_pose_estimated_rate) or camera_missing:status="needs_major_adjustment"
elif min(pv,cv)<self.config.target_valid_records_per_condition or not balanced:status="needs_minor_adjustment"
else:status="ready"
return PilotDiagnosticReport(f"diagnostic_{datetime.now():%Y%m%d_%H%M%S}",datetime.now().astimezone().isoformat(timespec="seconds"),str(analysis_dir),str(logs_dir) if logs_dir else None,status,status=="ready",total,pv,cv,p_resp,c_resp,vr,ne,np,balance,sessions,metrics,recs,"診断は本実験移行の目安であり、研究妥当性や因果関係を保証しない")
def _metric(self,name,value,threshold,passed,message,action):return PilotDiagnosticMetric(name,value,threshold,passed,"info" if passed else "warning",message,action)
def export(self,report:PilotDiagnosticReport,output:Path)->list[Path]:
output.mkdir(parents=True,exist_ok=True);paths=[]
paths.append(self._csv(output/"pilot_diagnostic_metrics.csv",[asdict(x) for x in report.metrics],["metric_name","value","threshold","passed","severity","message","suggested_action"]))
recs=[]
for x in report.recommendations:d=asdict(x);d["related_metrics_json"]=json.dumps(d.pop("related_metrics"),ensure_ascii=False);recs.append(d)
paths.append(self._csv(output/"improvement_recommendations.csv",recs,["recommendation_id","category","priority","title","reason","suggested_action","related_metrics_json","expected_effect"]))
paths.append(self._csv(output/"session_quality_summary.csv",[asdict(x) for x in report.session_summaries],list(asdict(report.session_summaries[0]).keys()) if report.session_summaries else ["session_id","condition","total_tracks","valid_voice_analysis_tracks","valid_voice_analysis_rate","response_detected_count","response_rate","not_evaluable_count","not_evaluable_rate","no_pose_estimated_count","no_pose_estimated_rate","mean_reaction_time_sec","median_reaction_time_sec","camera_position_note","quality_level","main_issue","suggested_action"]))
md=output/"pilot_diagnostic_report.md";lines=["# パイロット実験診断",f"- overall_status: {report.overall_status}",f"- ready_for_main_experiment: {report.ready_for_main_experiment}",f"- Prompt有効人数: {report.prompt_valid_records}",f"- Control有効人数: {report.control_valid_records}",f"- valid_voice_analysis_rate: {report.valid_voice_analysis_rate}",f"- not_evaluable_rate: {report.not_evaluable_rate}",f"- no_pose_estimated_rate: {report.no_pose_estimated_rate}","","## 改善提案"]+[f"- [{r.priority}] {r.title}: {r.suggested_action}" for r in report.recommendations]+["","## 注意",report.notes];md.write_text("\n".join(lines)+"\n",encoding="utf-8");paths.insert(0,md);return paths
def _csv(self,path,rows,fields):
with path.open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=fields);w.writeheader();w.writerows(rows)
return path

@ -0,0 +1,22 @@
"""Build a cautious pilot-session summary from Phase 8 outputs."""
from __future__ import annotations
import csv
from datetime import datetime
from pathlib import Path
from .models import LogQualityIssue,PilotSessionReport
class PilotSessionReporter:
def build_report(self,analysis_dir:Path,quality_issues:list[LogQualityIssue]|None=None)->PilotSessionReport:
rows=[];path=analysis_dir/"condition_summary.csv"
if path.exists():
with path.open(encoding="utf-8-sig",newline="") as f:rows=list(csv.DictReader(f))
by={r.get("condition"):r for r in rows};p=by.get("prompt",{});c=by.get("control",{})
def integer(d,k):return int(float(d.get(k) or 0))
def number(d,k):return float(d[k]) if d.get(k) not in {None,""} else None
pr,cr=number(p,"response_rate"),number(c,"response_rate")
total=integer(p,"total_records")+integer(c,"total_records");valid=integer(p,"valid_voice_analysis_count")+integer(c,"valid_voice_analysis_count")
issues=quality_issues or [];quality="; ".join(i.message for i in issues) or "重大な品質Issueは記録されていません"
finding="Prompt/Controlの記述的比較。因果関係は判定しない。" if rows else "解析データなし。テンプレートとして生成。"
return PilotSessionReport(f"pilot_{datetime.now():%Y%m%d_%H%M%S}",datetime.now().astimezone().isoformat(timespec="seconds"),str(analysis_dir),str(analysis_dir),0,0,0,total,valid,valid/total if total else None,pr,cr,(pr-cr) if pr is not None and cr is not None else None,number(p,"mean_reaction_time_sec"),number(c,"mean_reaction_time_sec"),finding,quality,"有効解析人数を増やし、カメラ位置とPrompt/Control条件を固定して再確認する")
def export_markdown(self,report:PilotSessionReport,output_path:Path)->Path:
output_path.parent.mkdir(parents=True,exist_ok=True);lines=["# パイロット実験レポート","",f"生成日時: {report.generated_at}","","## 記述的結果",f"- 総track数: {report.total_tracks}",f"- 有効音声解析track数: {report.valid_voice_analysis_tracks}",f"- Prompt反応率: {report.response_rate_prompt}",f"- Control反応率: {report.response_rate_control}",f"- 反応率差: {report.rate_difference}",f"- Prompt平均反応時間: {report.mean_reaction_time_prompt}",f"- Control平均反応時間: {report.mean_reaction_time_control}","","## 品質",report.quality_notes,"","## 次の対応",report.recommended_next_actions,"","## 注意","これは予備実験の記述的要約であり、統計的有意差や因果関係を断定しない。"]
output_path.write_text("\n".join(lines)+"\n",encoding="utf-8");return output_path

@ -0,0 +1,10 @@
"""Generate a slide-friendly Markdown outline from the pilot report."""
from __future__ import annotations
from pathlib import Path
from .models import PilotSessionReport
class PresentationSummaryBuilder:
def build(self,report:PilotSessionReport,analysis_dir:Path,output_path:Path)->Path:
plots=sorted((analysis_dir/"plots").glob("*.png"));output_path.parent.mkdir(parents=True,exist_ok=True)
lines=["# 中間発表結果整理","","## 1. 研究目的","デジタルサイネージ付近の通行人に対する音声提示と顔向き反応を観測する。","","## 2. 実装済みシステム","YOLO人物検出、ByteTrack追跡、Head Pose、振り向き判定、音声制御、統合ログ、統計解析、GUI。","","## 3. 実験条件","PromptとControlで音声以外の条件を可能な限り揃える。","","## 4. パイロット結果",f"- Prompt反応率: {report.response_rate_prompt}",f"- Control反応率: {report.response_rate_control}",f"- 反応率差: {report.rate_difference}",f"- Prompt平均反応時間: {report.mean_reaction_time_prompt}",f"- Control平均反応時間: {report.mean_reaction_time_control}","","## 5. グラフ"]
lines.extend(f"- ![{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

@ -0,0 +1,45 @@
"""Offline Phase 7/8 artifact quality checks using the standard CSV module."""
from __future__ import annotations
import csv,json
from dataclasses import asdict
from pathlib import Path
from .config_loader import QualityCheckSettings
from .models import LogQualityIssue
def _bool(value:str|None)->bool:return str(value).strip().lower() in {"true","1","yes"}
class QualityChecker:
def __init__(self,config:QualityCheckSettings)->None:self.config=config
def _find(self,root:Path,suffix:str)->list[Path]:return sorted(root.rglob(f"*{suffix}")) if root.exists() else []
def check(self,unified_log_dir:Path,analysis_dir:Path)->list[LogQualityIssue]:
issues=[];tables=self._find(unified_log_dir,"person_analysis_table.csv");sessions=self._find(unified_log_dir,"session_summary.csv")
def add(kind,severity,path,message,action,metadata=None):issues.append(LogQualityIssue(kind,severity,str(path) if path else None,None,None,message,action,metadata or {}))
rows: list[dict[str, str]] = []
if not tables:
add("missing_file","error",None,"person_analysis_table.csv がありません","統合ログを有効にして実験を再確認する")
for path in tables:
with path.open(encoding="utf-8-sig",newline="") as f:rows.extend(dict(r,_file=str(path)) for r in csv.DictReader(f))
if tables and not rows:add("empty_analysis_table","error",tables[0],"解析テーブルが空です","追跡とfinalize処理を確認する")
if not sessions:add("missing_session_summary","warning",None,"session_summary.csv がありません","正常終了と統合ログ設定を確認する")
conditions={r.get("prompt_condition") for r in rows}
if self.config.require_prompt_and_control:
if "prompt" not in conditions:add("no_prompt_records","warning",None,"Prompt記録がありません","Prompt条件を収集する")
if "control" not in conditions:add("no_control_records","warning",None,"Control記録がありません","Control条件を収集する")
total=len(rows);valid=sum(_bool(r.get("valid_for_voice_analysis")) for r in rows);not_eval=sum(r.get("turn_level")=="not_evaluable" for r in rows);no_pose=sum(not _bool(r.get("pose_estimated_ever")) for r in rows)
if total and valid/total<self.config.min_valid_voice_analysis_rate:add("low_valid_voice_analysis_rate","warning",None,"有効音声解析率が閾値未満です","顔向き・追跡・カメラ位置を確認する",{"rate":valid/total})
if total and not_eval/total>self.config.max_not_evaluable_rate:add("high_not_evaluable_rate","warning",None,"評価不能率が閾値を超えています","顔が映るカメラ位置へ調整する",{"rate":not_eval/total})
if total and no_pose/total>self.config.max_no_pose_rate:add("high_no_pose_rate","warning",None,"Pose未推定率が閾値を超えています","照明、顔ROI、カメラ角度を確認する",{"rate":no_pose/total})
if self.config.require_camera_position_note and any(not r.get("camera_position_note","").strip() for r in rows):add("missing_camera_position_note","warning",None,"camera_position_noteが空です","セッションメモと統合ログへ記録する")
if any(float(r["reaction_time_sec"])<0 for r in rows if r.get("reaction_time_sec") not in {None,""}):add("negative_reaction_time","error",None,"負の反応時間があります","prompt時刻とturn時刻を確認する")
if any(_bool(r.get("response_detected")) and not r.get("reaction_time_sec") for r in rows):add("low_response_window_count","warning",None,"反応ありで反応時間が欠損しています","response window処理を確認する")
if not self._find(analysis_dir,"condition_summary.csv"):add("missing_file","warning",None,"condition_summary.csv がありません","Phase 8解析を実行する")
if self.config.require_plots and not list((analysis_dir/"plots").glob("*.png")):add("no_plot_outputs","warning",analysis_dir/"plots","解析グラフがありません","グラフ生成を有効にする")
return issues
def export_csv(self,issues:list[LogQualityIssue],output_path:Path)->Path:
output_path.parent.mkdir(parents=True,exist_ok=True);fields=["issue_type","severity","file_path","session_id","track_id","message","suggested_action","metadata_json"]
with output_path.open("w",encoding="utf-8-sig",newline="") as f:
w=csv.DictWriter(f,fieldnames=fields);w.writeheader()
for i in issues:d=asdict(i);d["metadata_json"]=json.dumps(d.pop("metadata"),ensure_ascii=False);w.writerow(d)
return output_path
def export_markdown(self,issues:list[LogQualityIssue],output_path:Path)->Path:
output_path.parent.mkdir(parents=True,exist_ok=True);counts={s:sum(i.severity==s for i in issues) for s in ("error","warning","info")};lines=["# ログ品質レポート","",f"- 総Issue数: {len(issues)}",f"- Error: {counts['error']}",f"- Warning: {counts['warning']}",f"- Info: {counts['info']}","","## 詳細"]
lines.extend(f"- [{i.severity.upper()}] {i.message} — 推奨: {i.suggested_action}" for i in issues);output_path.write_text("\n".join(lines)+"\n",encoding="utf-8");return output_path

@ -0,0 +1,16 @@
"""Aggregate person analysis rows into comparable session quality summaries."""
from __future__ import annotations
import statistics
from collections import defaultdict
from .models import SessionQualitySummary
def _b(v)->bool:return str(v).lower() in {"true","1","yes"}
class SessionComparator:
def summarize(self,rows:list[dict[str,str]])->list[SessionQualitySummary]:
groups=defaultdict(list)
for r in rows:groups[r.get("session_id") or "unknown"].append(r)
out=[]
for sid,items in groups.items():
total=len(items);valid=sum(_b(x.get("valid_for_voice_analysis")) for x in items);resp=sum(_b(x.get("response_detected")) and _b(x.get("valid_for_voice_analysis")) for x in items);ne=sum(x.get("turn_level")=="not_evaluable" for x in items);np=sum(not _b(x.get("pose_estimated_ever")) for x in items);times=[float(x["reaction_time_sec"]) for x in items if x.get("reaction_time_sec") not in {None,""} and _b(x.get("response_detected"))];camera=next((x.get("camera_position_note") for x in items if x.get("camera_position_note")),None);vr=valid/total if total else None;ner=ne/total if total else None;npr=np/total if total else None
poor=(vr is not None and vr<.5) or (ner is not None and ner>.4) or (npr is not None and npr>.4);caution=not camera or valid<5;level="poor" if poor else "caution" if caution else "good";issue="評価可能率・Pose取得率" if poor else "カメラ位置メモまたはサンプル数" if caution else "none";action="カメラ位置と顔処理条件を見直す" if poor else "記録と有効人数を追加する" if caution else "条件を維持する"
out.append(SessionQualitySummary(sid,items[0].get("prompt_condition","unknown"),total,valid,vr,resp,resp/valid if valid else None,ne,ner,np,npr,statistics.mean(times) if times else None,statistics.median(times) if times else None,camera,level,issue,action))
return out

@ -0,0 +1,38 @@
"""Aggregate analysis records without mixing non-evaluable tracks into rates."""
from __future__ import annotations
import statistics
from .config_loader import UnifiedLogSettings
from .models import LogConsistencyIssue, PersonAnalysisRecord, SessionSummary
from .turn_rules import is_weak_or_higher
class SessionSummaryBuilder:
"""Calculate session-level counts and rates using valid denominators."""
def __init__(self, config: UnifiedLogSettings, experiment_id: str, session_id: str, started_at: str) -> None:
self.config, self.experiment_id, self.session_id, self.started_at = config, experiment_id, session_id, started_at
def build(
self, records: list[PersonAnalysisRecord], ended_at: str, duration_sec: float,
voice_mode: str | None, average_fps: float | None = None,
issues: list[LogConsistencyIssue] | None = None,
) -> SessionSummary:
valid = [item for item in records if item.valid_for_voice_analysis is True]
reactions = [item.reaction_time_sec for item in valid if item.response_detected and item.reaction_time_sec is not None]
warnings = sum(item.severity == "warning" for item in (issues or []))
errors = sum(item.severity == "error" for item in (issues or []))
levels = [item.turn_level for item in records]
return SessionSummary(self.experiment_id, self.session_id, self.started_at, ended_at, duration_sec, voice_mode,
len(records), sum(item.completed for item in records), sum(item.excluded for item in records),
sum(item.valid_for_turn_analysis is True for item in records), len(valid),
sum(item.prompt_played for item in records), sum(item.pseudo_prompt for item in records),
sum(item.response_detected for item in valid), sum(not item.response_detected for item in valid),
sum(item.valid_for_turn_analysis is False for item in records),
(sum(item.response_detected for item in valid) / len(valid)) if valid else None,
statistics.mean(reactions) if reactions else None, statistics.median(reactions) if reactions else None,
sum(bool(level and is_weak_or_higher(level)) for level in levels), sum(level == "subtle" for level in levels),
sum(level in {"medium", "strong"} for level in levels), sum(level == "strong" for level in levels),
average_fps, self.config.camera_position_note or None, warnings, errors, "observational summary")

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save