Spaces:
Sleeping
Sleeping
Commit ·
2278049
1
Parent(s): 23769bb
feat: Full DetectifAI backend with B2 storage, DEMO_MODE, Stripe bypass
Browse files- Complete Flask backend with video processing, object detection, facial recognition
- Backblaze B2 cloud storage (replaced MinIO)
- DEMO_MODE support: set DEMO_MODE=true for Pro features without Stripe
- Lazy Stripe initialization (no crash if keys missing)
- Subscription routes with demo fallback
- Docker-ready deployment for HF Spaces
This view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +74 -0
- DetectifAI_db/app_integrated.py +1250 -0
- DetectifAI_db/caption_search.py +209 -0
- DetectifAI_db/check_minio.py +26 -0
- DetectifAI_db/check_video_storage.py +191 -0
- DetectifAI_db/create_admin.py +120 -0
- DetectifAI_db/database_seed.py +212 -0
- DetectifAI_db/database_setup.py +375 -0
- DetectifAI_db/env.example +19 -0
- DetectifAI_db/faiss_captions.index +0 -0
- DetectifAI_db/faiss_captions_idmap.json +12 -0
- DetectifAI_db/migrate_stripe_integration.py +209 -0
- DetectifAI_db/minio_config.py +37 -0
- DetectifAI_db/requirements.txt +14 -0
- DetectifAI_db/reset_minio.py +104 -0
- DetectifAI_db/reset_users_collection.py +29 -0
- DetectifAI_db/seed_stripe_plans.py +141 -0
- DetectifAI_db/setup_database.py +44 -0
- DetectifAI_db/setup_minio.py +91 -0
- DetectifAI_db/setup_nlp_bucket.py +61 -0
- DetectifAI_db/upload_caption_images.py +264 -0
- DetectifAI_db/upload_captions.py +349 -0
- DetectifAI_db/vector_index.py +348 -0
- Dockerfile +92 -0
- README.md +27 -6
- alert_routes.py +361 -0
- app.py +0 -0
- behavior_analysis/action_recognition.py +381 -0
- behavior_analysis/wallclimb.pt +3 -0
- behavior_analysis/yolov11_wallclimb.pt +3 -0
- behavior_analysis_integrator.py +580 -0
- config.py +369 -0
- core/video_processing.py +384 -0
- database/config.py +173 -0
- database/keyframe_repository.py +243 -0
- database/models.py +432 -0
- database/models_backup.py +330 -0
- database/repositories.py +516 -0
- database/repositories_old.py +653 -0
- database/storage_logger.py +41 -0
- database/video_compression_service.py +379 -0
- database_video_service.py +1804 -0
- detectifai_events.py +577 -0
- event_aggregation.py +819 -0
- event_clip_generator.py +390 -0
- extract_upload_keyframes.py +240 -0
- facial_recognition.py +926 -0
- highlight_reel.py +542 -0
- json_reports.py +575 -0
- live_stream_processor.py +866 -0
.dockerignore
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ignore everything we don't need in the Docker image
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
venv/
|
| 6 |
+
.venv/
|
| 7 |
+
.env
|
| 8 |
+
.env.example
|
| 9 |
+
|
| 10 |
+
# Videos & test files
|
| 11 |
+
*.mp4
|
| 12 |
+
*.avi
|
| 13 |
+
*.mov
|
| 14 |
+
*.mkv
|
| 15 |
+
*.mpeg
|
| 16 |
+
*.wmv
|
| 17 |
+
*.flv
|
| 18 |
+
*.jpeg
|
| 19 |
+
*.jpg
|
| 20 |
+
download.jpeg
|
| 21 |
+
images.jpeg
|
| 22 |
+
|
| 23 |
+
# Large model files — downloaded at build time from HF Hub instead
|
| 24 |
+
behavior_analysis/accident_detection.pt
|
| 25 |
+
behavior_analysis/fight_detection.pt
|
| 26 |
+
report_generation/models/qwen2.5-3b-instruct-q4_k_m.gguf
|
| 27 |
+
report_generation/models/.cache/
|
| 28 |
+
|
| 29 |
+
# Output directories
|
| 30 |
+
video_processing_outputs/
|
| 31 |
+
logs/
|
| 32 |
+
uploads/
|
| 33 |
+
temp_faces/
|
| 34 |
+
|
| 35 |
+
# Test & debug files
|
| 36 |
+
test_*.py
|
| 37 |
+
check_*.py
|
| 38 |
+
debug_*.py
|
| 39 |
+
verify_*.py
|
| 40 |
+
reproduce_issue.py
|
| 41 |
+
fix_*.py
|
| 42 |
+
clear_cache_and_test.py
|
| 43 |
+
simple_test_report.py
|
| 44 |
+
quick_fix_reports.py
|
| 45 |
+
scan_imports_temp.py
|
| 46 |
+
protected_api_example.py
|
| 47 |
+
|
| 48 |
+
# Misc
|
| 49 |
+
output*.txt
|
| 50 |
+
verify_log.txt
|
| 51 |
+
*.zip
|
| 52 |
+
*.bat
|
| 53 |
+
README.md
|
| 54 |
+
BUCKET_NAMES.md
|
| 55 |
+
VIDEO_CAPTIONING_MONGODB_INTEGRATION.md
|
| 56 |
+
video_captioning_store/
|
| 57 |
+
backfill_*.py
|
| 58 |
+
create_subscriptions.py
|
| 59 |
+
|
| 60 |
+
# Unnecessary sub-items
|
| 61 |
+
behavior_analysis/action_recognition_outputs/
|
| 62 |
+
video_captioning/video_captioning/captions.db
|
| 63 |
+
video_captioning/video_captioning/tests/
|
| 64 |
+
video_captioning/video_captioning/vector_store/
|
| 65 |
+
video_captioning/video_captioning/example_usage.py
|
| 66 |
+
video_captioning/video_captioning/install_requirements.py
|
| 67 |
+
video_captioning/video_captioning/integration_example.py
|
| 68 |
+
video_captioning/video_captioning/quick_test.py
|
| 69 |
+
video_captioning/video_captioning/run_video_test.py
|
| 70 |
+
video_captioning/video_captioning/simple_test.py
|
| 71 |
+
video_captioning/video_captioning/test_runner.py
|
| 72 |
+
video_captioning/video_captioning/working_test.py
|
| 73 |
+
video_captioning/video_captioning/data_flow_diagram.md
|
| 74 |
+
video_captioning/video_captioning/README.md
|
DetectifAI_db/app_integrated.py
ADDED
|
@@ -0,0 +1,1250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DetectifAI Flask Backend - AI-Powered CCTV Surveillance System with Database Integration
|
| 3 |
+
|
| 4 |
+
Enhanced Flask API for:
|
| 5 |
+
- Video upload and processing with DetectifAI security focus
|
| 6 |
+
- Real-time processing status and results
|
| 7 |
+
- Object detection with fire/weapon recognition
|
| 8 |
+
- Security event analysis and threat assessment
|
| 9 |
+
- Database integration with MongoDB and FAISS vector search
|
| 10 |
+
- User authentication and authorization
|
| 11 |
+
- Frontend integration for surveillance dashboard
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
from datetime import datetime, timedelta, timezone
|
| 16 |
+
from uuid import uuid4
|
| 17 |
+
|
| 18 |
+
from flask import Flask, request, jsonify, send_file, send_from_directory, g
|
| 19 |
+
from flask_cors import CORS
|
| 20 |
+
from werkzeug.utils import secure_filename
|
| 21 |
+
import threading
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
import jwt
|
| 25 |
+
from dotenv import load_dotenv
|
| 26 |
+
import numpy as np
|
| 27 |
+
|
| 28 |
+
# Import DetectifAI components
|
| 29 |
+
from main_pipeline import CompleteVideoProcessingPipeline
|
| 30 |
+
from config import get_security_focused_config, VideoProcessingConfig
|
| 31 |
+
|
| 32 |
+
# Import database components
|
| 33 |
+
from pymongo import MongoClient
|
| 34 |
+
from minio import Minio
|
| 35 |
+
from minio.error import S3Error
|
| 36 |
+
from vector_index import get_faiss_manager, generate_text_embedding, generate_visual_embedding
|
| 37 |
+
|
| 38 |
+
# Try to import caption search (optional - may not be available)
|
| 39 |
+
try:
|
| 40 |
+
from caption_search import get_caption_search_engine
|
| 41 |
+
CAPTION_SEARCH_AVAILABLE = True
|
| 42 |
+
except ImportError as e:
|
| 43 |
+
logger.warning(f"Caption search not available: {e}")
|
| 44 |
+
CAPTION_SEARCH_AVAILABLE = False
|
| 45 |
+
get_caption_search_engine = None
|
| 46 |
+
|
| 47 |
+
# Try to import DetectifAI-specific components
|
| 48 |
+
try:
|
| 49 |
+
from detectifai_events import DetectifAIEventType, ThreatLevel
|
| 50 |
+
DETECTIFAI_EVENTS_AVAILABLE = True
|
| 51 |
+
except ImportError:
|
| 52 |
+
DETECTIFAI_EVENTS_AVAILABLE = False
|
| 53 |
+
logging.warning("DetectifAI events module not available - using basic functionality")
|
| 54 |
+
|
| 55 |
+
# === Load Environment ===
|
| 56 |
+
load_dotenv()
|
| 57 |
+
MONGO_URI = os.getenv("MONGO_URI")
|
| 58 |
+
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT")
|
| 59 |
+
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY")
|
| 60 |
+
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY")
|
| 61 |
+
MINIO_BUCKET = os.getenv("MINIO_BUCKET")
|
| 62 |
+
JWT_SECRET = os.getenv("JWT_SECRET", "defaultsecret")
|
| 63 |
+
|
| 64 |
+
# Initialize Flask app
|
| 65 |
+
app = Flask(__name__)
|
| 66 |
+
CORS(app, resources={r"/api/*": {"origins": "*"}})
|
| 67 |
+
|
| 68 |
+
# Configure logging
|
| 69 |
+
logging.basicConfig(
|
| 70 |
+
level=logging.INFO,
|
| 71 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 72 |
+
handlers=[
|
| 73 |
+
logging.StreamHandler(),
|
| 74 |
+
logging.FileHandler('logs/detectifai_api.log')
|
| 75 |
+
]
|
| 76 |
+
)
|
| 77 |
+
logger = logging.getLogger(__name__)
|
| 78 |
+
|
| 79 |
+
# Configuration
|
| 80 |
+
UPLOAD_FOLDER = 'uploads'
|
| 81 |
+
OUTPUT_FOLDER = 'video_processing_outputs'
|
| 82 |
+
ALLOWED_EXTENSIONS = {'mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv'}
|
| 83 |
+
MAX_CONTENT_LENGTH = 500 * 1024 * 1024 # 500MB max file size
|
| 84 |
+
|
| 85 |
+
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
| 86 |
+
app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH
|
| 87 |
+
|
| 88 |
+
# Create necessary directories
|
| 89 |
+
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
| 90 |
+
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
|
| 91 |
+
os.makedirs('logs', exist_ok=True)
|
| 92 |
+
|
| 93 |
+
# === MongoDB Atlas Setup ===
|
| 94 |
+
mongo = MongoClient(MONGO_URI)
|
| 95 |
+
db = mongo.get_default_database()
|
| 96 |
+
|
| 97 |
+
# Collections from schema
|
| 98 |
+
admin = db.admin
|
| 99 |
+
user = db.users # Use 'users' to match database_setup.py
|
| 100 |
+
users = db.users # Alias for clarity
|
| 101 |
+
video_file = db.video_file
|
| 102 |
+
event = db.event
|
| 103 |
+
event_clip = db.event_clip
|
| 104 |
+
detected_faces = db.detected_faces
|
| 105 |
+
face_matches = db.face_matches
|
| 106 |
+
event_description = db.event_description
|
| 107 |
+
event_caption = db.event_caption
|
| 108 |
+
query = db.query
|
| 109 |
+
query_result = db.query_result
|
| 110 |
+
subscription_plan = db.subscription_plan
|
| 111 |
+
user_subscription = db.user_subscription
|
| 112 |
+
|
| 113 |
+
# === MinIO Setup ===
|
| 114 |
+
minio_client = Minio(
|
| 115 |
+
MINIO_ENDPOINT,
|
| 116 |
+
access_key=MINIO_ACCESS_KEY,
|
| 117 |
+
secret_key=MINIO_SECRET_KEY,
|
| 118 |
+
secure=False
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
if not minio_client.bucket_exists(MINIO_BUCKET):
|
| 123 |
+
minio_client.make_bucket(MINIO_BUCKET)
|
| 124 |
+
except S3Error as err:
|
| 125 |
+
if err.code != "BucketAlreadyOwnedByYou" and err.code != "BucketAlreadyExists":
|
| 126 |
+
raise
|
| 127 |
+
|
| 128 |
+
# === FAISS Setup ===
|
| 129 |
+
faiss_manager = get_faiss_manager()
|
| 130 |
+
|
| 131 |
+
# Store processing status in memory (use Redis in production)
|
| 132 |
+
processing_status = {}
|
| 133 |
+
|
| 134 |
+
# === Auth Helpers ===
|
| 135 |
+
def generate_jwt(user):
|
| 136 |
+
payload = {
|
| 137 |
+
"user_id": user["user_id"],
|
| 138 |
+
"email": user["email"],
|
| 139 |
+
"role": user.get("role", "user"),
|
| 140 |
+
"exp": datetime.now(timezone.utc) + timedelta(hours=24)
|
| 141 |
+
}
|
| 142 |
+
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
| 143 |
+
|
| 144 |
+
def decode_jwt(token):
|
| 145 |
+
try:
|
| 146 |
+
return jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
| 147 |
+
except jwt.ExpiredSignatureError:
|
| 148 |
+
return None
|
| 149 |
+
except jwt.InvalidTokenError:
|
| 150 |
+
return None
|
| 151 |
+
|
| 152 |
+
def auth_required(role=None):
|
| 153 |
+
def decorator(func):
|
| 154 |
+
def wrapper(*args, **kwargs):
|
| 155 |
+
token = request.headers.get("Authorization", "").replace("Bearer ", "")
|
| 156 |
+
if not token:
|
| 157 |
+
return jsonify({"error": "missing token"}), 401
|
| 158 |
+
decoded = decode_jwt(token)
|
| 159 |
+
if not decoded:
|
| 160 |
+
return jsonify({"error": "invalid or expired token"}), 401
|
| 161 |
+
if role and decoded.get("role") != role:
|
| 162 |
+
return jsonify({"error": "unauthorized"}), 403
|
| 163 |
+
g.user = decoded
|
| 164 |
+
return func(*args, **kwargs)
|
| 165 |
+
wrapper.__name__ = func.__name__
|
| 166 |
+
return wrapper
|
| 167 |
+
return decorator
|
| 168 |
+
|
| 169 |
+
def allowed_file(filename):
|
| 170 |
+
"""Check if file extension is allowed"""
|
| 171 |
+
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
| 172 |
+
|
| 173 |
+
def extract_detectifai_results(pipeline_results):
|
| 174 |
+
"""Extract DetectifAI-specific results from pipeline output"""
|
| 175 |
+
try:
|
| 176 |
+
detectifai_results = {
|
| 177 |
+
# Basic video metrics
|
| 178 |
+
'video_info': {
|
| 179 |
+
'total_keyframes': pipeline_results['outputs'].get('total_keyframes', 0),
|
| 180 |
+
'processing_time': pipeline_results['processing_stats'].get('total_processing_time', 0),
|
| 181 |
+
'output_directory': pipeline_results['outputs'].get('output_directory', '')
|
| 182 |
+
},
|
| 183 |
+
|
| 184 |
+
# Security detection results
|
| 185 |
+
'security_detection': {
|
| 186 |
+
'total_object_detections': pipeline_results['outputs'].get('total_object_detections', 0),
|
| 187 |
+
'total_object_events': pipeline_results['outputs'].get('total_object_events', 0),
|
| 188 |
+
'detectifai_events': pipeline_results['outputs'].get('detectifai_events', 0),
|
| 189 |
+
'fire_detections': 0, # Will be populated from actual results
|
| 190 |
+
'weapon_detections': 0,
|
| 191 |
+
'security_alerts': []
|
| 192 |
+
},
|
| 193 |
+
|
| 194 |
+
# Event analysis
|
| 195 |
+
'event_analysis': {
|
| 196 |
+
'canonical_events': pipeline_results['outputs'].get('canonical_events', 0),
|
| 197 |
+
'total_motion_events': pipeline_results['outputs'].get('total_motion_events', 0),
|
| 198 |
+
'high_priority_events': 0,
|
| 199 |
+
'critical_events': 0
|
| 200 |
+
},
|
| 201 |
+
|
| 202 |
+
# Output files
|
| 203 |
+
'output_files': {
|
| 204 |
+
'keyframes_directory': os.path.join(pipeline_results['outputs'].get('output_directory', ''), 'frames'),
|
| 205 |
+
'reports': pipeline_results['outputs'].get('reports', {}),
|
| 206 |
+
'highlight_reels': pipeline_results['outputs'].get('highlight_reels', {}),
|
| 207 |
+
'compressed_video': pipeline_results['outputs'].get('compressed_video', '')
|
| 208 |
+
},
|
| 209 |
+
|
| 210 |
+
# System performance
|
| 211 |
+
'performance': {
|
| 212 |
+
'frames_processed': pipeline_results['processing_stats'].get('frames_processed', 0),
|
| 213 |
+
'frames_enhanced': pipeline_results['processing_stats'].get('frames_enhanced', 0),
|
| 214 |
+
'gpu_acceleration': pipeline_results['processing_stats'].get('gpu_used', False)
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
return detectifai_results
|
| 219 |
+
|
| 220 |
+
except Exception as e:
|
| 221 |
+
logger.error(f"Error extracting DetectifAI results: {e}")
|
| 222 |
+
return {'error': 'Failed to extract results'}
|
| 223 |
+
|
| 224 |
+
def process_video_async(video_id, video_path, config_type='detectifai', user_id=None):
|
| 225 |
+
"""Process video in background thread with DetectifAI focus and database integration"""
|
| 226 |
+
try:
|
| 227 |
+
processing_status[video_id]['status'] = 'processing'
|
| 228 |
+
processing_status[video_id]['progress'] = 0
|
| 229 |
+
processing_status[video_id]['message'] = 'Initializing DetectifAI processing...'
|
| 230 |
+
|
| 231 |
+
# Select configuration with DetectifAI optimizations
|
| 232 |
+
if config_type == 'detectifai' or config_type == 'security':
|
| 233 |
+
config = get_security_focused_config()
|
| 234 |
+
# Removed robbery detection - using security focused config
|
| 235 |
+
elif config_type == 'high_recall':
|
| 236 |
+
try:
|
| 237 |
+
from config import get_high_recall_config
|
| 238 |
+
config = get_high_recall_config()
|
| 239 |
+
except ImportError:
|
| 240 |
+
config = get_security_focused_config()
|
| 241 |
+
elif config_type == 'balanced':
|
| 242 |
+
try:
|
| 243 |
+
from config import get_balanced_config
|
| 244 |
+
config = get_balanced_config()
|
| 245 |
+
except ImportError:
|
| 246 |
+
config = VideoProcessingConfig()
|
| 247 |
+
else:
|
| 248 |
+
config = VideoProcessingConfig()
|
| 249 |
+
|
| 250 |
+
# DetectifAI-specific configuration enhancements
|
| 251 |
+
config.enable_object_detection = True
|
| 252 |
+
config.enable_facial_recognition = True
|
| 253 |
+
config.keyframe_extraction_fps = 1.0 # Extract 1 frame per second for surveillance
|
| 254 |
+
config.enable_adaptive_processing = True
|
| 255 |
+
|
| 256 |
+
# Set custom output directory for this video
|
| 257 |
+
config.output_base_dir = os.path.join(OUTPUT_FOLDER, video_id)
|
| 258 |
+
|
| 259 |
+
# Initialize pipeline
|
| 260 |
+
pipeline = CompleteVideoProcessingPipeline(config)
|
| 261 |
+
|
| 262 |
+
# Update progress
|
| 263 |
+
processing_status[video_id]['progress'] = 10
|
| 264 |
+
processing_status[video_id]['message'] = 'Extracting keyframes for security analysis...'
|
| 265 |
+
|
| 266 |
+
# Process video with DetectifAI (with error tolerance)
|
| 267 |
+
output_name = os.path.splitext(os.path.basename(video_path))[0]
|
| 268 |
+
results = None
|
| 269 |
+
processing_errors = []
|
| 270 |
+
|
| 271 |
+
try:
|
| 272 |
+
results = pipeline.process_video_complete(video_path, output_name)
|
| 273 |
+
logger.info(f"✅ Core pipeline processing completed for {video_id}")
|
| 274 |
+
except Exception as pipeline_error:
|
| 275 |
+
logger.error(f"⚠️ Pipeline error (but continuing): {str(pipeline_error)}")
|
| 276 |
+
processing_errors.append(f"Pipeline: {str(pipeline_error)}")
|
| 277 |
+
# Create minimal results structure
|
| 278 |
+
results = {
|
| 279 |
+
'outputs': {
|
| 280 |
+
'total_keyframes': 0,
|
| 281 |
+
'total_events': 0,
|
| 282 |
+
'total_motion_events': 0,
|
| 283 |
+
'total_object_events': 0,
|
| 284 |
+
'total_object_detections': 0,
|
| 285 |
+
'canonical_events': [],
|
| 286 |
+
'total_segments': 1,
|
| 287 |
+
'highlight_reels': {},
|
| 288 |
+
'reports': {},
|
| 289 |
+
'compressed_video': ''
|
| 290 |
+
},
|
| 291 |
+
'processing_stats': {'total_processing_time': 0}
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
# Extract DetectifAI-specific results (with error tolerance)
|
| 295 |
+
detectifai_results = {}
|
| 296 |
+
try:
|
| 297 |
+
detectifai_results = extract_detectifai_results(results)
|
| 298 |
+
except Exception as extract_error:
|
| 299 |
+
logger.error(f"⚠️ Result extraction error (but continuing): {str(extract_error)}")
|
| 300 |
+
processing_errors.append(f"Extraction: {str(extract_error)}")
|
| 301 |
+
detectifai_results = {'security_detection': {}, 'event_analysis': {}, 'performance': {}}
|
| 302 |
+
|
| 303 |
+
# Store results in database
|
| 304 |
+
try:
|
| 305 |
+
# Update video file record with processing results
|
| 306 |
+
video_file.update_one(
|
| 307 |
+
{"video_id": video_id},
|
| 308 |
+
{
|
| 309 |
+
"$set": {
|
| 310 |
+
"processing_status": "completed",
|
| 311 |
+
"processing_results": {
|
| 312 |
+
"total_keyframes": results['outputs']['total_keyframes'],
|
| 313 |
+
"total_events": results['outputs']['total_events'],
|
| 314 |
+
"processing_time": results['processing_stats']['total_processing_time'],
|
| 315 |
+
"detectifai_results": detectifai_results
|
| 316 |
+
},
|
| 317 |
+
"updated_at": datetime.now(timezone.utc)
|
| 318 |
+
}
|
| 319 |
+
}
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
# Create events in database
|
| 323 |
+
for i, canonical_event in enumerate(results['outputs'].get('canonical_events', [])):
|
| 324 |
+
event_doc = {
|
| 325 |
+
"event_id": str(uuid4()),
|
| 326 |
+
"video_id": video_id,
|
| 327 |
+
"start_timestamp_ms": int(canonical_event.get('start_time', 0) * 1000),
|
| 328 |
+
"end_timestamp_ms": int(canonical_event.get('end_time', 0) * 1000),
|
| 329 |
+
"confidence_score": canonical_event.get('importance', 0.0),
|
| 330 |
+
"is_verified": False,
|
| 331 |
+
"is_false_positive": False,
|
| 332 |
+
"verified_at": None,
|
| 333 |
+
"verified_by": None,
|
| 334 |
+
"visual_embedding": generate_visual_embedding(),
|
| 335 |
+
"bounding_boxes": canonical_event.get('bounding_boxes', {}),
|
| 336 |
+
"event_type": canonical_event.get('event_type', 'motion_detection')
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
event.insert_one(event_doc)
|
| 340 |
+
|
| 341 |
+
# Add to FAISS index
|
| 342 |
+
faiss_manager.add_visual_embedding(event_doc["event_id"], event_doc["visual_embedding"])
|
| 343 |
+
|
| 344 |
+
# Create event description
|
| 345 |
+
description_doc = {
|
| 346 |
+
"description_id": str(uuid4()),
|
| 347 |
+
"event_id": event_doc["event_id"],
|
| 348 |
+
"text_embedding": generate_text_embedding(f"Event {i+1}: {canonical_event.get('description', 'Motion detected')}"),
|
| 349 |
+
"caption": canonical_event.get('description', f'Motion detected at {canonical_event.get("start_time", 0):.2f}s'),
|
| 350 |
+
"confidence": canonical_event.get('importance', 0.0),
|
| 351 |
+
"created_at": datetime.now(timezone.utc),
|
| 352 |
+
"updated_at": datetime.now(timezone.utc)
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
event_description.insert_one(description_doc)
|
| 356 |
+
|
| 357 |
+
# Add to FAISS text index
|
| 358 |
+
faiss_manager.add_text_embedding(description_doc["description_id"], description_doc["text_embedding"])
|
| 359 |
+
|
| 360 |
+
logger.info(f"✅ Database integration completed for {video_id}")
|
| 361 |
+
|
| 362 |
+
except Exception as db_error:
|
| 363 |
+
logger.error(f"⚠️ Database integration error (but continuing): {str(db_error)}")
|
| 364 |
+
processing_errors.append(f"Database: {str(db_error)}")
|
| 365 |
+
|
| 366 |
+
# Always mark as completed (even with errors)
|
| 367 |
+
processing_status[video_id]['status'] = 'completed'
|
| 368 |
+
processing_status[video_id]['progress'] = 100
|
| 369 |
+
completion_message = 'DetectifAI processing completed successfully!'
|
| 370 |
+
if processing_errors:
|
| 371 |
+
completion_message = f'DetectifAI processing completed with warnings: {len(processing_errors)} non-critical errors'
|
| 372 |
+
processing_status[video_id]['message'] = completion_message
|
| 373 |
+
processing_status[video_id]['results'] = {
|
| 374 |
+
# Original results for backward compatibility
|
| 375 |
+
'total_keyframes': results['outputs']['total_keyframes'],
|
| 376 |
+
'total_events': results['outputs']['total_events'],
|
| 377 |
+
'total_motion_events': results['outputs'].get('total_motion_events', 0),
|
| 378 |
+
'total_object_events': results['outputs'].get('total_object_events', 0),
|
| 379 |
+
'total_object_detections': results['outputs'].get('total_object_detections', 0),
|
| 380 |
+
'canonical_events': results['outputs']['canonical_events'],
|
| 381 |
+
'total_segments': results['outputs']['total_segments'],
|
| 382 |
+
'processing_time': results['processing_stats']['total_processing_time'],
|
| 383 |
+
'highlight_reels': results['outputs'].get('highlight_reels', {}),
|
| 384 |
+
'reports': results['outputs'].get('reports', {}),
|
| 385 |
+
'compressed_video': results['outputs'].get('compressed_video', ''),
|
| 386 |
+
'output_directory': config.output_base_dir,
|
| 387 |
+
'object_detection_enabled': config.enable_object_detection,
|
| 388 |
+
|
| 389 |
+
# DetectifAI-specific results
|
| 390 |
+
'detectifai_results': detectifai_results,
|
| 391 |
+
'security_detection': detectifai_results.get('security_detection', {}),
|
| 392 |
+
'event_analysis': detectifai_results.get('event_analysis', {}),
|
| 393 |
+
'performance': detectifai_results.get('performance', {}),
|
| 394 |
+
|
| 395 |
+
# Processing status
|
| 396 |
+
'processing_errors': processing_errors,
|
| 397 |
+
'has_warnings': len(processing_errors) > 0
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
logger.info(f"Video {video_id} processed successfully")
|
| 401 |
+
|
| 402 |
+
except Exception as e:
|
| 403 |
+
logger.error(f"Error processing video {video_id}: {str(e)}")
|
| 404 |
+
processing_status[video_id]['status'] = 'failed'
|
| 405 |
+
processing_status[video_id]['message'] = f'Error: {str(e)}'
|
| 406 |
+
processing_status[video_id]['error'] = str(e)
|
| 407 |
+
|
| 408 |
+
# === API Endpoints ===
|
| 409 |
+
|
| 410 |
+
@app.route('/')
|
| 411 |
+
def index():
|
| 412 |
+
return jsonify({"message": "DetectifAI backend running with database integration"})
|
| 413 |
+
|
| 414 |
+
@app.route('/api/health', methods=['GET'])
|
| 415 |
+
def health_check():
|
| 416 |
+
"""Health check endpoint"""
|
| 417 |
+
return jsonify({'status': 'healthy', 'timestamp': datetime.now().isoformat()})
|
| 418 |
+
|
| 419 |
+
# === Authentication Endpoints ===
|
| 420 |
+
|
| 421 |
+
@app.route("/api/register", methods=["POST"])
|
| 422 |
+
def register():
|
| 423 |
+
data = request.json or {}
|
| 424 |
+
email = data.get("email")
|
| 425 |
+
password = data.get("password")
|
| 426 |
+
username = data.get("username", email.split("@")[0] if email else None)
|
| 427 |
+
|
| 428 |
+
if not email or not password:
|
| 429 |
+
return jsonify({"error": "email and password required"}), 400
|
| 430 |
+
if user.find_one({"email": email}):
|
| 431 |
+
return jsonify({"error": "email exists"}), 400
|
| 432 |
+
|
| 433 |
+
user_doc = {
|
| 434 |
+
"user_id": str(uuid4()),
|
| 435 |
+
"username": username,
|
| 436 |
+
"email": email,
|
| 437 |
+
"password": password, # TODO: hash properly
|
| 438 |
+
"role": "user",
|
| 439 |
+
"created_at": datetime.now(timezone.utc),
|
| 440 |
+
"updated_at": datetime.now(timezone.utc),
|
| 441 |
+
"last_login": None
|
| 442 |
+
}
|
| 443 |
+
user.insert_one(user_doc)
|
| 444 |
+
token = generate_jwt(user_doc)
|
| 445 |
+
return jsonify({"token": token})
|
| 446 |
+
|
| 447 |
+
@app.route("/api/login", methods=["POST", "OPTIONS"])
|
| 448 |
+
def login():
|
| 449 |
+
if request.method == "OPTIONS":
|
| 450 |
+
return '', 200 # Handle preflight CORS request
|
| 451 |
+
|
| 452 |
+
data = request.json or {}
|
| 453 |
+
email = data.get("email")
|
| 454 |
+
password = data.get("password")
|
| 455 |
+
|
| 456 |
+
if not email or not password:
|
| 457 |
+
return jsonify({"error": "email and password required"}), 400
|
| 458 |
+
|
| 459 |
+
# Check against Mongo
|
| 460 |
+
user_doc = user.find_one({"email": email})
|
| 461 |
+
if not user_doc or user_doc.get("password") != password:
|
| 462 |
+
return jsonify({"error": "invalid credentials"}), 401
|
| 463 |
+
|
| 464 |
+
token = generate_jwt(user_doc)
|
| 465 |
+
return jsonify({
|
| 466 |
+
"message": "login successful",
|
| 467 |
+
"token": token,
|
| 468 |
+
"user": {
|
| 469 |
+
"user_id": user_doc["user_id"],
|
| 470 |
+
"username": user_doc.get("username"),
|
| 471 |
+
"email": user_doc["email"]
|
| 472 |
+
}
|
| 473 |
+
})
|
| 474 |
+
|
| 475 |
+
# === Admin User Management Endpoints ===
|
| 476 |
+
|
| 477 |
+
@app.route("/api/admin/users", methods=["GET"])
|
| 478 |
+
@auth_required(role="admin")
|
| 479 |
+
def get_all_users():
|
| 480 |
+
"""Get all users - Admin only"""
|
| 481 |
+
try:
|
| 482 |
+
# Get query parameters for pagination and filtering
|
| 483 |
+
page = int(request.args.get("page", 1))
|
| 484 |
+
limit = int(request.args.get("limit", 50))
|
| 485 |
+
search = request.args.get("search", "")
|
| 486 |
+
role_filter = request.args.get("role", "")
|
| 487 |
+
status_filter = request.args.get("status", "")
|
| 488 |
+
|
| 489 |
+
# Build query
|
| 490 |
+
query = {}
|
| 491 |
+
if search:
|
| 492 |
+
query["$or"] = [
|
| 493 |
+
{"email": {"$regex": search, "$options": "i"}},
|
| 494 |
+
{"username": {"$regex": search, "$options": "i"}}
|
| 495 |
+
]
|
| 496 |
+
if role_filter:
|
| 497 |
+
query["role"] = role_filter
|
| 498 |
+
if status_filter:
|
| 499 |
+
if status_filter == "active":
|
| 500 |
+
query["is_active"] = True
|
| 501 |
+
elif status_filter == "inactive":
|
| 502 |
+
query["is_active"] = False
|
| 503 |
+
|
| 504 |
+
# Get total count
|
| 505 |
+
total = users.count_documents(query)
|
| 506 |
+
|
| 507 |
+
# Get users with pagination
|
| 508 |
+
skip = (page - 1) * limit
|
| 509 |
+
user_list = list(users.find(query).skip(skip).limit(limit).sort("created_at", -1))
|
| 510 |
+
|
| 511 |
+
# Remove sensitive data
|
| 512 |
+
for u in user_list:
|
| 513 |
+
u["_id"] = str(u["_id"])
|
| 514 |
+
u.pop("password", None)
|
| 515 |
+
u.pop("password_hash", None)
|
| 516 |
+
|
| 517 |
+
return jsonify({
|
| 518 |
+
"users": user_list,
|
| 519 |
+
"total": total,
|
| 520 |
+
"page": page,
|
| 521 |
+
"limit": limit,
|
| 522 |
+
"pages": (total + limit - 1) // limit
|
| 523 |
+
})
|
| 524 |
+
except Exception as e:
|
| 525 |
+
logger.error(f"Error fetching users: {str(e)}")
|
| 526 |
+
return jsonify({"error": "Failed to fetch users"}), 500
|
| 527 |
+
|
| 528 |
+
@app.route("/api/admin/users", methods=["POST"])
|
| 529 |
+
@auth_required(role="admin")
|
| 530 |
+
def create_user():
|
| 531 |
+
"""Create a new user - Admin only"""
|
| 532 |
+
try:
|
| 533 |
+
data = request.json or {}
|
| 534 |
+
email = data.get("email")
|
| 535 |
+
password = data.get("password")
|
| 536 |
+
username = data.get("username") or data.get("name")
|
| 537 |
+
role = data.get("role", "user")
|
| 538 |
+
|
| 539 |
+
if not email or not password:
|
| 540 |
+
return jsonify({"error": "email and password required"}), 400
|
| 541 |
+
|
| 542 |
+
# Check if user already exists
|
| 543 |
+
if users.find_one({"email": email}):
|
| 544 |
+
return jsonify({"error": "User with this email already exists"}), 400
|
| 545 |
+
|
| 546 |
+
# Create user document
|
| 547 |
+
user_doc = {
|
| 548 |
+
"user_id": str(uuid4()),
|
| 549 |
+
"username": username or email.split("@")[0],
|
| 550 |
+
"email": email,
|
| 551 |
+
"password": password, # TODO: hash properly with bcrypt
|
| 552 |
+
"password_hash": password, # For compatibility
|
| 553 |
+
"role": role,
|
| 554 |
+
"is_active": True,
|
| 555 |
+
"profile_data": {},
|
| 556 |
+
"created_at": datetime.now(timezone.utc),
|
| 557 |
+
"updated_at": datetime.now(timezone.utc),
|
| 558 |
+
"last_login": None
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
users.insert_one(user_doc)
|
| 562 |
+
|
| 563 |
+
# Remove sensitive data before returning
|
| 564 |
+
user_doc["_id"] = str(user_doc["_id"])
|
| 565 |
+
user_doc.pop("password", None)
|
| 566 |
+
user_doc.pop("password_hash", None)
|
| 567 |
+
|
| 568 |
+
return jsonify({
|
| 569 |
+
"message": "User created successfully",
|
| 570 |
+
"user": user_doc
|
| 571 |
+
}), 201
|
| 572 |
+
except Exception as e:
|
| 573 |
+
logger.error(f"Error creating user: {str(e)}")
|
| 574 |
+
return jsonify({"error": "Failed to create user"}), 500
|
| 575 |
+
|
| 576 |
+
@app.route("/api/admin/users/<user_id>", methods=["GET"])
|
| 577 |
+
@auth_required(role="admin")
|
| 578 |
+
def get_user(user_id):
|
| 579 |
+
"""Get a specific user by ID - Admin only"""
|
| 580 |
+
try:
|
| 581 |
+
user_doc = users.find_one({"user_id": user_id})
|
| 582 |
+
if not user_doc:
|
| 583 |
+
return jsonify({"error": "User not found"}), 404
|
| 584 |
+
|
| 585 |
+
# Remove sensitive data
|
| 586 |
+
user_doc["_id"] = str(user_doc["_id"])
|
| 587 |
+
user_doc.pop("password", None)
|
| 588 |
+
user_doc.pop("password_hash", None)
|
| 589 |
+
|
| 590 |
+
return jsonify({"user": user_doc})
|
| 591 |
+
except Exception as e:
|
| 592 |
+
logger.error(f"Error fetching user: {str(e)}")
|
| 593 |
+
return jsonify({"error": "Failed to fetch user"}), 500
|
| 594 |
+
|
| 595 |
+
@app.route("/api/admin/users/<user_id>", methods=["PUT"])
|
| 596 |
+
@auth_required(role="admin")
|
| 597 |
+
def update_user(user_id):
|
| 598 |
+
"""Update a user - Admin only"""
|
| 599 |
+
try:
|
| 600 |
+
data = request.json or {}
|
| 601 |
+
user_doc = users.find_one({"user_id": user_id})
|
| 602 |
+
|
| 603 |
+
if not user_doc:
|
| 604 |
+
return jsonify({"error": "User not found"}), 404
|
| 605 |
+
|
| 606 |
+
# Update allowed fields
|
| 607 |
+
update_data = {}
|
| 608 |
+
if "username" in data or "name" in data:
|
| 609 |
+
update_data["username"] = data.get("username") or data.get("name")
|
| 610 |
+
if "email" in data:
|
| 611 |
+
# Check if new email already exists
|
| 612 |
+
existing = users.find_one({"email": data["email"], "user_id": {"$ne": user_id}})
|
| 613 |
+
if existing:
|
| 614 |
+
return jsonify({"error": "Email already in use"}), 400
|
| 615 |
+
update_data["email"] = data["email"]
|
| 616 |
+
if "role" in data:
|
| 617 |
+
update_data["role"] = data["role"]
|
| 618 |
+
if "is_active" in data:
|
| 619 |
+
update_data["is_active"] = data["is_active"]
|
| 620 |
+
if "password" in data and data["password"]:
|
| 621 |
+
update_data["password"] = data["password"]
|
| 622 |
+
update_data["password_hash"] = data["password"]
|
| 623 |
+
|
| 624 |
+
if not update_data:
|
| 625 |
+
return jsonify({"error": "No valid fields to update"}), 400
|
| 626 |
+
|
| 627 |
+
update_data["updated_at"] = datetime.now(timezone.utc)
|
| 628 |
+
|
| 629 |
+
users.update_one({"user_id": user_id}, {"$set": update_data})
|
| 630 |
+
|
| 631 |
+
# Fetch updated user
|
| 632 |
+
updated_user = users.find_one({"user_id": user_id})
|
| 633 |
+
updated_user["_id"] = str(updated_user["_id"])
|
| 634 |
+
updated_user.pop("password", None)
|
| 635 |
+
updated_user.pop("password_hash", None)
|
| 636 |
+
|
| 637 |
+
return jsonify({
|
| 638 |
+
"message": "User updated successfully",
|
| 639 |
+
"user": updated_user
|
| 640 |
+
})
|
| 641 |
+
except Exception as e:
|
| 642 |
+
logger.error(f"Error updating user: {str(e)}")
|
| 643 |
+
return jsonify({"error": "Failed to update user"}), 500
|
| 644 |
+
|
| 645 |
+
@app.route("/api/admin/users/<user_id>", methods=["DELETE"])
|
| 646 |
+
@auth_required(role="admin")
|
| 647 |
+
def delete_user(user_id):
|
| 648 |
+
"""Delete a user - Admin only"""
|
| 649 |
+
try:
|
| 650 |
+
user_doc = users.find_one({"user_id": user_id})
|
| 651 |
+
if not user_doc:
|
| 652 |
+
return jsonify({"error": "User not found"}), 404
|
| 653 |
+
|
| 654 |
+
# Prevent deleting yourself
|
| 655 |
+
current_user = g.user
|
| 656 |
+
if current_user.get("user_id") == user_id:
|
| 657 |
+
return jsonify({"error": "Cannot delete your own account"}), 400
|
| 658 |
+
|
| 659 |
+
users.delete_one({"user_id": user_id})
|
| 660 |
+
|
| 661 |
+
return jsonify({"message": "User deleted successfully"})
|
| 662 |
+
except Exception as e:
|
| 663 |
+
logger.error(f"Error deleting user: {str(e)}")
|
| 664 |
+
return jsonify({"error": "Failed to delete user"}), 500
|
| 665 |
+
|
| 666 |
+
# === Video Processing Endpoints ===
|
| 667 |
+
|
| 668 |
+
@app.route('/api/video/upload', methods=['POST'])
|
| 669 |
+
@app.route('/api/upload', methods=['POST'])
|
| 670 |
+
@auth_required()
|
| 671 |
+
def upload_video():
|
| 672 |
+
"""Upload video endpoint with database integration"""
|
| 673 |
+
try:
|
| 674 |
+
# Check if file is present
|
| 675 |
+
if 'video' not in request.files:
|
| 676 |
+
return jsonify({'error': 'No video file provided'}), 400
|
| 677 |
+
|
| 678 |
+
file = request.files['video']
|
| 679 |
+
|
| 680 |
+
if file.filename == '':
|
| 681 |
+
return jsonify({'error': 'No file selected'}), 400
|
| 682 |
+
|
| 683 |
+
if not allowed_file(file.filename):
|
| 684 |
+
return jsonify({'error': 'Invalid file type. Allowed: mp4, avi, mov, mkv, wmv, flv'}), 400
|
| 685 |
+
|
| 686 |
+
# Get processing configuration (default to DetectifAI optimized)
|
| 687 |
+
config_type = request.form.get('config_type', 'detectifai')
|
| 688 |
+
|
| 689 |
+
# Generate unique video ID
|
| 690 |
+
video_id = f"video_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{os.urandom(4).hex()}"
|
| 691 |
+
|
| 692 |
+
# Save uploaded file
|
| 693 |
+
filename = secure_filename(file.filename)
|
| 694 |
+
video_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{video_id}_{filename}")
|
| 695 |
+
file.save(video_path)
|
| 696 |
+
|
| 697 |
+
# Get file size
|
| 698 |
+
file.seek(0, os.SEEK_END)
|
| 699 |
+
file_size = file.tell()
|
| 700 |
+
file.seek(0)
|
| 701 |
+
|
| 702 |
+
# Store in MinIO using standardized paths
|
| 703 |
+
from minio_config import VIDEOS_BUCKET, get_minio_paths
|
| 704 |
+
|
| 705 |
+
minio_paths = get_minio_paths(video_id, filename)
|
| 706 |
+
object_name = minio_paths["original"]
|
| 707 |
+
|
| 708 |
+
try:
|
| 709 |
+
with open(video_path, 'rb') as file_data:
|
| 710 |
+
minio_client.put_object(
|
| 711 |
+
VIDEOS_BUCKET,
|
| 712 |
+
object_name,
|
| 713 |
+
file_data,
|
| 714 |
+
file_size,
|
| 715 |
+
content_type='video/mp4'
|
| 716 |
+
)
|
| 717 |
+
logger.info(f"✅ Video uploaded to MinIO: {object_name}")
|
| 718 |
+
except Exception as e:
|
| 719 |
+
logger.error(f"❌ MinIO upload failed: {e}")
|
| 720 |
+
raise
|
| 721 |
+
|
| 722 |
+
# Create video record in database
|
| 723 |
+
video_doc = {
|
| 724 |
+
"video_id": video_id,
|
| 725 |
+
"user_id": g.user.get("user_id"),
|
| 726 |
+
"file_path": video_path,
|
| 727 |
+
"minio_object_key": object_name,
|
| 728 |
+
"minio_bucket": MINIO_BUCKET,
|
| 729 |
+
"codec": None,
|
| 730 |
+
"fps": None,
|
| 731 |
+
"upload_date": datetime.now(timezone.utc),
|
| 732 |
+
"duration_secs": None,
|
| 733 |
+
"file_size_bytes": file_size,
|
| 734 |
+
"meta_data": {},
|
| 735 |
+
"processing_status": "uploaded"
|
| 736 |
+
}
|
| 737 |
+
video_file.insert_one(video_doc)
|
| 738 |
+
|
| 739 |
+
# Initialize processing status
|
| 740 |
+
processing_status[video_id] = {
|
| 741 |
+
'video_id': video_id,
|
| 742 |
+
'filename': filename,
|
| 743 |
+
'status': 'queued',
|
| 744 |
+
'progress': 0,
|
| 745 |
+
'message': 'Video uploaded successfully. Processing queued.',
|
| 746 |
+
'uploaded_at': datetime.now().isoformat(),
|
| 747 |
+
'config_type': config_type
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
# Start background processing
|
| 751 |
+
thread = threading.Thread(
|
| 752 |
+
target=process_video_async,
|
| 753 |
+
args=(video_id, video_path, config_type, g.user.get("user_id"))
|
| 754 |
+
)
|
| 755 |
+
thread.daemon = True
|
| 756 |
+
thread.start()
|
| 757 |
+
|
| 758 |
+
return jsonify({
|
| 759 |
+
'success': True,
|
| 760 |
+
'video_id': video_id,
|
| 761 |
+
'message': 'Video uploaded successfully. Processing started.',
|
| 762 |
+
'status_url': f'/api/status/{video_id}'
|
| 763 |
+
}), 200
|
| 764 |
+
|
| 765 |
+
except Exception as e:
|
| 766 |
+
logger.error(f"Upload error: {str(e)}")
|
| 767 |
+
return jsonify({'error': str(e)}), 500
|
| 768 |
+
|
| 769 |
+
@app.route('/api/video/status/<video_id>', methods=['GET'])
|
| 770 |
+
@app.route('/api/status/<video_id>', methods=['GET'])
|
| 771 |
+
@auth_required()
|
| 772 |
+
def get_status(video_id):
|
| 773 |
+
"""Get processing status for a video"""
|
| 774 |
+
# Check memory first
|
| 775 |
+
if video_id in processing_status:
|
| 776 |
+
return jsonify(processing_status[video_id]), 200
|
| 777 |
+
|
| 778 |
+
# Check database for video record
|
| 779 |
+
video_doc = video_file.find_one({"video_id": video_id})
|
| 780 |
+
if video_doc:
|
| 781 |
+
status = {
|
| 782 |
+
'video_id': video_id,
|
| 783 |
+
'filename': video_doc.get('file_path', '').split('/')[-1],
|
| 784 |
+
'status': video_doc.get('processing_status', 'unknown'),
|
| 785 |
+
'progress': 100 if video_doc.get('processing_status') == 'completed' else 0,
|
| 786 |
+
'message': f"Video status: {video_doc.get('processing_status', 'unknown')}",
|
| 787 |
+
'uploaded_at': video_doc.get('upload_date', '').isoformat() if video_doc.get('upload_date') else '',
|
| 788 |
+
'results': video_doc.get('processing_results', {})
|
| 789 |
+
}
|
| 790 |
+
return jsonify(status), 200
|
| 791 |
+
|
| 792 |
+
return jsonify({'error': 'Video not found'}), 404
|
| 793 |
+
|
| 794 |
+
# === Database Query Endpoints ===
|
| 795 |
+
|
| 796 |
+
@app.route("/api/videos", methods=["GET"])
|
| 797 |
+
@auth_required()
|
| 798 |
+
def list_videos():
|
| 799 |
+
"""List all videos for the authenticated user"""
|
| 800 |
+
user_id = g.user.get("user_id")
|
| 801 |
+
vids = list(video_file.find({"user_id": user_id}, {"_id": 0}))
|
| 802 |
+
return jsonify(vids)
|
| 803 |
+
|
| 804 |
+
@app.route("/api/video/<video_id>", methods=["GET"])
|
| 805 |
+
@auth_required()
|
| 806 |
+
def get_video(video_id):
|
| 807 |
+
"""Get specific video details"""
|
| 808 |
+
user_id = g.user.get("user_id")
|
| 809 |
+
vid = video_file.find_one({"video_id": video_id, "user_id": user_id}, {"_id": 0})
|
| 810 |
+
if not vid:
|
| 811 |
+
return jsonify({"error": "not found"}), 404
|
| 812 |
+
return jsonify(vid)
|
| 813 |
+
|
| 814 |
+
@app.route("/api/video/<video_id>/events", methods=["GET"])
|
| 815 |
+
@auth_required()
|
| 816 |
+
def get_video_events(video_id):
|
| 817 |
+
"""Get events for a specific video"""
|
| 818 |
+
user_id = g.user.get("user_id")
|
| 819 |
+
# Verify user owns the video
|
| 820 |
+
video_doc = video_file.find_one({"video_id": video_id, "user_id": user_id})
|
| 821 |
+
if not video_doc:
|
| 822 |
+
return jsonify({"error": "video not found or access denied"}), 404
|
| 823 |
+
|
| 824 |
+
events_list = list(event.find({"video_id": video_id}, {"_id": 0}))
|
| 825 |
+
return jsonify(events_list)
|
| 826 |
+
|
| 827 |
+
@app.route("/api/event/<event_id>", methods=["GET"])
|
| 828 |
+
@auth_required()
|
| 829 |
+
def get_event_details(event_id):
|
| 830 |
+
"""Get event details with descriptions"""
|
| 831 |
+
event_doc = event.find_one({"event_id": event_id}, {"_id": 0})
|
| 832 |
+
if not event_doc:
|
| 833 |
+
return jsonify({"error": "event not found"}), 404
|
| 834 |
+
|
| 835 |
+
# Get descriptions for this event
|
| 836 |
+
descriptions = list(event_description.find({"event_id": event_id}, {"_id": 0}))
|
| 837 |
+
event_doc["descriptions"] = descriptions
|
| 838 |
+
|
| 839 |
+
return jsonify(event_doc)
|
| 840 |
+
|
| 841 |
+
# === Search Endpoints ===
|
| 842 |
+
|
| 843 |
+
@app.route("/api/search", methods=["GET"])
|
| 844 |
+
@auth_required()
|
| 845 |
+
def search():
|
| 846 |
+
"""Simple text search in event descriptions"""
|
| 847 |
+
q = request.args.get("q", "")
|
| 848 |
+
user_id = g.user.get("user_id")
|
| 849 |
+
|
| 850 |
+
# Get user's videos first
|
| 851 |
+
user_videos = [v["video_id"] for v in video_file.find({"user_id": user_id}, {"video_id": 1})]
|
| 852 |
+
|
| 853 |
+
# Search in descriptions for user's videos
|
| 854 |
+
matches = list(event_description.find({
|
| 855 |
+
"caption": {"$regex": q, "$options": "i"},
|
| 856 |
+
"event_id": {"$in": [e["event_id"] for e in event.find({"video_id": {"$in": user_videos}}, {"event_id": 1})]}
|
| 857 |
+
}, {"_id": 0}))
|
| 858 |
+
|
| 859 |
+
return jsonify(matches)
|
| 860 |
+
|
| 861 |
+
@app.route("/api/search-vector", methods=["POST"])
|
| 862 |
+
@auth_required()
|
| 863 |
+
def search_vector():
|
| 864 |
+
"""Vector search for similar text embeddings using FAISS"""
|
| 865 |
+
data = request.json or {}
|
| 866 |
+
query_text = data.get("query_text")
|
| 867 |
+
k = data.get("k", 10) # Number of results to return
|
| 868 |
+
|
| 869 |
+
if not query_text:
|
| 870 |
+
return jsonify({"error": "query_text is required"}), 400
|
| 871 |
+
|
| 872 |
+
try:
|
| 873 |
+
# Generate embedding for the query text
|
| 874 |
+
query_embedding = generate_text_embedding(query_text)
|
| 875 |
+
|
| 876 |
+
# Search FAISS index
|
| 877 |
+
results = faiss_manager.search_text_embeddings(query_embedding, k)
|
| 878 |
+
|
| 879 |
+
return jsonify({
|
| 880 |
+
"query_text": query_text,
|
| 881 |
+
"results": results,
|
| 882 |
+
"total_results": len(results)
|
| 883 |
+
})
|
| 884 |
+
|
| 885 |
+
except Exception as e:
|
| 886 |
+
return jsonify({"error": f"Search failed: {str(e)}"}), 500
|
| 887 |
+
|
| 888 |
+
@app.route("/api/search-visual", methods=["POST"])
|
| 889 |
+
@auth_required()
|
| 890 |
+
def search_visual():
|
| 891 |
+
"""Vector search for similar visual embeddings using FAISS"""
|
| 892 |
+
data = request.json or {}
|
| 893 |
+
query_embedding = data.get("query_embedding")
|
| 894 |
+
k = data.get("k", 10) # Number of results to return
|
| 895 |
+
|
| 896 |
+
if not query_embedding:
|
| 897 |
+
return jsonify({"error": "query_embedding is required"}), 400
|
| 898 |
+
|
| 899 |
+
if not isinstance(query_embedding, list):
|
| 900 |
+
return jsonify({"error": "query_embedding must be a list of floats"}), 400
|
| 901 |
+
|
| 902 |
+
try:
|
| 903 |
+
# Search FAISS index
|
| 904 |
+
results = faiss_manager.search_visual_embeddings(query_embedding, k)
|
| 905 |
+
|
| 906 |
+
return jsonify({
|
| 907 |
+
"query_embedding_dim": len(query_embedding),
|
| 908 |
+
"results": results,
|
| 909 |
+
"total_results": len(results)
|
| 910 |
+
})
|
| 911 |
+
|
| 912 |
+
except Exception as e:
|
| 913 |
+
return jsonify({"error": f"Visual search failed: {str(e)}"}), 500
|
| 914 |
+
|
| 915 |
+
@app.route("/api/search/captions", methods=["POST"])
|
| 916 |
+
@auth_required()
|
| 917 |
+
def search_captions():
|
| 918 |
+
"""Search captions using FAISS index and sentence transformers"""
|
| 919 |
+
try:
|
| 920 |
+
if not CAPTION_SEARCH_AVAILABLE:
|
| 921 |
+
return jsonify({
|
| 922 |
+
"error": "Caption search not available",
|
| 923 |
+
"message": "Caption search module not installed or not available"
|
| 924 |
+
}), 503
|
| 925 |
+
|
| 926 |
+
data = request.json or {}
|
| 927 |
+
query_text = data.get("query", "").strip()
|
| 928 |
+
top_k = data.get("top_k", 10)
|
| 929 |
+
min_score = data.get("min_score", 0.0)
|
| 930 |
+
|
| 931 |
+
if not query_text:
|
| 932 |
+
return jsonify({"error": "query is required"}), 400
|
| 933 |
+
|
| 934 |
+
# Get caption search engine
|
| 935 |
+
search_engine = get_caption_search_engine()
|
| 936 |
+
|
| 937 |
+
if not search_engine or not search_engine.is_ready():
|
| 938 |
+
return jsonify({
|
| 939 |
+
"error": "Caption search engine not ready",
|
| 940 |
+
"stats": search_engine.get_stats() if search_engine else {}
|
| 941 |
+
}), 503
|
| 942 |
+
|
| 943 |
+
# Perform search
|
| 944 |
+
results = search_engine.search(query_text, top_k=top_k, min_score=min_score)
|
| 945 |
+
|
| 946 |
+
# Format results for frontend
|
| 947 |
+
formatted_results = []
|
| 948 |
+
for result in results:
|
| 949 |
+
video_ref = result.get("video_reference", {})
|
| 950 |
+
minio_path = video_ref.get("minio_path", "")
|
| 951 |
+
object_name = video_ref.get("object_name", "")
|
| 952 |
+
|
| 953 |
+
# Generate MinIO URL for the image/video
|
| 954 |
+
image_url = None
|
| 955 |
+
if object_name:
|
| 956 |
+
try:
|
| 957 |
+
bucket = video_ref.get("bucket", "nlp-images")
|
| 958 |
+
|
| 959 |
+
# Create bucket if it doesn't exist
|
| 960 |
+
try:
|
| 961 |
+
if not minio_client.bucket_exists(bucket):
|
| 962 |
+
logger.info(f"Creating MinIO bucket: {bucket}")
|
| 963 |
+
minio_client.make_bucket(bucket)
|
| 964 |
+
except S3Error as e:
|
| 965 |
+
if e.code != "BucketAlreadyOwnedByYou" and e.code != "BucketAlreadyExists":
|
| 966 |
+
logger.warning(f"Could not create bucket {bucket}: {e}")
|
| 967 |
+
|
| 968 |
+
# Generate presigned URL for MinIO object (valid for 1 hour)
|
| 969 |
+
from datetime import timedelta
|
| 970 |
+
image_url = minio_client.presigned_get_object(
|
| 971 |
+
bucket,
|
| 972 |
+
object_name,
|
| 973 |
+
expires=timedelta(hours=1)
|
| 974 |
+
)
|
| 975 |
+
except Exception as e:
|
| 976 |
+
logger.warning(f"Could not generate MinIO URL: {e}")
|
| 977 |
+
# Fallback: use unified image serving endpoint
|
| 978 |
+
bucket = video_ref.get("bucket", "nlp-images")
|
| 979 |
+
image_url = f"/api/minio/image/{bucket}/{object_name}"
|
| 980 |
+
|
| 981 |
+
formatted_result = {
|
| 982 |
+
"id": result.get("description_id"),
|
| 983 |
+
"event_id": result.get("event_id"),
|
| 984 |
+
"description": result.get("caption", ""),
|
| 985 |
+
"caption": result.get("caption", ""),
|
| 986 |
+
"confidence": result.get("confidence", 0.0),
|
| 987 |
+
"similarity_score": result.get("similarity_score", 0.0),
|
| 988 |
+
"thumbnail": image_url,
|
| 989 |
+
"video_reference": video_ref,
|
| 990 |
+
"timestamp": result.get("created_at"),
|
| 991 |
+
"zone": "N/A" # Can be enhanced with actual zone data
|
| 992 |
+
}
|
| 993 |
+
formatted_results.append(formatted_result)
|
| 994 |
+
|
| 995 |
+
return jsonify({
|
| 996 |
+
"query": query_text,
|
| 997 |
+
"results": formatted_results,
|
| 998 |
+
"total_results": len(formatted_results),
|
| 999 |
+
"stats": search_engine.get_stats()
|
| 1000 |
+
})
|
| 1001 |
+
|
| 1002 |
+
except Exception as e:
|
| 1003 |
+
logger.error(f"Error in caption search: {e}")
|
| 1004 |
+
return jsonify({"error": f"Search failed: {str(e)}"}), 500
|
| 1005 |
+
|
| 1006 |
+
# === FAISS Management Endpoints ===
|
| 1007 |
+
|
| 1008 |
+
@app.route("/api/rebuild-indices", methods=["POST"])
|
| 1009 |
+
@auth_required()
|
| 1010 |
+
def rebuild_indices():
|
| 1011 |
+
"""Rebuild FAISS indices from MongoDB data"""
|
| 1012 |
+
try:
|
| 1013 |
+
# Rebuild both indices
|
| 1014 |
+
faiss_manager.rebuild_text_index()
|
| 1015 |
+
faiss_manager.rebuild_visual_index()
|
| 1016 |
+
|
| 1017 |
+
# Get updated stats
|
| 1018 |
+
stats = faiss_manager.get_index_stats()
|
| 1019 |
+
|
| 1020 |
+
return jsonify({
|
| 1021 |
+
"message": "Indices rebuilt successfully",
|
| 1022 |
+
"stats": stats
|
| 1023 |
+
})
|
| 1024 |
+
|
| 1025 |
+
except Exception as e:
|
| 1026 |
+
return jsonify({"error": f"Failed to rebuild indices: {str(e)}"}), 500
|
| 1027 |
+
|
| 1028 |
+
@app.route("/api/index-stats", methods=["GET"])
|
| 1029 |
+
@auth_required()
|
| 1030 |
+
def get_index_stats():
|
| 1031 |
+
"""Get statistics about FAISS indices"""
|
| 1032 |
+
try:
|
| 1033 |
+
stats = faiss_manager.get_index_stats()
|
| 1034 |
+
return jsonify(stats)
|
| 1035 |
+
|
| 1036 |
+
except Exception as e:
|
| 1037 |
+
return jsonify({"error": f"Failed to get index stats: {str(e)}"}), 500
|
| 1038 |
+
|
| 1039 |
+
# === Legacy DetectifAI Endpoints (for backward compatibility) ===
|
| 1040 |
+
|
| 1041 |
+
@app.route('/api/results/<video_id>', methods=['GET'])
|
| 1042 |
+
@auth_required()
|
| 1043 |
+
def get_results(video_id):
|
| 1044 |
+
"""Get processing results for a video"""
|
| 1045 |
+
if video_id not in processing_status:
|
| 1046 |
+
return jsonify({'error': 'Video not found'}), 404
|
| 1047 |
+
|
| 1048 |
+
status = processing_status[video_id]
|
| 1049 |
+
|
| 1050 |
+
if status['status'] != 'completed':
|
| 1051 |
+
return jsonify({
|
| 1052 |
+
'error': 'Processing not completed',
|
| 1053 |
+
'current_status': status['status']
|
| 1054 |
+
}), 400
|
| 1055 |
+
|
| 1056 |
+
return jsonify(status.get('results', {})), 200
|
| 1057 |
+
|
| 1058 |
+
@app.route('/api/video/results/<video_id>', methods=['GET'])
|
| 1059 |
+
@auth_required()
|
| 1060 |
+
def get_video_results(video_id):
|
| 1061 |
+
"""Get video processing results with availability flags"""
|
| 1062 |
+
# First check if video is in memory status
|
| 1063 |
+
if video_id in processing_status:
|
| 1064 |
+
status = processing_status[video_id]
|
| 1065 |
+
|
| 1066 |
+
if status['status'] != 'completed':
|
| 1067 |
+
return jsonify({
|
| 1068 |
+
'error': 'Processing not completed',
|
| 1069 |
+
'current_status': status['status']
|
| 1070 |
+
}), 400
|
| 1071 |
+
|
| 1072 |
+
# Check if status has results structure (normal processing)
|
| 1073 |
+
if 'results' in status and 'output_directory' in status['results']:
|
| 1074 |
+
output_dir = status['results']['output_directory']
|
| 1075 |
+
else:
|
| 1076 |
+
# Fallback to standard directory structure
|
| 1077 |
+
output_dir = os.path.join('video_processing_outputs', video_id)
|
| 1078 |
+
else:
|
| 1079 |
+
# Check database for video record
|
| 1080 |
+
video_doc = video_file.find_one({"video_id": video_id})
|
| 1081 |
+
if not video_doc:
|
| 1082 |
+
return jsonify({'error': 'Video not found'}), 404
|
| 1083 |
+
|
| 1084 |
+
output_dir = os.path.join('video_processing_outputs', video_id)
|
| 1085 |
+
if not os.path.exists(output_dir):
|
| 1086 |
+
return jsonify({'error': 'Video processing results not found'}), 404
|
| 1087 |
+
|
| 1088 |
+
logger.info(f"📁 Found video files on disk for {video_id}, recovering results")
|
| 1089 |
+
|
| 1090 |
+
# Check for compressed video
|
| 1091 |
+
compressed_dir = os.path.join(output_dir, 'compressed')
|
| 1092 |
+
compressed_video_available = False
|
| 1093 |
+
compressed_video_url = None
|
| 1094 |
+
|
| 1095 |
+
if os.path.exists(compressed_dir):
|
| 1096 |
+
video_files = [f for f in os.listdir(compressed_dir) if f.endswith('.mp4')]
|
| 1097 |
+
if video_files:
|
| 1098 |
+
compressed_video_available = True
|
| 1099 |
+
compressed_video_url = f'/api/video/compressed/{video_id}'
|
| 1100 |
+
|
| 1101 |
+
# Check for keyframes
|
| 1102 |
+
frames_dir = os.path.join(output_dir, 'frames')
|
| 1103 |
+
keyframes_available = os.path.exists(frames_dir) and len([f for f in os.listdir(frames_dir) if f.endswith('.jpg')]) > 0
|
| 1104 |
+
keyframes_count = len([f for f in os.listdir(frames_dir) if f.endswith('.jpg')]) if keyframes_available else 0
|
| 1105 |
+
|
| 1106 |
+
# Check for reports
|
| 1107 |
+
reports_dir = os.path.join(output_dir, 'reports')
|
| 1108 |
+
reports_available = os.path.exists(reports_dir)
|
| 1109 |
+
report_files = []
|
| 1110 |
+
if reports_available:
|
| 1111 |
+
report_files = [f for f in os.listdir(reports_dir) if f.endswith('.json')]
|
| 1112 |
+
|
| 1113 |
+
return jsonify({
|
| 1114 |
+
'video_id': video_id,
|
| 1115 |
+
'compressed_video_available': compressed_video_available,
|
| 1116 |
+
'compressed_video_url': compressed_video_url,
|
| 1117 |
+
'keyframes_available': keyframes_available,
|
| 1118 |
+
'keyframes_count': keyframes_count,
|
| 1119 |
+
'keyframes_url': f'/api/video/keyframes/{video_id}',
|
| 1120 |
+
'reports_available': reports_available,
|
| 1121 |
+
'reports': report_files
|
| 1122 |
+
}), 200
|
| 1123 |
+
|
| 1124 |
+
# === File Serving Endpoints ===
|
| 1125 |
+
|
| 1126 |
+
@app.route('/api/video/keyframes/<video_id>', methods=['GET'])
|
| 1127 |
+
@app.route('/api/keyframes/<video_id>', methods=['GET'])
|
| 1128 |
+
@auth_required()
|
| 1129 |
+
def get_keyframes(video_id):
|
| 1130 |
+
"""Get list of extracted keyframes with DetectifAI annotations"""
|
| 1131 |
+
if video_id not in processing_status:
|
| 1132 |
+
return jsonify({'error': 'Video not found'}), 404
|
| 1133 |
+
|
| 1134 |
+
status = processing_status[video_id]
|
| 1135 |
+
|
| 1136 |
+
if status['status'] != 'completed':
|
| 1137 |
+
return jsonify({'error': 'Processing not completed'}), 400
|
| 1138 |
+
|
| 1139 |
+
output_dir = status['results']['output_directory']
|
| 1140 |
+
frames_dir = os.path.join(output_dir, 'frames')
|
| 1141 |
+
|
| 1142 |
+
if not os.path.exists(frames_dir):
|
| 1143 |
+
return jsonify({'error': 'Frames directory not found'}), 404
|
| 1144 |
+
|
| 1145 |
+
# Load detection metadata if available
|
| 1146 |
+
detection_metadata = {}
|
| 1147 |
+
detection_metadata_path = os.path.join(output_dir, 'detection_metadata.json')
|
| 1148 |
+
if os.path.exists(detection_metadata_path):
|
| 1149 |
+
try:
|
| 1150 |
+
with open(detection_metadata_path, 'r') as f:
|
| 1151 |
+
detection_metadata = json.load(f)
|
| 1152 |
+
except Exception as e:
|
| 1153 |
+
logger.warning(f"Could not load detection metadata: {e}")
|
| 1154 |
+
|
| 1155 |
+
# Get filter parameter
|
| 1156 |
+
filter_detections = request.args.get('filter_detections', 'false').lower() == 'true'
|
| 1157 |
+
|
| 1158 |
+
keyframes = []
|
| 1159 |
+
frames_with_detections = {item['original_path']: item for item in detection_metadata.get('detection_summary', [])}
|
| 1160 |
+
|
| 1161 |
+
for filename in sorted(os.listdir(frames_dir)):
|
| 1162 |
+
if filename.endswith('.jpg') and not filename.endswith('_annotated.jpg'):
|
| 1163 |
+
# Extract timestamp from filename
|
| 1164 |
+
timestamp = 0.0
|
| 1165 |
+
try:
|
| 1166 |
+
if '_' in filename:
|
| 1167 |
+
timestamp_part = filename.split('_')[1].replace('s', '').replace('.jpg', '')
|
| 1168 |
+
timestamp = float(timestamp_part)
|
| 1169 |
+
except:
|
| 1170 |
+
pass
|
| 1171 |
+
|
| 1172 |
+
frame_path = os.path.join(frames_dir, filename)
|
| 1173 |
+
has_detections = frame_path in frames_with_detections
|
| 1174 |
+
|
| 1175 |
+
# Skip frames without detections if filtering is enabled
|
| 1176 |
+
if filter_detections and not has_detections:
|
| 1177 |
+
continue
|
| 1178 |
+
|
| 1179 |
+
keyframe_data = {
|
| 1180 |
+
'filename': filename,
|
| 1181 |
+
'timestamp': timestamp,
|
| 1182 |
+
'url': f'/api/keyframe/{video_id}/{filename}',
|
| 1183 |
+
'has_detections': has_detections
|
| 1184 |
+
}
|
| 1185 |
+
|
| 1186 |
+
# Add detection details if available
|
| 1187 |
+
if has_detections:
|
| 1188 |
+
detection_info = frames_with_detections[frame_path]
|
| 1189 |
+
keyframe_data.update({
|
| 1190 |
+
'detection_count': detection_info.get('detection_count', 0),
|
| 1191 |
+
'objects': detection_info.get('objects', []),
|
| 1192 |
+
'confidence_avg': detection_info.get('confidence_avg', 0.0)
|
| 1193 |
+
})
|
| 1194 |
+
|
| 1195 |
+
keyframes.append(keyframe_data)
|
| 1196 |
+
|
| 1197 |
+
return jsonify({
|
| 1198 |
+
'video_id': video_id,
|
| 1199 |
+
'total_keyframes': detection_metadata.get('total_keyframes', len(keyframes)),
|
| 1200 |
+
'keyframes_with_detections': detection_metadata.get('frames_with_detections', 0),
|
| 1201 |
+
'keyframes': keyframes,
|
| 1202 |
+
'objects_detected': detection_metadata.get('objects_detected', {}),
|
| 1203 |
+
'filter_applied': filter_detections
|
| 1204 |
+
}), 200
|
| 1205 |
+
|
| 1206 |
+
@app.route('/api/keyframe/<video_id>/<filename>', methods=['GET'])
|
| 1207 |
+
@auth_required()
|
| 1208 |
+
def get_keyframe_image(video_id, filename):
|
| 1209 |
+
"""Serve keyframe image"""
|
| 1210 |
+
if video_id not in processing_status:
|
| 1211 |
+
return jsonify({'error': 'Video not found'}), 404
|
| 1212 |
+
|
| 1213 |
+
status = processing_status[video_id]
|
| 1214 |
+
output_dir = status['results']['output_directory']
|
| 1215 |
+
frames_dir = os.path.join(output_dir, 'frames')
|
| 1216 |
+
|
| 1217 |
+
return send_from_directory(frames_dir, filename)
|
| 1218 |
+
|
| 1219 |
+
@app.route('/api/video/compressed/<video_id>', methods=['GET'])
|
| 1220 |
+
@auth_required()
|
| 1221 |
+
def get_compressed_video(video_id):
|
| 1222 |
+
"""Serve compressed video"""
|
| 1223 |
+
if video_id not in processing_status:
|
| 1224 |
+
return jsonify({'error': 'Video not found'}), 404
|
| 1225 |
+
|
| 1226 |
+
status = processing_status[video_id]
|
| 1227 |
+
|
| 1228 |
+
if status['status'] != 'completed':
|
| 1229 |
+
return jsonify({'error': 'Processing not completed'}), 400
|
| 1230 |
+
|
| 1231 |
+
output_dir = status['results']['output_directory']
|
| 1232 |
+
compressed_dir = os.path.join(output_dir, 'compressed')
|
| 1233 |
+
|
| 1234 |
+
if not os.path.exists(compressed_dir):
|
| 1235 |
+
return jsonify({'error': 'Compressed video directory not found'}), 404
|
| 1236 |
+
|
| 1237 |
+
# Find the compressed video file
|
| 1238 |
+
video_files = [f for f in os.listdir(compressed_dir) if f.endswith('.mp4')]
|
| 1239 |
+
|
| 1240 |
+
if not video_files:
|
| 1241 |
+
return jsonify({'error': 'Compressed video file not found'}), 404
|
| 1242 |
+
|
| 1243 |
+
# Use the first video file found (should only be one)
|
| 1244 |
+
video_filename = video_files[0]
|
| 1245 |
+
|
| 1246 |
+
return send_from_directory(compressed_dir, video_filename)
|
| 1247 |
+
|
| 1248 |
+
if __name__ == '__main__':
|
| 1249 |
+
logger.info("Starting DetectifAI Flask API server with database integration...")
|
| 1250 |
+
app.run(host='0.0.0.0', port=5000, debug=True)
|
DetectifAI_db/caption_search.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Caption Search Module for DetectifAI
|
| 3 |
+
|
| 4 |
+
This module provides caption-based search functionality using FAISS index
|
| 5 |
+
and MongoDB for retrieving video descriptions based on text queries.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import numpy as np
|
| 12 |
+
import faiss
|
| 13 |
+
from typing import List, Dict, Optional, Tuple
|
| 14 |
+
from pymongo import MongoClient
|
| 15 |
+
from dotenv import load_dotenv
|
| 16 |
+
|
| 17 |
+
# Optional import for sentence transformers
|
| 18 |
+
try:
|
| 19 |
+
from sentence_transformers import SentenceTransformer
|
| 20 |
+
SENTENCE_TRANSFORMERS_AVAILABLE = True
|
| 21 |
+
except ImportError:
|
| 22 |
+
SENTENCE_TRANSFORMERS_AVAILABLE = False
|
| 23 |
+
logging.warning("sentence-transformers not available - caption search will not work")
|
| 24 |
+
|
| 25 |
+
load_dotenv()
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
# Paths for FAISS index and id map
|
| 30 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 31 |
+
FAISS_INDEX_PATH = os.path.join(BASE_DIR, "faiss_captions.index")
|
| 32 |
+
FAISS_IDMAP_PATH = os.path.join(BASE_DIR, "faiss_captions_idmap.json")
|
| 33 |
+
|
| 34 |
+
# MongoDB connection
|
| 35 |
+
MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/detectifai")
|
| 36 |
+
|
| 37 |
+
# Embedding model name
|
| 38 |
+
EMBEDDING_MODEL = "all-mpnet-base-v2"
|
| 39 |
+
EMBEDDING_DIM = 768 # Dimension for all-mpnet-base-v2
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class CaptionSearchEngine:
|
| 43 |
+
"""Search engine for caption-based video search using FAISS"""
|
| 44 |
+
|
| 45 |
+
def __init__(self):
|
| 46 |
+
"""Initialize the caption search engine"""
|
| 47 |
+
self.faiss_index = None
|
| 48 |
+
self.id_map = {} # Maps FAISS index -> description_id
|
| 49 |
+
self.embedding_model = None
|
| 50 |
+
self.mongo_client = None
|
| 51 |
+
self.db = None
|
| 52 |
+
self.collection = None
|
| 53 |
+
|
| 54 |
+
# Initialize components
|
| 55 |
+
self._load_faiss_index()
|
| 56 |
+
self._load_embedding_model()
|
| 57 |
+
self._connect_mongodb()
|
| 58 |
+
|
| 59 |
+
def _load_faiss_index(self):
|
| 60 |
+
"""Load FAISS index and id map from disk"""
|
| 61 |
+
try:
|
| 62 |
+
if os.path.exists(FAISS_INDEX_PATH):
|
| 63 |
+
self.faiss_index = faiss.read_index(FAISS_INDEX_PATH)
|
| 64 |
+
logger.info(f"✅ Loaded FAISS index from {FAISS_INDEX_PATH}")
|
| 65 |
+
logger.info(f" Index size: {self.faiss_index.ntotal} vectors")
|
| 66 |
+
else:
|
| 67 |
+
logger.warning(f"⚠️ FAISS index not found at {FAISS_INDEX_PATH}")
|
| 68 |
+
return
|
| 69 |
+
|
| 70 |
+
if os.path.exists(FAISS_IDMAP_PATH):
|
| 71 |
+
with open(FAISS_IDMAP_PATH, 'r', encoding='utf-8') as f:
|
| 72 |
+
id_map_list = json.load(f)
|
| 73 |
+
# Convert list to dict: index -> description_id
|
| 74 |
+
self.id_map = {i: desc_id for i, desc_id in enumerate(id_map_list)}
|
| 75 |
+
logger.info(f"✅ Loaded FAISS id map from {FAISS_IDMAP_PATH}")
|
| 76 |
+
logger.info(f" Mapped {len(self.id_map)} indices")
|
| 77 |
+
else:
|
| 78 |
+
logger.warning(f"⚠️ FAISS id map not found at {FAISS_IDMAP_PATH}")
|
| 79 |
+
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.error(f"❌ Error loading FAISS index: {e}")
|
| 82 |
+
self.faiss_index = None
|
| 83 |
+
|
| 84 |
+
def _load_embedding_model(self):
|
| 85 |
+
"""Load sentence transformer model for generating query embeddings"""
|
| 86 |
+
if not SENTENCE_TRANSFORMERS_AVAILABLE:
|
| 87 |
+
logger.warning("⚠️ sentence-transformers not available - cannot generate embeddings")
|
| 88 |
+
return
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
logger.info(f"Loading embedding model: {EMBEDDING_MODEL}...")
|
| 92 |
+
self.embedding_model = SentenceTransformer(EMBEDDING_MODEL)
|
| 93 |
+
logger.info(f"✅ Loaded embedding model: {EMBEDDING_MODEL}")
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.error(f"❌ Error loading embedding model: {e}")
|
| 96 |
+
self.embedding_model = None
|
| 97 |
+
|
| 98 |
+
def _connect_mongodb(self):
|
| 99 |
+
"""Connect to MongoDB"""
|
| 100 |
+
try:
|
| 101 |
+
self.mongo_client = MongoClient(MONGO_URI)
|
| 102 |
+
self.db = self.mongo_client.get_default_database()
|
| 103 |
+
self.collection = self.db["event_descriptions"]
|
| 104 |
+
logger.info("✅ Connected to MongoDB")
|
| 105 |
+
except Exception as e:
|
| 106 |
+
logger.error(f"❌ Error connecting to MongoDB: {e}")
|
| 107 |
+
self.mongo_client = None
|
| 108 |
+
|
| 109 |
+
def is_ready(self) -> bool:
|
| 110 |
+
"""Check if the search engine is ready to use"""
|
| 111 |
+
return (
|
| 112 |
+
self.faiss_index is not None and
|
| 113 |
+
self.embedding_model is not None and
|
| 114 |
+
self.mongo_client is not None and
|
| 115 |
+
self.faiss_index.ntotal > 0
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
def search(self, query_text: str, top_k: int = 10, min_score: float = 0.0) -> List[Dict]:
|
| 119 |
+
"""
|
| 120 |
+
Search for captions similar to the query text
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
query_text: Text query to search for
|
| 124 |
+
top_k: Number of results to return
|
| 125 |
+
min_score: Minimum similarity score threshold
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
List of result dictionaries with caption, video reference, and similarity score
|
| 129 |
+
"""
|
| 130 |
+
if not self.is_ready():
|
| 131 |
+
logger.warning("⚠️ Search engine not ready - missing components")
|
| 132 |
+
return []
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
# Generate query embedding
|
| 136 |
+
query_embedding = self.embedding_model.encode(
|
| 137 |
+
query_text,
|
| 138 |
+
normalize_embeddings=True,
|
| 139 |
+
show_progress_bar=False
|
| 140 |
+
).astype("float32")
|
| 141 |
+
|
| 142 |
+
# Reshape for FAISS (1, dim)
|
| 143 |
+
query_embedding = query_embedding.reshape(1, -1)
|
| 144 |
+
|
| 145 |
+
# Search FAISS index
|
| 146 |
+
k = min(top_k, self.faiss_index.ntotal)
|
| 147 |
+
scores, indices = self.faiss_index.search(query_embedding, k)
|
| 148 |
+
|
| 149 |
+
# Process results
|
| 150 |
+
results = []
|
| 151 |
+
for score, idx in zip(scores[0], indices[0]):
|
| 152 |
+
if idx < 0 or idx not in self.id_map:
|
| 153 |
+
continue
|
| 154 |
+
|
| 155 |
+
if score < min_score:
|
| 156 |
+
continue
|
| 157 |
+
|
| 158 |
+
description_id = self.id_map[idx]
|
| 159 |
+
|
| 160 |
+
# Fetch document from MongoDB
|
| 161 |
+
doc = self.collection.find_one(
|
| 162 |
+
{"description_id": description_id},
|
| 163 |
+
{"_id": 0}
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
if doc:
|
| 167 |
+
result = {
|
| 168 |
+
"description_id": doc.get("description_id"),
|
| 169 |
+
"event_id": doc.get("event_id"),
|
| 170 |
+
"caption": doc.get("caption"),
|
| 171 |
+
"confidence": doc.get("confidence", 0.0),
|
| 172 |
+
"similarity_score": float(score),
|
| 173 |
+
"video_reference": doc.get("video_reference", {}),
|
| 174 |
+
"created_at": doc.get("created_at").isoformat() if doc.get("created_at") else None
|
| 175 |
+
}
|
| 176 |
+
results.append(result)
|
| 177 |
+
|
| 178 |
+
logger.info(f"✅ Found {len(results)} results for query: '{query_text[:50]}...'")
|
| 179 |
+
return results
|
| 180 |
+
|
| 181 |
+
except Exception as e:
|
| 182 |
+
logger.error(f"❌ Error during search: {e}")
|
| 183 |
+
return []
|
| 184 |
+
|
| 185 |
+
def get_stats(self) -> Dict:
|
| 186 |
+
"""Get statistics about the search engine"""
|
| 187 |
+
return {
|
| 188 |
+
"faiss_index_loaded": self.faiss_index is not None,
|
| 189 |
+
"faiss_index_size": self.faiss_index.ntotal if self.faiss_index else 0,
|
| 190 |
+
"id_map_size": len(self.id_map),
|
| 191 |
+
"embedding_model_loaded": self.embedding_model is not None,
|
| 192 |
+
"embedding_model": EMBEDDING_MODEL if self.embedding_model else None,
|
| 193 |
+
"embedding_dim": EMBEDDING_DIM,
|
| 194 |
+
"mongodb_connected": self.mongo_client is not None,
|
| 195 |
+
"ready": self.is_ready()
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# Global instance
|
| 200 |
+
_caption_search_engine = None
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def get_caption_search_engine() -> CaptionSearchEngine:
|
| 204 |
+
"""Get the global caption search engine instance"""
|
| 205 |
+
global _caption_search_engine
|
| 206 |
+
if _caption_search_engine is None:
|
| 207 |
+
_caption_search_engine = CaptionSearchEngine()
|
| 208 |
+
return _caption_search_engine
|
| 209 |
+
|
DetectifAI_db/check_minio.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from minio import Minio
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
# Load environment variables
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
# MinIO client setup
|
| 9 |
+
client = Minio(
|
| 10 |
+
os.getenv("MINIO_ENDPOINT", "s3.eu-central-003.backblazeb2.com"),
|
| 11 |
+
access_key=os.getenv("MINIO_ACCESS_KEY", "00367479ffb7e4e0000000001"),
|
| 12 |
+
secret_key=os.getenv("MINIO_SECRET_KEY", "K003opTvf92ijRj5dM7H1dgrlwcGTdA"),
|
| 13 |
+
secure=os.getenv("MINIO_SECURE", "true").lower() == "true",
|
| 14 |
+
region=os.getenv("MINIO_REGION", "eu-central-003")
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
# Check if bucket exists
|
| 18 |
+
bucket_name = "detectifai-videos"
|
| 19 |
+
found = client.bucket_exists(bucket_name)
|
| 20 |
+
print(f"Bucket '{bucket_name}' exists: {found}")
|
| 21 |
+
|
| 22 |
+
if found:
|
| 23 |
+
print("\nListing objects in bucket:")
|
| 24 |
+
objects = client.list_objects(bucket_name, recursive=True)
|
| 25 |
+
for obj in objects:
|
| 26 |
+
print(f"- {obj.object_name} (size: {obj.size} bytes)")
|
DetectifAI_db/check_video_storage.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility script to validate and fix video storage
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 9 |
+
|
| 10 |
+
from database.config import DatabaseManager
|
| 11 |
+
from database.models import VideoFileModel
|
| 12 |
+
import logging
|
| 13 |
+
|
| 14 |
+
# Configure logging
|
| 15 |
+
logging.basicConfig(level=logging.INFO)
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
def check_video_storage():
|
| 19 |
+
"""Check and validate video storage in MongoDB and MinIO"""
|
| 20 |
+
db_manager = DatabaseManager()
|
| 21 |
+
|
| 22 |
+
# 1. Check MongoDB video records
|
| 23 |
+
logger.info("Checking MongoDB video records...")
|
| 24 |
+
video_collection = db_manager.db.video_file
|
| 25 |
+
videos = list(video_collection.find({}))
|
| 26 |
+
logger.info(f"Found {len(videos)} video records in MongoDB")
|
| 27 |
+
|
| 28 |
+
# 2. Check MinIO storage
|
| 29 |
+
logger.info("\nChecking MinIO storage...")
|
| 30 |
+
try:
|
| 31 |
+
# Check video bucket
|
| 32 |
+
video_objects = list(db_manager.minio_client.list_objects(
|
| 33 |
+
db_manager.config.minio_video_bucket,
|
| 34 |
+
recursive=True
|
| 35 |
+
))
|
| 36 |
+
logger.info(f"Found {len(video_objects)} objects in video bucket")
|
| 37 |
+
|
| 38 |
+
# Check keyframe bucket
|
| 39 |
+
keyframe_objects = list(db_manager.minio_client.list_objects(
|
| 40 |
+
db_manager.config.minio_keyframe_bucket,
|
| 41 |
+
recursive=True
|
| 42 |
+
))
|
| 43 |
+
logger.info(f"Found {len(keyframe_objects)} objects in keyframe bucket")
|
| 44 |
+
|
| 45 |
+
# Map MinIO objects to video IDs
|
| 46 |
+
minio_video_ids = set()
|
| 47 |
+
minio_keyframe_video_ids = set()
|
| 48 |
+
|
| 49 |
+
for obj in video_objects:
|
| 50 |
+
parts = obj.object_name.split('/')
|
| 51 |
+
if len(parts) > 1:
|
| 52 |
+
minio_video_ids.add(parts[1]) # original/{video_id}/video.mp4
|
| 53 |
+
|
| 54 |
+
for obj in keyframe_objects:
|
| 55 |
+
parts = obj.object_name.split('/')
|
| 56 |
+
if len(parts) > 0:
|
| 57 |
+
minio_keyframe_video_ids.add(parts[0]) # {video_id}/keyframes/...
|
| 58 |
+
|
| 59 |
+
# 3. Cross-reference and find inconsistencies
|
| 60 |
+
logger.info("\nCross-referencing storage...")
|
| 61 |
+
mongo_video_ids = {str(v['video_id']) for v in videos}
|
| 62 |
+
|
| 63 |
+
# Find mismatches
|
| 64 |
+
missing_in_minio = mongo_video_ids - minio_video_ids
|
| 65 |
+
missing_keyframes = mongo_video_ids - minio_keyframe_video_ids
|
| 66 |
+
orphaned_in_minio = minio_video_ids - mongo_video_ids
|
| 67 |
+
|
| 68 |
+
if missing_in_minio:
|
| 69 |
+
logger.warning(f"\n⚠️ Found {len(missing_in_minio)} videos missing in MinIO:")
|
| 70 |
+
for vid in missing_in_minio:
|
| 71 |
+
logger.warning(f"- {vid}")
|
| 72 |
+
|
| 73 |
+
if missing_keyframes:
|
| 74 |
+
logger.warning(f"\n⚠️ Found {len(missing_keyframes)} videos missing keyframes:")
|
| 75 |
+
for vid in missing_keyframes:
|
| 76 |
+
logger.warning(f"- {vid}")
|
| 77 |
+
|
| 78 |
+
if orphaned_in_minio:
|
| 79 |
+
logger.warning(f"\n⚠️ Found {len(orphaned_in_minio)} orphaned videos in MinIO:")
|
| 80 |
+
for vid in orphaned_in_minio:
|
| 81 |
+
logger.warning(f"- {vid}")
|
| 82 |
+
|
| 83 |
+
# 4. Check MongoDB metadata completeness
|
| 84 |
+
logger.info("\nChecking metadata completeness...")
|
| 85 |
+
incomplete_metadata = []
|
| 86 |
+
for video in videos:
|
| 87 |
+
if not video.get('meta_data'):
|
| 88 |
+
incomplete_metadata.append(video['video_id'])
|
| 89 |
+
continue
|
| 90 |
+
|
| 91 |
+
meta = video['meta_data']
|
| 92 |
+
required_fields = ['filename', 'processing_status', 'upload_date']
|
| 93 |
+
missing_fields = [f for f in required_fields if f not in meta]
|
| 94 |
+
|
| 95 |
+
if missing_fields:
|
| 96 |
+
incomplete_metadata.append({
|
| 97 |
+
'video_id': video['video_id'],
|
| 98 |
+
'missing_fields': missing_fields
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
if incomplete_metadata:
|
| 102 |
+
logger.warning(f"\n⚠️ Found {len(incomplete_metadata)} videos with incomplete metadata:")
|
| 103 |
+
for item in incomplete_metadata:
|
| 104 |
+
if isinstance(item, dict):
|
| 105 |
+
logger.warning(f"- {item['video_id']} (missing: {', '.join(item['missing_fields'])})")
|
| 106 |
+
else:
|
| 107 |
+
logger.warning(f"- {item} (missing entire meta_data object)")
|
| 108 |
+
|
| 109 |
+
return {
|
| 110 |
+
'mongodb_videos': len(videos),
|
| 111 |
+
'minio_videos': len(video_objects),
|
| 112 |
+
'minio_keyframes': len(keyframe_objects),
|
| 113 |
+
'missing_in_minio': list(missing_in_minio),
|
| 114 |
+
'missing_keyframes': list(missing_keyframes),
|
| 115 |
+
'orphaned_in_minio': list(orphaned_in_minio),
|
| 116 |
+
'incomplete_metadata': incomplete_metadata
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(f"Error checking storage: {e}")
|
| 121 |
+
raise
|
| 122 |
+
|
| 123 |
+
def fix_metadata():
|
| 124 |
+
"""Fix incomplete metadata in MongoDB records"""
|
| 125 |
+
db_manager = DatabaseManager()
|
| 126 |
+
video_collection = db_manager.db.video_file
|
| 127 |
+
|
| 128 |
+
logger.info("Fixing incomplete metadata...")
|
| 129 |
+
fixed_count = 0
|
| 130 |
+
|
| 131 |
+
for video in video_collection.find({}):
|
| 132 |
+
needs_update = False
|
| 133 |
+
update_fields = {}
|
| 134 |
+
|
| 135 |
+
# Ensure meta_data exists
|
| 136 |
+
if 'meta_data' not in video:
|
| 137 |
+
update_fields['meta_data'] = {
|
| 138 |
+
'processing_status': 'unknown',
|
| 139 |
+
'upload_date': video.get('upload_date', datetime.utcnow()),
|
| 140 |
+
'filename': f"video_{video['video_id']}.mp4"
|
| 141 |
+
}
|
| 142 |
+
needs_update = True
|
| 143 |
+
else:
|
| 144 |
+
meta = video['meta_data']
|
| 145 |
+
|
| 146 |
+
# Check and fix required fields
|
| 147 |
+
if 'processing_status' not in meta:
|
| 148 |
+
meta['processing_status'] = 'unknown'
|
| 149 |
+
needs_update = True
|
| 150 |
+
|
| 151 |
+
if 'upload_date' not in meta and 'upload_date' in video:
|
| 152 |
+
meta['upload_date'] = video['upload_date']
|
| 153 |
+
needs_update = True
|
| 154 |
+
|
| 155 |
+
if 'filename' not in meta:
|
| 156 |
+
meta['filename'] = f"video_{video['video_id']}.mp4"
|
| 157 |
+
needs_update = True
|
| 158 |
+
|
| 159 |
+
if needs_update:
|
| 160 |
+
update_fields['meta_data'] = meta
|
| 161 |
+
|
| 162 |
+
# Apply updates if needed
|
| 163 |
+
if needs_update:
|
| 164 |
+
try:
|
| 165 |
+
video_collection.update_one(
|
| 166 |
+
{'_id': video['_id']},
|
| 167 |
+
{'$set': update_fields}
|
| 168 |
+
)
|
| 169 |
+
fixed_count += 1
|
| 170 |
+
logger.info(f"Fixed metadata for video {video['video_id']}")
|
| 171 |
+
except Exception as e:
|
| 172 |
+
logger.error(f"Failed to fix metadata for {video['video_id']}: {e}")
|
| 173 |
+
|
| 174 |
+
logger.info(f"\n✅ Fixed metadata for {fixed_count} videos")
|
| 175 |
+
return fixed_count
|
| 176 |
+
|
| 177 |
+
if __name__ == "__main__":
|
| 178 |
+
try:
|
| 179 |
+
# First check storage
|
| 180 |
+
results = check_video_storage()
|
| 181 |
+
|
| 182 |
+
# If there are metadata issues, fix them
|
| 183 |
+
if results['incomplete_metadata']:
|
| 184 |
+
if input("\nFix incomplete metadata? (y/n): ").lower() == 'y':
|
| 185 |
+
fixed = fix_metadata()
|
| 186 |
+
print(f"\nFixed {fixed} video records")
|
| 187 |
+
|
| 188 |
+
print("\nStorage check complete!")
|
| 189 |
+
except Exception as e:
|
| 190 |
+
print(f"Error: {e}")
|
| 191 |
+
sys.exit(1)
|
DetectifAI_db/create_admin.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Script to create an admin user in the DetectifAI database
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from pymongo import MongoClient
|
| 7 |
+
from uuid import uuid4
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
+
import bcrypt
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
|
| 14 |
+
load_dotenv()
|
| 15 |
+
|
| 16 |
+
def create_admin_user():
|
| 17 |
+
"""Create an admin user in the database"""
|
| 18 |
+
|
| 19 |
+
# Get MongoDB connection
|
| 20 |
+
mongo_uri = os.getenv("MONGO_URI", "mongodb://localhost:27017/detectifai")
|
| 21 |
+
client = MongoClient(mongo_uri)
|
| 22 |
+
db = client.get_default_database()
|
| 23 |
+
users = db.users
|
| 24 |
+
|
| 25 |
+
# Admin credentials (change these!)
|
| 26 |
+
admin_email = "admin@detectifai.com"
|
| 27 |
+
admin_password = "admin123" # ⚠️ CHANGE THIS PASSWORD!
|
| 28 |
+
admin_username = "admin"
|
| 29 |
+
|
| 30 |
+
# Check if admin already exists
|
| 31 |
+
existing_admin = users.find_one({"email": admin_email})
|
| 32 |
+
if existing_admin:
|
| 33 |
+
print(f"⚠️ Admin user with email '{admin_email}' already exists!")
|
| 34 |
+
update = input("Do you want to update the password? (y/n): ").lower().strip()
|
| 35 |
+
if update == 'y':
|
| 36 |
+
new_password = input("Enter new password: ").strip()
|
| 37 |
+
if not new_password:
|
| 38 |
+
print("❌ Password cannot be empty")
|
| 39 |
+
sys.exit(1)
|
| 40 |
+
|
| 41 |
+
# Hash new password
|
| 42 |
+
password_hash = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
| 43 |
+
|
| 44 |
+
# Update admin user
|
| 45 |
+
users.update_one(
|
| 46 |
+
{"email": admin_email},
|
| 47 |
+
{
|
| 48 |
+
"$set": {
|
| 49 |
+
"password_hash": password_hash,
|
| 50 |
+
"password": new_password, # For Flask backend compatibility
|
| 51 |
+
"role": "admin",
|
| 52 |
+
"is_active": True,
|
| 53 |
+
"updated_at": datetime.now(timezone.utc)
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
)
|
| 57 |
+
print(f"✅ Admin password updated successfully!")
|
| 58 |
+
print(f" Email: {admin_email}")
|
| 59 |
+
print(f" Password: {new_password}")
|
| 60 |
+
else:
|
| 61 |
+
print("ℹ️ Keeping existing admin user")
|
| 62 |
+
client.close()
|
| 63 |
+
return
|
| 64 |
+
|
| 65 |
+
# Create new admin user
|
| 66 |
+
print(f"Creating admin user...")
|
| 67 |
+
print(f" Email: {admin_email}")
|
| 68 |
+
print(f" Username: {admin_username}")
|
| 69 |
+
|
| 70 |
+
# Hash password
|
| 71 |
+
password_hash = bcrypt.hashpw(admin_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
| 72 |
+
|
| 73 |
+
admin_user = {
|
| 74 |
+
"user_id": str(uuid4()),
|
| 75 |
+
"username": admin_username,
|
| 76 |
+
"email": admin_email,
|
| 77 |
+
"password_hash": password_hash,
|
| 78 |
+
"password": admin_password, # For Flask backend compatibility (plain text - TODO: remove in production)
|
| 79 |
+
"role": "admin",
|
| 80 |
+
"is_active": True,
|
| 81 |
+
"profile_data": {},
|
| 82 |
+
"created_at": datetime.now(timezone.utc),
|
| 83 |
+
"updated_at": datetime.now(timezone.utc),
|
| 84 |
+
"last_login": None
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
users.insert_one(admin_user)
|
| 89 |
+
print("\n✅ Admin user created successfully!")
|
| 90 |
+
print(f"\n📋 Login Credentials:")
|
| 91 |
+
print(f" Email: {admin_email}")
|
| 92 |
+
print(f" Password: {admin_password}")
|
| 93 |
+
print(f"\n⚠️ IMPORTANT: Change this password after first login!")
|
| 94 |
+
print(f"\n🌐 Access the admin panel at: http://localhost:3000/admin/signin")
|
| 95 |
+
except Exception as e:
|
| 96 |
+
print(f"❌ Error creating admin user: {e}")
|
| 97 |
+
sys.exit(1)
|
| 98 |
+
finally:
|
| 99 |
+
client.close()
|
| 100 |
+
|
| 101 |
+
if __name__ == "__main__":
|
| 102 |
+
print("=" * 60)
|
| 103 |
+
print("DetectifAI - Admin User Creation Script")
|
| 104 |
+
print("=" * 60)
|
| 105 |
+
print()
|
| 106 |
+
|
| 107 |
+
# Check if MONGO_URI is set
|
| 108 |
+
if not os.getenv("MONGO_URI"):
|
| 109 |
+
print("❌ Error: MONGO_URI environment variable not set")
|
| 110 |
+
print("Please create a .env file with your MongoDB connection string")
|
| 111 |
+
print("Example: MONGO_URI=mongodb://localhost:27017/detectifai")
|
| 112 |
+
sys.exit(1)
|
| 113 |
+
|
| 114 |
+
create_admin_user()
|
| 115 |
+
print("\n" + "=" * 60)
|
| 116 |
+
print("✅ Script completed!")
|
| 117 |
+
print("=" * 60)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
|
DetectifAI_db/database_seed.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pymongo import MongoClient
|
| 2 |
+
from uuid import uuid4
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
client = MongoClient(os.getenv("MONGO_URI", "mongodb://localhost:27017/detectifai"))
|
| 10 |
+
db = client.get_default_database()
|
| 11 |
+
users = db.users
|
| 12 |
+
video_files = db.video_files
|
| 13 |
+
event_descriptions = db.event_descriptions
|
| 14 |
+
subscription_plans = db.subscription_plans
|
| 15 |
+
events = db.events
|
| 16 |
+
|
| 17 |
+
# Add sample user if not exists
|
| 18 |
+
sample_user = {
|
| 19 |
+
"user_id": str(uuid4()),
|
| 20 |
+
"username": "testuser",
|
| 21 |
+
"email": "user@detectifai.test",
|
| 22 |
+
"password": "userpass",
|
| 23 |
+
"role": "user",
|
| 24 |
+
"created_at": datetime.now(timezone.utc),
|
| 25 |
+
"updated_at": datetime.now(timezone.utc),
|
| 26 |
+
"last_login": None
|
| 27 |
+
}
|
| 28 |
+
if users.count_documents({"email": "user@detectifai.test"}) == 0:
|
| 29 |
+
users.insert_one(sample_user)
|
| 30 |
+
print("Added sample user: user@detectifai.test / userpass")
|
| 31 |
+
else:
|
| 32 |
+
print("Sample user already exists")
|
| 33 |
+
|
| 34 |
+
# Add sample subscription plans
|
| 35 |
+
sample_plans = [
|
| 36 |
+
{
|
| 37 |
+
"plan_id": str(uuid4()),
|
| 38 |
+
"plan_name": "Basic",
|
| 39 |
+
"description": "Basic surveillance features",
|
| 40 |
+
"price": 9.99,
|
| 41 |
+
"features": "basic_ai,email_support",
|
| 42 |
+
"storage_limit": 10,
|
| 43 |
+
"is_active": True
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"plan_id": str(uuid4()),
|
| 47 |
+
"plan_name": "Pro",
|
| 48 |
+
"description": "Advanced AI features with priority support",
|
| 49 |
+
"price": 29.99,
|
| 50 |
+
"features": "advanced_ai,priority_support,face_recognition",
|
| 51 |
+
"storage_limit": 100,
|
| 52 |
+
"is_active": True
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"plan_id": str(uuid4()),
|
| 56 |
+
"plan_name": "Enterprise",
|
| 57 |
+
"description": "Full enterprise features with 24/7 support",
|
| 58 |
+
"price": 99.99,
|
| 59 |
+
"features": "premium_ai,24_7_support,face_recognition,custom_integrations",
|
| 60 |
+
"storage_limit": 1000,
|
| 61 |
+
"is_active": True
|
| 62 |
+
}
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
for plan in sample_plans:
|
| 66 |
+
if subscription_plans.count_documents({"plan_id": plan["plan_id"]}) == 0:
|
| 67 |
+
subscription_plans.insert_one(plan)
|
| 68 |
+
print(f"Added subscription plan: {plan['plan_name']}")
|
| 69 |
+
else:
|
| 70 |
+
print(f"Subscription plan {plan['plan_name']} already exists")
|
| 71 |
+
|
| 72 |
+
# Get existing video files to add sample events and descriptions
|
| 73 |
+
existing_videos = list(video_files.find({}))
|
| 74 |
+
|
| 75 |
+
if not existing_videos:
|
| 76 |
+
print("No video files found. Upload some videos first, then run this script.")
|
| 77 |
+
else:
|
| 78 |
+
# Add sample events and descriptions to the first video
|
| 79 |
+
video = existing_videos[0]
|
| 80 |
+
video_id = video["video_id"]
|
| 81 |
+
|
| 82 |
+
# Create sample events
|
| 83 |
+
sample_events = [
|
| 84 |
+
{
|
| 85 |
+
"event_id": str(uuid4()),
|
| 86 |
+
"video_id": video_id,
|
| 87 |
+
"event_type": "person_detection",
|
| 88 |
+
"confidence_score": 0.95,
|
| 89 |
+
"start_timestamp_ms": 0,
|
| 90 |
+
"end_timestamp_ms": 5000,
|
| 91 |
+
"bounding_boxes": {"x": 100, "y": 150, "width": 200, "height": 300},
|
| 92 |
+
"visual_embedding": [],
|
| 93 |
+
"is_verified": False,
|
| 94 |
+
"is_false_positive": False,
|
| 95 |
+
"verified_by": None,
|
| 96 |
+
"verified_at": None
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"event_id": str(uuid4()),
|
| 100 |
+
"video_id": video_id,
|
| 101 |
+
"event_type": "object_detection",
|
| 102 |
+
"confidence_score": 0.87,
|
| 103 |
+
"start_timestamp_ms": 5200,
|
| 104 |
+
"end_timestamp_ms": 12800,
|
| 105 |
+
"bounding_boxes": {"x": 300, "y": 200, "width": 150, "height": 100},
|
| 106 |
+
"visual_embedding": [],
|
| 107 |
+
"is_verified": False,
|
| 108 |
+
"is_false_positive": False,
|
| 109 |
+
"verified_by": None,
|
| 110 |
+
"verified_at": None
|
| 111 |
+
}
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
# Insert events
|
| 115 |
+
for event in sample_events:
|
| 116 |
+
if events.count_documents({"event_id": event["event_id"]}) == 0:
|
| 117 |
+
events.insert_one(event)
|
| 118 |
+
print(f"Added event: {event['event_type']}")
|
| 119 |
+
|
| 120 |
+
# Add sample descriptions for the events
|
| 121 |
+
sample_descriptions = [
|
| 122 |
+
{
|
| 123 |
+
"description_id": str(uuid4()),
|
| 124 |
+
"event_id": sample_events[0]["event_id"],
|
| 125 |
+
"caption": "Person walking into the room carrying a briefcase",
|
| 126 |
+
"text_embedding": [],
|
| 127 |
+
"confidence": 0.92,
|
| 128 |
+
"created_at": datetime.now(timezone.utc),
|
| 129 |
+
"updated_at": datetime.now(timezone.utc)
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
"description_id": str(uuid4()),
|
| 133 |
+
"event_id": sample_events[1]["event_id"],
|
| 134 |
+
"caption": "Individual sits down at desk and opens laptop computer",
|
| 135 |
+
"text_embedding": [],
|
| 136 |
+
"confidence": 0.88,
|
| 137 |
+
"created_at": datetime.now(timezone.utc),
|
| 138 |
+
"updated_at": datetime.now(timezone.utc)
|
| 139 |
+
}
|
| 140 |
+
]
|
| 141 |
+
|
| 142 |
+
# Insert descriptions
|
| 143 |
+
for desc in sample_descriptions:
|
| 144 |
+
if event_descriptions.count_documents({"description_id": desc["description_id"]}) == 0:
|
| 145 |
+
event_descriptions.insert_one(desc)
|
| 146 |
+
print(f"Added description: {desc['caption'][:50]}...")
|
| 147 |
+
|
| 148 |
+
# If there are more videos, add different events to the second one
|
| 149 |
+
if len(existing_videos) > 1:
|
| 150 |
+
video2 = existing_videos[1]
|
| 151 |
+
video2_id = video2["video_id"]
|
| 152 |
+
|
| 153 |
+
sample_events2 = [
|
| 154 |
+
{
|
| 155 |
+
"event_id": str(uuid4()),
|
| 156 |
+
"video_id": video2_id,
|
| 157 |
+
"event_type": "security_patrol",
|
| 158 |
+
"confidence_score": 0.93,
|
| 159 |
+
"start_timestamp_ms": 2100,
|
| 160 |
+
"end_timestamp_ms": 15400,
|
| 161 |
+
"bounding_boxes": {"x": 50, "y": 100, "width": 180, "height": 250},
|
| 162 |
+
"visual_embedding": [],
|
| 163 |
+
"is_verified": False,
|
| 164 |
+
"is_false_positive": False,
|
| 165 |
+
"verified_by": None,
|
| 166 |
+
"verified_at": None
|
| 167 |
+
}
|
| 168 |
+
]
|
| 169 |
+
|
| 170 |
+
for event in sample_events2:
|
| 171 |
+
if events.count_documents({"event_id": event["event_id"]}) == 0:
|
| 172 |
+
events.insert_one(event)
|
| 173 |
+
print(f"Added event: {event['event_type']}")
|
| 174 |
+
|
| 175 |
+
sample_descriptions2 = [
|
| 176 |
+
{
|
| 177 |
+
"description_id": str(uuid4()),
|
| 178 |
+
"event_id": sample_events2[0]["event_id"],
|
| 179 |
+
"caption": "Security guard patrolling the hallway with flashlight",
|
| 180 |
+
"text_embedding": [],
|
| 181 |
+
"confidence": 0.91,
|
| 182 |
+
"created_at": datetime.now(timezone.utc),
|
| 183 |
+
"updated_at": datetime.now(timezone.utc)
|
| 184 |
+
}
|
| 185 |
+
]
|
| 186 |
+
|
| 187 |
+
for desc in sample_descriptions2:
|
| 188 |
+
if event_descriptions.count_documents({"description_id": desc["description_id"]}) == 0:
|
| 189 |
+
event_descriptions.insert_one(desc)
|
| 190 |
+
print(f"Added description: {desc['caption'][:50]}...")
|
| 191 |
+
|
| 192 |
+
print("\n--- Database Seeding Complete ---")
|
| 193 |
+
print("You can now test search functionality with terms like:")
|
| 194 |
+
print("- 'briefcase' or 'laptop'")
|
| 195 |
+
print("- 'security' or 'guard'")
|
| 196 |
+
print("- 'person' or 'detection'")
|
| 197 |
+
print("- 'desk' or 'computer'")
|
| 198 |
+
print("- 'patrol' or 'hallway'")
|
| 199 |
+
|
| 200 |
+
# Show summary
|
| 201 |
+
total_videos = video_files.count_documents({})
|
| 202 |
+
total_events = events.count_documents({})
|
| 203 |
+
total_descriptions = event_descriptions.count_documents({})
|
| 204 |
+
total_users = users.count_documents({})
|
| 205 |
+
total_plans = subscription_plans.count_documents({})
|
| 206 |
+
|
| 207 |
+
print(f"\nDatabase Summary:")
|
| 208 |
+
print(f"Total users: {total_users}")
|
| 209 |
+
print(f"Total subscription plans: {total_plans}")
|
| 210 |
+
print(f"Total video files: {total_videos}")
|
| 211 |
+
print(f"Total events: {total_events}")
|
| 212 |
+
print(f"Total event descriptions: {total_descriptions}")
|
DetectifAI_db/database_setup.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pymongo import MongoClient, ASCENDING
|
| 2 |
+
import os
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
MONGO_URI = os.getenv("MONGO_URI")
|
| 7 |
+
|
| 8 |
+
client = MongoClient(MONGO_URI)
|
| 9 |
+
db = client.get_default_database()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def create_collection_if_not_exists(name, validator=None, indexes=None):
|
| 13 |
+
"""Create collection if it doesn't exist, otherwise skip"""
|
| 14 |
+
try:
|
| 15 |
+
if validator:
|
| 16 |
+
db.create_collection(name, validator=validator)
|
| 17 |
+
else:
|
| 18 |
+
db.create_collection(name)
|
| 19 |
+
print(f"Created collection: {name}")
|
| 20 |
+
except Exception as e:
|
| 21 |
+
if "already exists" in str(e):
|
| 22 |
+
print(f"Collection {name} already exists, skipping...")
|
| 23 |
+
else:
|
| 24 |
+
print(f"Error creating collection {name}: {e}")
|
| 25 |
+
return False
|
| 26 |
+
|
| 27 |
+
# Create indexes if specified
|
| 28 |
+
if indexes:
|
| 29 |
+
for index in indexes:
|
| 30 |
+
try:
|
| 31 |
+
if isinstance(index, tuple):
|
| 32 |
+
# Index with options
|
| 33 |
+
db[name].create_index(index[0], **index[1])
|
| 34 |
+
else:
|
| 35 |
+
# Simple index
|
| 36 |
+
db[name].create_index(index)
|
| 37 |
+
print(f" Created index on {name}")
|
| 38 |
+
except Exception as e:
|
| 39 |
+
if "already exists" in str(e) or "duplicate key" in str(e):
|
| 40 |
+
print(f" Index on {name} already exists")
|
| 41 |
+
else:
|
| 42 |
+
print(f" Error creating index on {name}: {e}")
|
| 43 |
+
return True
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# === ADMIN ===
|
| 47 |
+
create_collection_if_not_exists("admin", validator={
|
| 48 |
+
"$jsonSchema": {
|
| 49 |
+
"bsonType": "object",
|
| 50 |
+
"required": ["admin_id", "username", "email", "password"],
|
| 51 |
+
"properties": {
|
| 52 |
+
"admin_id": {"bsonType": "string"},
|
| 53 |
+
"username": {"bsonType": "string"},
|
| 54 |
+
"email": {"bsonType": "string"},
|
| 55 |
+
"password": {"bsonType": "string"},
|
| 56 |
+
"role": {"bsonType": "string"},
|
| 57 |
+
"created_at": {"bsonType": "date"},
|
| 58 |
+
"updated_at": {"bsonType": "date"},
|
| 59 |
+
"last_login": {"bsonType": ["date", "null"]}
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
}, indexes=[([("email", ASCENDING)], {"unique": True}), "username"])
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# === USERS ===
|
| 66 |
+
create_collection_if_not_exists("users", validator={
|
| 67 |
+
"$jsonSchema": {
|
| 68 |
+
"bsonType": "object",
|
| 69 |
+
"required": ["user_id", "email"],
|
| 70 |
+
"properties": {
|
| 71 |
+
"user_id": {"bsonType": "string"},
|
| 72 |
+
"username": {"bsonType": "string"},
|
| 73 |
+
"email": {"bsonType": "string"},
|
| 74 |
+
"password_hash": {"bsonType": "string"},
|
| 75 |
+
"role": {"bsonType": "string"},
|
| 76 |
+
"profile_data": {"bsonType": "object"},
|
| 77 |
+
"is_active": {"bsonType": "bool"},
|
| 78 |
+
"created_at": {"bsonType": "date"},
|
| 79 |
+
"updated_at": {"bsonType": "date"},
|
| 80 |
+
"last_login": {"bsonType": ["date", "null"]}
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
}, indexes=[([("email", ASCENDING)], {"unique": True}), "username"])
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# === VIDEO FILES ===
|
| 87 |
+
create_collection_if_not_exists("video_files", validator={
|
| 88 |
+
"$jsonSchema": {
|
| 89 |
+
"bsonType": "object",
|
| 90 |
+
"required": ["video_id", "user_id", "file_path"],
|
| 91 |
+
"properties": {
|
| 92 |
+
"video_id": {"bsonType": "string"},
|
| 93 |
+
"user_id": {"bsonType": "string"},
|
| 94 |
+
"file_path": {"bsonType": "string"},
|
| 95 |
+
"minio_object_key": {"bsonType": "string"},
|
| 96 |
+
"minio_bucket": {"bsonType": "string"},
|
| 97 |
+
"codec": {"bsonType": "string"},
|
| 98 |
+
"fps": {"bsonType": "double"},
|
| 99 |
+
"upload_date": {"bsonType": "date"},
|
| 100 |
+
"duration_secs": {"bsonType": "int"},
|
| 101 |
+
"file_size_bytes": {"bsonType": "long"},
|
| 102 |
+
"meta_data": {"bsonType": "object"}
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
}, indexes=["user_id", "upload_date"])
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# === EVENTS ===
|
| 109 |
+
create_collection_if_not_exists("events", validator={
|
| 110 |
+
"$jsonSchema": {
|
| 111 |
+
"bsonType": "object",
|
| 112 |
+
"required": ["event_id", "video_id", "start_timestamp_ms", "end_timestamp_ms"],
|
| 113 |
+
"properties": {
|
| 114 |
+
"event_id": {"bsonType": "string"},
|
| 115 |
+
"video_id": {"bsonType": "string"},
|
| 116 |
+
"start_timestamp_ms": {"bsonType": "long"},
|
| 117 |
+
"end_timestamp_ms": {"bsonType": "long"},
|
| 118 |
+
"confidence_score": {"bsonType": "double"},
|
| 119 |
+
"is_verified": {"bsonType": "bool"},
|
| 120 |
+
"is_false_positive": {"bsonType": "bool"},
|
| 121 |
+
"verified_at": {"bsonType": ["date", "null"]},
|
| 122 |
+
"verified_by": {"bsonType": ["string", "null"]},
|
| 123 |
+
"visual_embedding": {"bsonType": "array"},
|
| 124 |
+
"bounding_boxes": {"bsonType": "object"},
|
| 125 |
+
"event_type": {"bsonType": "string"}
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
}, indexes=["video_id", "event_type", "is_verified"])
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# === EVENT CLIPS ===
|
| 132 |
+
create_collection_if_not_exists("event_clips", validator={
|
| 133 |
+
"$jsonSchema": {
|
| 134 |
+
"bsonType": "object",
|
| 135 |
+
"required": ["clip_id", "event_id", "clip_path"],
|
| 136 |
+
"properties": {
|
| 137 |
+
"clip_id": {"bsonType": "string"},
|
| 138 |
+
"event_id": {"bsonType": "string"},
|
| 139 |
+
"clip_path": {"bsonType": "string"},
|
| 140 |
+
"thumbnail_path": {"bsonType": "string"},
|
| 141 |
+
"minio_object_key": {"bsonType": "string"},
|
| 142 |
+
"minio_bucket": {"bsonType": "string"},
|
| 143 |
+
"duration_ms": {"bsonType": "long"},
|
| 144 |
+
"extracted_at": {"bsonType": "date"},
|
| 145 |
+
"file_size_bytes": {"bsonType": "long"}
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
}, indexes=["event_id"])
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# === DETECTED FACES ===
|
| 152 |
+
create_collection_if_not_exists("detected_faces", validator={
|
| 153 |
+
"$jsonSchema": {
|
| 154 |
+
"bsonType": "object",
|
| 155 |
+
"required": ["face_id", "event_id", "detected_at"],
|
| 156 |
+
"properties": {
|
| 157 |
+
"face_id": {"bsonType": "string"},
|
| 158 |
+
"event_id": {"bsonType": "string"},
|
| 159 |
+
"detected_at": {"bsonType": "date"},
|
| 160 |
+
"confidence_score": {"bsonType": "double"},
|
| 161 |
+
"face_embedding": {"bsonType": "array"},
|
| 162 |
+
"minio_object_key": {"bsonType": "string"},
|
| 163 |
+
"minio_bucket": {"bsonType": "string"},
|
| 164 |
+
"face_image_path": {"bsonType": "string"},
|
| 165 |
+
"bounding_boxes": {"bsonType": "object"}
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
}, indexes=["event_id", "detected_at"])
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# === FACE MATCHES ===
|
| 172 |
+
create_collection_if_not_exists("face_matches", validator={
|
| 173 |
+
"$jsonSchema": {
|
| 174 |
+
"bsonType": "object",
|
| 175 |
+
"required": ["match_id", "face_id_1", "face_id_2", "similarity_score"],
|
| 176 |
+
"properties": {
|
| 177 |
+
"match_id": {"bsonType": "string"},
|
| 178 |
+
"face_id_1": {"bsonType": "string"},
|
| 179 |
+
"face_id_2": {"bsonType": "string"},
|
| 180 |
+
"similarity_score": {"bsonType": "double"},
|
| 181 |
+
"matched_at": {"bsonType": "date"}
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
}, indexes=["face_id_1", "face_id_2", "similarity_score"])
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# === EVENT DESCRIPTIONS ===
|
| 188 |
+
create_collection_if_not_exists("event_descriptions", validator={
|
| 189 |
+
"$jsonSchema": {
|
| 190 |
+
"bsonType": "object",
|
| 191 |
+
"required": ["description_id", "event_id", "text_embedding"],
|
| 192 |
+
"properties": {
|
| 193 |
+
"description_id": {"bsonType": "string"},
|
| 194 |
+
"event_id": {"bsonType": "string"},
|
| 195 |
+
"text_embedding": {"bsonType": "array"},
|
| 196 |
+
"caption": {"bsonType": "string"},
|
| 197 |
+
"confidence": {"bsonType": "double"},
|
| 198 |
+
"created_at": {"bsonType": "date"},
|
| 199 |
+
"updated_at": {"bsonType": "date"}
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
}, indexes=["event_id", "created_at"])
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
# === EVENT CAPTIONS ===
|
| 206 |
+
create_collection_if_not_exists("event_captions", validator={
|
| 207 |
+
"$jsonSchema": {
|
| 208 |
+
"bsonType": "object",
|
| 209 |
+
"required": ["description_id", "description"],
|
| 210 |
+
"properties": {
|
| 211 |
+
"description_id": {"bsonType": "string"},
|
| 212 |
+
"description": {"bsonType": "string"}
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
}, indexes=["description_id"])
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# === QUERY ===
|
| 219 |
+
create_collection_if_not_exists("query", validator={
|
| 220 |
+
"$jsonSchema": {
|
| 221 |
+
"bsonType": "object",
|
| 222 |
+
"required": ["query_id", "user_id", "query_text"],
|
| 223 |
+
"properties": {
|
| 224 |
+
"query_id": {"bsonType": "string"},
|
| 225 |
+
"user_id": {"bsonType": "string"},
|
| 226 |
+
"query_text": {"bsonType": "string"},
|
| 227 |
+
"query_embedding": {"bsonType": "array"},
|
| 228 |
+
"executed_at": {"bsonType": "date"}
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
}, indexes=["user_id", "executed_at"])
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# === QUERY RESULT ===
|
| 235 |
+
create_collection_if_not_exists("query_result", validator={
|
| 236 |
+
"$jsonSchema": {
|
| 237 |
+
"bsonType": "object",
|
| 238 |
+
"required": ["result_id", "query_id", "event_id"],
|
| 239 |
+
"properties": {
|
| 240 |
+
"result_id": {"bsonType": "string"},
|
| 241 |
+
"query_id": {"bsonType": "string"},
|
| 242 |
+
"event_id": {"bsonType": "string"},
|
| 243 |
+
"relevance_score": {"bsonType": "double"},
|
| 244 |
+
"match_details": {"bsonType": "object"},
|
| 245 |
+
"returned_at": {"bsonType": "date"}
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
}, indexes=["query_id", "event_id", "relevance_score"])
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# === SUBSCRIPTION PLANS ===
|
| 252 |
+
create_collection_if_not_exists("subscription_plans", validator={
|
| 253 |
+
"$jsonSchema": {
|
| 254 |
+
"bsonType": "object",
|
| 255 |
+
"required": ["plan_id", "plan_name", "price"],
|
| 256 |
+
"properties": {
|
| 257 |
+
"plan_id": {"bsonType": "string"},
|
| 258 |
+
"plan_name": {"bsonType": "string"},
|
| 259 |
+
"description": {"bsonType": "string"},
|
| 260 |
+
"price": {"bsonType": "decimal"},
|
| 261 |
+
"features": {"bsonType": "string"},
|
| 262 |
+
"storage_limit": {"bsonType": "int"},
|
| 263 |
+
"is_active": {"bsonType": "bool"},
|
| 264 |
+
"stripe_product_id": {"bsonType": "string"},
|
| 265 |
+
"stripe_price_ids": {"bsonType": "object"},
|
| 266 |
+
"billing_periods": {"bsonType": "array"},
|
| 267 |
+
"created_at": {"bsonType": "date"},
|
| 268 |
+
"updated_at": {"bsonType": "date"}
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
}, indexes=[([("plan_id", ASCENDING)], {"unique": True}), "is_active", "stripe_product_id"])
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# === USER SUBSCRIPTIONS ===
|
| 275 |
+
create_collection_if_not_exists("user_subscriptions", validator={
|
| 276 |
+
"$jsonSchema": {
|
| 277 |
+
"bsonType": "object",
|
| 278 |
+
"required": ["subscription_id", "user_id", "plan_id"],
|
| 279 |
+
"properties": {
|
| 280 |
+
"subscription_id": {"bsonType": "string"},
|
| 281 |
+
"user_id": {"bsonType": "string"},
|
| 282 |
+
"plan_id": {"bsonType": "string"},
|
| 283 |
+
"start_date": {"bsonType": "date"},
|
| 284 |
+
"end_date": {"bsonType": "date"},
|
| 285 |
+
"stripe_customer_id": {"bsonType": "string"},
|
| 286 |
+
"stripe_subscription_id": {"bsonType": "string"},
|
| 287 |
+
"billing_period": {"bsonType": "string"},
|
| 288 |
+
"status": {"bsonType": "string"},
|
| 289 |
+
"current_period_start": {"bsonType": "date"},
|
| 290 |
+
"current_period_end": {"bsonType": "date"},
|
| 291 |
+
"cancel_at_period_end": {"bsonType": "bool"},
|
| 292 |
+
"created_at": {"bsonType": "date"},
|
| 293 |
+
"updated_at": {"bsonType": "date"}
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
}, indexes=["user_id", "plan_id", "start_date", "stripe_customer_id", "stripe_subscription_id", "status"])
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
# === SUBSCRIPTION EVENTS === (NEW - for audit trail)
|
| 300 |
+
create_collection_if_not_exists("subscription_events", validator={
|
| 301 |
+
"$jsonSchema": {
|
| 302 |
+
"bsonType": "object",
|
| 303 |
+
"required": ["event_id", "subscription_id", "event_type"],
|
| 304 |
+
"properties": {
|
| 305 |
+
"event_id": {"bsonType": "string"},
|
| 306 |
+
"subscription_id": {"bsonType": "string"},
|
| 307 |
+
"event_type": {"bsonType": "string"},
|
| 308 |
+
"stripe_event_id": {"bsonType": "string"},
|
| 309 |
+
"event_data": {"bsonType": "object"},
|
| 310 |
+
"created_at": {"bsonType": "date"}
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
}, indexes=["subscription_id", "event_type", "created_at", "stripe_event_id"])
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# === PAYMENT HISTORY === (NEW - for transaction records)
|
| 317 |
+
create_collection_if_not_exists("payment_history", validator={
|
| 318 |
+
"$jsonSchema": {
|
| 319 |
+
"bsonType": "object",
|
| 320 |
+
"required": ["payment_id", "user_id", "amount"],
|
| 321 |
+
"properties": {
|
| 322 |
+
"payment_id": {"bsonType": "string"},
|
| 323 |
+
"user_id": {"bsonType": "string"},
|
| 324 |
+
"stripe_payment_intent_id": {"bsonType": "string"},
|
| 325 |
+
"amount": {"bsonType": "double"},
|
| 326 |
+
"currency": {"bsonType": "string"},
|
| 327 |
+
"status": {"bsonType": "string"},
|
| 328 |
+
"payment_method": {"bsonType": "string"},
|
| 329 |
+
"created_at": {"bsonType": "date"}
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
}, indexes=["user_id", "created_at", "status", "stripe_payment_intent_id"])
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
# === SUBSCRIPTION USAGE === (NEW - for analytics and limits)
|
| 336 |
+
create_collection_if_not_exists("subscription_usage", validator={
|
| 337 |
+
"$jsonSchema": {
|
| 338 |
+
"bsonType": "object",
|
| 339 |
+
"required": ["usage_id", "user_id", "usage_type"],
|
| 340 |
+
"properties": {
|
| 341 |
+
"usage_id": {"bsonType": "string"},
|
| 342 |
+
"user_id": {"bsonType": "string"},
|
| 343 |
+
"usage_type": {"bsonType": "string"},
|
| 344 |
+
"usage_value": {"bsonType": "double"},
|
| 345 |
+
"usage_date": {"bsonType": "date"},
|
| 346 |
+
"created_at": {"bsonType": "date"}
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
}, indexes=["user_id", "usage_type", "usage_date"])
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# === USER SESSIONS ===
|
| 353 |
+
create_collection_if_not_exists("user_sessions", validator={
|
| 354 |
+
"$jsonSchema": {
|
| 355 |
+
"bsonType": "object",
|
| 356 |
+
"required": ["session_id", "user_id", "session_token", "expires_at"],
|
| 357 |
+
"properties": {
|
| 358 |
+
"session_id": {"bsonType": "string"},
|
| 359 |
+
"user_id": {"bsonType": "string"},
|
| 360 |
+
"session_token": {"bsonType": "string"},
|
| 361 |
+
"expires_at": {"bsonType": "date"},
|
| 362 |
+
"ip_address": {"bsonType": "string"},
|
| 363 |
+
"user_agent": {"bsonType": "string"},
|
| 364 |
+
"created_at": {"bsonType": "date"}
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
}, indexes=[
|
| 368 |
+
([("session_token", ASCENDING)], {"unique": True}),
|
| 369 |
+
"user_id",
|
| 370 |
+
"expires_at"
|
| 371 |
+
])
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
print("\nDatabase schema setup completed successfully.")
|
| 375 |
+
print("All collections are ready with validation and indexes.")
|
DetectifAI_db/env.example
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MongoDB Configuration
|
| 2 |
+
MONGO_URI=mongodb://localhost:27017/detectifai
|
| 3 |
+
|
| 4 |
+
# S3-compatible Storage (Backblaze B2)
|
| 5 |
+
MINIO_ENDPOINT=s3.eu-central-003.backblazeb2.com
|
| 6 |
+
MINIO_ACCESS_KEY=your-b2-key-id
|
| 7 |
+
MINIO_SECRET_KEY=your-b2-application-key
|
| 8 |
+
MINIO_VIDEO_BUCKET=detectifai-videos
|
| 9 |
+
MINIO_KEYFRAME_BUCKET=detectifai-keyframes
|
| 10 |
+
MINIO_REPORTS_BUCKET=detectifai-reports
|
| 11 |
+
MINIO_SECURE=true
|
| 12 |
+
MINIO_REGION=eu-central-003
|
| 13 |
+
|
| 14 |
+
# JWT Configuration
|
| 15 |
+
JWT_SECRET=your-super-secret-jwt-key-here
|
| 16 |
+
|
| 17 |
+
# Flask Configuration
|
| 18 |
+
FLASK_ENV=development
|
| 19 |
+
FLASK_DEBUG=True
|
DetectifAI_db/faiss_captions.index
ADDED
|
Binary file (30.8 kB). View file
|
|
|
DetectifAI_db/faiss_captions_idmap.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"desc_fe5f4141f350",
|
| 3 |
+
"desc_6683c8f65ca9",
|
| 4 |
+
"desc_93f7c560626c",
|
| 5 |
+
"desc_02ac022c7621",
|
| 6 |
+
"desc_9fc4ce829b64",
|
| 7 |
+
"desc_3b45f7543394",
|
| 8 |
+
"desc_49df9ce76beb",
|
| 9 |
+
"desc_e119f53298d0",
|
| 10 |
+
"desc_e6a2154fb826",
|
| 11 |
+
"desc_3e3ca6f4637d"
|
| 12 |
+
]
|
DetectifAI_db/migrate_stripe_integration.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database Migration Script: Add Stripe Integration to Subscription Plans
|
| 3 |
+
|
| 4 |
+
This script updates existing subscription_plans and prepares the database
|
| 5 |
+
for Stripe payment integration.
|
| 6 |
+
|
| 7 |
+
Run this script ONCE after updating the database schema.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from pymongo import MongoClient
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
import os
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
from uuid import uuid4
|
| 15 |
+
|
| 16 |
+
load_dotenv()
|
| 17 |
+
|
| 18 |
+
# Connect to MongoDB
|
| 19 |
+
MONGO_URI = os.getenv("MONGO_URI")
|
| 20 |
+
client = MongoClient(MONGO_URI)
|
| 21 |
+
db = client.get_default_database()
|
| 22 |
+
|
| 23 |
+
subscription_plans = db.subscription_plans
|
| 24 |
+
user_subscriptions = db.user_subscriptions
|
| 25 |
+
|
| 26 |
+
print("🔄 Starting Stripe integration migration...")
|
| 27 |
+
|
| 28 |
+
# ========================================
|
| 29 |
+
# Step 1: Update existing subscription plans with Stripe data
|
| 30 |
+
# ========================================
|
| 31 |
+
|
| 32 |
+
print("\n📋 Step 1: Updating subscription plans with Stripe data...")
|
| 33 |
+
|
| 34 |
+
# DetectifAI Basic Plan
|
| 35 |
+
basic_plan = subscription_plans.find_one({"plan_name": "Basic"})
|
| 36 |
+
if basic_plan:
|
| 37 |
+
subscription_plans.update_one(
|
| 38 |
+
{"_id": basic_plan["_id"]},
|
| 39 |
+
{
|
| 40 |
+
"$set": {
|
| 41 |
+
"stripe_product_id": "prod_TqIuL76gNG4hxu",
|
| 42 |
+
"stripe_price_ids": {
|
| 43 |
+
"monthly": "price_1SscIsBC7V4mGo7rR4T0YZIc",
|
| 44 |
+
"yearly": "price_1SscMQBC7V4mGo7rigJ4bFFE"
|
| 45 |
+
},
|
| 46 |
+
"billing_periods": ["monthly", "yearly"],
|
| 47 |
+
"price": 19.00,
|
| 48 |
+
"description": "Essential AI-powered security monitoring",
|
| 49 |
+
"features": "single_video,object_detection,face_recognition,7day_history,dashboard,basic_reports",
|
| 50 |
+
"updated_at": datetime.utcnow()
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
)
|
| 54 |
+
print("✅ Updated Basic plan with Stripe integration")
|
| 55 |
+
else:
|
| 56 |
+
# Create Basic plan if it doesn't exist
|
| 57 |
+
basic_plan_data = {
|
| 58 |
+
"plan_id": str(uuid4()),
|
| 59 |
+
"plan_name": "Basic",
|
| 60 |
+
"description": "Essential AI-powered security monitoring",
|
| 61 |
+
"price": 19.00,
|
| 62 |
+
"features": "single_video,object_detection,face_recognition,7day_history,dashboard,basic_reports",
|
| 63 |
+
"storage_limit": 50,
|
| 64 |
+
"is_active": True,
|
| 65 |
+
"stripe_product_id": "prod_TqIuL76gNG4hxu",
|
| 66 |
+
"stripe_price_ids": {
|
| 67 |
+
"monthly": "price_1SscIsBC7V4mGo7rR4T0YZIc",
|
| 68 |
+
"yearly": "price_1SscMQBC7V4mGo7rigJ4bFFE"
|
| 69 |
+
},
|
| 70 |
+
"billing_periods": ["monthly", "yearly"],
|
| 71 |
+
"created_at": datetime.utcnow(),
|
| 72 |
+
"updated_at": datetime.utcnow()
|
| 73 |
+
}
|
| 74 |
+
subscription_plans.insert_one(basic_plan_data)
|
| 75 |
+
print("✅ Created Basic plan with Stripe integration")
|
| 76 |
+
|
| 77 |
+
# DetectifAI Pro Plan
|
| 78 |
+
pro_plan = subscription_plans.find_one({"plan_name": "Pro"})
|
| 79 |
+
if pro_plan:
|
| 80 |
+
subscription_plans.update_one(
|
| 81 |
+
{"_id": pro_plan["_id"]},
|
| 82 |
+
{
|
| 83 |
+
"$set": {
|
| 84 |
+
"stripe_product_id": "prod_TqIyhR08zDDa2B",
|
| 85 |
+
"stripe_price_ids": {
|
| 86 |
+
"monthly": "price_1SscMwBC7V4mGo7rmmRPTTOz",
|
| 87 |
+
"yearly": "price_1SscNXBC7V4mGo7rdGgYAYRs"
|
| 88 |
+
},
|
| 89 |
+
"billing_periods": ["monthly", "yearly"],
|
| 90 |
+
"price": 49.00,
|
| 91 |
+
"description": "Advanced security intelligence with extended capabilities",
|
| 92 |
+
"features": "everything_basic,30day_history,behavior_analysis,person_tracking,nlp_search,image_search,custom_reports,priority_queue",
|
| 93 |
+
"updated_at": datetime.utcnow()
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
)
|
| 97 |
+
print("✅ Updated Pro plan with Stripe integration")
|
| 98 |
+
else:
|
| 99 |
+
# Create Pro plan if it doesn't exist
|
| 100 |
+
pro_plan_data = {
|
| 101 |
+
"plan_id": str(uuid4()),
|
| 102 |
+
"plan_name": "Pro",
|
| 103 |
+
"description": "Advanced security intelligence with extended capabilities",
|
| 104 |
+
"price": 49.00,
|
| 105 |
+
"features": "everything_basic,30day_history,behavior_analysis,person_tracking,nlp_search,image_search,custom_reports,priority_queue",
|
| 106 |
+
"storage_limit": 200,
|
| 107 |
+
"is_active": True,
|
| 108 |
+
"stripe_product_id": "prod_TqIyhR08zDDa2B",
|
| 109 |
+
"stripe_price_ids": {
|
| 110 |
+
"monthly": "price_1SscMwBC7V4mGo7rmmRPTTOz",
|
| 111 |
+
"yearly": "price_1SscNXBC7V4mGo7rdGgYAYRs"
|
| 112 |
+
},
|
| 113 |
+
"billing_periods": ["monthly", "yearly"],
|
| 114 |
+
"created_at": datetime.utcnow(),
|
| 115 |
+
"updated_at": datetime.utcnow()
|
| 116 |
+
}
|
| 117 |
+
subscription_plans.insert_one(pro_plan_data)
|
| 118 |
+
print("✅ Created Pro plan with Stripe integration")
|
| 119 |
+
|
| 120 |
+
# Remove Enterprise plan if it exists (not part of current offering)
|
| 121 |
+
enterprise_plan = subscription_plans.find_one({"plan_name": "Enterprise"})
|
| 122 |
+
if enterprise_plan:
|
| 123 |
+
subscription_plans.update_one(
|
| 124 |
+
{"_id": enterprise_plan["_id"]},
|
| 125 |
+
{"$set": {"is_active": False, "updated_at": datetime.utcnow()}}
|
| 126 |
+
)
|
| 127 |
+
print("✅ Deactivated Enterprise plan (not in current offering)")
|
| 128 |
+
|
| 129 |
+
# ========================================
|
| 130 |
+
# Step 2: Add Stripe fields to existing user subscriptions
|
| 131 |
+
# ========================================
|
| 132 |
+
|
| 133 |
+
print("\n📋 Step 2: Adding Stripe fields to existing user subscriptions...")
|
| 134 |
+
|
| 135 |
+
existing_subscriptions = user_subscriptions.find({})
|
| 136 |
+
updated_count = 0
|
| 137 |
+
|
| 138 |
+
for sub in existing_subscriptions:
|
| 139 |
+
# Check if Stripe fields already exist
|
| 140 |
+
if "stripe_customer_id" not in sub:
|
| 141 |
+
user_subscriptions.update_one(
|
| 142 |
+
{"_id": sub["_id"]},
|
| 143 |
+
{
|
| 144 |
+
"$set": {
|
| 145 |
+
"stripe_customer_id": None,
|
| 146 |
+
"stripe_subscription_id": None,
|
| 147 |
+
"billing_period": "monthly",
|
| 148 |
+
"status": "active",
|
| 149 |
+
"current_period_start": sub.get("start_date"),
|
| 150 |
+
"current_period_end": sub.get("end_date"),
|
| 151 |
+
"cancel_at_period_end": False,
|
| 152 |
+
"updated_at": datetime.utcnow()
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
)
|
| 156 |
+
updated_count += 1
|
| 157 |
+
|
| 158 |
+
if updated_count > 0:
|
| 159 |
+
print(f"✅ Updated {updated_count} existing subscriptions with Stripe fields")
|
| 160 |
+
else:
|
| 161 |
+
print("✅ No existing subscriptions to update")
|
| 162 |
+
|
| 163 |
+
# ========================================
|
| 164 |
+
# Step 3: Verify collections exist
|
| 165 |
+
# ========================================
|
| 166 |
+
|
| 167 |
+
print("\n📋 Step 3: Verifying new collections...")
|
| 168 |
+
|
| 169 |
+
collections_to_check = [
|
| 170 |
+
"subscription_events",
|
| 171 |
+
"payment_history",
|
| 172 |
+
"subscription_usage"
|
| 173 |
+
]
|
| 174 |
+
|
| 175 |
+
for collection_name in collections_to_check:
|
| 176 |
+
if collection_name in db.list_collection_names():
|
| 177 |
+
count = db[collection_name].count_documents({})
|
| 178 |
+
print(f"✅ Collection '{collection_name}' exists (documents: {count})")
|
| 179 |
+
else:
|
| 180 |
+
print(f"⚠️ Collection '{collection_name}' not found - run database_setup.py first")
|
| 181 |
+
|
| 182 |
+
# ========================================
|
| 183 |
+
# Step 4: Display summary
|
| 184 |
+
# ========================================
|
| 185 |
+
|
| 186 |
+
print("\n" + "="*60)
|
| 187 |
+
print("📊 MIGRATION SUMMARY")
|
| 188 |
+
print("="*60)
|
| 189 |
+
|
| 190 |
+
all_plans = list(subscription_plans.find({"is_active": True}))
|
| 191 |
+
print(f"\n✅ Active Subscription Plans: {len(all_plans)}")
|
| 192 |
+
for plan in all_plans:
|
| 193 |
+
print(f" • {plan['plan_name']}: ${plan['price']}/month")
|
| 194 |
+
print(f" Stripe Product: {plan.get('stripe_product_id', 'NOT SET')}")
|
| 195 |
+
print(f" Billing: {', '.join(plan.get('billing_periods', []))}")
|
| 196 |
+
|
| 197 |
+
all_subs = user_subscriptions.count_documents({})
|
| 198 |
+
print(f"\n✅ Total User Subscriptions: {all_subs}")
|
| 199 |
+
|
| 200 |
+
print("\n" + "="*60)
|
| 201 |
+
print("✅ Migration completed successfully!")
|
| 202 |
+
print("="*60)
|
| 203 |
+
print("\nNext steps:")
|
| 204 |
+
print("1. Test Stripe integration endpoints")
|
| 205 |
+
print("2. Create webhook endpoint for Stripe events")
|
| 206 |
+
print("3. Test checkout flow with test cards")
|
| 207 |
+
print("4. Update frontend pricing components")
|
| 208 |
+
|
| 209 |
+
client.close()
|
DetectifAI_db/minio_config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
S3-compatible storage configuration for DetectifAI (Backblaze B2)
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
# S3 bucket names (matching actual Backblaze B2 buckets)
|
| 6 |
+
VIDEOS_BUCKET = "detectifai-videos"
|
| 7 |
+
KEYFRAMES_BUCKET = "detectifai-keyframes"
|
| 8 |
+
COMPRESSED_BUCKET = "detectifai-compressed"
|
| 9 |
+
NLP_IMAGES_BUCKET = "nlp-images"
|
| 10 |
+
REPORTS_BUCKET = "detectifai-reports"
|
| 11 |
+
|
| 12 |
+
# Object prefixes/paths
|
| 13 |
+
ORIGINAL_VIDEO_PREFIX = "original"
|
| 14 |
+
COMPRESSED_VIDEO_PREFIX = "compressed"
|
| 15 |
+
KEYFRAME_PREFIX = "keyframes"
|
| 16 |
+
|
| 17 |
+
# S3-compatible storage default configuration (Backblaze B2)
|
| 18 |
+
MINIO_CONFIG = {
|
| 19 |
+
"endpoint": "s3.eu-central-003.backblazeb2.com",
|
| 20 |
+
"access_key": "00367479ffb7e4e0000000001",
|
| 21 |
+
"secret_key": "K003opTvf92ijRj5dM7H1dgrlwcGTdA",
|
| 22 |
+
"secure": True,
|
| 23 |
+
"region": "eu-central-003"
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
# Function to generate MinIO paths
|
| 27 |
+
def get_minio_paths(video_id: str, filename: str = None):
|
| 28 |
+
"""Generate standardized MinIO paths for a video"""
|
| 29 |
+
if filename is None:
|
| 30 |
+
filename = f"{video_id}.mp4"
|
| 31 |
+
|
| 32 |
+
return {
|
| 33 |
+
"original": f"{ORIGINAL_VIDEO_PREFIX}/{video_id}/{filename}",
|
| 34 |
+
"compressed": f"{COMPRESSED_VIDEO_PREFIX}/{video_id}/{filename}",
|
| 35 |
+
"keyframes": f"{KEYFRAME_PREFIX}/{video_id}",
|
| 36 |
+
"reports": f"reports/{video_id}"
|
| 37 |
+
}
|
DetectifAI_db/requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask==2.3.3
|
| 2 |
+
Flask-CORS==4.0.0
|
| 3 |
+
Werkzeug==3.0.0
|
| 4 |
+
PyJWT==2.8.0
|
| 5 |
+
pymongo>=4.6.3,<5.0
|
| 6 |
+
python-multipart==0.0.6
|
| 7 |
+
minio==7.1.11
|
| 8 |
+
opencv-python==4.8.0.74
|
| 9 |
+
python-dotenv==1.0.0
|
| 10 |
+
faiss-cpu
|
| 11 |
+
numpy
|
| 12 |
+
Pillow
|
| 13 |
+
scikit-learn
|
| 14 |
+
sentence-transformers
|
DetectifAI_db/reset_minio.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reset MinIO buckets and test storage paths for DetectifAI.
|
| 3 |
+
|
| 4 |
+
This script ensures that all required MinIO buckets and storage paths
|
| 5 |
+
are properly configured for video processing.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from minio import Minio
|
| 9 |
+
from minio.error import S3Error
|
| 10 |
+
import os
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
import logging
|
| 14 |
+
|
| 15 |
+
# Load environment variables
|
| 16 |
+
load_dotenv()
|
| 17 |
+
|
| 18 |
+
# Configure logging
|
| 19 |
+
logging.basicConfig(
|
| 20 |
+
level=logging.INFO,
|
| 21 |
+
format='%(asctime)s - %(levelname)s - %(message)s'
|
| 22 |
+
)
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# MinIO configuration
|
| 26 |
+
MINIO_CONFIG = {
|
| 27 |
+
"endpoint": os.getenv("MINIO_ENDPOINT", "s3.eu-central-003.backblazeb2.com"),
|
| 28 |
+
"access_key": os.getenv("MINIO_ACCESS_KEY", "00367479ffb7e4e0000000001"),
|
| 29 |
+
"secret_key": os.getenv("MINIO_SECRET_KEY", "K003opTvf92ijRj5dM7H1dgrlwcGTdA"),
|
| 30 |
+
"secure": os.getenv("MINIO_SECURE", "true").lower() == "true",
|
| 31 |
+
"region": os.getenv("MINIO_REGION", "eu-central-003")
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
# Bucket configuration with descriptions
|
| 35 |
+
BUCKETS = {
|
| 36 |
+
"detectifai-videos": {
|
| 37 |
+
"description": "Main bucket for video storage",
|
| 38 |
+
"prefixes": {
|
| 39 |
+
"original": "Original uploaded videos",
|
| 40 |
+
"compressed": "Compressed video versions"
|
| 41 |
+
}
|
| 42 |
+
},
|
| 43 |
+
"detectifai-keyframes": {
|
| 44 |
+
"description": "Storage for extracted video frames",
|
| 45 |
+
"prefixes": {
|
| 46 |
+
"keyframes": "Extracted keyframes and annotated frames"
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
def reset_minio_storage():
|
| 52 |
+
"""Reset and verify MinIO storage configuration"""
|
| 53 |
+
client = Minio(**MINIO_CONFIG)
|
| 54 |
+
|
| 55 |
+
print("Checking MinIO connection and buckets...")
|
| 56 |
+
|
| 57 |
+
for bucket_name, config in BUCKETS.items():
|
| 58 |
+
try:
|
| 59 |
+
# Check if bucket exists
|
| 60 |
+
found = client.bucket_exists(bucket_name)
|
| 61 |
+
if not found:
|
| 62 |
+
print(f"Creating bucket: {bucket_name}")
|
| 63 |
+
client.make_bucket(bucket_name)
|
| 64 |
+
|
| 65 |
+
# Test each prefix path
|
| 66 |
+
for prefix in config["prefixes"]:
|
| 67 |
+
test_object = f"{prefix}/test.txt"
|
| 68 |
+
test_data = f"Test data for {bucket_name}/{prefix}"
|
| 69 |
+
|
| 70 |
+
print(f"\nTesting path: {bucket_name}/{test_object}")
|
| 71 |
+
|
| 72 |
+
# Upload test object
|
| 73 |
+
test_bytes = bytes(test_data, 'utf-8')
|
| 74 |
+
from io import BytesIO
|
| 75 |
+
test_stream = BytesIO(test_bytes)
|
| 76 |
+
client.put_object(
|
| 77 |
+
bucket_name,
|
| 78 |
+
test_object,
|
| 79 |
+
test_stream,
|
| 80 |
+
len(test_bytes)
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# Verify upload
|
| 84 |
+
try:
|
| 85 |
+
client.stat_object(bucket_name, test_object)
|
| 86 |
+
print(f"✅ Test file uploaded successfully")
|
| 87 |
+
|
| 88 |
+
# Clean up test file
|
| 89 |
+
client.remove_object(bucket_name, test_object)
|
| 90 |
+
print(f"✅ Test file removed")
|
| 91 |
+
except:
|
| 92 |
+
print(f"❌ Could not verify test file")
|
| 93 |
+
|
| 94 |
+
print(f"\nListing objects in {bucket_name}:")
|
| 95 |
+
objects = client.list_objects(bucket_name, recursive=True)
|
| 96 |
+
for obj in objects:
|
| 97 |
+
print(f"- {obj.object_name} (size: {obj.size} bytes)")
|
| 98 |
+
|
| 99 |
+
except S3Error as e:
|
| 100 |
+
print(f"❌ Error with bucket {bucket_name}: {e}")
|
| 101 |
+
continue
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
reset_minio_storage()
|
DetectifAI_db/reset_users_collection.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pymongo import MongoClient
|
| 2 |
+
import os
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
MONGO_URI = os.getenv("MONGO_URI")
|
| 7 |
+
|
| 8 |
+
def reset_users_collection():
|
| 9 |
+
try:
|
| 10 |
+
client = MongoClient(MONGO_URI)
|
| 11 |
+
db = client.get_default_database()
|
| 12 |
+
|
| 13 |
+
# Drop the existing users collection
|
| 14 |
+
print("Dropping existing users collection...")
|
| 15 |
+
db.users.drop()
|
| 16 |
+
|
| 17 |
+
# Run database_setup.py to recreate with new schema
|
| 18 |
+
print("Creating users collection with new schema...")
|
| 19 |
+
import database_setup
|
| 20 |
+
|
| 21 |
+
print("✅ Users collection reset successfully!")
|
| 22 |
+
|
| 23 |
+
except Exception as e:
|
| 24 |
+
print(f"❌ Error: {e}")
|
| 25 |
+
finally:
|
| 26 |
+
client.close()
|
| 27 |
+
|
| 28 |
+
if __name__ == "__main__":
|
| 29 |
+
reset_users_collection()
|
DetectifAI_db/seed_stripe_plans.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Seed Stripe-Integrated Subscription Plans
|
| 3 |
+
|
| 4 |
+
This script populates the subscription_plans collection with accurate
|
| 5 |
+
DetectifAI Basic and Pro plans connected to Stripe.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from pymongo import MongoClient
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
import os
|
| 11 |
+
from dotenv import load_dotenv
|
| 12 |
+
from uuid import uuid4
|
| 13 |
+
|
| 14 |
+
load_dotenv()
|
| 15 |
+
|
| 16 |
+
MONGO_URI = os.getenv("MONGO_URI")
|
| 17 |
+
client = MongoClient(MONGO_URI)
|
| 18 |
+
db = client.get_default_database()
|
| 19 |
+
subscription_plans = db.subscription_plans
|
| 20 |
+
|
| 21 |
+
print("🌱 Seeding Stripe-integrated subscription plans...")
|
| 22 |
+
|
| 23 |
+
# DetectifAI Basic Plan
|
| 24 |
+
basic_plan = {
|
| 25 |
+
"plan_id": "detectifai_basic",
|
| 26 |
+
"plan_name": "DetectifAI Basic",
|
| 27 |
+
"description": "Essential AI-powered security monitoring for single installations",
|
| 28 |
+
"price": 19.00,
|
| 29 |
+
"features": [
|
| 30 |
+
"single_video",
|
| 31 |
+
"object_detection",
|
| 32 |
+
"face_recognition",
|
| 33 |
+
"event_history_7day",
|
| 34 |
+
"dashboard",
|
| 35 |
+
"basic_reports",
|
| 36 |
+
"video_clips"
|
| 37 |
+
],
|
| 38 |
+
"limits": {
|
| 39 |
+
"video_processing": 10, # Videos per month
|
| 40 |
+
"history_retention_days": 7,
|
| 41 |
+
"nlp_searches": 0, # Not available in Basic
|
| 42 |
+
"image_searches": 0, # Not available in Basic
|
| 43 |
+
"concurrent_streams": 1
|
| 44 |
+
},
|
| 45 |
+
"is_active": True,
|
| 46 |
+
"stripe_product_id": "prod_TqIuL76gNG4hxu",
|
| 47 |
+
"stripe_price_ids": {
|
| 48 |
+
"monthly": "price_1SscIsBC7V4mGo7rR4T0YZIc",
|
| 49 |
+
"yearly": "price_1SscMQBC7V4mGo7rigJ4bFFE"
|
| 50 |
+
},
|
| 51 |
+
"billing_periods": ["monthly", "yearly"],
|
| 52 |
+
"created_at": datetime.utcnow(),
|
| 53 |
+
"updated_at": datetime.utcnow()
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
# DetectifAI Pro Plan
|
| 57 |
+
pro_plan = {
|
| 58 |
+
"plan_id": "detectifai_pro",
|
| 59 |
+
"plan_name": "DetectifAI Pro",
|
| 60 |
+
"description": "Advanced security intelligence with extended capabilities",
|
| 61 |
+
"price": 49.00,
|
| 62 |
+
"features": [
|
| 63 |
+
"single_video",
|
| 64 |
+
"object_detection",
|
| 65 |
+
"face_recognition",
|
| 66 |
+
"event_history_30day",
|
| 67 |
+
"dashboard",
|
| 68 |
+
"basic_reports",
|
| 69 |
+
"video_clips",
|
| 70 |
+
"behavior_analysis",
|
| 71 |
+
"person_tracking",
|
| 72 |
+
"nlp_search",
|
| 73 |
+
"image_search",
|
| 74 |
+
"custom_reports",
|
| 75 |
+
"priority_queue"
|
| 76 |
+
],
|
| 77 |
+
"limits": {
|
| 78 |
+
"video_processing": 999999, # Unlimited videos per month for Pro
|
| 79 |
+
"history_retention_days": 30,
|
| 80 |
+
"nlp_searches": 200, # NLP searches per month
|
| 81 |
+
"image_searches": 100, # Image searches per month
|
| 82 |
+
"concurrent_streams": 1
|
| 83 |
+
},
|
| 84 |
+
"is_active": True,
|
| 85 |
+
"stripe_product_id": "prod_TqIyhR08zDDa2B",
|
| 86 |
+
"stripe_price_ids": {
|
| 87 |
+
"monthly": "price_1SscMwBC7V4mGo7rmmRPTTOz",
|
| 88 |
+
"yearly": "price_1SscNXBC7V4mGo7rdGgYAYRs"
|
| 89 |
+
},
|
| 90 |
+
"billing_periods": ["monthly", "yearly"],
|
| 91 |
+
"created_at": datetime.utcnow(),
|
| 92 |
+
"updated_at": datetime.utcnow()
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
# Upsert plans
|
| 96 |
+
for plan in [basic_plan, pro_plan]:
|
| 97 |
+
result = subscription_plans.update_one(
|
| 98 |
+
{"plan_id": plan["plan_id"]},
|
| 99 |
+
{"$set": plan},
|
| 100 |
+
upsert=True
|
| 101 |
+
)
|
| 102 |
+
if result.upserted_id:
|
| 103 |
+
print(f"✅ Created plan: {plan['plan_name']}")
|
| 104 |
+
else:
|
| 105 |
+
print(f"✅ Updated plan: {plan['plan_name']}")
|
| 106 |
+
|
| 107 |
+
# Display summary
|
| 108 |
+
print("\n" + "="*60)
|
| 109 |
+
print("📊 SUBSCRIPTION PLANS")
|
| 110 |
+
print("="*60)
|
| 111 |
+
|
| 112 |
+
all_plans = list(subscription_plans.find({"is_active": True}))
|
| 113 |
+
for plan in all_plans:
|
| 114 |
+
print(f"\n{plan['plan_name']} - ${plan['price']}/month")
|
| 115 |
+
print(f" Description: {plan['description']}")
|
| 116 |
+
|
| 117 |
+
# Only print if exists (for compatibility with old plans)
|
| 118 |
+
if 'stripe_product_id' in plan:
|
| 119 |
+
print(f" Stripe Product: {plan['stripe_product_id']}")
|
| 120 |
+
|
| 121 |
+
if 'stripe_price_ids' in plan:
|
| 122 |
+
monthly_price = plan['stripe_price_ids'].get('monthly', 'N/A')
|
| 123 |
+
yearly_price = plan['stripe_price_ids'].get('yearly', 'N/A')
|
| 124 |
+
print(f" Monthly Price ID: {monthly_price}")
|
| 125 |
+
print(f" Yearly Price ID: {yearly_price}")
|
| 126 |
+
|
| 127 |
+
if 'features' in plan:
|
| 128 |
+
features = plan['features']
|
| 129 |
+
if isinstance(features, list):
|
| 130 |
+
print(f" Features: {', '.join(features)}")
|
| 131 |
+
else:
|
| 132 |
+
print(f" Features: {features}")
|
| 133 |
+
|
| 134 |
+
if 'limits' in plan:
|
| 135 |
+
print(f" Limits:")
|
| 136 |
+
for limit_name, limit_value in plan['limits'].items():
|
| 137 |
+
print(f" - {limit_name}: {limit_value}")
|
| 138 |
+
|
| 139 |
+
print("\n✅ Subscription plans seeded successfully!")
|
| 140 |
+
|
| 141 |
+
client.close()
|
DetectifAI_db/setup_database.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Database setup script for DetectifAI backend
|
| 4 |
+
This script initializes the MongoDB database with the required collections and indexes.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
# Load environment variables
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
# Check if MONGO_URI is set
|
| 15 |
+
if not os.getenv("MONGO_URI"):
|
| 16 |
+
print("❌ Error: MONGO_URI environment variable not set")
|
| 17 |
+
print("Please create a .env file with your MongoDB connection string")
|
| 18 |
+
print("Example: MONGO_URI=mongodb://localhost:27017/detectifai")
|
| 19 |
+
sys.exit(1)
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
# Import and run database setup
|
| 23 |
+
from database_setup import *
|
| 24 |
+
print("\n✅ Database setup completed successfully!")
|
| 25 |
+
|
| 26 |
+
# Ask if user wants to seed the database
|
| 27 |
+
seed_choice = input("\nWould you like to seed the database with sample data? (y/n): ").lower().strip()
|
| 28 |
+
|
| 29 |
+
if seed_choice in ['y', 'yes']:
|
| 30 |
+
print("\n🌱 Seeding database with sample data...")
|
| 31 |
+
from database_seed import *
|
| 32 |
+
print("\n✅ Database seeding completed!")
|
| 33 |
+
else:
|
| 34 |
+
print("\n⏭️ Skipping database seeding")
|
| 35 |
+
|
| 36 |
+
print("\n🎉 Database initialization complete!")
|
| 37 |
+
print("\nNext steps:")
|
| 38 |
+
print("1. Start the integrated Flask app: python app_integrated.py")
|
| 39 |
+
print("2. Or start the original app: python app.py")
|
| 40 |
+
print("3. Test the API endpoints at http://localhost:5000")
|
| 41 |
+
|
| 42 |
+
except Exception as e:
|
| 43 |
+
print(f"❌ Error during database setup: {e}")
|
| 44 |
+
sys.exit(1)
|
DetectifAI_db/setup_minio.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
S3-compatible Storage Setup and Test Script for DetectifAI (Backblaze B2)
|
| 3 |
+
"""
|
| 4 |
+
from minio import Minio
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
import os
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
# Set up logging
|
| 10 |
+
logging.basicConfig(level=logging.INFO)
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
# Load environment variables
|
| 14 |
+
load_dotenv()
|
| 15 |
+
|
| 16 |
+
def setup_minio():
|
| 17 |
+
"""Setup S3-compatible storage (Backblaze B2)"""
|
| 18 |
+
try:
|
| 19 |
+
endpoint = os.getenv('MINIO_ENDPOINT', 's3.eu-central-003.backblazeb2.com')
|
| 20 |
+
access_key = os.getenv('MINIO_ACCESS_KEY', '00367479ffb7e4e0000000001')
|
| 21 |
+
secret_key = os.getenv('MINIO_SECRET_KEY', 'K003opTvf92ijRj5dM7H1dgrlwcGTdA')
|
| 22 |
+
secure = os.getenv('MINIO_SECURE', 'true').lower() == 'true'
|
| 23 |
+
region = os.getenv('MINIO_REGION', 'eu-central-003')
|
| 24 |
+
|
| 25 |
+
# S3 client setup
|
| 26 |
+
client = Minio(
|
| 27 |
+
endpoint,
|
| 28 |
+
access_key=access_key,
|
| 29 |
+
secret_key=secret_key,
|
| 30 |
+
secure=secure,
|
| 31 |
+
region=region or None
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Define required buckets
|
| 35 |
+
buckets = [
|
| 36 |
+
"detectifai-videos", # Original and compressed videos
|
| 37 |
+
"detectifai-keyframes", # Extracted keyframes
|
| 38 |
+
"detectifai-reports" # Generated reports (HTML/PDF)
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
# Verify buckets exist (don't create — buckets managed in B2 dashboard)
|
| 42 |
+
for bucket in buckets:
|
| 43 |
+
found = client.bucket_exists(bucket)
|
| 44 |
+
if found:
|
| 45 |
+
logger.info(f"✅ Bucket exists: {bucket}")
|
| 46 |
+
else:
|
| 47 |
+
logger.warning(f"⚠️ Bucket NOT found: {bucket} — create it in Backblaze B2 dashboard")
|
| 48 |
+
|
| 49 |
+
# Test upload to each bucket
|
| 50 |
+
test_data = b"DetectifAI Test Data"
|
| 51 |
+
for bucket in buckets:
|
| 52 |
+
try:
|
| 53 |
+
test_object = f"test_{bucket}.txt"
|
| 54 |
+
client.put_object(
|
| 55 |
+
bucket,
|
| 56 |
+
test_object,
|
| 57 |
+
bytes(test_data),
|
| 58 |
+
len(test_data)
|
| 59 |
+
)
|
| 60 |
+
logger.info(f"✅ Test upload successful to {bucket}")
|
| 61 |
+
|
| 62 |
+
# Clean up test file
|
| 63 |
+
client.remove_object(bucket, test_object)
|
| 64 |
+
|
| 65 |
+
except Exception as bucket_error:
|
| 66 |
+
logger.error(f"❌ Failed to upload test file to {bucket}: {str(bucket_error)}")
|
| 67 |
+
|
| 68 |
+
# List objects in each bucket
|
| 69 |
+
logger.info("\nCurrent bucket contents:")
|
| 70 |
+
for bucket in buckets:
|
| 71 |
+
logger.info(f"\nBucket: {bucket}")
|
| 72 |
+
try:
|
| 73 |
+
objects = client.list_objects(bucket, recursive=True)
|
| 74 |
+
for obj in objects:
|
| 75 |
+
logger.info(f"- {obj.object_name} (size: {obj.size} bytes)")
|
| 76 |
+
except Exception as list_error:
|
| 77 |
+
logger.error(f"❌ Failed to list objects in {bucket}: {str(list_error)}")
|
| 78 |
+
|
| 79 |
+
return True, "MinIO setup completed successfully"
|
| 80 |
+
|
| 81 |
+
except Exception as e:
|
| 82 |
+
error_message = f"MinIO setup failed: {str(e)}"
|
| 83 |
+
logger.error(f"❌ {error_message}")
|
| 84 |
+
return False, error_message
|
| 85 |
+
|
| 86 |
+
if __name__ == "__main__":
|
| 87 |
+
success, message = setup_minio()
|
| 88 |
+
if success:
|
| 89 |
+
logger.info("✅ MinIO setup completed successfully!")
|
| 90 |
+
else:
|
| 91 |
+
logger.error(f"❌ MinIO setup failed: {message}")
|
DetectifAI_db/setup_nlp_bucket.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Setup script to create the nlp-images bucket in MinIO
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
from minio import Minio
|
| 8 |
+
from minio.error import S3Error
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
load_dotenv()
|
| 12 |
+
|
| 13 |
+
logging.basicConfig(level=logging.INFO)
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "s3.eu-central-003.backblazeb2.com")
|
| 17 |
+
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "00367479ffb7e4e0000000001")
|
| 18 |
+
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "K003opTvf92ijRj5dM7H1dgrlwcGTdA")
|
| 19 |
+
MINIO_SECURE = os.getenv("MINIO_SECURE", "true").lower() == "true"
|
| 20 |
+
MINIO_REGION = os.getenv("MINIO_REGION", "eu-central-003")
|
| 21 |
+
NLP_IMAGES_BUCKET = "nlp-images"
|
| 22 |
+
|
| 23 |
+
def setup_nlp_bucket():
|
| 24 |
+
"""Create the nlp-images bucket if it doesn't exist"""
|
| 25 |
+
try:
|
| 26 |
+
client = Minio(
|
| 27 |
+
MINIO_ENDPOINT,
|
| 28 |
+
access_key=MINIO_ACCESS_KEY,
|
| 29 |
+
secret_key=MINIO_SECRET_KEY,
|
| 30 |
+
secure=MINIO_SECURE,
|
| 31 |
+
region=MINIO_REGION
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
if client.bucket_exists(NLP_IMAGES_BUCKET):
|
| 35 |
+
logger.info(f"✅ MinIO bucket '{NLP_IMAGES_BUCKET}' already exists")
|
| 36 |
+
return True
|
| 37 |
+
else:
|
| 38 |
+
logger.info(f"Creating MinIO bucket '{NLP_IMAGES_BUCKET}'...")
|
| 39 |
+
client.make_bucket(NLP_IMAGES_BUCKET)
|
| 40 |
+
logger.info(f"✅ MinIO bucket '{NLP_IMAGES_BUCKET}' created successfully")
|
| 41 |
+
return True
|
| 42 |
+
except S3Error as e:
|
| 43 |
+
if e.code == "BucketAlreadyOwnedByYou" or e.code == "BucketAlreadyExists":
|
| 44 |
+
logger.info(f"✅ MinIO bucket '{NLP_IMAGES_BUCKET}' already exists")
|
| 45 |
+
return True
|
| 46 |
+
else:
|
| 47 |
+
logger.error(f"❌ Error creating bucket: {e}")
|
| 48 |
+
return False
|
| 49 |
+
except Exception as e:
|
| 50 |
+
logger.error(f"❌ Error connecting to MinIO: {e}")
|
| 51 |
+
return False
|
| 52 |
+
|
| 53 |
+
if __name__ == "__main__":
|
| 54 |
+
logger.info("Setting up nlp-images bucket...")
|
| 55 |
+
success = setup_nlp_bucket()
|
| 56 |
+
if success:
|
| 57 |
+
logger.info("✅ Setup complete!")
|
| 58 |
+
else:
|
| 59 |
+
logger.error("❌ Setup failed!")
|
| 60 |
+
exit(1)
|
| 61 |
+
|
DetectifAI_db/upload_caption_images.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Upload Caption Images to MinIO
|
| 3 |
+
|
| 4 |
+
This script uploads the image files referenced in the captions to the MinIO nlp-images bucket.
|
| 5 |
+
The images should be in a local directory (e.g., 'caption_images' folder).
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python upload_caption_images.py [--image-dir <directory>]
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from dotenv import load_dotenv
|
| 15 |
+
from minio import Minio
|
| 16 |
+
from minio.error import S3Error
|
| 17 |
+
import logging
|
| 18 |
+
|
| 19 |
+
# Configure logging
|
| 20 |
+
logging.basicConfig(
|
| 21 |
+
level=logging.INFO,
|
| 22 |
+
format='%(asctime)s - %(levelname)s - %(message)s'
|
| 23 |
+
)
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
# Load environment variables
|
| 27 |
+
load_dotenv()
|
| 28 |
+
|
| 29 |
+
MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/detectifai")
|
| 30 |
+
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "s3.eu-central-003.backblazeb2.com")
|
| 31 |
+
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "00367479ffb7e4e0000000001")
|
| 32 |
+
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "K003opTvf92ijRj5dM7H1dgrlwcGTdA")
|
| 33 |
+
MINIO_SECURE = os.getenv("MINIO_SECURE", "true").lower() == "true"
|
| 34 |
+
MINIO_REGION = os.getenv("MINIO_REGION", "eu-central-003")
|
| 35 |
+
NLP_IMAGES_BUCKET = "nlp-images"
|
| 36 |
+
|
| 37 |
+
# Expected image files from upload_captions.py
|
| 38 |
+
EXPECTED_IMAGES = [
|
| 39 |
+
"img1.webp",
|
| 40 |
+
"img2.jpg",
|
| 41 |
+
"img3.png",
|
| 42 |
+
"img4.png",
|
| 43 |
+
"img5.jpg",
|
| 44 |
+
"img6.webp",
|
| 45 |
+
"img7.webp",
|
| 46 |
+
"img8.webp",
|
| 47 |
+
"img9.jpg",
|
| 48 |
+
"img10.png"
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def setup_minio_client():
|
| 53 |
+
"""Initialize MinIO client"""
|
| 54 |
+
try:
|
| 55 |
+
client = Minio(
|
| 56 |
+
MINIO_ENDPOINT,
|
| 57 |
+
access_key=MINIO_ACCESS_KEY,
|
| 58 |
+
secret_key=MINIO_SECRET_KEY,
|
| 59 |
+
secure=MINIO_SECURE,
|
| 60 |
+
region=MINIO_REGION
|
| 61 |
+
)
|
| 62 |
+
return client
|
| 63 |
+
except Exception as e:
|
| 64 |
+
logger.error(f"❌ Error connecting to MinIO: {e}")
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def ensure_bucket_exists(client, bucket_name):
|
| 69 |
+
"""Ensure the bucket exists, create if it doesn't"""
|
| 70 |
+
try:
|
| 71 |
+
if not client.bucket_exists(bucket_name):
|
| 72 |
+
logger.info(f"Creating bucket: {bucket_name}")
|
| 73 |
+
client.make_bucket(bucket_name)
|
| 74 |
+
logger.info(f"✅ Created bucket: {bucket_name}")
|
| 75 |
+
else:
|
| 76 |
+
logger.info(f"✅ Bucket '{bucket_name}' already exists")
|
| 77 |
+
return True
|
| 78 |
+
except S3Error as e:
|
| 79 |
+
if e.code == "BucketAlreadyOwnedByYou" or e.code == "BucketAlreadyExists":
|
| 80 |
+
logger.info(f"✅ Bucket '{bucket_name}' already exists")
|
| 81 |
+
return True
|
| 82 |
+
logger.error(f"❌ Error creating bucket: {e}")
|
| 83 |
+
return False
|
| 84 |
+
except Exception as e:
|
| 85 |
+
logger.error(f"❌ Unexpected error: {e}")
|
| 86 |
+
return False
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def upload_image(client, bucket_name, image_path, object_name):
|
| 90 |
+
"""Upload a single image file to MinIO"""
|
| 91 |
+
try:
|
| 92 |
+
if not os.path.exists(image_path):
|
| 93 |
+
logger.warning(f"⚠️ Image file not found: {image_path}")
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
file_size = os.path.getsize(image_path)
|
| 97 |
+
|
| 98 |
+
# Determine content type based on extension
|
| 99 |
+
ext = image_path.lower().split('.')[-1]
|
| 100 |
+
content_type_map = {
|
| 101 |
+
'jpg': 'image/jpeg',
|
| 102 |
+
'jpeg': 'image/jpeg',
|
| 103 |
+
'png': 'image/png',
|
| 104 |
+
'webp': 'image/webp',
|
| 105 |
+
'gif': 'image/gif'
|
| 106 |
+
}
|
| 107 |
+
content_type = content_type_map.get(ext, 'application/octet-stream')
|
| 108 |
+
|
| 109 |
+
with open(image_path, 'rb') as file_data:
|
| 110 |
+
client.put_object(
|
| 111 |
+
bucket_name,
|
| 112 |
+
object_name,
|
| 113 |
+
file_data,
|
| 114 |
+
length=file_size,
|
| 115 |
+
content_type=content_type
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
logger.info(f"✅ Uploaded: {object_name} ({file_size} bytes)")
|
| 119 |
+
return True
|
| 120 |
+
except S3Error as e:
|
| 121 |
+
logger.error(f"❌ S3Error uploading {object_name}: {e}")
|
| 122 |
+
return False
|
| 123 |
+
except Exception as e:
|
| 124 |
+
logger.error(f"❌ Error uploading {object_name}: {e}")
|
| 125 |
+
return False
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def find_image_directory():
|
| 129 |
+
"""Try to find the directory containing caption images"""
|
| 130 |
+
# Common locations to check
|
| 131 |
+
possible_dirs = [
|
| 132 |
+
Path(__file__).parent / "caption_images",
|
| 133 |
+
Path(__file__).parent.parent / "caption_images",
|
| 134 |
+
Path(__file__).parent / "images",
|
| 135 |
+
Path(__file__).parent.parent / "images",
|
| 136 |
+
Path(__file__).parent / "DetectifAI_db" / "caption_images",
|
| 137 |
+
]
|
| 138 |
+
|
| 139 |
+
for dir_path in possible_dirs:
|
| 140 |
+
if dir_path.exists() and dir_path.is_dir():
|
| 141 |
+
# Check if it contains any of the expected images
|
| 142 |
+
files = [f.name for f in dir_path.iterdir() if f.is_file()]
|
| 143 |
+
if any(img in files for img in EXPECTED_IMAGES):
|
| 144 |
+
return dir_path
|
| 145 |
+
|
| 146 |
+
return None
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def upload_all_images(image_dir=None):
|
| 150 |
+
"""Upload all caption images to MinIO"""
|
| 151 |
+
logger.info("🚀 Starting Caption Image Upload Process")
|
| 152 |
+
logger.info("=" * 80)
|
| 153 |
+
|
| 154 |
+
# Initialize MinIO client
|
| 155 |
+
client = setup_minio_client()
|
| 156 |
+
if not client:
|
| 157 |
+
logger.error("❌ Failed to initialize MinIO client")
|
| 158 |
+
return False
|
| 159 |
+
|
| 160 |
+
# Ensure bucket exists
|
| 161 |
+
if not ensure_bucket_exists(client, NLP_IMAGES_BUCKET):
|
| 162 |
+
logger.error("❌ Failed to ensure bucket exists")
|
| 163 |
+
return False
|
| 164 |
+
|
| 165 |
+
# Find image directory
|
| 166 |
+
if image_dir is None:
|
| 167 |
+
image_dir = find_image_directory()
|
| 168 |
+
|
| 169 |
+
if image_dir is None:
|
| 170 |
+
logger.error("❌ Could not find image directory")
|
| 171 |
+
logger.info("💡 Please provide the image directory path:")
|
| 172 |
+
logger.info(" python upload_caption_images.py --image-dir <path>")
|
| 173 |
+
logger.info("")
|
| 174 |
+
logger.info("Expected image files:")
|
| 175 |
+
for img in EXPECTED_IMAGES:
|
| 176 |
+
logger.info(f" - {img}")
|
| 177 |
+
return False
|
| 178 |
+
|
| 179 |
+
image_dir = Path(image_dir)
|
| 180 |
+
if not image_dir.exists():
|
| 181 |
+
logger.error(f"❌ Image directory does not exist: {image_dir}")
|
| 182 |
+
return False
|
| 183 |
+
|
| 184 |
+
logger.info(f"📁 Using image directory: {image_dir}")
|
| 185 |
+
logger.info("")
|
| 186 |
+
|
| 187 |
+
# Upload each image
|
| 188 |
+
uploaded_count = 0
|
| 189 |
+
failed_count = 0
|
| 190 |
+
missing_count = 0
|
| 191 |
+
|
| 192 |
+
for image_name in EXPECTED_IMAGES:
|
| 193 |
+
image_path = image_dir / image_name
|
| 194 |
+
|
| 195 |
+
if not image_path.exists():
|
| 196 |
+
logger.warning(f"⚠️ Image not found: {image_name}")
|
| 197 |
+
missing_count += 1
|
| 198 |
+
continue
|
| 199 |
+
|
| 200 |
+
if upload_image(client, NLP_IMAGES_BUCKET, str(image_path), image_name):
|
| 201 |
+
uploaded_count += 1
|
| 202 |
+
else:
|
| 203 |
+
failed_count += 1
|
| 204 |
+
|
| 205 |
+
# Summary
|
| 206 |
+
logger.info("")
|
| 207 |
+
logger.info("=" * 80)
|
| 208 |
+
logger.info("📊 Upload Summary:")
|
| 209 |
+
logger.info(f" ✅ Successfully uploaded: {uploaded_count}")
|
| 210 |
+
logger.info(f" ❌ Failed: {failed_count}")
|
| 211 |
+
logger.info(f" ⚠️ Missing: {missing_count}")
|
| 212 |
+
logger.info(f" 📦 Total expected: {len(EXPECTED_IMAGES)}")
|
| 213 |
+
logger.info("=" * 80)
|
| 214 |
+
|
| 215 |
+
if uploaded_count > 0:
|
| 216 |
+
logger.info("✅ Image upload process completed!")
|
| 217 |
+
return True
|
| 218 |
+
else:
|
| 219 |
+
logger.error("❌ No images were uploaded")
|
| 220 |
+
return False
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def list_bucket_contents(client, bucket_name):
|
| 224 |
+
"""List all objects in the bucket"""
|
| 225 |
+
try:
|
| 226 |
+
logger.info(f"\n📦 Contents of '{bucket_name}' bucket:")
|
| 227 |
+
objects = client.list_objects(bucket_name, recursive=True)
|
| 228 |
+
count = 0
|
| 229 |
+
for obj in objects:
|
| 230 |
+
logger.info(f" - {obj.object_name} ({obj.size} bytes)")
|
| 231 |
+
count += 1
|
| 232 |
+
if count == 0:
|
| 233 |
+
logger.info(" (bucket is empty)")
|
| 234 |
+
return count
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.error(f"❌ Error listing bucket contents: {e}")
|
| 237 |
+
return 0
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
if __name__ == "__main__":
|
| 241 |
+
import argparse
|
| 242 |
+
|
| 243 |
+
parser = argparse.ArgumentParser(description="Upload caption images to MinIO")
|
| 244 |
+
parser.add_argument(
|
| 245 |
+
"--image-dir",
|
| 246 |
+
type=str,
|
| 247 |
+
help="Directory containing the caption images"
|
| 248 |
+
)
|
| 249 |
+
parser.add_argument(
|
| 250 |
+
"--list",
|
| 251 |
+
action="store_true",
|
| 252 |
+
help="List current contents of nlp-images bucket"
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
args = parser.parse_args()
|
| 256 |
+
|
| 257 |
+
if args.list:
|
| 258 |
+
client = setup_minio_client()
|
| 259 |
+
if client:
|
| 260 |
+
list_bucket_contents(client, NLP_IMAGES_BUCKET)
|
| 261 |
+
else:
|
| 262 |
+
success = upload_all_images(args.image_dir)
|
| 263 |
+
sys.exit(0 if success else 1)
|
| 264 |
+
|
DetectifAI_db/upload_captions.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Upload Captions to MongoDB
|
| 3 |
+
|
| 4 |
+
This script uploads 10 hardcoded captions linked to videos stored in the
|
| 5 |
+
MinIO 'nlp-images' bucket. The captions are inserted into the MongoDB
|
| 6 |
+
'event_descriptions' collection.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python upload_captions.py
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import uuid
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
from dotenv import load_dotenv
|
| 16 |
+
from pymongo import MongoClient
|
| 17 |
+
from minio import Minio
|
| 18 |
+
import logging
|
| 19 |
+
import numpy as np
|
| 20 |
+
import json
|
| 21 |
+
|
| 22 |
+
# Optional imports for embeddings and FAISS
|
| 23 |
+
try:
|
| 24 |
+
from sentence_transformers import SentenceTransformer
|
| 25 |
+
import faiss
|
| 26 |
+
SENTER_AVAILABLE = True
|
| 27 |
+
except Exception:
|
| 28 |
+
SENTER_AVAILABLE = False
|
| 29 |
+
|
| 30 |
+
# Configure logging
|
| 31 |
+
logging.basicConfig(
|
| 32 |
+
level=logging.INFO,
|
| 33 |
+
format='%(asctime)s - %(levelname)s - %(message)s'
|
| 34 |
+
)
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
# Load environment variables
|
| 38 |
+
load_dotenv()
|
| 39 |
+
MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/detectifai")
|
| 40 |
+
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "s3.eu-central-003.backblazeb2.com")
|
| 41 |
+
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "00367479ffb7e4e0000000001")
|
| 42 |
+
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "K003opTvf92ijRj5dM7H1dgrlwcGTdA")
|
| 43 |
+
MINIO_SECURE = os.getenv("MINIO_SECURE", "true").lower() == "true"
|
| 44 |
+
MINIO_REGION = os.getenv("MINIO_REGION", "eu-central-003")
|
| 45 |
+
|
| 46 |
+
# MinIO bucket for NLP images/videos
|
| 47 |
+
NLP_IMAGES_BUCKET = "nlp-images"
|
| 48 |
+
|
| 49 |
+
# Hardcoded captions with video references
|
| 50 |
+
HARDCODED_CAPTIONS = [
|
| 51 |
+
{
|
| 52 |
+
"video_filename": "img1.webp",
|
| 53 |
+
"caption": "Forty story building reported to be on fire with smoke visible from several floors",
|
| 54 |
+
"confidence": 0.95
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"video_filename": "img2.jpg",
|
| 58 |
+
"caption": "Smoke seen to be coming from a building next to tower by the road",
|
| 59 |
+
"confidence": 0.87
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"video_filename": "img3.png",
|
| 63 |
+
"caption": "Large flames visible on a local high-rise building with fire department on the scene",
|
| 64 |
+
"confidence": 0.92
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"video_filename": "img4.png",
|
| 68 |
+
"caption": "Wide parking of local school building with many parked cars",
|
| 69 |
+
"confidence": 0.92
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"video_filename": "img5.jpg",
|
| 73 |
+
"caption": "Smoke coming from skyscraper fire brigade on scene trying to extinguish the flames",
|
| 74 |
+
"confidence": 0.89
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"video_filename": "img6.webp",
|
| 78 |
+
"caption": "dog sitting on grass",
|
| 79 |
+
"confidence": 0.91
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
"video_filename": "img7.webp",
|
| 83 |
+
"caption": "dog sitting infront of tree trunk in park",
|
| 84 |
+
"confidence": 0.88
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"video_filename": "img8.webp",
|
| 88 |
+
"caption": "dog out on a hike with owner",
|
| 89 |
+
"confidence": 0.84
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"video_filename": "img9.jpg",
|
| 93 |
+
"caption": "dog jumping over obstacle",
|
| 94 |
+
"confidence": 0.96
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"video_filename": "img10.png",
|
| 98 |
+
"caption": "puppy sleeping while hugging stuffed animal",
|
| 99 |
+
"confidence": 0.79
|
| 100 |
+
}
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
# Paths for FAISS index and id map
|
| 104 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 105 |
+
FAISS_INDEX_PATH = os.path.join(BASE_DIR, "faiss_captions.index")
|
| 106 |
+
FAISS_IDMAP_PATH = os.path.join(BASE_DIR, "faiss_captions_idmap.json")
|
| 107 |
+
|
| 108 |
+
def verify_minio_bucket():
|
| 109 |
+
"""Verify that the nlp-images bucket exists in MinIO"""
|
| 110 |
+
try:
|
| 111 |
+
client = Minio(
|
| 112 |
+
MINIO_ENDPOINT,
|
| 113 |
+
access_key=MINIO_ACCESS_KEY,
|
| 114 |
+
secret_key=MINIO_SECRET_KEY,
|
| 115 |
+
secure=MINIO_SECURE,
|
| 116 |
+
region=MINIO_REGION
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
if client.bucket_exists(NLP_IMAGES_BUCKET):
|
| 120 |
+
logger.info(f"✅ MinIO bucket '{NLP_IMAGES_BUCKET}' exists")
|
| 121 |
+
return True
|
| 122 |
+
else:
|
| 123 |
+
logger.warning(f"⚠️ MinIO bucket '{NLP_IMAGES_BUCKET}' does not exist")
|
| 124 |
+
logger.info(f"Creating bucket '{NLP_IMAGES_BUCKET}'...")
|
| 125 |
+
client.make_bucket(NLP_IMAGES_BUCKET)
|
| 126 |
+
logger.info(f"✅ MinIO bucket '{NLP_IMAGES_BUCKET}' created")
|
| 127 |
+
return True
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.error(f"❌ Error connecting to MinIO: {e}")
|
| 130 |
+
return False
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def list_objects_in_bucket():
|
| 134 |
+
"""List all objects in the nlp-images bucket"""
|
| 135 |
+
try:
|
| 136 |
+
client = Minio(
|
| 137 |
+
MINIO_ENDPOINT,
|
| 138 |
+
access_key=MINIO_ACCESS_KEY,
|
| 139 |
+
secret_key=MINIO_SECRET_KEY,
|
| 140 |
+
secure=MINIO_SECURE,
|
| 141 |
+
region=MINIO_REGION
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
objects = client.list_objects(NLP_IMAGES_BUCKET)
|
| 145 |
+
object_list = [obj.object_name for obj in objects]
|
| 146 |
+
|
| 147 |
+
if object_list:
|
| 148 |
+
logger.info(f"📁 Objects in '{NLP_IMAGES_BUCKET}' bucket:")
|
| 149 |
+
for obj in object_list:
|
| 150 |
+
logger.info(f" - {obj}")
|
| 151 |
+
return object_list
|
| 152 |
+
else:
|
| 153 |
+
logger.warning(f"⚠️ No objects found in '{NLP_IMAGES_BUCKET}' bucket")
|
| 154 |
+
return []
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error(f"❌ Error listing objects: {e}")
|
| 157 |
+
return []
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def upload_captions_to_mongodb():
|
| 161 |
+
"""Upload captions to MongoDB event_descriptions collection"""
|
| 162 |
+
try:
|
| 163 |
+
# Connect to MongoDB
|
| 164 |
+
client = MongoClient(MONGO_URI)
|
| 165 |
+
db = client.get_default_database()
|
| 166 |
+
collection = db["event_descriptions"]
|
| 167 |
+
|
| 168 |
+
logger.info(f"📊 Connected to MongoDB database")
|
| 169 |
+
logger.info(f"📝 Uploading {len(HARDCODED_CAPTIONS)} captions to 'event_descriptions' collection...")
|
| 170 |
+
|
| 171 |
+
inserted_count = 0
|
| 172 |
+
inserted_documents = []
|
| 173 |
+
|
| 174 |
+
# Prepare embedding model and lists for FAISS
|
| 175 |
+
embeddings = []
|
| 176 |
+
id_map = [] # maps faiss idx -> description_id
|
| 177 |
+
|
| 178 |
+
if not SENTER_AVAILABLE:
|
| 179 |
+
logger.warning("⚠️ sentence-transformers or faiss not available; captions will be stored without embeddings")
|
| 180 |
+
else:
|
| 181 |
+
# Load model once
|
| 182 |
+
try:
|
| 183 |
+
embed_model = SentenceTransformer("all-mpnet-base-v2")
|
| 184 |
+
embed_dim = 768
|
| 185 |
+
logger.info("✅ Loaded SentenceTransformer 'all-mpnet-base-v2' for embeddings")
|
| 186 |
+
except Exception as e:
|
| 187 |
+
logger.error(f"❌ Failed to load embedding model: {e}")
|
| 188 |
+
embed_model = None
|
| 189 |
+
|
| 190 |
+
for i, caption_data in enumerate(HARDCODED_CAPTIONS, 1):
|
| 191 |
+
# Generate unique IDs
|
| 192 |
+
description_id = f"desc_{uuid.uuid4().hex[:12]}"
|
| 193 |
+
event_id = f"event_{uuid.uuid4().hex[:12]}"
|
| 194 |
+
|
| 195 |
+
# Compute embedding if available
|
| 196 |
+
text_emb_list = []
|
| 197 |
+
if SENTER_AVAILABLE and embed_model is not None:
|
| 198 |
+
try:
|
| 199 |
+
emb = embed_model.encode(caption_data["caption"], normalize_embeddings=True).astype("float32")
|
| 200 |
+
text_emb_list = emb.tolist()
|
| 201 |
+
embeddings.append(emb)
|
| 202 |
+
id_map.append(description_id)
|
| 203 |
+
except Exception as e:
|
| 204 |
+
logger.warning(f"⚠️ Failed to compute embedding for caption {i}: {e}")
|
| 205 |
+
|
| 206 |
+
# Create caption document
|
| 207 |
+
caption_doc = {
|
| 208 |
+
"description_id": description_id,
|
| 209 |
+
"event_id": event_id,
|
| 210 |
+
"caption": caption_data["caption"],
|
| 211 |
+
"confidence": caption_data["confidence"],
|
| 212 |
+
"text_embedding": text_emb_list,
|
| 213 |
+
"video_reference": {
|
| 214 |
+
"bucket": NLP_IMAGES_BUCKET,
|
| 215 |
+
"object_name": caption_data["video_filename"],
|
| 216 |
+
"minio_path": f"{NLP_IMAGES_BUCKET}/{caption_data['video_filename']}"
|
| 217 |
+
},
|
| 218 |
+
"created_at": datetime.utcnow(),
|
| 219 |
+
"updated_at": datetime.utcnow()
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
# Insert into MongoDB
|
| 223 |
+
result = collection.insert_one(caption_doc)
|
| 224 |
+
inserted_count += 1
|
| 225 |
+
inserted_documents.append({
|
| 226 |
+
"index": i,
|
| 227 |
+
"description_id": description_id,
|
| 228 |
+
"event_id": event_id,
|
| 229 |
+
"video": caption_data["video_filename"],
|
| 230 |
+
"confidence": caption_data["confidence"]
|
| 231 |
+
})
|
| 232 |
+
|
| 233 |
+
logger.info(f"✅ [{i}/10] Inserted caption: {description_id}")
|
| 234 |
+
|
| 235 |
+
logger.info(f"\n🎉 Successfully uploaded {inserted_count} captions to MongoDB")
|
| 236 |
+
logger.info("\n📋 Inserted Captions Summary:")
|
| 237 |
+
logger.info("=" * 80)
|
| 238 |
+
|
| 239 |
+
for doc in inserted_documents:
|
| 240 |
+
logger.info(
|
| 241 |
+
f"[{doc['index']:2d}] ID: {doc['description_id']} | "
|
| 242 |
+
f"Event: {doc['event_id']} | "
|
| 243 |
+
f"Video: {doc['video']} | "
|
| 244 |
+
f"Confidence: {doc['confidence']:.2f}"
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
logger.info("=" * 80)
|
| 248 |
+
|
| 249 |
+
# Display summary statistics
|
| 250 |
+
total_captions = collection.count_documents({})
|
| 251 |
+
logger.info(f"\n📊 Total captions in collection: {total_captions}")
|
| 252 |
+
|
| 253 |
+
# Build and persist FAISS index if embeddings were computed
|
| 254 |
+
if SENTER_AVAILABLE and embeddings:
|
| 255 |
+
try:
|
| 256 |
+
emb_matrix = np.stack(embeddings, axis=0).astype("float32")
|
| 257 |
+
dim = emb_matrix.shape[1]
|
| 258 |
+
index = faiss.IndexFlatIP(dim)
|
| 259 |
+
# Add embeddings
|
| 260 |
+
index.add(emb_matrix)
|
| 261 |
+
|
| 262 |
+
# Write index to disk
|
| 263 |
+
faiss.write_index(index, FAISS_INDEX_PATH)
|
| 264 |
+
|
| 265 |
+
# Save id map (index -> description_id)
|
| 266 |
+
with open(FAISS_IDMAP_PATH, "w", encoding="utf-8") as f:
|
| 267 |
+
json.dump(id_map, f, indent=2)
|
| 268 |
+
|
| 269 |
+
logger.info(f"✅ FAISS index saved to: {FAISS_INDEX_PATH}")
|
| 270 |
+
logger.info(f"✅ FAISS id map saved to: {FAISS_IDMAP_PATH}")
|
| 271 |
+
except Exception as e:
|
| 272 |
+
logger.error(f"❌ Failed to build/save FAISS index: {e}")
|
| 273 |
+
|
| 274 |
+
return True
|
| 275 |
+
|
| 276 |
+
except Exception as e:
|
| 277 |
+
logger.error(f"❌ Error uploading captions to MongoDB: {e}")
|
| 278 |
+
return False
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def verify_uploaded_captions():
|
| 282 |
+
"""Verify that captions were successfully uploaded"""
|
| 283 |
+
try:
|
| 284 |
+
client = MongoClient(MONGO_URI)
|
| 285 |
+
db = client.get_default_database()
|
| 286 |
+
collection = db["event_descriptions"]
|
| 287 |
+
|
| 288 |
+
# Find recently uploaded captions
|
| 289 |
+
captions = list(collection.find(
|
| 290 |
+
{"video_reference": {"$exists": True}},
|
| 291 |
+
{"_id": 0, "description_id": 1, "caption": 1, "confidence": 1, "video_reference": 1}
|
| 292 |
+
).limit(10))
|
| 293 |
+
|
| 294 |
+
if captions:
|
| 295 |
+
logger.info(f"\n✅ Verification: Found {len(captions)} captions with video references")
|
| 296 |
+
logger.info("\n📝 Sample Captions:")
|
| 297 |
+
logger.info("=" * 80)
|
| 298 |
+
for cap in captions[:3]:
|
| 299 |
+
logger.info(f"ID: {cap['description_id']}")
|
| 300 |
+
logger.info(f"Caption: {cap['caption']}")
|
| 301 |
+
logger.info(f"Confidence: {cap['confidence']:.2f}")
|
| 302 |
+
logger.info(f"Video: {cap['video_reference']['object_name']}")
|
| 303 |
+
logger.info("-" * 80)
|
| 304 |
+
return True
|
| 305 |
+
else:
|
| 306 |
+
logger.warning("⚠️ No captions found with video references")
|
| 307 |
+
return False
|
| 308 |
+
|
| 309 |
+
except Exception as e:
|
| 310 |
+
logger.error(f"❌ Error verifying captions: {e}")
|
| 311 |
+
return False
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def main():
|
| 315 |
+
"""Main execution function"""
|
| 316 |
+
logger.info("🚀 Starting Caption Upload Process")
|
| 317 |
+
logger.info("=" * 80)
|
| 318 |
+
|
| 319 |
+
# Step 1: Verify MinIO bucket
|
| 320 |
+
logger.info("\n[Step 1/4] Verifying MinIO bucket...")
|
| 321 |
+
if not verify_minio_bucket():
|
| 322 |
+
logger.error("❌ Failed to verify MinIO bucket. Exiting.")
|
| 323 |
+
return False
|
| 324 |
+
|
| 325 |
+
# Step 2: List objects in bucket
|
| 326 |
+
logger.info("\n[Step 2/4] Listing objects in MinIO bucket...")
|
| 327 |
+
objects = list_objects_in_bucket()
|
| 328 |
+
|
| 329 |
+
# Step 3: Upload captions to MongoDB
|
| 330 |
+
logger.info("\n[Step 3/4] Uploading captions to MongoDB...")
|
| 331 |
+
if not upload_captions_to_mongodb():
|
| 332 |
+
logger.error("❌ Failed to upload captions. Exiting.")
|
| 333 |
+
return False
|
| 334 |
+
|
| 335 |
+
# Step 4: Verify upload
|
| 336 |
+
logger.info("\n[Step 4/4] Verifying uploaded captions...")
|
| 337 |
+
if not verify_uploaded_captions():
|
| 338 |
+
logger.warning("⚠️ Verification encountered issues")
|
| 339 |
+
|
| 340 |
+
logger.info("\n" + "=" * 80)
|
| 341 |
+
logger.info("🎉 Caption Upload Process Completed Successfully!")
|
| 342 |
+
logger.info("=" * 80)
|
| 343 |
+
|
| 344 |
+
return True
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
if __name__ == "__main__":
|
| 348 |
+
success = main()
|
| 349 |
+
exit(0 if success else 1)
|
DetectifAI_db/vector_index.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import faiss
|
| 2 |
+
import numpy as np
|
| 3 |
+
from pymongo import MongoClient
|
| 4 |
+
import os
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
import pickle
|
| 7 |
+
from typing import List, Dict, Tuple, Optional
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
# Configure logging
|
| 13 |
+
logging.basicConfig(level=logging.INFO)
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
class FAISSIndexManager:
|
| 17 |
+
"""Manages FAISS indices for text and visual embeddings"""
|
| 18 |
+
|
| 19 |
+
def __init__(self, mongo_uri: str, db_name: str = None):
|
| 20 |
+
self.mongo_client = MongoClient(mongo_uri)
|
| 21 |
+
self.db = self.mongo_client.get_default_database() if not db_name else self.mongo_client[db_name]
|
| 22 |
+
|
| 23 |
+
# Collection references
|
| 24 |
+
self.event_descriptions = self.db.event_description
|
| 25 |
+
self.events = self.db.event
|
| 26 |
+
|
| 27 |
+
# FAISS indices
|
| 28 |
+
self.text_index = None
|
| 29 |
+
self.visual_index = None
|
| 30 |
+
|
| 31 |
+
# Index metadata
|
| 32 |
+
self.text_index_metadata = {} # Maps FAISS ID to MongoDB document ID
|
| 33 |
+
self.visual_index_metadata = {} # Maps FAISS ID to MongoDB document ID
|
| 34 |
+
|
| 35 |
+
# Embedding dimensions (adjust based on your embedding model)
|
| 36 |
+
self.text_embedding_dim = 384 # Common for sentence-transformers
|
| 37 |
+
self.visual_embedding_dim = 512 # Common for visual embeddings
|
| 38 |
+
|
| 39 |
+
# Index file paths
|
| 40 |
+
self.text_index_path = "faiss_text_index.bin"
|
| 41 |
+
self.visual_index_path = "faiss_visual_index.bin"
|
| 42 |
+
self.text_metadata_path = "faiss_text_metadata.pkl"
|
| 43 |
+
self.visual_metadata_path = "faiss_visual_metadata.pkl"
|
| 44 |
+
|
| 45 |
+
self._initialize_indices()
|
| 46 |
+
|
| 47 |
+
def _initialize_indices(self):
|
| 48 |
+
"""Initialize or load existing FAISS indices"""
|
| 49 |
+
try:
|
| 50 |
+
# Try to load existing indices
|
| 51 |
+
if os.path.exists(self.text_index_path) and os.path.exists(self.text_metadata_path):
|
| 52 |
+
self._load_text_index()
|
| 53 |
+
logger.info("Loaded existing text index")
|
| 54 |
+
else:
|
| 55 |
+
self._create_text_index()
|
| 56 |
+
logger.info("Created new text index")
|
| 57 |
+
|
| 58 |
+
if os.path.exists(self.visual_index_path) and os.path.exists(self.visual_metadata_path):
|
| 59 |
+
self._load_visual_index()
|
| 60 |
+
logger.info("Loaded existing visual index")
|
| 61 |
+
else:
|
| 62 |
+
self._create_visual_index()
|
| 63 |
+
logger.info("Created new visual index")
|
| 64 |
+
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error(f"Error initializing indices: {e}")
|
| 67 |
+
# Fallback to creating new indices
|
| 68 |
+
self._create_text_index()
|
| 69 |
+
self._create_visual_index()
|
| 70 |
+
|
| 71 |
+
def _create_text_index(self):
|
| 72 |
+
"""Create a new FAISS index for text embeddings"""
|
| 73 |
+
self.text_index = faiss.IndexFlatIP(self.text_embedding_dim) # Inner product for cosine similarity
|
| 74 |
+
self.text_index_metadata = {}
|
| 75 |
+
self._save_text_index()
|
| 76 |
+
|
| 77 |
+
def _create_visual_index(self):
|
| 78 |
+
"""Create a new FAISS index for visual embeddings"""
|
| 79 |
+
self.visual_index = faiss.IndexFlatIP(self.visual_embedding_dim) # Inner product for cosine similarity
|
| 80 |
+
self.visual_index_metadata = {}
|
| 81 |
+
self._save_visual_index()
|
| 82 |
+
|
| 83 |
+
def _load_text_index(self):
|
| 84 |
+
"""Load text index from disk"""
|
| 85 |
+
self.text_index = faiss.read_index(self.text_index_path)
|
| 86 |
+
with open(self.text_metadata_path, 'rb') as f:
|
| 87 |
+
self.text_index_metadata = pickle.load(f)
|
| 88 |
+
|
| 89 |
+
def _load_visual_index(self):
|
| 90 |
+
"""Load visual index from disk"""
|
| 91 |
+
self.visual_index = faiss.read_index(self.visual_index_path)
|
| 92 |
+
with open(self.visual_metadata_path, 'rb') as f:
|
| 93 |
+
self.visual_index_metadata = pickle.load(f)
|
| 94 |
+
|
| 95 |
+
def _save_text_index(self):
|
| 96 |
+
"""Save text index to disk"""
|
| 97 |
+
if self.text_index is not None:
|
| 98 |
+
faiss.write_index(self.text_index, self.text_index_path)
|
| 99 |
+
with open(self.text_metadata_path, 'wb') as f:
|
| 100 |
+
pickle.dump(self.text_index_metadata, f)
|
| 101 |
+
|
| 102 |
+
def _save_visual_index(self):
|
| 103 |
+
"""Save visual index to disk"""
|
| 104 |
+
if self.visual_index is not None:
|
| 105 |
+
faiss.write_index(self.visual_index, self.visual_index_path)
|
| 106 |
+
with open(self.visual_metadata_path, 'wb') as f:
|
| 107 |
+
pickle.dump(self.visual_index_metadata, f)
|
| 108 |
+
|
| 109 |
+
def rebuild_text_index(self):
|
| 110 |
+
"""Rebuild text index from MongoDB data"""
|
| 111 |
+
logger.info("Rebuilding text index from MongoDB...")
|
| 112 |
+
|
| 113 |
+
# Create new index
|
| 114 |
+
self._create_text_index()
|
| 115 |
+
|
| 116 |
+
# Fetch all event descriptions with embeddings
|
| 117 |
+
cursor = self.event_descriptions.find(
|
| 118 |
+
{"text_embedding": {"$exists": True, "$ne": []}},
|
| 119 |
+
{"_id": 0, "description_id": 1, "text_embedding": 1}
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
embeddings = []
|
| 123 |
+
metadata = {}
|
| 124 |
+
|
| 125 |
+
for doc in cursor:
|
| 126 |
+
embedding = np.array(doc["text_embedding"], dtype=np.float32)
|
| 127 |
+
if len(embedding) == self.text_embedding_dim:
|
| 128 |
+
faiss_id = len(embeddings)
|
| 129 |
+
embeddings.append(embedding)
|
| 130 |
+
metadata[faiss_id] = doc["description_id"]
|
| 131 |
+
|
| 132 |
+
if embeddings:
|
| 133 |
+
embeddings_array = np.vstack(embeddings)
|
| 134 |
+
self.text_index.add(embeddings_array)
|
| 135 |
+
self.text_index_metadata = metadata
|
| 136 |
+
self._save_text_index()
|
| 137 |
+
logger.info(f"Rebuilt text index with {len(embeddings)} embeddings")
|
| 138 |
+
else:
|
| 139 |
+
logger.warning("No text embeddings found in MongoDB")
|
| 140 |
+
|
| 141 |
+
def rebuild_visual_index(self):
|
| 142 |
+
"""Rebuild visual index from MongoDB data"""
|
| 143 |
+
logger.info("Rebuilding visual index from MongoDB...")
|
| 144 |
+
|
| 145 |
+
# Create new index
|
| 146 |
+
self._create_visual_index()
|
| 147 |
+
|
| 148 |
+
# Fetch all events with visual embeddings
|
| 149 |
+
cursor = self.events.find(
|
| 150 |
+
{"visual_embedding": {"$exists": True, "$ne": []}},
|
| 151 |
+
{"_id": 0, "event_id": 1, "visual_embedding": 1}
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
embeddings = []
|
| 155 |
+
metadata = {}
|
| 156 |
+
|
| 157 |
+
for doc in cursor:
|
| 158 |
+
embedding = np.array(doc["visual_embedding"], dtype=np.float32)
|
| 159 |
+
if len(embedding) == self.visual_embedding_dim:
|
| 160 |
+
faiss_id = len(embeddings)
|
| 161 |
+
embeddings.append(embedding)
|
| 162 |
+
metadata[faiss_id] = doc["event_id"]
|
| 163 |
+
|
| 164 |
+
if embeddings:
|
| 165 |
+
embeddings_array = np.vstack(embeddings)
|
| 166 |
+
self.visual_index.add(embeddings_array)
|
| 167 |
+
self.visual_index_metadata = metadata
|
| 168 |
+
self._save_visual_index()
|
| 169 |
+
logger.info(f"Rebuilt visual index with {len(embeddings)} embeddings")
|
| 170 |
+
else:
|
| 171 |
+
logger.warning("No visual embeddings found in MongoDB")
|
| 172 |
+
|
| 173 |
+
def add_text_embedding(self, description_id: str, embedding: List[float]) -> bool:
|
| 174 |
+
"""Add a text embedding to the index"""
|
| 175 |
+
try:
|
| 176 |
+
embedding_array = np.array(embedding, dtype=np.float32).reshape(1, -1)
|
| 177 |
+
|
| 178 |
+
if embedding_array.shape[1] != self.text_embedding_dim:
|
| 179 |
+
logger.error(f"Text embedding dimension mismatch: expected {self.text_embedding_dim}, got {embedding_array.shape[1]}")
|
| 180 |
+
return False
|
| 181 |
+
|
| 182 |
+
faiss_id = self.text_index.ntotal
|
| 183 |
+
self.text_index.add(embedding_array)
|
| 184 |
+
self.text_index_metadata[faiss_id] = description_id
|
| 185 |
+
self._save_text_index()
|
| 186 |
+
|
| 187 |
+
logger.info(f"Added text embedding for description_id: {description_id}")
|
| 188 |
+
return True
|
| 189 |
+
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logger.error(f"Error adding text embedding: {e}")
|
| 192 |
+
return False
|
| 193 |
+
|
| 194 |
+
def add_visual_embedding(self, event_id: str, embedding: List[float]) -> bool:
|
| 195 |
+
"""Add a visual embedding to the index"""
|
| 196 |
+
try:
|
| 197 |
+
embedding_array = np.array(embedding, dtype=np.float32).reshape(1, -1)
|
| 198 |
+
|
| 199 |
+
if embedding_array.shape[1] != self.visual_embedding_dim:
|
| 200 |
+
logger.error(f"Visual embedding dimension mismatch: expected {self.visual_embedding_dim}, got {embedding_array.shape[1]}")
|
| 201 |
+
return False
|
| 202 |
+
|
| 203 |
+
faiss_id = self.visual_index.ntotal
|
| 204 |
+
self.visual_index.add(embedding_array)
|
| 205 |
+
self.visual_index_metadata[faiss_id] = event_id
|
| 206 |
+
self._save_visual_index()
|
| 207 |
+
|
| 208 |
+
logger.info(f"Added visual embedding for event_id: {event_id}")
|
| 209 |
+
return True
|
| 210 |
+
|
| 211 |
+
except Exception as e:
|
| 212 |
+
logger.error(f"Error adding visual embedding: {e}")
|
| 213 |
+
return False
|
| 214 |
+
|
| 215 |
+
def search_text_embeddings(self, query_embedding: List[float], k: int = 10) -> List[Dict]:
|
| 216 |
+
"""Search for similar text embeddings"""
|
| 217 |
+
try:
|
| 218 |
+
if self.text_index.ntotal == 0:
|
| 219 |
+
return []
|
| 220 |
+
|
| 221 |
+
query_array = np.array(query_embedding, dtype=np.float32).reshape(1, -1)
|
| 222 |
+
|
| 223 |
+
if query_array.shape[1] != self.text_embedding_dim:
|
| 224 |
+
logger.error(f"Query embedding dimension mismatch: expected {self.text_embedding_dim}, got {query_array.shape[1]}")
|
| 225 |
+
return []
|
| 226 |
+
|
| 227 |
+
# Search FAISS
|
| 228 |
+
scores, indices = self.text_index.search(query_array, min(k, self.text_index.ntotal))
|
| 229 |
+
|
| 230 |
+
# Fetch corresponding documents from MongoDB
|
| 231 |
+
results = []
|
| 232 |
+
for score, idx in zip(scores[0], indices[0]):
|
| 233 |
+
if idx in self.text_index_metadata:
|
| 234 |
+
description_id = self.text_index_metadata[idx]
|
| 235 |
+
doc = self.event_descriptions.find_one(
|
| 236 |
+
{"description_id": description_id},
|
| 237 |
+
{"_id": 0}
|
| 238 |
+
)
|
| 239 |
+
if doc:
|
| 240 |
+
doc["similarity_score"] = float(score)
|
| 241 |
+
results.append(doc)
|
| 242 |
+
|
| 243 |
+
return results
|
| 244 |
+
|
| 245 |
+
except Exception as e:
|
| 246 |
+
logger.error(f"Error searching text embeddings: {e}")
|
| 247 |
+
return []
|
| 248 |
+
|
| 249 |
+
def search_visual_embeddings(self, query_embedding: List[float], k: int = 10) -> List[Dict]:
|
| 250 |
+
"""Search for similar visual embeddings"""
|
| 251 |
+
try:
|
| 252 |
+
if self.visual_index.ntotal == 0:
|
| 253 |
+
return []
|
| 254 |
+
|
| 255 |
+
query_array = np.array(query_embedding, dtype=np.float32).reshape(1, -1)
|
| 256 |
+
|
| 257 |
+
if query_array.shape[1] != self.visual_embedding_dim:
|
| 258 |
+
logger.error(f"Query embedding dimension mismatch: expected {self.visual_embedding_dim}, got {query_array.shape[1]}")
|
| 259 |
+
return []
|
| 260 |
+
|
| 261 |
+
# Search FAISS
|
| 262 |
+
scores, indices = self.visual_index.search(query_array, min(k, self.visual_index.ntotal))
|
| 263 |
+
|
| 264 |
+
# Fetch corresponding documents from MongoDB
|
| 265 |
+
results = []
|
| 266 |
+
for score, idx in zip(scores[0], indices[0]):
|
| 267 |
+
if idx in self.visual_index_metadata:
|
| 268 |
+
event_id = self.visual_index_metadata[idx]
|
| 269 |
+
doc = self.events.find_one(
|
| 270 |
+
{"event_id": event_id},
|
| 271 |
+
{"_id": 0}
|
| 272 |
+
)
|
| 273 |
+
if doc:
|
| 274 |
+
doc["similarity_score"] = float(score)
|
| 275 |
+
results.append(doc)
|
| 276 |
+
|
| 277 |
+
return results
|
| 278 |
+
|
| 279 |
+
except Exception as e:
|
| 280 |
+
logger.error(f"Error searching visual embeddings: {e}")
|
| 281 |
+
return []
|
| 282 |
+
|
| 283 |
+
def get_index_stats(self) -> Dict:
|
| 284 |
+
"""Get statistics about the indices"""
|
| 285 |
+
return {
|
| 286 |
+
"text_index_size": self.text_index.ntotal if self.text_index else 0,
|
| 287 |
+
"visual_index_size": self.visual_index.ntotal if self.visual_index else 0,
|
| 288 |
+
"text_embedding_dim": self.text_embedding_dim,
|
| 289 |
+
"visual_embedding_dim": self.visual_embedding_dim
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
def close(self):
|
| 293 |
+
"""Close the index manager and save indices"""
|
| 294 |
+
self._save_text_index()
|
| 295 |
+
self._save_visual_index()
|
| 296 |
+
self.mongo_client.close()
|
| 297 |
+
|
| 298 |
+
# Global instance
|
| 299 |
+
faiss_manager = None
|
| 300 |
+
|
| 301 |
+
def get_faiss_manager() -> FAISSIndexManager:
|
| 302 |
+
"""Get the global FAISS manager instance"""
|
| 303 |
+
global faiss_manager
|
| 304 |
+
if faiss_manager is None:
|
| 305 |
+
mongo_uri = os.getenv("MONGO_URI")
|
| 306 |
+
faiss_manager = FAISSIndexManager(mongo_uri)
|
| 307 |
+
return faiss_manager
|
| 308 |
+
|
| 309 |
+
def generate_text_embedding(text: str) -> List[float]:
|
| 310 |
+
"""
|
| 311 |
+
Generate text embeddings using SentenceTransformer.
|
| 312 |
+
Uses all-mpnet-base-v2 for compatibility with NLP search (query_retreival.py).
|
| 313 |
+
Model is lazy-loaded and cached on first call.
|
| 314 |
+
"""
|
| 315 |
+
global _text_embedding_model
|
| 316 |
+
|
| 317 |
+
if '_text_embedding_model' not in globals() or _text_embedding_model is None:
|
| 318 |
+
try:
|
| 319 |
+
from sentence_transformers import SentenceTransformer
|
| 320 |
+
_text_embedding_model = SentenceTransformer('all-mpnet-base-v2')
|
| 321 |
+
logger.info("✅ Loaded SentenceTransformer (all-mpnet-base-v2) for text embeddings")
|
| 322 |
+
except Exception as e:
|
| 323 |
+
logger.error(f"Failed to load SentenceTransformer: {e}")
|
| 324 |
+
# Fallback to deterministic random for graceful degradation
|
| 325 |
+
np.random.seed(hash(text) % 2**32)
|
| 326 |
+
return np.random.randn(768).astype(np.float32).tolist()
|
| 327 |
+
|
| 328 |
+
try:
|
| 329 |
+
embedding = _text_embedding_model.encode(text, normalize_embeddings=True)
|
| 330 |
+
return embedding.astype(np.float32).tolist()
|
| 331 |
+
except Exception as e:
|
| 332 |
+
logger.error(f"Failed to generate embedding for text: {e}")
|
| 333 |
+
np.random.seed(hash(text) % 2**32)
|
| 334 |
+
return np.random.randn(768).astype(np.float32).tolist()
|
| 335 |
+
|
| 336 |
+
# Global model cache
|
| 337 |
+
_text_embedding_model = None
|
| 338 |
+
|
| 339 |
+
def generate_visual_embedding(image_data: bytes = None) -> List[float]:
|
| 340 |
+
"""
|
| 341 |
+
Placeholder function to generate visual embeddings.
|
| 342 |
+
Replace this with your actual visual embedding model.
|
| 343 |
+
"""
|
| 344 |
+
# For now, return a random embedding of the correct dimension
|
| 345 |
+
# In production, use a proper visual embedding model
|
| 346 |
+
|
| 347 |
+
np.random.seed(42) # Fixed seed for demo
|
| 348 |
+
return np.random.randn(512).astype(np.float32).tolist()
|
Dockerfile
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# DetectifAI Backend — Hugging Face Spaces (Docker SDK, CPU)
|
| 3 |
+
# ============================================================
|
| 4 |
+
FROM python:3.11-slim
|
| 5 |
+
|
| 6 |
+
# ---- Non-interactive, UTF-8 ----
|
| 7 |
+
ENV DEBIAN_FRONTEND=noninteractive \
|
| 8 |
+
PYTHONUNBUFFERED=1 \
|
| 9 |
+
PIP_NO_CACHE_DIR=1 \
|
| 10 |
+
PORT=7860
|
| 11 |
+
|
| 12 |
+
WORKDIR /app
|
| 13 |
+
|
| 14 |
+
# ---- System deps (OpenCV, WeasyPrint, ffmpeg) ----
|
| 15 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 16 |
+
build-essential \
|
| 17 |
+
libgl1-mesa-glx libglib2.0-0 libsm6 libxext6 libxrender-dev \
|
| 18 |
+
libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 \
|
| 19 |
+
libffi-dev shared-mime-info \
|
| 20 |
+
ffmpeg \
|
| 21 |
+
git \
|
| 22 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 23 |
+
|
| 24 |
+
# ---- Install PyTorch CPU-only first (saves ~1 GB vs CUDA) ----
|
| 25 |
+
RUN pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
| 26 |
+
|
| 27 |
+
# ---- Python deps (torch excluded — installed above as CPU-only) ----
|
| 28 |
+
COPY requirements-docker.txt .
|
| 29 |
+
RUN pip install --no-cache-dir -r requirements-docker.txt
|
| 30 |
+
|
| 31 |
+
# ---- Copy application code ----
|
| 32 |
+
# Core application files
|
| 33 |
+
COPY app.py config.py main_pipeline.py database_video_service.py \
|
| 34 |
+
object_detection.py behavior_analysis_integrator.py \
|
| 35 |
+
video_captioning_integrator.py event_aggregation.py \
|
| 36 |
+
video_segmentation.py highlight_reel.py video_compression.py \
|
| 37 |
+
json_reports.py detectifai_events.py facial_recognition.py \
|
| 38 |
+
stripe_service.py subscription_middleware.py subscription_routes.py \
|
| 39 |
+
alert_routes.py real_time_alerts.py event_clip_generator.py \
|
| 40 |
+
extract_upload_keyframes.py live_stream_processor.py \
|
| 41 |
+
start_detectifai.py ./
|
| 42 |
+
|
| 43 |
+
# Sub-packages
|
| 44 |
+
COPY core/ core/
|
| 45 |
+
COPY database/ database/
|
| 46 |
+
COPY report_generation/ report_generation/
|
| 47 |
+
COPY video_captioning/ video_captioning/
|
| 48 |
+
COPY behavior_analysis/ behavior_analysis/
|
| 49 |
+
COPY nlp_search/ nlp_search/
|
| 50 |
+
COPY DetectifAI_db/ DetectifAI_db/
|
| 51 |
+
|
| 52 |
+
# Small model files (<50 MB each) — ship in image
|
| 53 |
+
COPY models/fire_YOLO11.pt models/fire_YOLO11.pt
|
| 54 |
+
COPY models/weapon_YOLO11.pt models/weapon_YOLO11.pt
|
| 55 |
+
COPY models/merged_fire_knife_gun.pt models/merged_fire_knife_gun.pt
|
| 56 |
+
COPY "models/best (2).pt" "models/best (2).pt"
|
| 57 |
+
COPY models/classifier_svm.pkl models/classifier_svm.pkl
|
| 58 |
+
COPY models/label_encoder.pkl models/label_encoder.pkl
|
| 59 |
+
COPY models/metadata.json models/metadata.json
|
| 60 |
+
|
| 61 |
+
# Copy the top-level model/ directory (FAISS/SVM face index)
|
| 62 |
+
COPY model/ /app/model/
|
| 63 |
+
|
| 64 |
+
# ---- Pre-create writable directories ----
|
| 65 |
+
RUN mkdir -p /app/uploads /app/video_processing_outputs /app/logs \
|
| 66 |
+
/app/temp_faces /app/report_generation/models \
|
| 67 |
+
&& chmod -R 777 /app/uploads /app/video_processing_outputs /app/logs /app/temp_faces
|
| 68 |
+
|
| 69 |
+
# ---- Download large models at build time (cached in Docker layer) ----
|
| 70 |
+
# fight_detection.pt & accident_detection.pt (~127 MB each)
|
| 71 |
+
# Qwen2.5-3B GGUF (~2 GB)
|
| 72 |
+
# This runs once during build; layer is cached on HF Spaces.
|
| 73 |
+
RUN python -c "\
|
| 74 |
+
from huggingface_hub import hf_hub_download; \
|
| 75 |
+
print('Downloading fight_detection.pt...'); \
|
| 76 |
+
hf_hub_download('blacksinisterx/detectifai-models', 'fight_detection.pt', local_dir='/app/behavior_analysis', local_dir_use_symlinks=False); \
|
| 77 |
+
print('Downloading accident_detection.pt...'); \
|
| 78 |
+
hf_hub_download('blacksinisterx/detectifai-models', 'accident_detection.pt', local_dir='/app/behavior_analysis', local_dir_use_symlinks=False); \
|
| 79 |
+
print('Done with behavior models.'); \
|
| 80 |
+
" || echo "WARNING: Could not download behavior models — will retry at startup"
|
| 81 |
+
|
| 82 |
+
RUN python -c "\
|
| 83 |
+
from huggingface_hub import hf_hub_download; \
|
| 84 |
+
print('Downloading Qwen2.5-3B GGUF (~2 GB)...'); \
|
| 85 |
+
hf_hub_download('Qwen/Qwen2.5-3B-Instruct-GGUF', 'qwen2.5-3b-instruct-q4_k_m.gguf', local_dir='/app/report_generation/models', local_dir_use_symlinks=False); \
|
| 86 |
+
print('Done with LLM model.'); \
|
| 87 |
+
" || echo "WARNING: Could not download LLM model — report generation will download on first use"
|
| 88 |
+
|
| 89 |
+
EXPOSE 7860
|
| 90 |
+
|
| 91 |
+
# ---- Start Flask ----
|
| 92 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,10 +1,31 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: DetectifAI Backend
|
| 3 |
+
emoji: "\U0001F50D"
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# DetectifAI Backend API
|
| 11 |
+
|
| 12 |
+
AI-powered CCTV surveillance system backend. Runs Flask + PyTorch + YOLO on CPU.
|
| 13 |
+
|
| 14 |
+
## Features
|
| 15 |
+
- Video upload & processing (object detection, action recognition)
|
| 16 |
+
- Fire / weapon / fight / accident / wall-climbing detection
|
| 17 |
+
- Video captioning with BLIP
|
| 18 |
+
- Facial recognition with FaceNet
|
| 19 |
+
- Forensic report generation with local LLM (Qwen2.5-3B)
|
| 20 |
+
- Stripe subscription management
|
| 21 |
+
|
| 22 |
+
## Environment Variables (set in Space Settings → Secrets)
|
| 23 |
+
- `MONGO_URI` — MongoDB Atlas connection string
|
| 24 |
+
- `MINIO_ENDPOINT` — Cloud object storage endpoint (Cloudflare R2 recommended)
|
| 25 |
+
- `MINIO_ACCESS_KEY` — Storage access key
|
| 26 |
+
- `MINIO_SECRET_KEY` — Storage secret key
|
| 27 |
+
- `MINIO_SECURE` — `true` for HTTPS
|
| 28 |
+
- `JWT_SECRET` — JWT signing secret
|
| 29 |
+
- `STRIPE_SECRET_KEY` — Stripe secret key
|
| 30 |
+
- `FRONTEND_URL` — Vercel frontend URL (for CORS)
|
| 31 |
+
- `CORS_ORIGINS` — Comma-separated allowed origins
|
alert_routes.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Alert API Routes for DetectifAI
|
| 3 |
+
|
| 4 |
+
Flask Blueprint providing:
|
| 5 |
+
- SSE (Server-Sent Events) endpoint for real-time alert streaming
|
| 6 |
+
- REST endpoints for alert confirmation/dismissal
|
| 7 |
+
- Alert history and statistics
|
| 8 |
+
- Alert snapshot image serving
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import time
|
| 13 |
+
import logging
|
| 14 |
+
import queue
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
|
| 17 |
+
from flask import Blueprint, request, jsonify, Response, stream_with_context
|
| 18 |
+
|
| 19 |
+
from real_time_alerts import get_alert_engine
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
alert_bp = Blueprint('alerts', __name__, url_prefix='/api/alerts')
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ========================================
|
| 27 |
+
# SSE Stream Endpoint
|
| 28 |
+
# ========================================
|
| 29 |
+
|
| 30 |
+
@alert_bp.route('/stream', methods=['GET'])
|
| 31 |
+
def alert_stream():
|
| 32 |
+
"""
|
| 33 |
+
SSE (Server-Sent Events) endpoint for real-time alert streaming.
|
| 34 |
+
|
| 35 |
+
Frontend connects to this endpoint and receives push notifications
|
| 36 |
+
whenever a new alert is generated by the live stream pipeline.
|
| 37 |
+
|
| 38 |
+
Response format (SSE):
|
| 39 |
+
event: alert
|
| 40 |
+
data: {"alert_id": "...", "severity": "critical", ...}
|
| 41 |
+
|
| 42 |
+
event: alert_update
|
| 43 |
+
data: {"alert_id": "...", "status": "confirmed", ...}
|
| 44 |
+
|
| 45 |
+
event: heartbeat
|
| 46 |
+
data: {"time": 1234567890}
|
| 47 |
+
"""
|
| 48 |
+
engine = get_alert_engine()
|
| 49 |
+
subscriber_queue = engine.subscribe()
|
| 50 |
+
|
| 51 |
+
def event_stream():
|
| 52 |
+
try:
|
| 53 |
+
# Send initial connection event
|
| 54 |
+
yield f"event: connected\ndata: {json.dumps({'message': 'Connected to alert stream', 'timestamp': time.time()})}\n\n"
|
| 55 |
+
|
| 56 |
+
# Send any active pending alerts immediately
|
| 57 |
+
active = engine.get_active_alerts()
|
| 58 |
+
if active:
|
| 59 |
+
yield f"event: active_alerts\ndata: {json.dumps(active)}\n\n"
|
| 60 |
+
|
| 61 |
+
heartbeat_interval = 15 # seconds
|
| 62 |
+
last_heartbeat = time.time()
|
| 63 |
+
|
| 64 |
+
while True:
|
| 65 |
+
try:
|
| 66 |
+
# Wait for alert with timeout (for heartbeat)
|
| 67 |
+
alert_data = subscriber_queue.get(timeout=heartbeat_interval)
|
| 68 |
+
|
| 69 |
+
if alert_data is None:
|
| 70 |
+
# Poison pill — disconnect
|
| 71 |
+
break
|
| 72 |
+
|
| 73 |
+
# Determine event type
|
| 74 |
+
event_type = alert_data.pop("type", "alert") if isinstance(alert_data, dict) and "type" in alert_data else "alert"
|
| 75 |
+
|
| 76 |
+
yield f"event: {event_type}\ndata: {json.dumps(alert_data)}\n\n"
|
| 77 |
+
|
| 78 |
+
except queue.Empty:
|
| 79 |
+
# Send heartbeat to keep connection alive
|
| 80 |
+
now = time.time()
|
| 81 |
+
if now - last_heartbeat >= heartbeat_interval:
|
| 82 |
+
stats = engine.get_stats()
|
| 83 |
+
yield f"event: heartbeat\ndata: {json.dumps({'time': now, 'pending': stats.get('active_pending_count', 0)})}\n\n"
|
| 84 |
+
last_heartbeat = now
|
| 85 |
+
|
| 86 |
+
except GeneratorExit:
|
| 87 |
+
logger.info("SSE client disconnected")
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"SSE stream error: {e}")
|
| 90 |
+
finally:
|
| 91 |
+
engine.unsubscribe(subscriber_queue)
|
| 92 |
+
|
| 93 |
+
return Response(
|
| 94 |
+
stream_with_context(event_stream()),
|
| 95 |
+
mimetype='text/event-stream',
|
| 96 |
+
headers={
|
| 97 |
+
'Cache-Control': 'no-cache',
|
| 98 |
+
'X-Accel-Buffering': 'no',
|
| 99 |
+
'Connection': 'keep-alive',
|
| 100 |
+
'Access-Control-Allow-Origin': '*',
|
| 101 |
+
}
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ========================================
|
| 106 |
+
# Alert Actions
|
| 107 |
+
# ========================================
|
| 108 |
+
|
| 109 |
+
@alert_bp.route('/confirm/<alert_id>', methods=['POST'])
|
| 110 |
+
def confirm_alert(alert_id):
|
| 111 |
+
"""
|
| 112 |
+
Confirm an alert as a real threat.
|
| 113 |
+
|
| 114 |
+
Body (JSON):
|
| 115 |
+
user_id: str (optional)
|
| 116 |
+
note: str (optional)
|
| 117 |
+
"""
|
| 118 |
+
try:
|
| 119 |
+
data = request.json or {}
|
| 120 |
+
user_id = data.get('user_id', 'anonymous')
|
| 121 |
+
note = data.get('note', '')
|
| 122 |
+
|
| 123 |
+
engine = get_alert_engine()
|
| 124 |
+
result = engine.confirm_alert(alert_id, user_id=user_id, note=note)
|
| 125 |
+
|
| 126 |
+
if result:
|
| 127 |
+
return jsonify({
|
| 128 |
+
'success': True,
|
| 129 |
+
'message': f'Alert {alert_id} confirmed as real threat',
|
| 130 |
+
'alert': result
|
| 131 |
+
})
|
| 132 |
+
else:
|
| 133 |
+
return jsonify({
|
| 134 |
+
'success': False,
|
| 135 |
+
'error': f'Alert {alert_id} not found'
|
| 136 |
+
}), 404
|
| 137 |
+
|
| 138 |
+
except Exception as e:
|
| 139 |
+
logger.error(f"Error confirming alert: {e}")
|
| 140 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@alert_bp.route('/dismiss/<alert_id>', methods=['POST'])
|
| 144 |
+
def dismiss_alert(alert_id):
|
| 145 |
+
"""
|
| 146 |
+
Dismiss an alert as a false positive.
|
| 147 |
+
|
| 148 |
+
Body (JSON):
|
| 149 |
+
user_id: str (optional)
|
| 150 |
+
note: str (optional)
|
| 151 |
+
"""
|
| 152 |
+
try:
|
| 153 |
+
data = request.json or {}
|
| 154 |
+
user_id = data.get('user_id', 'anonymous')
|
| 155 |
+
note = data.get('note', '')
|
| 156 |
+
|
| 157 |
+
engine = get_alert_engine()
|
| 158 |
+
result = engine.dismiss_alert(alert_id, user_id=user_id, note=note)
|
| 159 |
+
|
| 160 |
+
if result:
|
| 161 |
+
return jsonify({
|
| 162 |
+
'success': True,
|
| 163 |
+
'message': f'Alert {alert_id} dismissed as false positive',
|
| 164 |
+
'alert': result
|
| 165 |
+
})
|
| 166 |
+
else:
|
| 167 |
+
return jsonify({
|
| 168 |
+
'success': False,
|
| 169 |
+
'error': f'Alert {alert_id} not found'
|
| 170 |
+
}), 404
|
| 171 |
+
|
| 172 |
+
except Exception as e:
|
| 173 |
+
logger.error(f"Error dismissing alert: {e}")
|
| 174 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# ========================================
|
| 178 |
+
# Alert Queries
|
| 179 |
+
# ========================================
|
| 180 |
+
|
| 181 |
+
@alert_bp.route('/active', methods=['GET'])
|
| 182 |
+
def get_active_alerts():
|
| 183 |
+
"""Get all active (pending) alerts"""
|
| 184 |
+
try:
|
| 185 |
+
camera_id = request.args.get('camera_id')
|
| 186 |
+
|
| 187 |
+
engine = get_alert_engine()
|
| 188 |
+
alerts = engine.get_active_alerts(camera_id=camera_id)
|
| 189 |
+
|
| 190 |
+
return jsonify({
|
| 191 |
+
'success': True,
|
| 192 |
+
'count': len(alerts),
|
| 193 |
+
'alerts': alerts
|
| 194 |
+
})
|
| 195 |
+
|
| 196 |
+
except Exception as e:
|
| 197 |
+
logger.error(f"Error getting active alerts: {e}")
|
| 198 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
@alert_bp.route('/history', methods=['GET'])
|
| 202 |
+
def get_alert_history():
|
| 203 |
+
"""
|
| 204 |
+
Get alert history with optional filters.
|
| 205 |
+
|
| 206 |
+
Query params:
|
| 207 |
+
limit: int (default 50)
|
| 208 |
+
camera_id: str (optional)
|
| 209 |
+
severity: str (optional) - critical, high, medium, low
|
| 210 |
+
status: str (optional) - pending, confirmed, dismissed
|
| 211 |
+
"""
|
| 212 |
+
try:
|
| 213 |
+
limit = int(request.args.get('limit', 50))
|
| 214 |
+
camera_id = request.args.get('camera_id')
|
| 215 |
+
severity = request.args.get('severity')
|
| 216 |
+
status = request.args.get('status')
|
| 217 |
+
|
| 218 |
+
engine = get_alert_engine()
|
| 219 |
+
|
| 220 |
+
# Try to get from DB for persistence across restarts
|
| 221 |
+
try:
|
| 222 |
+
query = {}
|
| 223 |
+
if camera_id:
|
| 224 |
+
query["camera_id"] = camera_id
|
| 225 |
+
if severity:
|
| 226 |
+
query["severity"] = severity
|
| 227 |
+
if status:
|
| 228 |
+
query["status"] = status
|
| 229 |
+
|
| 230 |
+
db_alerts = list(
|
| 231 |
+
engine.alerts_collection.find(query)
|
| 232 |
+
.sort("timestamp", -1)
|
| 233 |
+
.limit(limit)
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# Convert ObjectId to string
|
| 237 |
+
for alert in db_alerts:
|
| 238 |
+
alert["_id"] = str(alert["_id"])
|
| 239 |
+
|
| 240 |
+
return jsonify({
|
| 241 |
+
'success': True,
|
| 242 |
+
'count': len(db_alerts),
|
| 243 |
+
'alerts': db_alerts
|
| 244 |
+
})
|
| 245 |
+
except Exception:
|
| 246 |
+
# Fallback to in-memory
|
| 247 |
+
alerts = engine.get_alert_history(
|
| 248 |
+
limit=limit, camera_id=camera_id,
|
| 249 |
+
severity=severity, status=status
|
| 250 |
+
)
|
| 251 |
+
return jsonify({
|
| 252 |
+
'success': True,
|
| 253 |
+
'count': len(alerts),
|
| 254 |
+
'alerts': alerts
|
| 255 |
+
})
|
| 256 |
+
|
| 257 |
+
except Exception as e:
|
| 258 |
+
logger.error(f"Error getting alert history: {e}")
|
| 259 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
@alert_bp.route('/<alert_id>', methods=['GET'])
|
| 263 |
+
def get_alert(alert_id):
|
| 264 |
+
"""Get a single alert by ID"""
|
| 265 |
+
try:
|
| 266 |
+
engine = get_alert_engine()
|
| 267 |
+
alert = engine.get_alert_by_id(alert_id)
|
| 268 |
+
|
| 269 |
+
if alert:
|
| 270 |
+
return jsonify({'success': True, 'alert': alert})
|
| 271 |
+
else:
|
| 272 |
+
return jsonify({'success': False, 'error': 'Alert not found'}), 404
|
| 273 |
+
|
| 274 |
+
except Exception as e:
|
| 275 |
+
logger.error(f"Error getting alert: {e}")
|
| 276 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
@alert_bp.route('/stats', methods=['GET'])
|
| 280 |
+
def get_alert_stats():
|
| 281 |
+
"""Get alert statistics"""
|
| 282 |
+
try:
|
| 283 |
+
engine = get_alert_engine()
|
| 284 |
+
stats = engine.get_stats()
|
| 285 |
+
|
| 286 |
+
return jsonify({'success': True, 'stats': stats})
|
| 287 |
+
|
| 288 |
+
except Exception as e:
|
| 289 |
+
logger.error(f"Error getting alert stats: {e}")
|
| 290 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@alert_bp.route('/snapshot/<alert_id>', methods=['GET'])
|
| 294 |
+
def get_alert_snapshot(alert_id):
|
| 295 |
+
"""Get the frame snapshot for an alert (proxied from MinIO)"""
|
| 296 |
+
try:
|
| 297 |
+
engine = get_alert_engine()
|
| 298 |
+
alert = engine.get_alert_by_id(alert_id)
|
| 299 |
+
|
| 300 |
+
if not alert:
|
| 301 |
+
return jsonify({'success': False, 'error': 'Alert not found'}), 404
|
| 302 |
+
|
| 303 |
+
snapshot_path = alert.get('frame_snapshot_path')
|
| 304 |
+
if not snapshot_path:
|
| 305 |
+
return jsonify({'success': False, 'error': 'No snapshot available'}), 404
|
| 306 |
+
|
| 307 |
+
# Generate fresh presigned URL
|
| 308 |
+
url = engine._get_snapshot_url(snapshot_path)
|
| 309 |
+
if url:
|
| 310 |
+
return jsonify({'success': True, 'url': url})
|
| 311 |
+
else:
|
| 312 |
+
return jsonify({'success': False, 'error': 'Failed to generate snapshot URL'}), 500
|
| 313 |
+
|
| 314 |
+
except Exception as e:
|
| 315 |
+
logger.error(f"Error getting snapshot: {e}")
|
| 316 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
# ========================================
|
| 320 |
+
# Test Endpoint (for development)
|
| 321 |
+
# ========================================
|
| 322 |
+
|
| 323 |
+
@alert_bp.route('/test', methods=['POST'])
|
| 324 |
+
def test_alert():
|
| 325 |
+
"""
|
| 326 |
+
Send a test alert for development/testing.
|
| 327 |
+
|
| 328 |
+
Body (JSON):
|
| 329 |
+
detection_class: str (e.g., 'fire', 'gun', 'fighting')
|
| 330 |
+
confidence: float (0.0-1.0)
|
| 331 |
+
camera_id: str (optional, default 'webcam_01')
|
| 332 |
+
"""
|
| 333 |
+
try:
|
| 334 |
+
data = request.json or {}
|
| 335 |
+
detection_class = data.get('detection_class', 'fire')
|
| 336 |
+
confidence = float(data.get('confidence', 0.85))
|
| 337 |
+
camera_id = data.get('camera_id', 'webcam_01')
|
| 338 |
+
|
| 339 |
+
engine = get_alert_engine()
|
| 340 |
+
alert = engine.process_detection(
|
| 341 |
+
camera_id=camera_id,
|
| 342 |
+
detection_class=detection_class,
|
| 343 |
+
confidence=confidence,
|
| 344 |
+
timestamp=time.time(),
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
if alert:
|
| 348 |
+
return jsonify({
|
| 349 |
+
'success': True,
|
| 350 |
+
'message': f'Test alert created: {alert.display_name}',
|
| 351 |
+
'alert': alert.to_sse_payload()
|
| 352 |
+
})
|
| 353 |
+
else:
|
| 354 |
+
return jsonify({
|
| 355 |
+
'success': False,
|
| 356 |
+
'message': 'Alert was suppressed (cooldown or low confidence)'
|
| 357 |
+
})
|
| 358 |
+
|
| 359 |
+
except Exception as e:
|
| 360 |
+
logger.error(f"Error creating test alert: {e}")
|
| 361 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
app.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
behavior_analysis/action_recognition.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# FULLY FIXED ACTION RECOGNITION PIPELINE
|
| 3 |
+
# Supports:
|
| 4 |
+
# - fight_detection.pt (3D ResNet18, state_dict)
|
| 5 |
+
# - road_accident.pt (3D ResNet18, state_dict)
|
| 6 |
+
# - wallclimb.pt (YOLO, Ultralytics)
|
| 7 |
+
# ============================================================
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass, asdict
|
| 10 |
+
import multiprocessing as mp
|
| 11 |
+
import torch
|
| 12 |
+
import cv2
|
| 13 |
+
import numpy as np
|
| 14 |
+
import os
|
| 15 |
+
import time
|
| 16 |
+
import json
|
| 17 |
+
import logging
|
| 18 |
+
from typing import List, Optional, Dict, Any
|
| 19 |
+
from torchvision.models.video import r3d_18
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
|
| 22 |
+
# --- YOLO + PyTorch 2.6 compatibility ---
|
| 23 |
+
from ultralytics import YOLO
|
| 24 |
+
import ultralytics
|
| 25 |
+
torch.serialization.add_safe_globals([ultralytics.nn.tasks.DetectionModel])
|
| 26 |
+
|
| 27 |
+
# --- Logging ---
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
logging.basicConfig(level=logging.INFO)
|
| 30 |
+
|
| 31 |
+
# ============================================================
|
| 32 |
+
# FIXED MODEL PATHS
|
| 33 |
+
# ============================================================
|
| 34 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 35 |
+
|
| 36 |
+
MODEL_PATHS = {
|
| 37 |
+
"fight_detection": os.path.join(BASE_DIR, "fight_detection.pt"),
|
| 38 |
+
"road_accident": os.path.join(BASE_DIR, "accident_detection.pt"),
|
| 39 |
+
"wallclimb": os.path.join(BASE_DIR, "wallclimb.pt"),
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# Define which models are 3D-ResNet (run separately) vs YOLO
|
| 43 |
+
RESNET_MODELS = {"fight_detection", "road_accident"}
|
| 44 |
+
YOLO_MODELS = {"wallclimb"}
|
| 45 |
+
|
| 46 |
+
# ============================================================
|
| 47 |
+
# Dataclasses
|
| 48 |
+
# ============================================================
|
| 49 |
+
@dataclass
|
| 50 |
+
class ActionPrediction:
|
| 51 |
+
timestamp: float
|
| 52 |
+
frame_index: int
|
| 53 |
+
label: str
|
| 54 |
+
confidence: float
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ============================================================
|
| 58 |
+
# MODEL LOADER (YOLO or 3D-ResNet)
|
| 59 |
+
# ============================================================
|
| 60 |
+
def load_model(model_path: str, device: torch.device):
|
| 61 |
+
|
| 62 |
+
name = os.path.basename(model_path).lower()
|
| 63 |
+
|
| 64 |
+
# -------- YOLO MODEL (wallclimb) --------
|
| 65 |
+
if "wall" in name or "yolo" in name:
|
| 66 |
+
logger.info(f"Loading YOLO model: {model_path}")
|
| 67 |
+
return YOLO(model_path)
|
| 68 |
+
|
| 69 |
+
# -------- TRY TorchScript --------
|
| 70 |
+
try:
|
| 71 |
+
model = torch.jit.load(model_path, map_location=device)
|
| 72 |
+
logger.info(f"Loaded TorchScript model")
|
| 73 |
+
model.eval()
|
| 74 |
+
return model
|
| 75 |
+
except:
|
| 76 |
+
pass
|
| 77 |
+
|
| 78 |
+
# -------- 3D-ResNet --------
|
| 79 |
+
try:
|
| 80 |
+
ckpt = torch.load(model_path, map_location=device)
|
| 81 |
+
|
| 82 |
+
if isinstance(ckpt, dict):
|
| 83 |
+
logger.info(f"Loading 3D-ResNet model: {model_path}")
|
| 84 |
+
|
| 85 |
+
model = r3d_18(weights=None)
|
| 86 |
+
model.fc = nn.Linear(512, 2)
|
| 87 |
+
|
| 88 |
+
state = ckpt.get("state_dict", ckpt)
|
| 89 |
+
model.load_state_dict(state)
|
| 90 |
+
|
| 91 |
+
model.to(device)
|
| 92 |
+
model.eval()
|
| 93 |
+
return model
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.error(f"3D-ResNet load failed: {e}")
|
| 96 |
+
|
| 97 |
+
raise RuntimeError(f"Unsupported model format: {model_path}")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ============================================================
|
| 101 |
+
# FRAME PREPROCESSING FOR 3D-ResNet
|
| 102 |
+
# ============================================================
|
| 103 |
+
def preprocess_clip(frames: List[np.ndarray], device: torch.device, target_size=None):
|
| 104 |
+
"""
|
| 105 |
+
frames = list of 16 RGB frames
|
| 106 |
+
output: tensor (1, 3, 16, H, W)
|
| 107 |
+
"""
|
| 108 |
+
processed = []
|
| 109 |
+
|
| 110 |
+
# default target size used in your training/preprocessing
|
| 111 |
+
if not target_size:
|
| 112 |
+
target_size = (112, 112)
|
| 113 |
+
|
| 114 |
+
for f in frames:
|
| 115 |
+
img = cv2.cvtColor(f, cv2.COLOR_BGR2RGB)
|
| 116 |
+
|
| 117 |
+
if target_size:
|
| 118 |
+
img = cv2.resize(img, (target_size[1], target_size[0]))
|
| 119 |
+
|
| 120 |
+
img = img / 255.0
|
| 121 |
+
img = img.transpose(2, 0, 1)
|
| 122 |
+
processed.append(img)
|
| 123 |
+
|
| 124 |
+
clip = np.stack(processed, axis=1)
|
| 125 |
+
tensor = torch.from_numpy(clip).float().unsqueeze(0).to(device)
|
| 126 |
+
return tensor
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ============================================================
|
| 130 |
+
# INTERPRET MODEL OUTPUT
|
| 131 |
+
# ============================================================
|
| 132 |
+
# Map class indices to action labels
|
| 133 |
+
ACTION_LABELS = {
|
| 134 |
+
0: "fighting",
|
| 135 |
+
1: "accident",
|
| 136 |
+
2: "climbing"
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
# Per-action confidence thresholds
|
| 140 |
+
ACTION_CONFIDENCE_THRESHOLDS = {
|
| 141 |
+
"fighting": 0.5,
|
| 142 |
+
"accident": 0.65,
|
| 143 |
+
"climbing": 0.8
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
def interpret_prediction(model, output, model_name, confidence_threshold=None):
|
| 147 |
+
"""
|
| 148 |
+
Interpret model output and return one of three actions: "fighting", "accident", or "climbing".
|
| 149 |
+
If confidence is below 0.5, suppress the prediction and return ("no_action", 0.0).
|
| 150 |
+
|
| 151 |
+
Model-specific handling:
|
| 152 |
+
- fight_detection: returns "fighting" if class 1, "no_action" for class 0
|
| 153 |
+
- road_accident: returns "accident" if class 1, "no_action" for class 0
|
| 154 |
+
- wallclimb (YOLO): returns "climbing" for class 2
|
| 155 |
+
"""
|
| 156 |
+
# -------- YOLO (wallclimb) --------
|
| 157 |
+
if hasattr(model, "predict") and isinstance(output, list):
|
| 158 |
+
logger.info(f"🔍 YOLO prediction for {model_name}")
|
| 159 |
+
boxes = output[0].boxes
|
| 160 |
+
if boxes is None or len(boxes) == 0:
|
| 161 |
+
logger.info("🚫 No boxes detected by YOLO")
|
| 162 |
+
return ("no_action", 0.0)
|
| 163 |
+
|
| 164 |
+
best = boxes[0]
|
| 165 |
+
cls_idx = int(best.cls)
|
| 166 |
+
conf = float(best.conf)
|
| 167 |
+
|
| 168 |
+
# YOLO returns climbing detections
|
| 169 |
+
label = "climbing" if cls_idx == 0 else "no_action"
|
| 170 |
+
|
| 171 |
+
# Use per-action threshold or provided threshold
|
| 172 |
+
threshold = confidence_threshold if confidence_threshold is not None else ACTION_CONFIDENCE_THRESHOLDS.get(label, 0.5)
|
| 173 |
+
logger.info(f"🎯 YOLO detection: class_idx={cls_idx}, confidence={conf:.3f}, threshold={threshold}")
|
| 174 |
+
|
| 175 |
+
# Suppress if confidence < threshold
|
| 176 |
+
if conf < threshold:
|
| 177 |
+
logger.info(f"🚫 Confidence {conf:.3f} below threshold {threshold}")
|
| 178 |
+
return ("no_action", 0.0)
|
| 179 |
+
|
| 180 |
+
logger.info(f"✅ YOLO final result: {label} (conf: {conf:.3f})")
|
| 181 |
+
return (label, conf)
|
| 182 |
+
|
| 183 |
+
# -------- 3D-ResNet (fight_detection or road_accident) --------
|
| 184 |
+
if isinstance(output, torch.Tensor):
|
| 185 |
+
logger.info(f"🔍 3D-ResNet prediction for {model_name}")
|
| 186 |
+
probs = torch.softmax(output, dim=1)[0]
|
| 187 |
+
cls_idx = int(torch.argmax(probs).item())
|
| 188 |
+
conf = float(probs[cls_idx])
|
| 189 |
+
|
| 190 |
+
logger.info(f"📊 Raw probabilities: {probs.tolist()}")
|
| 191 |
+
|
| 192 |
+
# Model-specific mapping (class 0 = negative, class 1 = positive)
|
| 193 |
+
if "fight" in model_name.lower():
|
| 194 |
+
label = "fighting" if cls_idx == 1 else "no_action"
|
| 195 |
+
logger.info(f"🥊 Fight detection: class {cls_idx} -> {label}")
|
| 196 |
+
elif "accident" in model_name.lower() or "road" in model_name.lower():
|
| 197 |
+
# match user's naming and capitalization for saved frames
|
| 198 |
+
label = "Accident" if cls_idx == 1 else "no_action"
|
| 199 |
+
else:
|
| 200 |
+
label = "no_action"
|
| 201 |
+
logger.info(f"❓ Unknown model type, defaulting to no_action")
|
| 202 |
+
|
| 203 |
+
# Use per-action threshold or provided threshold
|
| 204 |
+
threshold = confidence_threshold if confidence_threshold is not None else ACTION_CONFIDENCE_THRESHOLDS.get(label.lower(), 0.5)
|
| 205 |
+
logger.info(f"🎯 Predicted class: {cls_idx}, confidence: {conf:.3f}, threshold: {threshold}")
|
| 206 |
+
|
| 207 |
+
# Suppress if confidence < threshold
|
| 208 |
+
if conf < threshold:
|
| 209 |
+
logger.info(f"🚫 Confidence {conf:.3f} below threshold {threshold}")
|
| 210 |
+
return ("no_action", 0.0)
|
| 211 |
+
|
| 212 |
+
logger.info(f"✅ 3D-ResNet final result: {label} (conf: {conf:.3f})")
|
| 213 |
+
return (label, conf)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
return ("no_action", 0.0)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ============================================================
|
| 220 |
+
# VIDEO PROCESSING
|
| 221 |
+
# ============================================================
|
| 222 |
+
def process_video_with_model(
|
| 223 |
+
video_path,
|
| 224 |
+
model_path,
|
| 225 |
+
output_dir,
|
| 226 |
+
model_name=None,
|
| 227 |
+
use_gpu=True,
|
| 228 |
+
frame_skip=1,
|
| 229 |
+
target_size=None,
|
| 230 |
+
annotate=True):
|
| 231 |
+
|
| 232 |
+
device = torch.device("cuda" if (use_gpu and torch.cuda.is_available()) else "cpu")
|
| 233 |
+
|
| 234 |
+
model_name = model_name or os.path.splitext(os.path.basename(model_path))[0]
|
| 235 |
+
logger.info(f"[{model_name}] Loading model...")
|
| 236 |
+
|
| 237 |
+
model = load_model(model_path, device)
|
| 238 |
+
|
| 239 |
+
cap = cv2.VideoCapture(video_path)
|
| 240 |
+
if not cap.isOpened():
|
| 241 |
+
logger.error(f"[{model_name}] Could not open video")
|
| 242 |
+
return
|
| 243 |
+
|
| 244 |
+
fps = cap.get(cv2.CAP_PROP_FPS) or 25
|
| 245 |
+
frame_buffer = []
|
| 246 |
+
idx = 0
|
| 247 |
+
frames_processed = 0
|
| 248 |
+
predictions = []
|
| 249 |
+
|
| 250 |
+
# annotation folder
|
| 251 |
+
anno_dir = os.path.join(output_dir, f"{model_name}_annotated")
|
| 252 |
+
if annotate:
|
| 253 |
+
os.makedirs(anno_dir, exist_ok=True)
|
| 254 |
+
|
| 255 |
+
start = time.time()
|
| 256 |
+
|
| 257 |
+
while True:
|
| 258 |
+
ret, frame = cap.read()
|
| 259 |
+
if not ret:
|
| 260 |
+
break
|
| 261 |
+
|
| 262 |
+
if idx % frame_skip != 0:
|
| 263 |
+
idx += 1
|
| 264 |
+
continue
|
| 265 |
+
|
| 266 |
+
timestamp = idx / fps
|
| 267 |
+
|
| 268 |
+
try:
|
| 269 |
+
# -------- YOLO --------
|
| 270 |
+
if hasattr(model, "predict"):
|
| 271 |
+
output = model.predict(frame, verbose=False)
|
| 272 |
+
label, conf = interpret_prediction(model, output, model_name)
|
| 273 |
+
|
| 274 |
+
# -------- 3D-ResNet uses CLIPS of 16 frames --------
|
| 275 |
+
else:
|
| 276 |
+
frame_buffer.append(frame)
|
| 277 |
+
|
| 278 |
+
if len(frame_buffer) < 16:
|
| 279 |
+
idx += 1
|
| 280 |
+
continue
|
| 281 |
+
|
| 282 |
+
clip = preprocess_clip(frame_buffer[-16:], device, target_size)
|
| 283 |
+
|
| 284 |
+
with torch.no_grad():
|
| 285 |
+
output = model(clip)
|
| 286 |
+
|
| 287 |
+
label, conf = interpret_prediction(model, output, model_name)
|
| 288 |
+
|
| 289 |
+
# Only record and annotate positive detections
|
| 290 |
+
if label != "no_action":
|
| 291 |
+
predictions.append(ActionPrediction(timestamp, idx, label, conf))
|
| 292 |
+
frames_processed += 1
|
| 293 |
+
|
| 294 |
+
# -------- Annotate output --------
|
| 295 |
+
if annotate:
|
| 296 |
+
anno = frame.copy()
|
| 297 |
+
cv2.putText(
|
| 298 |
+
anno,
|
| 299 |
+
f"{label} {conf:.2f}",
|
| 300 |
+
(10, 35),
|
| 301 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 302 |
+
1.0,
|
| 303 |
+
(0, 255, 0),
|
| 304 |
+
2,
|
| 305 |
+
)
|
| 306 |
+
cv2.imwrite(os.path.join(anno_dir, f"{idx:06}.jpg"), anno)
|
| 307 |
+
|
| 308 |
+
except Exception as e:
|
| 309 |
+
logger.error(f"[{model_name}] Error on frame {idx}: {e}")
|
| 310 |
+
|
| 311 |
+
idx += 1
|
| 312 |
+
|
| 313 |
+
cap.release()
|
| 314 |
+
|
| 315 |
+
# Save results
|
| 316 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 317 |
+
json_path = os.path.join(output_dir, f"{os.path.basename(video_path)}__{model_name}.json")
|
| 318 |
+
|
| 319 |
+
with open(json_path, "w") as f:
|
| 320 |
+
json.dump({
|
| 321 |
+
"video": video_path,
|
| 322 |
+
"model": model_path,
|
| 323 |
+
"frames_processed": frames_processed,
|
| 324 |
+
"processing_time": time.time() - start,
|
| 325 |
+
"predictions": [asdict(p) for p in predictions]
|
| 326 |
+
}, f, indent=2)
|
| 327 |
+
|
| 328 |
+
logger.info(f"[{model_name}] Finished. Saved: {json_path}")
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
# ============================================================
|
| 332 |
+
# MULTI-MODEL EXECUTOR (Windows-safe)
|
| 333 |
+
# ============================================================
|
| 334 |
+
def run_models_on_videos(video_paths, model_paths,
|
| 335 |
+
output_dir="./action_recognition_outputs",
|
| 336 |
+
use_gpu=True, frame_skip=5,
|
| 337 |
+
target_size=None, annotate=True):
|
| 338 |
+
|
| 339 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 340 |
+
processes = []
|
| 341 |
+
|
| 342 |
+
for model_path in model_paths:
|
| 343 |
+
model_name = os.path.splitext(os.path.basename(model_path))[0]
|
| 344 |
+
for video in video_paths:
|
| 345 |
+
|
| 346 |
+
p = mp.Process(target=process_video_with_model,
|
| 347 |
+
args=(video, model_path, output_dir, model_name,
|
| 348 |
+
use_gpu, frame_skip, target_size, annotate))
|
| 349 |
+
p.start()
|
| 350 |
+
processes.append(p)
|
| 351 |
+
logger.info(f"Started PID={p.pid} → {model_name}")
|
| 352 |
+
|
| 353 |
+
for p in processes:
|
| 354 |
+
p.join()
|
| 355 |
+
logger.info(f"PID={p.pid} finished with code {p.exitcode}")
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
# ============================================================
|
| 359 |
+
# MAIN
|
| 360 |
+
# ============================================================
|
| 361 |
+
if __name__ == "__main__":
|
| 362 |
+
mp.set_start_method("spawn", force=True) # IMPORTANT FIX ON WINDOWS
|
| 363 |
+
|
| 364 |
+
import argparse
|
| 365 |
+
parser = argparse.ArgumentParser()
|
| 366 |
+
parser.add_argument("--videos", "-v", nargs="+", required=True)
|
| 367 |
+
parser.add_argument("--models", "-m", nargs="*", default=list(MODEL_PATHS.values()))
|
| 368 |
+
parser.add_argument("--output", "-o", default="./action_recognition_outputs")
|
| 369 |
+
parser.add_argument("--no-gpu", action="store_true")
|
| 370 |
+
parser.add_argument("--frame-skip", type=int, default=5)
|
| 371 |
+
parser.add_argument("--no-annotate", action="store_true")
|
| 372 |
+
args = parser.parse_args()
|
| 373 |
+
|
| 374 |
+
run_models_on_videos(
|
| 375 |
+
video_paths=args.videos,
|
| 376 |
+
model_paths=args.models,
|
| 377 |
+
output_dir=args.output,
|
| 378 |
+
use_gpu=not args.no_gpu,
|
| 379 |
+
frame_skip=max(1, args.frame_skip),
|
| 380 |
+
annotate=not args.no_annotate
|
| 381 |
+
)
|
behavior_analysis/wallclimb.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4b51bb0eec57891debefc3f1c1a53299229b716ac8385dfd759cc469058fe04e
|
| 3 |
+
size 5352882
|
behavior_analysis/yolov11_wallclimb.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6ae0285b20cf8ab66e4ddcf47f300c326c1b972e9bfc909e00f2cf6f65202ff3
|
| 3 |
+
size 5359282
|
behavior_analysis_integrator.py
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Behavior Analysis Integrator for DetectifAI
|
| 3 |
+
|
| 4 |
+
This module integrates behavior analysis (action recognition) into the video processing pipeline.
|
| 5 |
+
It processes video segments/keyframes to detect suspicious behaviors like fighting, accidents, and climbing.
|
| 6 |
+
Similar to ObjectDetectionIntegrator, it creates behavior-based events and identifies suspicious frames
|
| 7 |
+
for facial recognition processing.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import cv2
|
| 12 |
+
import time
|
| 13 |
+
import logging
|
| 14 |
+
import json
|
| 15 |
+
from typing import List, Dict, Any, Tuple, Optional
|
| 16 |
+
from dataclasses import dataclass, asdict
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
# Import behavior analysis module
|
| 20 |
+
from behavior_analysis.action_recognition import (
|
| 21 |
+
load_model, preprocess_clip, interpret_prediction,
|
| 22 |
+
MODEL_PATHS, RESNET_MODELS, YOLO_MODELS, ActionPrediction
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class BehaviorDetectionResult:
|
| 30 |
+
"""Result of behavior detection on a frame or segment"""
|
| 31 |
+
frame_path: str
|
| 32 |
+
timestamp: float
|
| 33 |
+
frame_index: int
|
| 34 |
+
behavior_detected: str # "fighting", "accident", "climbing", or "no_action"
|
| 35 |
+
confidence: float
|
| 36 |
+
model_used: str
|
| 37 |
+
processing_time: float
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class BehaviorEvent:
|
| 42 |
+
"""Behavior-based event created from detections"""
|
| 43 |
+
event_id: str
|
| 44 |
+
behavior_type: str
|
| 45 |
+
start_timestamp: float
|
| 46 |
+
end_timestamp: float
|
| 47 |
+
confidence: float
|
| 48 |
+
frame_indices: List[int]
|
| 49 |
+
keyframes: List[str]
|
| 50 |
+
model_used: str
|
| 51 |
+
importance_score: float
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class BehaviorAnalysisIntegrator:
|
| 55 |
+
"""Integration layer between behavior analysis and video processing pipeline"""
|
| 56 |
+
|
| 57 |
+
def __init__(self, config):
|
| 58 |
+
self.config = config
|
| 59 |
+
self.enabled = getattr(config, 'enable_behavior_analysis', False)
|
| 60 |
+
|
| 61 |
+
logger.info(f"🔍 Initializing BehaviorAnalysisIntegrator - enabled: {self.enabled}")
|
| 62 |
+
|
| 63 |
+
# Initialize models if enabled
|
| 64 |
+
self.models = {}
|
| 65 |
+
self.device = None
|
| 66 |
+
|
| 67 |
+
if self.enabled:
|
| 68 |
+
try:
|
| 69 |
+
import torch
|
| 70 |
+
self.device = torch.device("cuda" if (torch.cuda.is_available() and getattr(config, 'use_gpu_acceleration', True)) else "cpu")
|
| 71 |
+
|
| 72 |
+
# Load all available models
|
| 73 |
+
logger.info(f"🔧 Attempting to load models from: {MODEL_PATHS}")
|
| 74 |
+
for model_name, model_path in MODEL_PATHS.items():
|
| 75 |
+
logger.info(f"📁 Checking model {model_name} at: {model_path}")
|
| 76 |
+
if os.path.exists(model_path):
|
| 77 |
+
try:
|
| 78 |
+
logger.info(f"⏳ Loading {model_name}...")
|
| 79 |
+
self.models[model_name] = load_model(model_path, self.device)
|
| 80 |
+
logger.info(f"✅ Loaded behavior analysis model: {model_name}")
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.error(f"❌ Failed to load {model_name}: {e}")
|
| 83 |
+
else:
|
| 84 |
+
logger.error(f"❌ Model file not found: {model_path}")
|
| 85 |
+
|
| 86 |
+
if not self.models:
|
| 87 |
+
logger.warning("⚠️ No behavior analysis models loaded, disabling behavior analysis")
|
| 88 |
+
self.enabled = False
|
| 89 |
+
else:
|
| 90 |
+
logger.info(f"✅ Behavior analysis initialized with {len(self.models)} models")
|
| 91 |
+
|
| 92 |
+
except ImportError:
|
| 93 |
+
logger.warning("⚠️ PyTorch not available, disabling behavior analysis")
|
| 94 |
+
self.enabled = False
|
| 95 |
+
else:
|
| 96 |
+
logger.info("Behavior analysis disabled in config")
|
| 97 |
+
|
| 98 |
+
def detect_behavior_in_frame(self, frame_path: str, timestamp: float, frame_index: int = 0) -> List[BehaviorDetectionResult]:
|
| 99 |
+
"""
|
| 100 |
+
Detect behaviors in a single frame
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
frame_path: Path to frame image
|
| 104 |
+
timestamp: Timestamp in seconds
|
| 105 |
+
frame_index: Frame index number
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
List of BehaviorDetectionResult objects (one per model)
|
| 109 |
+
"""
|
| 110 |
+
if not self.enabled or not self.models:
|
| 111 |
+
return []
|
| 112 |
+
|
| 113 |
+
if not os.path.exists(frame_path):
|
| 114 |
+
logger.warning(f"Frame not found: {frame_path}")
|
| 115 |
+
return []
|
| 116 |
+
|
| 117 |
+
results = []
|
| 118 |
+
frame = cv2.imread(frame_path)
|
| 119 |
+
if frame is None:
|
| 120 |
+
logger.warning(f"Failed to read frame: {frame_path}")
|
| 121 |
+
return []
|
| 122 |
+
|
| 123 |
+
for model_name, model in self.models.items():
|
| 124 |
+
try:
|
| 125 |
+
start_time = time.time()
|
| 126 |
+
|
| 127 |
+
# YOLO models (wallclimb)
|
| 128 |
+
if model_name in YOLO_MODELS:
|
| 129 |
+
output = model.predict(frame, verbose=False)
|
| 130 |
+
# Use default per-action thresholds from ACTION_CONFIDENCE_THRESHOLDS
|
| 131 |
+
label, conf = interpret_prediction(model, output, model_name)
|
| 132 |
+
|
| 133 |
+
logger.info(f"🔍 YOLO model {model_name} prediction: {label} (confidence: {conf:.3f})")
|
| 134 |
+
|
| 135 |
+
if label != "no_action":
|
| 136 |
+
result = BehaviorDetectionResult(
|
| 137 |
+
frame_path=frame_path,
|
| 138 |
+
timestamp=timestamp,
|
| 139 |
+
frame_index=frame_index,
|
| 140 |
+
behavior_detected=label,
|
| 141 |
+
confidence=conf,
|
| 142 |
+
model_used=model_name,
|
| 143 |
+
processing_time=time.time() - start_time
|
| 144 |
+
)
|
| 145 |
+
results.append(result)
|
| 146 |
+
|
| 147 |
+
# 3D-ResNet models need clips of 16 frames
|
| 148 |
+
# For single frame detection, we'll need to handle this differently
|
| 149 |
+
# For now, skip 3D-ResNet models for single frame detection
|
| 150 |
+
# They should be used with video segments instead
|
| 151 |
+
|
| 152 |
+
except Exception as e:
|
| 153 |
+
logger.error(f"Error detecting behavior with {model_name}: {e}")
|
| 154 |
+
continue
|
| 155 |
+
|
| 156 |
+
return results
|
| 157 |
+
|
| 158 |
+
def detect_behavior_in_segment(self, video_path: str, start_time: float, end_time: float,
|
| 159 |
+
frame_indices: List[int] = None) -> List[BehaviorDetectionResult]:
|
| 160 |
+
"""
|
| 161 |
+
Detect behaviors in a video segment (for 3D-ResNet models that need temporal context)
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
video_path: Path to video file
|
| 165 |
+
start_time: Start timestamp in seconds
|
| 166 |
+
end_time: End timestamp in seconds
|
| 167 |
+
frame_indices: Optional list of frame indices to process
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
List of BehaviorDetectionResult objects
|
| 171 |
+
"""
|
| 172 |
+
if not self.enabled or not self.models:
|
| 173 |
+
return []
|
| 174 |
+
|
| 175 |
+
if not os.path.exists(video_path):
|
| 176 |
+
logger.warning(f"Video not found: {video_path}")
|
| 177 |
+
return []
|
| 178 |
+
|
| 179 |
+
results = []
|
| 180 |
+
cap = cv2.VideoCapture(video_path)
|
| 181 |
+
if not cap.isOpened():
|
| 182 |
+
logger.error(f"Could not open video: {video_path}")
|
| 183 |
+
return []
|
| 184 |
+
|
| 185 |
+
fps = cap.get(cv2.CAP_PROP_FPS) or 25
|
| 186 |
+
start_frame = int(start_time * fps)
|
| 187 |
+
end_frame = int(end_time * fps)
|
| 188 |
+
|
| 189 |
+
# Read frames for the segment
|
| 190 |
+
frame_buffer = []
|
| 191 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
| 192 |
+
|
| 193 |
+
for idx in range(start_frame, min(end_frame, int(cap.get(cv2.CAP_PROP_FRAME_COUNT)))):
|
| 194 |
+
ret, frame = cap.read()
|
| 195 |
+
if not ret:
|
| 196 |
+
break
|
| 197 |
+
frame_buffer.append(frame)
|
| 198 |
+
|
| 199 |
+
cap.release()
|
| 200 |
+
|
| 201 |
+
# Calculate mid frame index
|
| 202 |
+
mid_frame_idx = (start_frame + end_frame) // 2 if end_frame > start_frame else start_frame
|
| 203 |
+
return self._process_frame_buffer(frame_buffer, start_time, end_time, mid_frame_idx, video_path)
|
| 204 |
+
|
| 205 |
+
def detect_behavior_in_segment_from_buffer(self, frame_buffer: List[np.ndarray],
|
| 206 |
+
start_time: float, end_time: float,
|
| 207 |
+
frame_indices: List[int] = None) -> List[BehaviorDetectionResult]:
|
| 208 |
+
"""
|
| 209 |
+
Detect behaviors in a frame buffer (for live streams)
|
| 210 |
+
|
| 211 |
+
Args:
|
| 212 |
+
frame_buffer: List of frames (numpy arrays)
|
| 213 |
+
start_time: Start timestamp in seconds
|
| 214 |
+
end_time: End timestamp in seconds
|
| 215 |
+
frame_indices: Optional list of frame indices
|
| 216 |
+
|
| 217 |
+
Returns:
|
| 218 |
+
List of BehaviorDetectionResult objects
|
| 219 |
+
"""
|
| 220 |
+
if not self.enabled or not self.models:
|
| 221 |
+
return []
|
| 222 |
+
|
| 223 |
+
if len(frame_buffer) < 16:
|
| 224 |
+
logger.debug(f"Frame buffer too short ({len(frame_buffer)} frames), skipping 3D-ResNet models")
|
| 225 |
+
return []
|
| 226 |
+
|
| 227 |
+
# Use last 16 frames from buffer
|
| 228 |
+
frames_to_process = frame_buffer[-16:] if len(frame_buffer) >= 16 else frame_buffer
|
| 229 |
+
mid_frame_idx = len(frame_buffer) // 2 if frame_indices is None else (frame_indices[len(frame_indices) // 2] if frame_indices else len(frame_buffer) // 2)
|
| 230 |
+
|
| 231 |
+
return self._process_frame_buffer(frames_to_process, start_time, end_time, mid_frame_idx, "live_stream")
|
| 232 |
+
|
| 233 |
+
def _process_frame_buffer(self, frame_buffer: List[np.ndarray], start_time: float,
|
| 234 |
+
end_time: float, frame_index: int, video_path: str = "live_stream") -> List[BehaviorDetectionResult]:
|
| 235 |
+
"""
|
| 236 |
+
Process frame buffer with behavior analysis models
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
frame_buffer: List of frames (numpy arrays)
|
| 240 |
+
start_time: Start timestamp
|
| 241 |
+
end_time: End timestamp
|
| 242 |
+
frame_index: Frame index for result
|
| 243 |
+
video_path: Path to video file or "live_stream" for live streams
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
List of BehaviorDetectionResult objects
|
| 247 |
+
"""
|
| 248 |
+
if len(frame_buffer) < 16:
|
| 249 |
+
return []
|
| 250 |
+
|
| 251 |
+
results = []
|
| 252 |
+
|
| 253 |
+
# Process with 3D-ResNet models (need 16-frame clips)
|
| 254 |
+
for model_name, model in self.models.items():
|
| 255 |
+
if model_name not in RESNET_MODELS:
|
| 256 |
+
continue
|
| 257 |
+
|
| 258 |
+
try:
|
| 259 |
+
start_time_proc = time.time()
|
| 260 |
+
|
| 261 |
+
# Process last 16 frames from buffer
|
| 262 |
+
clip = preprocess_clip(frame_buffer[-16:], self.device)
|
| 263 |
+
|
| 264 |
+
import torch
|
| 265 |
+
model.eval()
|
| 266 |
+
with torch.no_grad():
|
| 267 |
+
output = model(clip)
|
| 268 |
+
|
| 269 |
+
# Use default per-action thresholds from ACTION_CONFIDENCE_THRESHOLDS
|
| 270 |
+
label, conf = interpret_prediction(model, output, model_name)
|
| 271 |
+
|
| 272 |
+
logger.info(f"🔍 Model {model_name} prediction: {label} (confidence: {conf:.3f})")
|
| 273 |
+
|
| 274 |
+
if label != "no_action":
|
| 275 |
+
# Use middle timestamp of the segment
|
| 276 |
+
mid_timestamp = (start_time + end_time) / 2
|
| 277 |
+
|
| 278 |
+
result = BehaviorDetectionResult(
|
| 279 |
+
frame_path="live_stream", # Live stream identifier
|
| 280 |
+
timestamp=mid_timestamp,
|
| 281 |
+
frame_index=frame_index,
|
| 282 |
+
behavior_detected=label,
|
| 283 |
+
confidence=conf,
|
| 284 |
+
model_used=model_name,
|
| 285 |
+
processing_time=time.time() - start_time_proc
|
| 286 |
+
)
|
| 287 |
+
results.append(result)
|
| 288 |
+
|
| 289 |
+
except Exception as e:
|
| 290 |
+
logger.error(f"Error detecting behavior with {model_name} in segment: {e}")
|
| 291 |
+
continue
|
| 292 |
+
|
| 293 |
+
return results
|
| 294 |
+
|
| 295 |
+
def detect_behavior_in_keyframes(self, keyframes: List, video_path: str = None) -> List[BehaviorDetectionResult]:
|
| 296 |
+
"""
|
| 297 |
+
Detect behaviors in keyframes
|
| 298 |
+
|
| 299 |
+
Args:
|
| 300 |
+
keyframes: List of KeyframeResult objects
|
| 301 |
+
video_path: Optional path to video file (needed for 3D-ResNet models)
|
| 302 |
+
|
| 303 |
+
Returns:
|
| 304 |
+
List of BehaviorDetectionResult objects
|
| 305 |
+
"""
|
| 306 |
+
if not self.enabled:
|
| 307 |
+
logger.info("🚫 Behavior analysis disabled, skipping")
|
| 308 |
+
return []
|
| 309 |
+
|
| 310 |
+
logger.info(f"🎬 Starting behavior detection on {len(keyframes)} keyframes")
|
| 311 |
+
logger.info(f"📹 Video path provided: {video_path}")
|
| 312 |
+
logger.info(f"🤖 Available models: {list(self.models.keys())}")
|
| 313 |
+
|
| 314 |
+
logger.info(f"🔍 Running behavior analysis on {len(keyframes)} keyframes...")
|
| 315 |
+
|
| 316 |
+
all_results = []
|
| 317 |
+
|
| 318 |
+
# Process YOLO models (single frame) - wallclimb
|
| 319 |
+
yolo_models_available = [m for m in self.models.keys() if m in YOLO_MODELS]
|
| 320 |
+
logger.info(f"🎯 Processing YOLO models (single frame): {yolo_models_available}")
|
| 321 |
+
|
| 322 |
+
for i, keyframe in enumerate(keyframes):
|
| 323 |
+
# Extract frame path and timestamp
|
| 324 |
+
frame_path = None
|
| 325 |
+
timestamp = 0.0
|
| 326 |
+
frame_index = i
|
| 327 |
+
|
| 328 |
+
if hasattr(keyframe, 'frame_data'):
|
| 329 |
+
frame_path = keyframe.frame_data.frame_path if hasattr(keyframe.frame_data, 'frame_path') else None
|
| 330 |
+
timestamp = keyframe.frame_data.timestamp if hasattr(keyframe.frame_data, 'timestamp') else 0.0
|
| 331 |
+
elif hasattr(keyframe, 'frame_path'):
|
| 332 |
+
frame_path = keyframe.frame_path
|
| 333 |
+
timestamp = getattr(keyframe, 'timestamp', 0.0)
|
| 334 |
+
|
| 335 |
+
if frame_path and os.path.exists(frame_path):
|
| 336 |
+
# Detect with YOLO models (single frame) - wallclimb
|
| 337 |
+
frame_results = self.detect_behavior_in_frame(frame_path, timestamp, frame_index)
|
| 338 |
+
all_results.extend(frame_results)
|
| 339 |
+
|
| 340 |
+
# Process 3D-ResNet models (need 16-frame clips) - fighting, road_accident
|
| 341 |
+
if video_path and os.path.exists(video_path) and RESNET_MODELS:
|
| 342 |
+
resnet_models_available = [m for m in self.models.keys() if m in RESNET_MODELS]
|
| 343 |
+
logger.info(f"🎬 Processing 3D-ResNet models using video segments...")
|
| 344 |
+
logger.info(f"📊 Available ResNet models: {resnet_models_available}")
|
| 345 |
+
logger.info(f"📊 Total ResNet models to process: {len(resnet_models_available)}")
|
| 346 |
+
|
| 347 |
+
# Group keyframes into temporal segments for 3D-ResNet processing
|
| 348 |
+
# Process segments of ~1 second (16 frames at ~30fps) around each keyframe
|
| 349 |
+
segment_window = 1.0 # 1 second window
|
| 350 |
+
|
| 351 |
+
processed_segments = set() # Track processed segments to avoid duplicates
|
| 352 |
+
|
| 353 |
+
for keyframe in keyframes:
|
| 354 |
+
timestamp = 0.0
|
| 355 |
+
if hasattr(keyframe, 'frame_data'):
|
| 356 |
+
timestamp = keyframe.frame_data.timestamp if hasattr(keyframe.frame_data, 'timestamp') else 0.0
|
| 357 |
+
elif hasattr(keyframe, 'timestamp'):
|
| 358 |
+
timestamp = getattr(keyframe, 'timestamp', 0.0)
|
| 359 |
+
|
| 360 |
+
if timestamp > 0:
|
| 361 |
+
# Create segment around this keyframe
|
| 362 |
+
start_time = max(0, timestamp - segment_window / 2)
|
| 363 |
+
end_time = timestamp + segment_window / 2
|
| 364 |
+
|
| 365 |
+
# Round to avoid processing same segment multiple times
|
| 366 |
+
segment_key = (int(start_time * 10), int(end_time * 10))
|
| 367 |
+
|
| 368 |
+
if segment_key not in processed_segments:
|
| 369 |
+
processed_segments.add(segment_key)
|
| 370 |
+
|
| 371 |
+
try:
|
| 372 |
+
logger.info(f"🎥 Processing video segment: {start_time:.1f}s - {end_time:.1f}s")
|
| 373 |
+
# Process segment with 3D-ResNet models
|
| 374 |
+
segment_results = self.detect_behavior_in_segment(
|
| 375 |
+
video_path=video_path,
|
| 376 |
+
start_time=start_time,
|
| 377 |
+
end_time=end_time,
|
| 378 |
+
frame_indices=None
|
| 379 |
+
)
|
| 380 |
+
logger.info(f"📈 Segment results: {len(segment_results)} detections")
|
| 381 |
+
for result in segment_results:
|
| 382 |
+
logger.info(f"🔍 Detected: {result.behavior_detected} (conf: {result.confidence:.3f})")
|
| 383 |
+
all_results.extend(segment_results)
|
| 384 |
+
except Exception as e:
|
| 385 |
+
logger.error(f"❌ Error processing segment {start_time:.1f}s-{end_time:.1f}s: {e}")
|
| 386 |
+
continue
|
| 387 |
+
|
| 388 |
+
logger.info(f"✅ Behavior analysis complete: {len(all_results)} behaviors detected")
|
| 389 |
+
return all_results
|
| 390 |
+
|
| 391 |
+
def create_behavior_events(self, detection_results: List[BehaviorDetectionResult],
|
| 392 |
+
temporal_window: float = 5.0) -> List[BehaviorEvent]:
|
| 393 |
+
"""
|
| 394 |
+
Create behavior-based events from detection results
|
| 395 |
+
|
| 396 |
+
Args:
|
| 397 |
+
detection_results: List of BehaviorDetectionResult objects
|
| 398 |
+
temporal_window: Time window in seconds for grouping detections
|
| 399 |
+
|
| 400 |
+
Returns:
|
| 401 |
+
List of BehaviorEvent objects
|
| 402 |
+
"""
|
| 403 |
+
if not detection_results:
|
| 404 |
+
return []
|
| 405 |
+
|
| 406 |
+
# Group detections by behavior type and temporal proximity
|
| 407 |
+
events = []
|
| 408 |
+
sorted_results = sorted(detection_results, key=lambda x: x.timestamp)
|
| 409 |
+
|
| 410 |
+
current_event = None
|
| 411 |
+
event_id_counter = 0
|
| 412 |
+
|
| 413 |
+
for result in sorted_results:
|
| 414 |
+
if result.behavior_detected == "no_action":
|
| 415 |
+
continue
|
| 416 |
+
|
| 417 |
+
if current_event is None:
|
| 418 |
+
# Start new event
|
| 419 |
+
event_id_counter += 1
|
| 420 |
+
current_event = {
|
| 421 |
+
'event_id': f"behavior_{result.behavior_detected}_{event_id_counter}",
|
| 422 |
+
'behavior_type': result.behavior_detected,
|
| 423 |
+
'start_timestamp': result.timestamp,
|
| 424 |
+
'end_timestamp': result.timestamp,
|
| 425 |
+
'confidences': [result.confidence],
|
| 426 |
+
'frame_indices': [result.frame_index],
|
| 427 |
+
'keyframes': [result.frame_path],
|
| 428 |
+
'model_used': result.model_used
|
| 429 |
+
}
|
| 430 |
+
elif (result.behavior_detected == current_event['behavior_type'] and
|
| 431 |
+
result.timestamp - current_event['end_timestamp'] <= temporal_window):
|
| 432 |
+
# Extend current event
|
| 433 |
+
current_event['end_timestamp'] = result.timestamp
|
| 434 |
+
current_event['confidences'].append(result.confidence)
|
| 435 |
+
current_event['frame_indices'].append(result.frame_index)
|
| 436 |
+
current_event['keyframes'].append(result.frame_path)
|
| 437 |
+
else:
|
| 438 |
+
# Finalize current event and start new one
|
| 439 |
+
avg_confidence = sum(current_event['confidences']) / len(current_event['confidences'])
|
| 440 |
+
importance = avg_confidence * (current_event['end_timestamp'] - current_event['start_timestamp'] + 1)
|
| 441 |
+
|
| 442 |
+
behavior_event = BehaviorEvent(
|
| 443 |
+
event_id=current_event['event_id'],
|
| 444 |
+
behavior_type=current_event['behavior_type'],
|
| 445 |
+
start_timestamp=current_event['start_timestamp'],
|
| 446 |
+
end_timestamp=current_event['end_timestamp'],
|
| 447 |
+
confidence=avg_confidence,
|
| 448 |
+
frame_indices=current_event['frame_indices'],
|
| 449 |
+
keyframes=current_event['keyframes'],
|
| 450 |
+
model_used=current_event['model_used'],
|
| 451 |
+
importance_score=importance
|
| 452 |
+
)
|
| 453 |
+
events.append(behavior_event)
|
| 454 |
+
|
| 455 |
+
# Start new event
|
| 456 |
+
event_id_counter += 1
|
| 457 |
+
current_event = {
|
| 458 |
+
'event_id': f"behavior_{result.behavior_detected}_{event_id_counter}",
|
| 459 |
+
'behavior_type': result.behavior_detected,
|
| 460 |
+
'start_timestamp': result.timestamp,
|
| 461 |
+
'end_timestamp': result.timestamp,
|
| 462 |
+
'confidences': [result.confidence],
|
| 463 |
+
'frame_indices': [result.frame_index],
|
| 464 |
+
'keyframes': [result.frame_path],
|
| 465 |
+
'model_used': result.model_used
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
# Finalize last event
|
| 469 |
+
if current_event:
|
| 470 |
+
avg_confidence = sum(current_event['confidences']) / len(current_event['confidences'])
|
| 471 |
+
importance = avg_confidence * (current_event['end_timestamp'] - current_event['start_timestamp'] + 1)
|
| 472 |
+
|
| 473 |
+
behavior_event = BehaviorEvent(
|
| 474 |
+
event_id=current_event['event_id'],
|
| 475 |
+
behavior_type=current_event['behavior_type'],
|
| 476 |
+
start_timestamp=current_event['start_timestamp'],
|
| 477 |
+
end_timestamp=current_event['end_timestamp'],
|
| 478 |
+
confidence=avg_confidence,
|
| 479 |
+
frame_indices=current_event['frame_indices'],
|
| 480 |
+
keyframes=current_event['keyframes'],
|
| 481 |
+
model_used=current_event['model_used'],
|
| 482 |
+
importance_score=importance
|
| 483 |
+
)
|
| 484 |
+
events.append(behavior_event)
|
| 485 |
+
|
| 486 |
+
logger.info(f"✅ Created {len(events)} behavior-based events")
|
| 487 |
+
return events
|
| 488 |
+
|
| 489 |
+
def process_keyframes_with_behavior_analysis(self, keyframes: List, video_path: str = None) -> Tuple[List[BehaviorDetectionResult], List[BehaviorEvent]]:
|
| 490 |
+
"""
|
| 491 |
+
Process keyframes with behavior analysis and create behavior-based events
|
| 492 |
+
|
| 493 |
+
Args:
|
| 494 |
+
keyframes: List of KeyframeResult objects
|
| 495 |
+
video_path: Optional path to video file (needed for 3D-ResNet models)
|
| 496 |
+
|
| 497 |
+
Returns:
|
| 498 |
+
Tuple of (detection_results, behavior_events)
|
| 499 |
+
"""
|
| 500 |
+
if not self.enabled:
|
| 501 |
+
logger.info("🚫 Behavior analysis disabled, skipping...")
|
| 502 |
+
return [], []
|
| 503 |
+
|
| 504 |
+
logger.info("🚀 ===== STARTING BEHAVIOR ANALYSIS INTEGRATION =====")
|
| 505 |
+
logger.info(f"📊 Input: {len(keyframes)} keyframes, video_path: {video_path}")
|
| 506 |
+
logger.info(f"🤖 Loaded models: {list(self.models.keys())}")
|
| 507 |
+
logger.info(f"⚙️ Confidence thresholds: fighting={getattr(self.config, 'fighting_detection_confidence', 0.5)}, accident={getattr(self.config, 'accident_detection_confidence', 0.6)}, climbing={getattr(self.config, 'climbing_detection_confidence', 0.7)}")
|
| 508 |
+
|
| 509 |
+
logger.info("🔍 Starting behavior analysis integration")
|
| 510 |
+
|
| 511 |
+
# Run behavior detection on keyframes (with video_path for 3D-ResNet models)
|
| 512 |
+
detection_results = self.detect_behavior_in_keyframes(keyframes, video_path=video_path)
|
| 513 |
+
|
| 514 |
+
# Create behavior-based events
|
| 515 |
+
temporal_window = getattr(self.config, 'behavior_event_temporal_window', 5.0)
|
| 516 |
+
logger.info(f"📅 Creating behavior events with temporal window: {temporal_window}s")
|
| 517 |
+
logger.info(f"📊 Total detections to process: {len(detection_results)}")
|
| 518 |
+
|
| 519 |
+
positive_detections = [r for r in detection_results if r.behavior_detected != "no_action"]
|
| 520 |
+
logger.info(f"✅ Positive detections: {len(positive_detections)}")
|
| 521 |
+
for detection in positive_detections:
|
| 522 |
+
logger.info(f" 🎯 {detection.behavior_detected} at {detection.timestamp:.1f}s (conf: {detection.confidence:.3f})")
|
| 523 |
+
|
| 524 |
+
behavior_events = self.create_behavior_events(detection_results, temporal_window)
|
| 525 |
+
|
| 526 |
+
# Store detection metadata
|
| 527 |
+
if hasattr(self.config, 'output_base_dir') and detection_results:
|
| 528 |
+
detection_metadata = {
|
| 529 |
+
'total_keyframes': len(keyframes),
|
| 530 |
+
'frames_with_behaviors': len([r for r in detection_results if r.behavior_detected != "no_action"]),
|
| 531 |
+
'behaviors_detected': {
|
| 532 |
+
'fighting': len([r for r in detection_results if r.behavior_detected == "fighting"]),
|
| 533 |
+
'accident': len([r for r in detection_results if r.behavior_detected == "accident"]),
|
| 534 |
+
'climbing': len([r for r in detection_results if r.behavior_detected == "climbing"])
|
| 535 |
+
},
|
| 536 |
+
'total_events': len(behavior_events),
|
| 537 |
+
'detection_summary': [asdict(r) for r in detection_results[:10]] # First 10 for summary
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
metadata_path = os.path.join(self.config.output_base_dir, 'behavior_analysis_metadata.json')
|
| 541 |
+
os.makedirs(os.path.dirname(metadata_path), exist_ok=True)
|
| 542 |
+
|
| 543 |
+
with open(metadata_path, 'w') as f:
|
| 544 |
+
json.dump(detection_metadata, f, indent=2, default=str)
|
| 545 |
+
|
| 546 |
+
logger.info(f"📊 Behavior analysis metadata saved: {metadata_path}")
|
| 547 |
+
|
| 548 |
+
logger.info("🏁 ===== BEHAVIOR ANALYSIS INTEGRATION COMPLETE =====")
|
| 549 |
+
logger.info(f"📈 Summary:")
|
| 550 |
+
logger.info(f" 📊 Total detections: {len(detection_results)}")
|
| 551 |
+
logger.info(f" ✅ Positive detections: {len([r for r in detection_results if r.behavior_detected != 'no_action'])}")
|
| 552 |
+
logger.info(f" 📅 Events created: {len(behavior_events)}")
|
| 553 |
+
|
| 554 |
+
for event in behavior_events:
|
| 555 |
+
logger.info(f" 🎬 Event: {event.behavior_type} ({event.start_timestamp:.1f}s-{event.end_timestamp:.1f}s, conf: {event.confidence:.3f})")
|
| 556 |
+
|
| 557 |
+
return detection_results, behavior_events
|
| 558 |
+
|
| 559 |
+
def get_suspicious_frames(self, detection_results: List[BehaviorDetectionResult]) -> List[BehaviorDetectionResult]:
|
| 560 |
+
"""
|
| 561 |
+
Get frames with suspicious behaviors (for facial recognition processing)
|
| 562 |
+
|
| 563 |
+
Args:
|
| 564 |
+
detection_results: List of BehaviorDetectionResult objects
|
| 565 |
+
|
| 566 |
+
Returns:
|
| 567 |
+
List of suspicious BehaviorDetectionResult objects
|
| 568 |
+
"""
|
| 569 |
+
suspicious = [r for r in detection_results if r.behavior_detected != "no_action"]
|
| 570 |
+
logger.info(f"🔍 Identified {len(suspicious)} suspicious frames from behavior analysis")
|
| 571 |
+
return suspicious
|
| 572 |
+
|
| 573 |
+
def get_behavior_analysis_summary(self) -> Dict[str, Any]:
|
| 574 |
+
"""Get summary statistics of behavior analysis"""
|
| 575 |
+
return {
|
| 576 |
+
'enabled': self.enabled,
|
| 577 |
+
'models_loaded': list(self.models.keys()) if self.models else [],
|
| 578 |
+
'device': str(self.device) if self.device else None
|
| 579 |
+
}
|
| 580 |
+
|
config.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration settings for the Video Event Detection and Preprocessing Pipeline.
|
| 3 |
+
|
| 4 |
+
This file contains all configurable parameters that can be tweaked to control:
|
| 5 |
+
- Keyframe extraction sensitivity
|
| 6 |
+
- Event detection thresholds
|
| 7 |
+
- Video quality settings
|
| 8 |
+
- Output formats and paths
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from typing import Dict, List, Optional, Tuple
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class VideoProcessingConfig:
|
| 17 |
+
"""Main configuration class for video processing pipeline"""
|
| 18 |
+
|
| 19 |
+
# ===== KEYFRAME EXTRACTION PARAMETERS =====
|
| 20 |
+
# Control how many keyframes are extracted
|
| 21 |
+
|
| 22 |
+
# Base quality threshold (0.1-0.3): Lower = more keyframes, Higher = fewer but better quality
|
| 23 |
+
base_quality_threshold: float = 0.15
|
| 24 |
+
|
| 25 |
+
# Motion detection threshold (0.005-0.02): Lower = more motion-sensitive, Higher = only significant motion
|
| 26 |
+
motion_threshold: float = 0.008
|
| 27 |
+
|
| 28 |
+
# Burst sampling rate (1-10): Higher = more frames during high activity periods
|
| 29 |
+
burst_sampling_rate: int = 3
|
| 30 |
+
|
| 31 |
+
# Frame sampling interval in seconds (0.5-3.0): Lower = more frequent sampling
|
| 32 |
+
frame_sampling_interval: float = 1.0
|
| 33 |
+
|
| 34 |
+
# ===== EVENT DETECTION PARAMETERS =====
|
| 35 |
+
# Control how events are detected and prioritized
|
| 36 |
+
|
| 37 |
+
# Event importance threshold (0.2-0.5): Lower = more events detected
|
| 38 |
+
event_importance_threshold: float = 0.25
|
| 39 |
+
|
| 40 |
+
# Burst activity weight (1.5-3.0): Higher = burst frames get higher priority
|
| 41 |
+
burst_weight: float = 2.5
|
| 42 |
+
|
| 43 |
+
# Temporal clustering window in seconds (10-30): Frames within this window are clustered
|
| 44 |
+
temporal_clustering_window: float = 15.0
|
| 45 |
+
|
| 46 |
+
# Scene change detection threshold (0.01-0.05): Lower = more scene changes detected
|
| 47 |
+
scene_change_threshold: float = 0.02
|
| 48 |
+
|
| 49 |
+
# ===== VIDEO SEGMENTATION PARAMETERS =====
|
| 50 |
+
# Control how video is divided into segments
|
| 51 |
+
|
| 52 |
+
# Segment duration in seconds (30-60): Length of each temporal segment
|
| 53 |
+
segment_duration: float = 45.0
|
| 54 |
+
|
| 55 |
+
# Keyframes per segment (3-8): How many keyframes to extract per segment
|
| 56 |
+
keyframes_per_segment: int = 5
|
| 57 |
+
|
| 58 |
+
# ===== HIGHLIGHT REEL PARAMETERS =====
|
| 59 |
+
# Control the final summary video creation
|
| 60 |
+
|
| 61 |
+
# Maximum summary duration in seconds (15-60): Total length of highlight reel
|
| 62 |
+
max_summary_duration: float = 25.0
|
| 63 |
+
|
| 64 |
+
# Frame display duration in seconds (0.5-3.0): How long each frame is shown
|
| 65 |
+
frame_display_duration: float = 1.5
|
| 66 |
+
|
| 67 |
+
# Maximum frames in summary (10-30): Total number of frames in highlight reel
|
| 68 |
+
max_summary_frames: int = 18
|
| 69 |
+
|
| 70 |
+
# Summary video FPS (0.4-1.0): Playback speed of summary
|
| 71 |
+
summary_fps: float = 0.6
|
| 72 |
+
|
| 73 |
+
# ===== DEDUPLICATION PARAMETERS =====
|
| 74 |
+
# Control duplicate frame removal
|
| 75 |
+
|
| 76 |
+
# Similarity threshold (0.80-0.95): Higher = stricter deduplication
|
| 77 |
+
similarity_threshold: float = 0.85
|
| 78 |
+
|
| 79 |
+
# Minimum time gap between frames in seconds (1-5): Prevents frames too close in time
|
| 80 |
+
min_frame_gap: float = 2.0
|
| 81 |
+
|
| 82 |
+
# ===== COMPRESSION PARAMETERS =====
|
| 83 |
+
# Control video compression settings
|
| 84 |
+
|
| 85 |
+
# Output resolution (720p, 1080p, or original)
|
| 86 |
+
output_resolution: str = "720p"
|
| 87 |
+
|
| 88 |
+
# Compression quality (18-28): Lower = better quality, larger files
|
| 89 |
+
compression_crf: int = 23
|
| 90 |
+
|
| 91 |
+
# Compression preset (ultrafast, fast, medium, slow): Affects encoding speed vs efficiency
|
| 92 |
+
compression_preset: str = "fast"
|
| 93 |
+
|
| 94 |
+
# ===== ADAPTIVE ENHANCEMENT PARAMETERS =====
|
| 95 |
+
# Control image enhancement
|
| 96 |
+
|
| 97 |
+
# Enable adaptive histogram equalization
|
| 98 |
+
enable_clahe: bool = True
|
| 99 |
+
|
| 100 |
+
# CLAHE clip limit (1.0-4.0): Higher = more contrast enhancement
|
| 101 |
+
clahe_clip_limit: float = 2.0
|
| 102 |
+
|
| 103 |
+
# Enable denoising
|
| 104 |
+
enable_denoising: bool = True
|
| 105 |
+
|
| 106 |
+
# Denoising strength (3-10): Higher = more denoising
|
| 107 |
+
denoise_strength: int = 5
|
| 108 |
+
|
| 109 |
+
# ===== OUTPUT SETTINGS =====
|
| 110 |
+
# Control output files and formats
|
| 111 |
+
|
| 112 |
+
# Base output directory
|
| 113 |
+
output_base_dir: str = "video_processing_outputs"
|
| 114 |
+
|
| 115 |
+
# Enable various output formats
|
| 116 |
+
generate_json_reports: bool = True
|
| 117 |
+
generate_html_gallery: bool = True
|
| 118 |
+
generate_compressed_video: bool = True
|
| 119 |
+
generate_segments: bool = True
|
| 120 |
+
generate_highlight_reels: bool = False # Disabled for security focus - saves processing time
|
| 121 |
+
|
| 122 |
+
# Video output format (mp4, avi, mov)
|
| 123 |
+
video_output_format: str = "mp4"
|
| 124 |
+
|
| 125 |
+
# ===== ADVANCED PARAMETERS =====
|
| 126 |
+
# Fine-tuning for specific use cases
|
| 127 |
+
|
| 128 |
+
# Enable GPU acceleration if available
|
| 129 |
+
use_gpu_acceleration: bool = True
|
| 130 |
+
|
| 131 |
+
# Enable face detection for human-centric events
|
| 132 |
+
enable_face_detection: bool = False
|
| 133 |
+
|
| 134 |
+
# Enable object detection for context-aware processing
|
| 135 |
+
enable_object_detection: bool = False
|
| 136 |
+
|
| 137 |
+
# Enable facial recognition for suspicious person tracking (FULL implementation with FAISS + MongoDB)
|
| 138 |
+
enable_facial_recognition: bool = True
|
| 139 |
+
|
| 140 |
+
# Face recognition confidence threshold (0.5-0.95)
|
| 141 |
+
face_recognition_confidence: float = 0.7
|
| 142 |
+
|
| 143 |
+
# Face detection model to use (MTCNN for detection, FaceNet for embeddings)
|
| 144 |
+
face_detection_model: str = "mtcnn"
|
| 145 |
+
|
| 146 |
+
# Face recognition model to use (InceptionResnetV1 with FAISS similarity search)
|
| 147 |
+
face_recognition_model: str = "facenet_faiss"
|
| 148 |
+
|
| 149 |
+
# Enable suspicious person database and tracking
|
| 150 |
+
suspicious_person_tracking: bool = True
|
| 151 |
+
|
| 152 |
+
# Face database settings
|
| 153 |
+
face_database_enabled: bool = True
|
| 154 |
+
|
| 155 |
+
# ===== OBJECT DETECTION PARAMETERS =====
|
| 156 |
+
# Configuration for fire, knife, gun detection
|
| 157 |
+
|
| 158 |
+
# Models directory path (relative to backend directory when running from project root)
|
| 159 |
+
models_dir: str = os.path.join(os.path.dirname(__file__), "models")
|
| 160 |
+
|
| 161 |
+
# Object detection confidence threshold (0.1-0.9)
|
| 162 |
+
object_detection_confidence: float = 0.5
|
| 163 |
+
|
| 164 |
+
# Temporal window for grouping object detections into events (seconds)
|
| 165 |
+
object_event_temporal_window: float = 5.0
|
| 166 |
+
|
| 167 |
+
# Enable annotation of detected objects on keyframes
|
| 168 |
+
enable_object_annotation: bool = True
|
| 169 |
+
|
| 170 |
+
# Object detection specific thresholds
|
| 171 |
+
fire_detection_confidence: float = 0.7 # Lower threshold for fire (safety critical)
|
| 172 |
+
weapon_detection_confidence: float = 0.7 # Higher threshold for weapons (reduce false positives)
|
| 173 |
+
|
| 174 |
+
# Enable specific object types
|
| 175 |
+
enable_fire_detection: bool = True
|
| 176 |
+
enable_weapon_detection: bool = True
|
| 177 |
+
|
| 178 |
+
# Object event importance multiplier
|
| 179 |
+
object_event_importance_multiplier: float = 2.0
|
| 180 |
+
|
| 181 |
+
# ===== BEHAVIOR ANALYSIS PARAMETERS =====
|
| 182 |
+
# Configuration for behavior/action recognition (fighting, accidents, climbing)
|
| 183 |
+
|
| 184 |
+
# Enable behavior analysis
|
| 185 |
+
enable_behavior_analysis: bool = False
|
| 186 |
+
|
| 187 |
+
# Behavior analysis models directory
|
| 188 |
+
behavior_models_dir: str = os.path.join(os.path.dirname(__file__), "behavior_analysis")
|
| 189 |
+
|
| 190 |
+
# Behavior detection confidence thresholds per action type (0.3-0.8)
|
| 191 |
+
fighting_detection_confidence: float = 0.5
|
| 192 |
+
accident_detection_confidence: float = 0.6
|
| 193 |
+
climbing_detection_confidence: float = 0.7
|
| 194 |
+
|
| 195 |
+
# Temporal window for grouping behavior detections into events (seconds)
|
| 196 |
+
behavior_event_temporal_window: float = 5.0
|
| 197 |
+
|
| 198 |
+
# Behavior event importance multiplier
|
| 199 |
+
behavior_event_importance_multiplier: float = 2.5
|
| 200 |
+
|
| 201 |
+
# Enable specific behavior types
|
| 202 |
+
enable_fighting_detection: bool = True
|
| 203 |
+
enable_accident_detection: bool = True
|
| 204 |
+
enable_climbing_detection: bool = True
|
| 205 |
+
|
| 206 |
+
# ===== VIDEO CAPTIONING PARAMETERS =====
|
| 207 |
+
# Configuration for video frame captioning with vision-language models
|
| 208 |
+
|
| 209 |
+
# Enable video captioning
|
| 210 |
+
enable_video_captioning: bool = False
|
| 211 |
+
|
| 212 |
+
# Vision model for caption generation
|
| 213 |
+
captioning_vision_model: str = "Salesforce/blip-image-captioning-base"
|
| 214 |
+
|
| 215 |
+
# Embedding model for semantic search
|
| 216 |
+
captioning_embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
|
| 217 |
+
|
| 218 |
+
# Device for captioning models (cpu or cuda)
|
| 219 |
+
captioning_device: str = "cpu"
|
| 220 |
+
|
| 221 |
+
# Batch size for captioning (increased for better throughput)
|
| 222 |
+
captioning_batch_size: int = 8
|
| 223 |
+
|
| 224 |
+
# Database paths for caption storage
|
| 225 |
+
captioning_db_path: str = None # Will use default if None
|
| 226 |
+
captioning_vector_db_path: str = "./video_captioning_store"
|
| 227 |
+
|
| 228 |
+
# Enable async processing for captioning
|
| 229 |
+
captioning_async: bool = True
|
| 230 |
+
|
| 231 |
+
# Parallel processing workers (1-8): More workers = faster but more memory
|
| 232 |
+
num_workers: int = 4
|
| 233 |
+
|
| 234 |
+
def __post_init__(self):
|
| 235 |
+
"""Validate configuration parameters"""
|
| 236 |
+
# Ensure output directory exists
|
| 237 |
+
os.makedirs(self.output_base_dir, exist_ok=True)
|
| 238 |
+
|
| 239 |
+
# Validate thresholds
|
| 240 |
+
assert 0.1 <= self.base_quality_threshold <= 0.3, "Quality threshold must be between 0.1-0.3"
|
| 241 |
+
assert 0.005 <= self.motion_threshold <= 0.02, "Motion threshold must be between 0.005-0.02"
|
| 242 |
+
assert 0.8 <= self.similarity_threshold <= 0.95, "Similarity threshold must be between 0.8-0.95"
|
| 243 |
+
|
| 244 |
+
# ===== PRESET CONFIGURATIONS =====
|
| 245 |
+
|
| 246 |
+
def get_high_recall_config() -> VideoProcessingConfig:
|
| 247 |
+
"""Configuration optimized for capturing more events (more keyframes)"""
|
| 248 |
+
return VideoProcessingConfig(
|
| 249 |
+
base_quality_threshold=0.12, # Lower quality threshold
|
| 250 |
+
motion_threshold=0.005, # Very sensitive motion detection
|
| 251 |
+
event_importance_threshold=0.20, # Lower event threshold
|
| 252 |
+
max_summary_frames=25, # More frames in summary
|
| 253 |
+
frame_sampling_interval=0.8, # More frequent sampling
|
| 254 |
+
temporal_clustering_window=20.0, # Wider clustering window
|
| 255 |
+
burst_weight=3.0, # Higher burst priority
|
| 256 |
+
keyframes_per_segment=6 # More keyframes per segment
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
def get_high_precision_config() -> VideoProcessingConfig:
|
| 260 |
+
"""Configuration optimized for quality over quantity (fewer but better keyframes)"""
|
| 261 |
+
return VideoProcessingConfig(
|
| 262 |
+
base_quality_threshold=0.20, # Higher quality threshold
|
| 263 |
+
motion_threshold=0.015, # Less sensitive motion detection
|
| 264 |
+
event_importance_threshold=0.35, # Higher event threshold
|
| 265 |
+
max_summary_frames=12, # Fewer frames in summary
|
| 266 |
+
frame_sampling_interval=1.5, # Less frequent sampling
|
| 267 |
+
temporal_clustering_window=10.0, # Tighter clustering
|
| 268 |
+
burst_weight=2.0, # Moderate burst priority
|
| 269 |
+
keyframes_per_segment=4 # Fewer keyframes per segment
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
def get_balanced_config() -> VideoProcessingConfig:
|
| 273 |
+
"""Balanced configuration for general use"""
|
| 274 |
+
return VideoProcessingConfig() # Uses default values
|
| 275 |
+
|
| 276 |
+
# Removed robbery detection config - using security_focused_config instead
|
| 277 |
+
|
| 278 |
+
def get_security_focused_config() -> VideoProcessingConfig:
|
| 279 |
+
"""Configuration optimized specifically for security and threat detection"""
|
| 280 |
+
return VideoProcessingConfig(
|
| 281 |
+
base_quality_threshold=0.12,
|
| 282 |
+
motion_threshold=0.005, # Very sensitive
|
| 283 |
+
event_importance_threshold=0.20,
|
| 284 |
+
burst_weight=3.0, # Highest priority for burst activity
|
| 285 |
+
temporal_clustering_window=20.0,
|
| 286 |
+
max_summary_frames=25,
|
| 287 |
+
frame_display_duration=2.0,
|
| 288 |
+
similarity_threshold=0.82,
|
| 289 |
+
enable_clahe=True,
|
| 290 |
+
clahe_clip_limit=3.0,
|
| 291 |
+
# Enhanced object detection for security
|
| 292 |
+
enable_object_detection=True,
|
| 293 |
+
object_detection_confidence=0.4, # Lower threshold for better recall
|
| 294 |
+
fire_detection_confidence=0.5, # Very sensitive for fire
|
| 295 |
+
weapon_detection_confidence=0.7, # Higher threshold for weapons to reduce false positives
|
| 296 |
+
object_event_temporal_window=8.0, # Longer window for complex events
|
| 297 |
+
enable_object_annotation=True,
|
| 298 |
+
object_event_importance_multiplier=3.0, # High importance for security events
|
| 299 |
+
# Enhanced behavior analysis for security
|
| 300 |
+
enable_behavior_analysis=True,
|
| 301 |
+
fighting_detection_confidence=0.5,
|
| 302 |
+
accident_detection_confidence=0.6,
|
| 303 |
+
climbing_detection_confidence=0.7,
|
| 304 |
+
behavior_event_temporal_window=8.0, # Longer window for complex events
|
| 305 |
+
behavior_event_importance_multiplier=3.0, # High importance for security events
|
| 306 |
+
# Video captioning for semantic search
|
| 307 |
+
enable_video_captioning=True,
|
| 308 |
+
captioning_device="cpu" # Change to "cuda" if GPU available
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
# ===== PARAMETER ADJUSTMENT GUIDE =====
|
| 312 |
+
|
| 313 |
+
PARAMETER_GUIDE = {
|
| 314 |
+
"More Keyframes": {
|
| 315 |
+
"base_quality_threshold": "Decrease (0.10-0.12)",
|
| 316 |
+
"motion_threshold": "Decrease (0.005-0.008)",
|
| 317 |
+
"event_importance_threshold": "Decrease (0.20-0.25)",
|
| 318 |
+
"max_summary_frames": "Increase (20-30)",
|
| 319 |
+
"keyframes_per_segment": "Increase (6-8)",
|
| 320 |
+
"frame_sampling_interval": "Decrease (0.5-1.0)"
|
| 321 |
+
},
|
| 322 |
+
"Fewer Keyframes": {
|
| 323 |
+
"base_quality_threshold": "Increase (0.18-0.25)",
|
| 324 |
+
"motion_threshold": "Increase (0.012-0.020)",
|
| 325 |
+
"event_importance_threshold": "Increase (0.30-0.40)",
|
| 326 |
+
"max_summary_frames": "Decrease (8-15)",
|
| 327 |
+
"keyframes_per_segment": "Decrease (3-4)",
|
| 328 |
+
"frame_sampling_interval": "Increase (1.5-2.5)"
|
| 329 |
+
},
|
| 330 |
+
"Better Quality": {
|
| 331 |
+
"base_quality_threshold": "Increase (0.18-0.25)",
|
| 332 |
+
"compression_crf": "Decrease (18-20)",
|
| 333 |
+
"enable_clahe": "True",
|
| 334 |
+
"enable_denoising": "True",
|
| 335 |
+
"output_resolution": "'1080p'"
|
| 336 |
+
},
|
| 337 |
+
"Faster Processing": {
|
| 338 |
+
"compression_preset": "'ultrafast'",
|
| 339 |
+
"num_workers": "Increase (6-8)",
|
| 340 |
+
"enable_face_detection": "False",
|
| 341 |
+
"enable_object_detection": "False",
|
| 342 |
+
"keyframes_per_segment": "Decrease (3-4)"
|
| 343 |
+
},
|
| 344 |
+
"More Sensitive Event Detection": {
|
| 345 |
+
"motion_threshold": "Decrease (0.005-0.008)",
|
| 346 |
+
"burst_weight": "Increase (2.5-3.0)",
|
| 347 |
+
"event_importance_threshold": "Decrease (0.20-0.25)",
|
| 348 |
+
"temporal_clustering_window": "Increase (15-25)"
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
def print_parameter_guide():
|
| 353 |
+
"""Print parameter adjustment guide"""
|
| 354 |
+
print("🔧 VIDEO PROCESSING PARAMETER ADJUSTMENT GUIDE")
|
| 355 |
+
print("=" * 60)
|
| 356 |
+
|
| 357 |
+
for goal, params in PARAMETER_GUIDE.items():
|
| 358 |
+
print(f"\n🎯 {goal}:")
|
| 359 |
+
for param, adjustment in params.items():
|
| 360 |
+
print(f" • {param}: {adjustment}")
|
| 361 |
+
|
| 362 |
+
print(f"\n📝 Available Preset Configurations:")
|
| 363 |
+
print(f" • get_high_recall_config() - More keyframes, sensitive detection")
|
| 364 |
+
print(f" • get_high_precision_config() - Fewer but higher quality keyframes")
|
| 365 |
+
print(f" • get_balanced_config() - General purpose settings")
|
| 366 |
+
print(f" • get_security_focused_config() - Optimized for security/threat detection")
|
| 367 |
+
|
| 368 |
+
if __name__ == "__main__":
|
| 369 |
+
print_parameter_guide()
|
core/video_processing.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Optimized Video Processing for DetectifAI
|
| 3 |
+
|
| 4 |
+
This module contains optimized video processing components focusing on:
|
| 5 |
+
- Efficient keyframe extraction for security footage
|
| 6 |
+
- Selective frame enhancement only when needed
|
| 7 |
+
- Memory-optimized processing for large surveillance videos
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import cv2
|
| 11 |
+
import numpy as np
|
| 12 |
+
import os
|
| 13 |
+
import uuid
|
| 14 |
+
from typing import Dict, List, Tuple, Optional, Any
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
import time
|
| 17 |
+
import logging
|
| 18 |
+
|
| 19 |
+
# Set up logging
|
| 20 |
+
logging.basicConfig(level=logging.INFO)
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class FrameData:
|
| 25 |
+
"""Data structure for frame information"""
|
| 26 |
+
frame_path: str
|
| 27 |
+
timestamp: float
|
| 28 |
+
frame_number: int
|
| 29 |
+
quality_score: float
|
| 30 |
+
motion_score: float
|
| 31 |
+
burst_active: bool
|
| 32 |
+
enhancement_applied: bool
|
| 33 |
+
face_count: int = 0
|
| 34 |
+
object_count: int = 0
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class KeyframeResult:
|
| 38 |
+
"""Result structure for keyframe extraction"""
|
| 39 |
+
frame_data: FrameData
|
| 40 |
+
keyframe_score: float
|
| 41 |
+
selection_reason: str
|
| 42 |
+
|
| 43 |
+
class OptimizedFrameEnhancer:
|
| 44 |
+
"""Optimized frame enhancement for DetectifAI - only enhance when necessary"""
|
| 45 |
+
|
| 46 |
+
def __init__(self, enable_clahe: bool = True, clahe_clip_limit: float = 2.0):
|
| 47 |
+
self.enable_clahe = enable_clahe
|
| 48 |
+
|
| 49 |
+
# Initialize CLAHE (skip denoising for performance)
|
| 50 |
+
if enable_clahe:
|
| 51 |
+
self.clahe = cv2.createCLAHE(clipLimit=clahe_clip_limit, tileGridSize=(8, 8))
|
| 52 |
+
|
| 53 |
+
logger.info(f"OptimizedFrameEnhancer initialized - CLAHE: {enable_clahe}")
|
| 54 |
+
|
| 55 |
+
def enhance_frame_if_needed(self, frame: np.ndarray) -> Tuple[np.ndarray, bool]:
|
| 56 |
+
"""
|
| 57 |
+
Enhance frame only if quality is poor (DetectifAI optimization)
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
frame: Input frame as numpy array
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
Tuple of (enhanced_frame, enhancement_applied)
|
| 64 |
+
"""
|
| 65 |
+
try:
|
| 66 |
+
# Quick quality assessment
|
| 67 |
+
if not self._needs_enhancement(frame):
|
| 68 |
+
return frame, False
|
| 69 |
+
|
| 70 |
+
enhanced = frame.copy()
|
| 71 |
+
|
| 72 |
+
# Apply CLAHE only to L channel for color frames
|
| 73 |
+
if len(frame.shape) == 3 and self.enable_clahe:
|
| 74 |
+
lab = cv2.cvtColor(enhanced, cv2.COLOR_BGR2LAB)
|
| 75 |
+
l_channel = lab[:, :, 0]
|
| 76 |
+
l_enhanced = self.clahe.apply(l_channel)
|
| 77 |
+
lab[:, :, 0] = l_enhanced
|
| 78 |
+
enhanced = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)
|
| 79 |
+
return enhanced, True
|
| 80 |
+
|
| 81 |
+
elif len(frame.shape) == 2 and self.enable_clahe:
|
| 82 |
+
# Grayscale frame
|
| 83 |
+
enhanced = self.clahe.apply(enhanced)
|
| 84 |
+
return enhanced, True
|
| 85 |
+
|
| 86 |
+
return frame, False
|
| 87 |
+
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"Error enhancing frame: {e}")
|
| 90 |
+
return frame, False
|
| 91 |
+
|
| 92 |
+
def _needs_enhancement(self, frame: np.ndarray) -> bool:
|
| 93 |
+
"""
|
| 94 |
+
Quick quality check - only enhance genuinely poor quality frames
|
| 95 |
+
"""
|
| 96 |
+
try:
|
| 97 |
+
# Convert to grayscale for analysis
|
| 98 |
+
if len(frame.shape) == 3:
|
| 99 |
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
| 100 |
+
else:
|
| 101 |
+
gray = frame
|
| 102 |
+
|
| 103 |
+
# Check brightness and contrast
|
| 104 |
+
mean_brightness = np.mean(gray)
|
| 105 |
+
contrast = np.std(gray)
|
| 106 |
+
|
| 107 |
+
# Only enhance if frame has quality issues
|
| 108 |
+
return (
|
| 109 |
+
mean_brightness < 50 or # Too dark
|
| 110 |
+
mean_brightness > 200 or # Too bright
|
| 111 |
+
contrast < 30 # Low contrast
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
except Exception:
|
| 115 |
+
return False
|
| 116 |
+
|
| 117 |
+
class OptimizedVideoProcessor:
|
| 118 |
+
"""
|
| 119 |
+
Optimized video processor for DetectifAI surveillance footage
|
| 120 |
+
"""
|
| 121 |
+
|
| 122 |
+
def __init__(self, config=None):
|
| 123 |
+
self.config = config
|
| 124 |
+
self.frame_enhancer = OptimizedFrameEnhancer(
|
| 125 |
+
enable_clahe=getattr(config, 'enable_adaptive_processing', True)
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Processing statistics
|
| 129 |
+
self.processing_stats = {
|
| 130 |
+
'frames_processed': 0,
|
| 131 |
+
'frames_enhanced': 0,
|
| 132 |
+
'keyframes_extracted': 0,
|
| 133 |
+
'total_processing_time': 0.0
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
logger.info("OptimizedVideoProcessor initialized")
|
| 137 |
+
|
| 138 |
+
def extract_keyframes_optimized(self, video_path: str, output_dir: str,
|
| 139 |
+
fps_interval: float = 1.0) -> List[KeyframeResult]:
|
| 140 |
+
"""
|
| 141 |
+
Extract keyframes with optimized processing for surveillance video
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
video_path: Path to input video
|
| 145 |
+
output_dir: Directory to save keyframes
|
| 146 |
+
fps_interval: Seconds between keyframes (default: 1 frame per second)
|
| 147 |
+
|
| 148 |
+
Returns:
|
| 149 |
+
List of KeyframeResult objects
|
| 150 |
+
"""
|
| 151 |
+
start_time = time.time()
|
| 152 |
+
keyframes = []
|
| 153 |
+
|
| 154 |
+
try:
|
| 155 |
+
# Open video
|
| 156 |
+
cap = cv2.VideoCapture(video_path)
|
| 157 |
+
if not cap.isOpened():
|
| 158 |
+
logger.error(f"Could not open video: {video_path}")
|
| 159 |
+
return []
|
| 160 |
+
|
| 161 |
+
# Get video properties
|
| 162 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 163 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 164 |
+
duration = total_frames / fps if fps > 0 else 0
|
| 165 |
+
|
| 166 |
+
logger.info(f"Video properties: {total_frames} frames, {fps:.2f} FPS, {duration:.2f}s")
|
| 167 |
+
|
| 168 |
+
# Calculate frame interval
|
| 169 |
+
frame_interval = int(fps * fps_interval) if fps > 0 else 30
|
| 170 |
+
|
| 171 |
+
# Create output directory
|
| 172 |
+
frames_dir = os.path.join(output_dir, 'frames')
|
| 173 |
+
os.makedirs(frames_dir, exist_ok=True)
|
| 174 |
+
|
| 175 |
+
frame_count = 0
|
| 176 |
+
extracted_count = 0
|
| 177 |
+
|
| 178 |
+
while True:
|
| 179 |
+
ret, frame = cap.read()
|
| 180 |
+
if not ret:
|
| 181 |
+
break
|
| 182 |
+
|
| 183 |
+
# Extract keyframes at specified intervals
|
| 184 |
+
if frame_count % frame_interval == 0:
|
| 185 |
+
timestamp = frame_count / fps if fps > 0 else frame_count
|
| 186 |
+
|
| 187 |
+
# Assess frame quality
|
| 188 |
+
quality_score = self._assess_frame_quality(frame)
|
| 189 |
+
|
| 190 |
+
# Enhance frame if needed
|
| 191 |
+
enhanced_frame, enhancement_applied = self.frame_enhancer.enhance_frame_if_needed(frame)
|
| 192 |
+
|
| 193 |
+
# Use consistent naming pattern for MinIO storage
|
| 194 |
+
frame_filename = f"frame_{frame_count:06d}.jpg"
|
| 195 |
+
frame_path = os.path.join(frames_dir, frame_filename)
|
| 196 |
+
|
| 197 |
+
cv2.imwrite(frame_path, enhanced_frame)
|
| 198 |
+
|
| 199 |
+
# Create frame data
|
| 200 |
+
frame_data = FrameData(
|
| 201 |
+
frame_path=frame_path,
|
| 202 |
+
timestamp=timestamp,
|
| 203 |
+
frame_number=frame_count,
|
| 204 |
+
quality_score=quality_score,
|
| 205 |
+
motion_score=0.0, # Can be calculated if needed
|
| 206 |
+
burst_active=False,
|
| 207 |
+
enhancement_applied=enhancement_applied
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
keyframe_result = KeyframeResult(
|
| 211 |
+
frame_data=frame_data,
|
| 212 |
+
keyframe_score=quality_score,
|
| 213 |
+
selection_reason="Regular interval extraction"
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
keyframes.append(keyframe_result)
|
| 217 |
+
extracted_count += 1
|
| 218 |
+
|
| 219 |
+
# Update stats
|
| 220 |
+
if enhancement_applied:
|
| 221 |
+
self.processing_stats['frames_enhanced'] += 1
|
| 222 |
+
|
| 223 |
+
frame_count += 1
|
| 224 |
+
self.processing_stats['frames_processed'] += 1
|
| 225 |
+
|
| 226 |
+
# Progress logging
|
| 227 |
+
if frame_count % 1000 == 0:
|
| 228 |
+
progress = (frame_count / total_frames) * 100 if total_frames > 0 else 0
|
| 229 |
+
logger.info(f"Progress: {progress:.1f}% ({frame_count}/{total_frames} frames)")
|
| 230 |
+
|
| 231 |
+
cap.release()
|
| 232 |
+
|
| 233 |
+
# Update final statistics
|
| 234 |
+
processing_time = time.time() - start_time
|
| 235 |
+
self.processing_stats['keyframes_extracted'] = extracted_count
|
| 236 |
+
self.processing_stats['total_processing_time'] = processing_time
|
| 237 |
+
|
| 238 |
+
logger.info(f"✅ Keyframe extraction complete:")
|
| 239 |
+
logger.info(f" 📊 Extracted {extracted_count} keyframes from {frame_count} frames")
|
| 240 |
+
logger.info(f" ⚡ Enhanced {self.processing_stats['frames_enhanced']} frames")
|
| 241 |
+
logger.info(f" ⏱️ Processing time: {processing_time:.2f}s")
|
| 242 |
+
|
| 243 |
+
return keyframes
|
| 244 |
+
|
| 245 |
+
except Exception as e:
|
| 246 |
+
logger.error(f"Error in keyframe extraction: {e}")
|
| 247 |
+
return []
|
| 248 |
+
|
| 249 |
+
def _assess_frame_quality(self, frame: np.ndarray) -> float:
|
| 250 |
+
"""
|
| 251 |
+
Quick frame quality assessment for keyframe selection
|
| 252 |
+
"""
|
| 253 |
+
try:
|
| 254 |
+
# Convert to grayscale
|
| 255 |
+
if len(frame.shape) == 3:
|
| 256 |
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
| 257 |
+
else:
|
| 258 |
+
gray = frame
|
| 259 |
+
|
| 260 |
+
# Calculate Laplacian variance (focus measure)
|
| 261 |
+
laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var()
|
| 262 |
+
|
| 263 |
+
# Normalize to 0-1 scale (higher = better quality)
|
| 264 |
+
quality_score = min(laplacian_var / 1000.0, 1.0)
|
| 265 |
+
|
| 266 |
+
return quality_score
|
| 267 |
+
|
| 268 |
+
except Exception:
|
| 269 |
+
return 0.5 # Default quality score
|
| 270 |
+
|
| 271 |
+
def extract_keyframes(self, video_path: str) -> List[KeyframeResult]:
|
| 272 |
+
"""
|
| 273 |
+
Main keyframe extraction method for DetectifAI pipeline compatibility
|
| 274 |
+
|
| 275 |
+
Args:
|
| 276 |
+
video_path: Path to input video file
|
| 277 |
+
|
| 278 |
+
Returns:
|
| 279 |
+
List of KeyframeResult objects
|
| 280 |
+
"""
|
| 281 |
+
if not self.config:
|
| 282 |
+
logger.error("No configuration provided for keyframe extraction")
|
| 283 |
+
return []
|
| 284 |
+
|
| 285 |
+
# Use output directory from config
|
| 286 |
+
output_dir = getattr(self.config, 'output_base_dir', 'video_processing_outputs')
|
| 287 |
+
fps_interval = getattr(self.config, 'keyframe_extraction_fps', 1.0)
|
| 288 |
+
|
| 289 |
+
return self.extract_keyframes_optimized(video_path, output_dir, fps_interval)
|
| 290 |
+
|
| 291 |
+
def get_processing_stats(self) -> Dict[str, Any]:
|
| 292 |
+
"""Get processing statistics"""
|
| 293 |
+
return self.processing_stats.copy()
|
| 294 |
+
|
| 295 |
+
class StreamingVideoProcessor:
|
| 296 |
+
"""
|
| 297 |
+
Streaming processor for large surveillance videos to reduce memory usage
|
| 298 |
+
"""
|
| 299 |
+
|
| 300 |
+
def __init__(self, config=None):
|
| 301 |
+
self.config = config
|
| 302 |
+
self.chunk_size = getattr(config, 'video_chunk_size', 1000) # Process 1000 frames at a time
|
| 303 |
+
|
| 304 |
+
def process_video_in_chunks(self, video_path: str, output_dir: str,
|
| 305 |
+
chunk_processor_func) -> Dict[str, Any]:
|
| 306 |
+
"""
|
| 307 |
+
Process large videos in chunks to manage memory usage
|
| 308 |
+
|
| 309 |
+
Args:
|
| 310 |
+
video_path: Path to input video
|
| 311 |
+
output_dir: Output directory
|
| 312 |
+
chunk_processor_func: Function to process each chunk
|
| 313 |
+
|
| 314 |
+
Returns:
|
| 315 |
+
Dictionary with processing results
|
| 316 |
+
"""
|
| 317 |
+
results = {
|
| 318 |
+
'total_chunks': 0,
|
| 319 |
+
'processed_chunks': 0,
|
| 320 |
+
'total_frames': 0,
|
| 321 |
+
'processing_time': 0.0
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
start_time = time.time()
|
| 325 |
+
|
| 326 |
+
try:
|
| 327 |
+
cap = cv2.VideoCapture(video_path)
|
| 328 |
+
if not cap.isOpened():
|
| 329 |
+
logger.error(f"Could not open video: {video_path}")
|
| 330 |
+
return results
|
| 331 |
+
|
| 332 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 333 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 334 |
+
|
| 335 |
+
results['total_frames'] = total_frames
|
| 336 |
+
results['total_chunks'] = (total_frames + self.chunk_size - 1) // self.chunk_size
|
| 337 |
+
|
| 338 |
+
logger.info(f"Processing video in {results['total_chunks']} chunks of {self.chunk_size} frames")
|
| 339 |
+
|
| 340 |
+
frame_count = 0
|
| 341 |
+
chunk_count = 0
|
| 342 |
+
|
| 343 |
+
while frame_count < total_frames:
|
| 344 |
+
# Process chunk
|
| 345 |
+
chunk_frames = []
|
| 346 |
+
chunk_start = frame_count
|
| 347 |
+
|
| 348 |
+
# Read chunk frames
|
| 349 |
+
for i in range(self.chunk_size):
|
| 350 |
+
ret, frame = cap.read()
|
| 351 |
+
if not ret:
|
| 352 |
+
break
|
| 353 |
+
|
| 354 |
+
chunk_frames.append({
|
| 355 |
+
'frame': frame,
|
| 356 |
+
'frame_number': frame_count,
|
| 357 |
+
'timestamp': frame_count / fps if fps > 0 else frame_count
|
| 358 |
+
})
|
| 359 |
+
frame_count += 1
|
| 360 |
+
|
| 361 |
+
if chunk_frames:
|
| 362 |
+
# Process chunk
|
| 363 |
+
chunk_processor_func(chunk_frames, chunk_count, output_dir)
|
| 364 |
+
chunk_count += 1
|
| 365 |
+
results['processed_chunks'] += 1
|
| 366 |
+
|
| 367 |
+
# Clear memory
|
| 368 |
+
del chunk_frames
|
| 369 |
+
|
| 370 |
+
logger.info(f"Processed chunk {chunk_count}/{results['total_chunks']}")
|
| 371 |
+
|
| 372 |
+
cap.release()
|
| 373 |
+
results['processing_time'] = time.time() - start_time
|
| 374 |
+
|
| 375 |
+
logger.info(f"✅ Streaming processing complete in {results['processing_time']:.2f}s")
|
| 376 |
+
|
| 377 |
+
except Exception as e:
|
| 378 |
+
logger.error(f"Error in streaming processing: {e}")
|
| 379 |
+
|
| 380 |
+
return results
|
| 381 |
+
|
| 382 |
+
def create_optimized_processor(config=None):
|
| 383 |
+
"""Factory function to create optimized video processor"""
|
| 384 |
+
return OptimizedVideoProcessor(config)
|
database/config.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database Configuration for DetectifAI Backend
|
| 3 |
+
|
| 4 |
+
This module handles connections to MongoDB Atlas and S3-compatible object storage
|
| 5 |
+
(Backblaze B2) for the DetectifAI system.
|
| 6 |
+
It provides centralized configuration and connection management.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
from pymongo import MongoClient
|
| 11 |
+
from minio import Minio
|
| 12 |
+
from minio.error import S3Error
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
import logging
|
| 15 |
+
from datetime import timedelta
|
| 16 |
+
|
| 17 |
+
# Load environment variables
|
| 18 |
+
load_dotenv()
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
class DatabaseConfig:
|
| 23 |
+
"""Configuration class for database connections"""
|
| 24 |
+
|
| 25 |
+
def __init__(self):
|
| 26 |
+
# MongoDB Atlas connection (same as frontend)
|
| 27 |
+
self.mongo_uri = os.getenv(
|
| 28 |
+
'MONGO_URI',
|
| 29 |
+
'mongodb+srv://detectifai_user:DetectifAI123@cluster0.6f9uj.mongodb.net/detectifai?retryWrites=true&w=majority&appName=Cluster0'
|
| 30 |
+
)
|
| 31 |
+
self.mongo_db_name = 'detectifai'
|
| 32 |
+
|
| 33 |
+
# S3-compatible object storage (Backblaze B2)
|
| 34 |
+
self.minio_endpoint = os.getenv('MINIO_ENDPOINT', 's3.eu-central-003.backblazeb2.com')
|
| 35 |
+
self.minio_access_key = os.getenv('MINIO_ACCESS_KEY', '00367479ffb7e4e0000000001')
|
| 36 |
+
self.minio_secret_key = os.getenv('MINIO_SECRET_KEY', 'K003opTvf92ijRj5dM7H1dgrlwcGTdA')
|
| 37 |
+
self.minio_video_bucket = os.getenv('MINIO_VIDEO_BUCKET', 'detectifai-videos')
|
| 38 |
+
self.minio_keyframe_bucket = os.getenv('MINIO_KEYFRAME_BUCKET', 'detectifai-keyframes')
|
| 39 |
+
self.minio_reports_bucket = os.getenv('MINIO_REPORTS_BUCKET', 'detectifai-reports')
|
| 40 |
+
self.minio_secure = os.getenv('MINIO_SECURE', 'true').lower() == 'true'
|
| 41 |
+
# Extract region from endpoint for S3 signing (e.g. 'eu-central-003')
|
| 42 |
+
self.minio_region = os.getenv('MINIO_REGION', self._extract_region(self.minio_endpoint))
|
| 43 |
+
|
| 44 |
+
@staticmethod
|
| 45 |
+
def _extract_region(endpoint: str) -> str:
|
| 46 |
+
"""Extract region from B2 S3 endpoint like s3.eu-central-003.backblazeb2.com"""
|
| 47 |
+
parts = endpoint.split('.')
|
| 48 |
+
if len(parts) >= 3 and parts[0] == 's3':
|
| 49 |
+
return parts[1] # e.g. 'eu-central-003'
|
| 50 |
+
return ''
|
| 51 |
+
|
| 52 |
+
class DatabaseManager:
|
| 53 |
+
"""Central database manager for MongoDB and MinIO connections"""
|
| 54 |
+
|
| 55 |
+
def __init__(self):
|
| 56 |
+
self.config = DatabaseConfig()
|
| 57 |
+
self._mongodb_client = None
|
| 58 |
+
self._db = None
|
| 59 |
+
self._minio_client = None
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def mongo_client(self):
|
| 63 |
+
"""Lazy loading MongoDB client"""
|
| 64 |
+
if self._mongodb_client is None:
|
| 65 |
+
try:
|
| 66 |
+
self._mongodb_client = MongoClient(self.config.mongo_uri)
|
| 67 |
+
# Test connection
|
| 68 |
+
self._mongodb_client.admin.command('ping')
|
| 69 |
+
logger.info("✅ MongoDB connection established successfully")
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.error(f"❌ Failed to connect to MongoDB: {e}")
|
| 72 |
+
raise
|
| 73 |
+
return self._mongodb_client
|
| 74 |
+
|
| 75 |
+
@property
|
| 76 |
+
def db(self):
|
| 77 |
+
"""Get MongoDB database instance"""
|
| 78 |
+
if self._db is None:
|
| 79 |
+
self._db = self.mongo_client[self.config.mongo_db_name]
|
| 80 |
+
return self._db
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def minio_client(self):
|
| 84 |
+
"""Lazy loading S3-compatible storage client — returns None when unavailable"""
|
| 85 |
+
if self._minio_client is None:
|
| 86 |
+
try:
|
| 87 |
+
self._minio_client = Minio(
|
| 88 |
+
self.config.minio_endpoint,
|
| 89 |
+
access_key=self.config.minio_access_key,
|
| 90 |
+
secret_key=self.config.minio_secret_key,
|
| 91 |
+
secure=self.config.minio_secure,
|
| 92 |
+
region=self.config.minio_region or None
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Test connection and verify buckets exist
|
| 96 |
+
self._ensure_bucket_exists()
|
| 97 |
+
logger.info("✅ S3 storage connection established (Backblaze B2)")
|
| 98 |
+
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.warning(f"⚠️ S3 storage unavailable (non-fatal): {e}")
|
| 101 |
+
self._minio_client = None # keep it None so we can retry later
|
| 102 |
+
return None
|
| 103 |
+
return self._minio_client
|
| 104 |
+
|
| 105 |
+
def _ensure_bucket_exists(self):
|
| 106 |
+
"""Verify that the required S3 buckets exist on Backblaze B2"""
|
| 107 |
+
try:
|
| 108 |
+
for bucket_name in [
|
| 109 |
+
self.config.minio_video_bucket,
|
| 110 |
+
self.config.minio_keyframe_bucket,
|
| 111 |
+
self.config.minio_reports_bucket,
|
| 112 |
+
]:
|
| 113 |
+
if self._minio_client.bucket_exists(bucket_name):
|
| 114 |
+
logger.info(f"✅ S3 bucket verified: {bucket_name}")
|
| 115 |
+
else:
|
| 116 |
+
logger.warning(f"⚠️ S3 bucket not found: {bucket_name} — create it in Backblaze B2 dashboard")
|
| 117 |
+
except S3Error as e:
|
| 118 |
+
logger.error(f"❌ Failed to verify S3 buckets: {e}")
|
| 119 |
+
raise
|
| 120 |
+
|
| 121 |
+
def test_connections(self):
|
| 122 |
+
"""Test both MongoDB and MinIO connections"""
|
| 123 |
+
mongodb_success = False
|
| 124 |
+
minio_success = False
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
# Test MongoDB
|
| 128 |
+
self.mongo_client.admin.command('ping')
|
| 129 |
+
collections = self.db.list_collection_names()
|
| 130 |
+
logger.info(f"✅ MongoDB test successful. Collections: {collections}")
|
| 131 |
+
print(f"✅ MongoDB connected successfully. Collections: {collections}")
|
| 132 |
+
mongodb_success = True
|
| 133 |
+
|
| 134 |
+
except Exception as e:
|
| 135 |
+
logger.error(f"❌ MongoDB connection failed: {e}")
|
| 136 |
+
print(f"❌ MongoDB connection failed: {e}")
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
# Test S3 storage (Backblaze B2)
|
| 140 |
+
buckets = self.minio_client.list_buckets()
|
| 141 |
+
bucket_names = [bucket.name for bucket in buckets]
|
| 142 |
+
logger.info(f"✅ S3 storage test successful. Buckets: {bucket_names}")
|
| 143 |
+
print(f"✅ S3 storage (Backblaze B2) connected successfully. Buckets: {bucket_names}")
|
| 144 |
+
minio_success = True
|
| 145 |
+
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.error(f"❌ S3 storage connection failed: {e}")
|
| 148 |
+
print(f"❌ S3 storage connection failed: {e}")
|
| 149 |
+
print("💡 Check MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY env vars.")
|
| 150 |
+
|
| 151 |
+
return mongodb_success # At minimum, we need MongoDB working
|
| 152 |
+
|
| 153 |
+
def close_connections(self):
|
| 154 |
+
"""Close database connections"""
|
| 155 |
+
if self._mongodb_client:
|
| 156 |
+
self._mongodb_client.close()
|
| 157 |
+
logger.info("MongoDB connection closed")
|
| 158 |
+
|
| 159 |
+
def get_presigned_url(minio_client, bucket_name: str, object_name: str, expires: timedelta = timedelta(hours=1)):
|
| 160 |
+
"""Generate presigned URL for S3 object access (works with Backblaze B2)"""
|
| 161 |
+
try:
|
| 162 |
+
return minio_client.presigned_get_object(bucket_name, object_name, expires=expires)
|
| 163 |
+
except S3Error as e:
|
| 164 |
+
logger.error(f"Failed to generate presigned URL for {object_name}: {e}")
|
| 165 |
+
return None
|
| 166 |
+
|
| 167 |
+
if __name__ == "__main__":
|
| 168 |
+
# Test connections
|
| 169 |
+
db_manager = DatabaseManager()
|
| 170 |
+
if db_manager.test_connections():
|
| 171 |
+
print("✅ All database connections working!")
|
| 172 |
+
else:
|
| 173 |
+
print("❌ Database connection issues detected")
|
database/keyframe_repository.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Keyframe Repository for DetectifAI Database Operations
|
| 3 |
+
|
| 4 |
+
This module provides MinIO storage and database operations for keyframes.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import io
|
| 9 |
+
import cv2
|
| 10 |
+
import numpy as np
|
| 11 |
+
from typing import List, Dict, Any, Optional
|
| 12 |
+
from datetime import datetime, timedelta
|
| 13 |
+
import logging
|
| 14 |
+
from minio.error import S3Error
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
class KeyframeRepository:
|
| 19 |
+
"""Repository for keyframe operations with S3 storage and MongoDB"""
|
| 20 |
+
|
| 21 |
+
def __init__(self, db_manager):
|
| 22 |
+
self._db_manager = db_manager
|
| 23 |
+
self.db = db_manager.db
|
| 24 |
+
self.bucket = db_manager.config.minio_keyframe_bucket # Use dedicated keyframes bucket
|
| 25 |
+
self.collection = self.db.keyframes # MongoDB collection for keyframe metadata
|
| 26 |
+
|
| 27 |
+
@property
|
| 28 |
+
def minio(self):
|
| 29 |
+
"""Lazy access to S3 storage — tolerates unavailable storage"""
|
| 30 |
+
return self._db_manager.minio_client
|
| 31 |
+
|
| 32 |
+
def save_keyframe_to_minio(self, video_id: str, frame_data: bytes, frame_number: int, timestamp: float) -> Optional[str]:
|
| 33 |
+
"""Save a single keyframe directly to S3 storage"""
|
| 34 |
+
if self.minio is None:
|
| 35 |
+
return None
|
| 36 |
+
try:
|
| 37 |
+
minio_path = f"{video_id}/frame_{frame_number:06d}.jpg" # Use consistent naming pattern
|
| 38 |
+
|
| 39 |
+
# Upload bytes directly to MinIO using BytesIO
|
| 40 |
+
from io import BytesIO
|
| 41 |
+
buffer = BytesIO(frame_data)
|
| 42 |
+
|
| 43 |
+
self.minio.put_object(
|
| 44 |
+
self.bucket,
|
| 45 |
+
minio_path,
|
| 46 |
+
buffer,
|
| 47 |
+
length=len(frame_data),
|
| 48 |
+
content_type='image/jpeg'
|
| 49 |
+
)
|
| 50 |
+
logger.info(f"✅ Uploaded keyframe to MinIO: {minio_path}")
|
| 51 |
+
return minio_path
|
| 52 |
+
|
| 53 |
+
except Exception as e:
|
| 54 |
+
logger.error(f"❌ Failed to upload keyframe to MinIO: {e}")
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
def save_keyframes_batch(self, video_id: str, keyframes: List) -> List[Dict]:
|
| 58 |
+
"""Save multiple keyframes directly to MinIO and locally, return their storage info"""
|
| 59 |
+
keyframe_info = []
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
# Create local storage directory
|
| 63 |
+
local_dir = os.path.join("video_processing_outputs", "keyframes", video_id)
|
| 64 |
+
os.makedirs(local_dir, exist_ok=True)
|
| 65 |
+
|
| 66 |
+
for keyframe in keyframes:
|
| 67 |
+
# Handle KeyframeResult objects
|
| 68 |
+
frame_data = keyframe.frame_data if hasattr(keyframe, 'frame_data') else keyframe
|
| 69 |
+
|
| 70 |
+
frame = frame_data.get('frame') # numpy array
|
| 71 |
+
frame_number = frame_data.get('frame_number', 0)
|
| 72 |
+
timestamp = frame_data.get('timestamp', 0.0)
|
| 73 |
+
|
| 74 |
+
if frame is not None:
|
| 75 |
+
# Convert numpy array to jpg bytes
|
| 76 |
+
is_success, buffer = cv2.imencode('.jpg', frame)
|
| 77 |
+
if not is_success:
|
| 78 |
+
continue
|
| 79 |
+
|
| 80 |
+
frame_bytes = buffer.tobytes()
|
| 81 |
+
|
| 82 |
+
# Save locally
|
| 83 |
+
local_filename = f"frame_{frame_number:06d}.jpg"
|
| 84 |
+
local_path = os.path.join(local_dir, local_filename)
|
| 85 |
+
with open(local_path, 'wb') as f:
|
| 86 |
+
f.write(frame_bytes)
|
| 87 |
+
logger.info(f"✅ Keyframe saved locally: {local_path}")
|
| 88 |
+
|
| 89 |
+
# Upload bytes directly to MinIO
|
| 90 |
+
minio_path = self.save_keyframe_to_minio(
|
| 91 |
+
video_id, frame_bytes, frame_number, timestamp
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
if minio_path:
|
| 95 |
+
info = {
|
| 96 |
+
'frame_number': frame_number,
|
| 97 |
+
'timestamp': timestamp,
|
| 98 |
+
'minio_path': minio_path,
|
| 99 |
+
'local_path': local_path,
|
| 100 |
+
'quality_score': frame_data.get('quality_score', 0.0),
|
| 101 |
+
'enhancement_applied': frame_data.get('enhancement_applied', False)
|
| 102 |
+
}
|
| 103 |
+
keyframe_info.append(info)
|
| 104 |
+
|
| 105 |
+
logger.info(f"✅ Uploaded {len(keyframe_info)} keyframes to MinIO and saved locally for video {video_id}")
|
| 106 |
+
return keyframe_info
|
| 107 |
+
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error(f"❌ Failed to upload keyframes batch: {e}")
|
| 110 |
+
return keyframe_info # Return whatever was successful
|
| 111 |
+
|
| 112 |
+
def get_keyframe_presigned_url(self, minio_path: str, expires: timedelta = timedelta(hours=1)) -> str:
|
| 113 |
+
"""Generate presigned URL for keyframe access"""
|
| 114 |
+
if self.minio is None:
|
| 115 |
+
return None
|
| 116 |
+
try:
|
| 117 |
+
return self.minio.presigned_get_object(self.bucket, minio_path, expires=expires)
|
| 118 |
+
except S3Error as e:
|
| 119 |
+
logger.error(f"❌ Failed to generate presigned URL for keyframe: {e}")
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
def get_video_keyframes_presigned_urls(self, video_id: str, expires: timedelta = timedelta(hours=1)) -> List[Dict]:
|
| 123 |
+
"""Get presigned URLs for all keyframes of a video"""
|
| 124 |
+
if self.minio is None:
|
| 125 |
+
return self._get_keyframes_from_local(video_id) if hasattr(self, '_get_keyframes_from_local') else []
|
| 126 |
+
try:
|
| 127 |
+
# Try both storage patterns:
|
| 128 |
+
# 1) {video_id}/keyframes/frame_*.jpg (legacy / some pipelines)
|
| 129 |
+
# 2) {video_id}/frame_*.jpg (save_keyframe_to_minio pattern)
|
| 130 |
+
logger.info(f"🔍 Looking for keyframes in bucket '{self.bucket}' for video '{video_id}'")
|
| 131 |
+
objects = list(self.minio.list_objects(self.bucket, prefix=f"{video_id}/keyframes/", recursive=True))
|
| 132 |
+
if not objects:
|
| 133 |
+
# Fallback: flat storage path used by save_keyframe_to_minio
|
| 134 |
+
objects = list(self.minio.list_objects(self.bucket, prefix=f"{video_id}/", recursive=True))
|
| 135 |
+
logger.info(f"📦 Found {len(objects)} objects in MinIO for keyframes")
|
| 136 |
+
|
| 137 |
+
keyframes_urls = []
|
| 138 |
+
for obj in objects:
|
| 139 |
+
if obj.object_name.endswith('.jpg'):
|
| 140 |
+
# Extract frame number and timestamp from filename
|
| 141 |
+
filename = obj.object_name.split('/')[-1] # e.g., "frame_000001.jpg"
|
| 142 |
+
frame_number = 0
|
| 143 |
+
timestamp = 0.0
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
# Parse frame number from filename like "frame_000001.jpg"
|
| 147 |
+
if 'frame_' in filename:
|
| 148 |
+
frame_str = filename.split('_')[1].split('.')[0]
|
| 149 |
+
frame_number = int(frame_str)
|
| 150 |
+
# Estimate timestamp from frame number (assuming 30 fps)
|
| 151 |
+
timestamp = frame_number / 30.0
|
| 152 |
+
except (ValueError, IndexError):
|
| 153 |
+
pass
|
| 154 |
+
|
| 155 |
+
# Try to get metadata from MinIO object
|
| 156 |
+
try:
|
| 157 |
+
obj_stat = self.minio.stat_object(self.bucket, obj.object_name)
|
| 158 |
+
if obj_stat.metadata:
|
| 159 |
+
# Extract timestamp from metadata if available
|
| 160 |
+
if 'timestamp' in obj_stat.metadata:
|
| 161 |
+
try:
|
| 162 |
+
timestamp = float(obj_stat.metadata['timestamp'])
|
| 163 |
+
except:
|
| 164 |
+
pass
|
| 165 |
+
if 'frame_number' in obj_stat.metadata:
|
| 166 |
+
try:
|
| 167 |
+
frame_number = int(obj_stat.metadata['frame_number'])
|
| 168 |
+
except:
|
| 169 |
+
pass
|
| 170 |
+
except:
|
| 171 |
+
pass
|
| 172 |
+
|
| 173 |
+
# Generate presigned URL and API URL
|
| 174 |
+
presigned_url = self.get_keyframe_presigned_url(obj.object_name, expires=expires)
|
| 175 |
+
# Also provide API endpoint URL for direct serving
|
| 176 |
+
api_url = f"/api/minio/image/{self.bucket}/{obj.object_name}"
|
| 177 |
+
|
| 178 |
+
if presigned_url:
|
| 179 |
+
keyframes_urls.append({
|
| 180 |
+
'frame_number': frame_number,
|
| 181 |
+
'timestamp': timestamp,
|
| 182 |
+
'minio_path': obj.object_name,
|
| 183 |
+
'presigned_url': presigned_url,
|
| 184 |
+
'url': api_url, # Use API endpoint for better reliability
|
| 185 |
+
'api_url': api_url,
|
| 186 |
+
'filename': filename
|
| 187 |
+
})
|
| 188 |
+
|
| 189 |
+
# Sort by frame number
|
| 190 |
+
keyframes_urls.sort(key=lambda x: x['frame_number'])
|
| 191 |
+
|
| 192 |
+
logger.info(f"✅ Generated {len(keyframes_urls)} presigned URLs for video {video_id} keyframes")
|
| 193 |
+
return keyframes_urls
|
| 194 |
+
|
| 195 |
+
except Exception as e:
|
| 196 |
+
logger.error(f"❌ Failed to get keyframes presigned URLs for video {video_id}: {e}")
|
| 197 |
+
return []
|
| 198 |
+
|
| 199 |
+
def create_keyframe(self, keyframe_doc: Dict[str, Any]) -> Optional[str]:
|
| 200 |
+
"""
|
| 201 |
+
Save keyframe metadata to MongoDB
|
| 202 |
+
|
| 203 |
+
Args:
|
| 204 |
+
keyframe_doc: Dictionary containing keyframe metadata:
|
| 205 |
+
- camera_id: Camera identifier (for live streams)
|
| 206 |
+
- video_id: Video identifier (for uploaded videos, optional)
|
| 207 |
+
- timestamp: Frame timestamp in seconds
|
| 208 |
+
- timestamp_ms: Frame timestamp in milliseconds
|
| 209 |
+
- frame_index: Frame number/index
|
| 210 |
+
- minio_path: Path to keyframe in MinIO
|
| 211 |
+
- objects_detected: List of detected objects
|
| 212 |
+
- behaviors_detected: List of detected behaviors
|
| 213 |
+
- motion_detected: Whether motion was detected
|
| 214 |
+
- motion_score: Motion detection score
|
| 215 |
+
- created_at: Creation timestamp
|
| 216 |
+
|
| 217 |
+
Returns:
|
| 218 |
+
MongoDB document ID or None
|
| 219 |
+
"""
|
| 220 |
+
try:
|
| 221 |
+
# Ensure required fields
|
| 222 |
+
if 'created_at' not in keyframe_doc:
|
| 223 |
+
keyframe_doc['created_at'] = datetime.utcnow()
|
| 224 |
+
|
| 225 |
+
# Convert numpy types if present
|
| 226 |
+
try:
|
| 227 |
+
from database.models import convert_numpy_types, prepare_for_mongodb
|
| 228 |
+
keyframe_doc = convert_numpy_types(keyframe_doc)
|
| 229 |
+
keyframe_doc = prepare_for_mongodb(keyframe_doc)
|
| 230 |
+
except ImportError:
|
| 231 |
+
# Fallback if models not available
|
| 232 |
+
pass
|
| 233 |
+
|
| 234 |
+
# Insert into MongoDB
|
| 235 |
+
result = self.collection.insert_one(keyframe_doc)
|
| 236 |
+
logger.info(f"✅ Saved keyframe metadata to MongoDB: {keyframe_doc.get('minio_path', 'unknown')}")
|
| 237 |
+
return str(result.inserted_id)
|
| 238 |
+
|
| 239 |
+
except Exception as e:
|
| 240 |
+
logger.error(f"❌ Failed to save keyframe metadata to MongoDB: {e}")
|
| 241 |
+
import traceback
|
| 242 |
+
logger.error(traceback.format_exc())
|
| 243 |
+
return None
|
database/models.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data Models for DetectifAI Database Integration
|
| 3 |
+
|
| 4 |
+
This module defines data models that map EXACTLY to the MongoDB collections
|
| 5 |
+
defined in DetectifAI_db/database_setup.py schema.
|
| 6 |
+
|
| 7 |
+
CRITICAL RULES:
|
| 8 |
+
1. Only use fields defined in the MongoDB schema validators
|
| 9 |
+
2. Extra fields must go in meta_data for video_file or use related collections
|
| 10 |
+
3. Always convert numpy types before MongoDB operations
|
| 11 |
+
4. Timestamps in events must be milliseconds (int/long), not seconds (float)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from typing import List, Dict, Any, Optional
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
from bson import ObjectId
|
| 17 |
+
from dataclasses import dataclass, asdict
|
| 18 |
+
import json
|
| 19 |
+
import numpy as np
|
| 20 |
+
|
| 21 |
+
# ========================================
|
| 22 |
+
# Schema-Compliant Data Models
|
| 23 |
+
# ========================================
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class VideoFileModel:
|
| 27 |
+
"""Maps EXACTLY to video_file collection schema in MongoDB Atlas"""
|
| 28 |
+
# Required fields (from schema)
|
| 29 |
+
video_id: str
|
| 30 |
+
user_id: str
|
| 31 |
+
file_path: str # MinIO path or local path
|
| 32 |
+
|
| 33 |
+
# Optional fields (from schema)
|
| 34 |
+
minio_object_key: Optional[str] = None
|
| 35 |
+
minio_bucket: Optional[str] = None
|
| 36 |
+
codec: Optional[str] = None
|
| 37 |
+
fps: Optional[float] = 30.0 # bsonType: double - must be float
|
| 38 |
+
upload_date: Optional[datetime] = None
|
| 39 |
+
duration_secs: Optional[int] = None # bsonType: int - must be INTEGER not float
|
| 40 |
+
file_size_bytes: Optional[int] = None # bsonType: long
|
| 41 |
+
meta_data: Optional[Dict] = None # Store ALL extra fields here (processing_status, resolution, etc.)
|
| 42 |
+
|
| 43 |
+
_id: Optional[ObjectId] = None
|
| 44 |
+
|
| 45 |
+
def to_dict(self) -> Dict:
|
| 46 |
+
"""Convert to dictionary for MongoDB insertion with proper type conversion"""
|
| 47 |
+
data = asdict(self)
|
| 48 |
+
|
| 49 |
+
# Set defaults
|
| 50 |
+
if data.get('upload_date') is None:
|
| 51 |
+
data['upload_date'] = datetime.utcnow()
|
| 52 |
+
if data.get('fps') is None:
|
| 53 |
+
data['fps'] = 30.0
|
| 54 |
+
|
| 55 |
+
# Ensure duration is integer (MongoDB schema requires int)
|
| 56 |
+
if data.get('duration_secs') is not None:
|
| 57 |
+
data['duration_secs'] = int(data['duration_secs'])
|
| 58 |
+
|
| 59 |
+
# Ensure file_size is integer (MongoDB schema requires long)
|
| 60 |
+
if data.get('file_size_bytes') is not None:
|
| 61 |
+
data['file_size_bytes'] = int(data['file_size_bytes'])
|
| 62 |
+
|
| 63 |
+
# Ensure fps is float (MongoDB schema requires double)
|
| 64 |
+
if data.get('fps') is not None:
|
| 65 |
+
data['fps'] = float(data['fps'])
|
| 66 |
+
|
| 67 |
+
return data
|
| 68 |
+
|
| 69 |
+
@dataclass
|
| 70 |
+
class EventModel:
|
| 71 |
+
"""Maps EXACTLY to event collection schema in MongoDB Atlas"""
|
| 72 |
+
# Required fields (from schema)
|
| 73 |
+
event_id: str
|
| 74 |
+
video_id: str
|
| 75 |
+
start_timestamp_ms: int # bsonType: long - MUST be milliseconds as INTEGER
|
| 76 |
+
end_timestamp_ms: int # bsonType: long - MUST be milliseconds as INTEGER
|
| 77 |
+
|
| 78 |
+
# Optional fields (from schema)
|
| 79 |
+
event_type: Optional[str] = None # 'object_detection', 'motion', 'fire', 'weapon', etc.
|
| 80 |
+
confidence_score: Optional[float] = None # bsonType: double (NOT 'confidence')
|
| 81 |
+
is_verified: bool = False
|
| 82 |
+
is_false_positive: bool = False
|
| 83 |
+
verified_at: Optional[datetime] = None
|
| 84 |
+
verified_by: Optional[str] = None
|
| 85 |
+
visual_embedding: Optional[List[float]] = None # For future FAISS integration
|
| 86 |
+
bounding_boxes: Optional[Dict] = None # Store detection bboxes here as object
|
| 87 |
+
|
| 88 |
+
_id: Optional[ObjectId] = None
|
| 89 |
+
|
| 90 |
+
def to_dict(self) -> Dict:
|
| 91 |
+
"""Convert to dictionary for MongoDB insertion with proper type conversion"""
|
| 92 |
+
data = asdict(self)
|
| 93 |
+
|
| 94 |
+
# Ensure timestamps are integers (milliseconds) - CRITICAL for MongoDB long type
|
| 95 |
+
data['start_timestamp_ms'] = int(data['start_timestamp_ms'])
|
| 96 |
+
data['end_timestamp_ms'] = int(data['end_timestamp_ms'])
|
| 97 |
+
|
| 98 |
+
# Ensure confidence_score is float
|
| 99 |
+
if data.get('confidence_score') is not None:
|
| 100 |
+
data['confidence_score'] = float(data['confidence_score'])
|
| 101 |
+
|
| 102 |
+
# Set default empty arrays/objects for schema compliance
|
| 103 |
+
if data.get('visual_embedding') is None:
|
| 104 |
+
data['visual_embedding'] = []
|
| 105 |
+
if data.get('bounding_boxes') is None:
|
| 106 |
+
data['bounding_boxes'] = {}
|
| 107 |
+
|
| 108 |
+
return data
|
| 109 |
+
|
| 110 |
+
@dataclass
|
| 111 |
+
class EventDescriptionModel:
|
| 112 |
+
"""Maps EXACTLY to event_description collection schema"""
|
| 113 |
+
# Required fields
|
| 114 |
+
description_id: str
|
| 115 |
+
event_id: str
|
| 116 |
+
text_embedding: List[float] # Required (empty array if not generated yet)
|
| 117 |
+
|
| 118 |
+
# Optional fields
|
| 119 |
+
caption: Optional[str] = None
|
| 120 |
+
confidence: Optional[float] = None
|
| 121 |
+
created_at: Optional[datetime] = None
|
| 122 |
+
updated_at: Optional[datetime] = None
|
| 123 |
+
_id: Optional[ObjectId] = None
|
| 124 |
+
|
| 125 |
+
def to_dict(self) -> Dict:
|
| 126 |
+
data = asdict(self)
|
| 127 |
+
if data.get('created_at') is None:
|
| 128 |
+
data['created_at'] = datetime.utcnow()
|
| 129 |
+
if data.get('updated_at') is None:
|
| 130 |
+
data['updated_at'] = datetime.utcnow()
|
| 131 |
+
# Ensure text_embedding is always a list
|
| 132 |
+
if data.get('text_embedding') is None:
|
| 133 |
+
data['text_embedding'] = []
|
| 134 |
+
return data
|
| 135 |
+
|
| 136 |
+
@dataclass
|
| 137 |
+
class EventCaptionModel:
|
| 138 |
+
"""Maps EXACTLY to event_caption collection schema"""
|
| 139 |
+
# Required fields
|
| 140 |
+
description_id: str
|
| 141 |
+
description: str
|
| 142 |
+
_id: Optional[ObjectId] = None
|
| 143 |
+
|
| 144 |
+
def to_dict(self) -> Dict:
|
| 145 |
+
return asdict(self)
|
| 146 |
+
|
| 147 |
+
@dataclass
|
| 148 |
+
class EventClipModel:
|
| 149 |
+
"""Maps EXACTLY to event_clip collection schema"""
|
| 150 |
+
# Required fields
|
| 151 |
+
clip_id: str
|
| 152 |
+
event_id: str
|
| 153 |
+
clip_path: str
|
| 154 |
+
|
| 155 |
+
# Optional fields
|
| 156 |
+
thumbnail_path: Optional[str] = None
|
| 157 |
+
minio_object_key: Optional[str] = None
|
| 158 |
+
minio_bucket: Optional[str] = None
|
| 159 |
+
duration_ms: Optional[int] = None # bsonType: long
|
| 160 |
+
extracted_at: Optional[datetime] = None
|
| 161 |
+
file_size_bytes: Optional[int] = None # bsonType: long
|
| 162 |
+
_id: Optional[ObjectId] = None
|
| 163 |
+
|
| 164 |
+
def to_dict(self) -> Dict:
|
| 165 |
+
data = asdict(self)
|
| 166 |
+
if data.get('extracted_at') is None:
|
| 167 |
+
data['extracted_at'] = datetime.utcnow()
|
| 168 |
+
# Ensure integer types
|
| 169 |
+
if data.get('duration_ms') is not None:
|
| 170 |
+
data['duration_ms'] = int(data['duration_ms'])
|
| 171 |
+
if data.get('file_size_bytes') is not None:
|
| 172 |
+
data['file_size_bytes'] = int(data['file_size_bytes'])
|
| 173 |
+
return data
|
| 174 |
+
|
| 175 |
+
@dataclass
|
| 176 |
+
class DetectedFaceModel:
|
| 177 |
+
"""Maps EXACTLY to detected_faces collection schema"""
|
| 178 |
+
# Required fields
|
| 179 |
+
face_id: str
|
| 180 |
+
event_id: str
|
| 181 |
+
detected_at: datetime
|
| 182 |
+
|
| 183 |
+
# Optional fields
|
| 184 |
+
confidence_score: Optional[float] = None
|
| 185 |
+
face_embedding: Optional[List[float]] = None
|
| 186 |
+
minio_object_key: Optional[str] = None
|
| 187 |
+
minio_bucket: Optional[str] = None
|
| 188 |
+
face_image_path: Optional[str] = None
|
| 189 |
+
bounding_boxes: Optional[Dict] = None
|
| 190 |
+
_id: Optional[ObjectId] = None
|
| 191 |
+
|
| 192 |
+
def to_dict(self) -> Dict:
|
| 193 |
+
data = asdict(self)
|
| 194 |
+
if data.get('face_embedding') is None:
|
| 195 |
+
data['face_embedding'] = []
|
| 196 |
+
return data
|
| 197 |
+
|
| 198 |
+
@dataclass
|
| 199 |
+
class FaceMatchModel:
|
| 200 |
+
"""Maps EXACTLY to face_matches collection schema"""
|
| 201 |
+
# Required fields
|
| 202 |
+
match_id: str
|
| 203 |
+
face_id_1: str
|
| 204 |
+
face_id_2: str
|
| 205 |
+
similarity_score: float
|
| 206 |
+
|
| 207 |
+
# Optional fields
|
| 208 |
+
matched_at: Optional[datetime] = None
|
| 209 |
+
_id: Optional[ObjectId] = None
|
| 210 |
+
|
| 211 |
+
def to_dict(self) -> Dict:
|
| 212 |
+
data = asdict(self)
|
| 213 |
+
if data.get('matched_at') is None:
|
| 214 |
+
data['matched_at'] = datetime.utcnow()
|
| 215 |
+
return data
|
| 216 |
+
|
| 217 |
+
# ========================================
|
| 218 |
+
# Helper Functions for Type Safety
|
| 219 |
+
# ========================================
|
| 220 |
+
|
| 221 |
+
def convert_numpy_types(obj):
|
| 222 |
+
"""
|
| 223 |
+
Recursively convert numpy types to native Python types for MongoDB compatibility.
|
| 224 |
+
|
| 225 |
+
MongoDB cannot serialize numpy types directly, causing BSON errors.
|
| 226 |
+
This function ensures all numpy integers become int, numpy floats become float, etc.
|
| 227 |
+
"""
|
| 228 |
+
if isinstance(obj, dict):
|
| 229 |
+
return {key: convert_numpy_types(value) for key, value in obj.items()}
|
| 230 |
+
elif isinstance(obj, list):
|
| 231 |
+
return [convert_numpy_types(item) for item in obj]
|
| 232 |
+
elif isinstance(obj, np.integer):
|
| 233 |
+
return int(obj)
|
| 234 |
+
elif isinstance(obj, np.floating):
|
| 235 |
+
return float(obj)
|
| 236 |
+
elif isinstance(obj, np.ndarray):
|
| 237 |
+
return obj.tolist()
|
| 238 |
+
elif isinstance(obj, np.bool_):
|
| 239 |
+
return bool(obj)
|
| 240 |
+
else:
|
| 241 |
+
return obj
|
| 242 |
+
|
| 243 |
+
def seconds_to_milliseconds(seconds: float) -> int:
|
| 244 |
+
"""Convert seconds (float) to milliseconds (int) for MongoDB long type"""
|
| 245 |
+
return int(seconds * 1000)
|
| 246 |
+
|
| 247 |
+
def milliseconds_to_seconds(milliseconds: int) -> float:
|
| 248 |
+
"""Convert milliseconds (int) to seconds (float) for display"""
|
| 249 |
+
return float(milliseconds) / 1000.0
|
| 250 |
+
|
| 251 |
+
def prepare_for_mongodb(data: Dict) -> Dict:
|
| 252 |
+
"""
|
| 253 |
+
Prepare data dictionary for MongoDB insertion.
|
| 254 |
+
- Remove None ObjectId fields
|
| 255 |
+
- Convert numpy types to Python natives
|
| 256 |
+
"""
|
| 257 |
+
# First convert numpy types
|
| 258 |
+
data = convert_numpy_types(data)
|
| 259 |
+
|
| 260 |
+
# Remove None ObjectId fields
|
| 261 |
+
cleaned_data = {}
|
| 262 |
+
for key, value in data.items():
|
| 263 |
+
if key == '_id' and value is None:
|
| 264 |
+
continue
|
| 265 |
+
cleaned_data[key] = value
|
| 266 |
+
return cleaned_data
|
| 267 |
+
|
| 268 |
+
def convert_objectid_to_string(doc: Dict) -> Dict:
|
| 269 |
+
"""Convert ObjectId fields to strings for JSON serialization"""
|
| 270 |
+
if isinstance(doc, dict):
|
| 271 |
+
for key, value in doc.items():
|
| 272 |
+
if isinstance(value, ObjectId):
|
| 273 |
+
doc[key] = str(value)
|
| 274 |
+
elif isinstance(value, list):
|
| 275 |
+
doc[key] = [
|
| 276 |
+
convert_objectid_to_string(item) if isinstance(item, dict)
|
| 277 |
+
else str(item) if isinstance(item, ObjectId)
|
| 278 |
+
else item
|
| 279 |
+
for item in value
|
| 280 |
+
]
|
| 281 |
+
elif isinstance(value, dict):
|
| 282 |
+
doc[key] = convert_objectid_to_string(value)
|
| 283 |
+
return doc
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
# ========================================
|
| 287 |
+
# Subscription & Payment Models
|
| 288 |
+
# ========================================
|
| 289 |
+
|
| 290 |
+
@dataclass
|
| 291 |
+
class SubscriptionPlanModel:
|
| 292 |
+
"""Maps to subscription_plans collection with Stripe integration"""
|
| 293 |
+
# Required fields
|
| 294 |
+
plan_id: str
|
| 295 |
+
plan_name: str
|
| 296 |
+
price: float
|
| 297 |
+
|
| 298 |
+
# Optional fields
|
| 299 |
+
description: Optional[str] = None
|
| 300 |
+
features: Optional[str] = None # Comma-separated feature list
|
| 301 |
+
storage_limit: Optional[int] = None
|
| 302 |
+
is_active: bool = True
|
| 303 |
+
stripe_product_id: Optional[str] = None
|
| 304 |
+
stripe_price_ids: Optional[Dict[str, str]] = None # {"monthly": "price_xxx", "yearly": "price_xxx"}
|
| 305 |
+
billing_periods: Optional[List[str]] = None # ["monthly", "yearly"]
|
| 306 |
+
created_at: Optional[datetime] = None
|
| 307 |
+
updated_at: Optional[datetime] = None
|
| 308 |
+
_id: Optional[ObjectId] = None
|
| 309 |
+
|
| 310 |
+
def to_dict(self) -> Dict:
|
| 311 |
+
"""Convert to dictionary for MongoDB insertion"""
|
| 312 |
+
data = asdict(self)
|
| 313 |
+
if data.get('created_at') is None:
|
| 314 |
+
data['created_at'] = datetime.utcnow()
|
| 315 |
+
if data.get('updated_at') is None:
|
| 316 |
+
data['updated_at'] = datetime.utcnow()
|
| 317 |
+
if data.get('stripe_price_ids') is None:
|
| 318 |
+
data['stripe_price_ids'] = {}
|
| 319 |
+
if data.get('billing_periods') is None:
|
| 320 |
+
data['billing_periods'] = []
|
| 321 |
+
return data
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
@dataclass
|
| 325 |
+
class UserSubscriptionModel:
|
| 326 |
+
"""Maps to user_subscriptions collection with Stripe integration"""
|
| 327 |
+
# Required fields
|
| 328 |
+
subscription_id: str
|
| 329 |
+
user_id: str
|
| 330 |
+
plan_id: str
|
| 331 |
+
|
| 332 |
+
# Optional fields
|
| 333 |
+
start_date: Optional[datetime] = None
|
| 334 |
+
end_date: Optional[datetime] = None
|
| 335 |
+
stripe_customer_id: Optional[str] = None
|
| 336 |
+
stripe_subscription_id: Optional[str] = None
|
| 337 |
+
billing_period: Optional[str] = None # "monthly" or "yearly"
|
| 338 |
+
status: Optional[str] = "active" # 'active', 'canceled', 'past_due', 'trialing'
|
| 339 |
+
current_period_start: Optional[datetime] = None
|
| 340 |
+
current_period_end: Optional[datetime] = None
|
| 341 |
+
cancel_at_period_end: bool = False
|
| 342 |
+
created_at: Optional[datetime] = None
|
| 343 |
+
updated_at: Optional[datetime] = None
|
| 344 |
+
_id: Optional[ObjectId] = None
|
| 345 |
+
|
| 346 |
+
def to_dict(self) -> Dict:
|
| 347 |
+
"""Convert to dictionary for MongoDB insertion"""
|
| 348 |
+
data = asdict(self)
|
| 349 |
+
if data.get('start_date') is None:
|
| 350 |
+
data['start_date'] = datetime.utcnow()
|
| 351 |
+
if data.get('created_at') is None:
|
| 352 |
+
data['created_at'] = datetime.utcnow()
|
| 353 |
+
if data.get('updated_at') is None:
|
| 354 |
+
data['updated_at'] = datetime.utcnow()
|
| 355 |
+
return data
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
@dataclass
|
| 359 |
+
class SubscriptionEventModel:
|
| 360 |
+
"""Maps to subscription_events collection for audit trail"""
|
| 361 |
+
# Required fields
|
| 362 |
+
event_id: str
|
| 363 |
+
subscription_id: str
|
| 364 |
+
event_type: str # 'created', 'updated', 'canceled', 'payment_succeeded', etc.
|
| 365 |
+
|
| 366 |
+
# Optional fields
|
| 367 |
+
stripe_event_id: Optional[str] = None
|
| 368 |
+
event_data: Optional[Dict] = None
|
| 369 |
+
created_at: Optional[datetime] = None
|
| 370 |
+
_id: Optional[ObjectId] = None
|
| 371 |
+
|
| 372 |
+
def to_dict(self) -> Dict:
|
| 373 |
+
"""Convert to dictionary for MongoDB insertion"""
|
| 374 |
+
data = asdict(self)
|
| 375 |
+
if data.get('created_at') is None:
|
| 376 |
+
data['created_at'] = datetime.utcnow()
|
| 377 |
+
if data.get('event_data') is None:
|
| 378 |
+
data['event_data'] = {}
|
| 379 |
+
return data
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
@dataclass
|
| 383 |
+
class PaymentHistoryModel:
|
| 384 |
+
"""Maps to payment_history collection for transaction records"""
|
| 385 |
+
# Required fields
|
| 386 |
+
payment_id: str
|
| 387 |
+
user_id: str
|
| 388 |
+
amount: float
|
| 389 |
+
|
| 390 |
+
# Optional fields
|
| 391 |
+
stripe_payment_intent_id: Optional[str] = None
|
| 392 |
+
currency: str = "USD"
|
| 393 |
+
status: Optional[str] = None # 'succeeded', 'pending', 'failed'
|
| 394 |
+
payment_method: Optional[str] = None
|
| 395 |
+
created_at: Optional[datetime] = None
|
| 396 |
+
_id: Optional[ObjectId] = None
|
| 397 |
+
|
| 398 |
+
def to_dict(self) -> Dict:
|
| 399 |
+
"""Convert to dictionary for MongoDB insertion"""
|
| 400 |
+
data = asdict(self)
|
| 401 |
+
if data.get('created_at') is None:
|
| 402 |
+
data['created_at'] = datetime.utcnow()
|
| 403 |
+
# Ensure amount is float
|
| 404 |
+
data['amount'] = float(data['amount'])
|
| 405 |
+
return data
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
@dataclass
|
| 409 |
+
class SubscriptionUsageModel:
|
| 410 |
+
"""Maps to subscription_usage collection for analytics and limits"""
|
| 411 |
+
# Required fields
|
| 412 |
+
usage_id: str
|
| 413 |
+
user_id: str
|
| 414 |
+
usage_type: str # 'video_processed', 'storage_used', 'searches_performed'
|
| 415 |
+
|
| 416 |
+
# Optional fields
|
| 417 |
+
usage_value: Optional[float] = None
|
| 418 |
+
usage_date: Optional[datetime] = None
|
| 419 |
+
created_at: Optional[datetime] = None
|
| 420 |
+
_id: Optional[ObjectId] = None
|
| 421 |
+
|
| 422 |
+
def to_dict(self) -> Dict:
|
| 423 |
+
"""Convert to dictionary for MongoDB insertion"""
|
| 424 |
+
data = asdict(self)
|
| 425 |
+
if data.get('usage_date') is None:
|
| 426 |
+
data['usage_date'] = datetime.utcnow()
|
| 427 |
+
if data.get('created_at') is None:
|
| 428 |
+
data['created_at'] = datetime.utcnow()
|
| 429 |
+
if data.get('usage_value') is not None:
|
| 430 |
+
data['usage_value'] = float(data['usage_value'])
|
| 431 |
+
return data
|
| 432 |
+
|
database/models_backup.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data Models for DetectifAI Database Integration
|
| 3 |
+
|
| 4 |
+
This module defines data models that map EXACTLY to the MongoDB collections
|
| 5 |
+
defined in DetectifAI_db/database_setup.py schema.
|
| 6 |
+
|
| 7 |
+
CRITICAL: Only use fields defined in the MongoDB schema validators.
|
| 8 |
+
Extra fields must go in meta_data for video_file or use related collections.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from typing import List, Dict, Any, Optional
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from bson import ObjectId
|
| 14 |
+
from dataclasses import dataclass, asdict
|
| 15 |
+
import json
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class VideoFileModel:
|
| 20 |
+
"""Maps EXACTLY to video_file collection schema in MongoDB Atlas"""
|
| 21 |
+
# Required fields (from schema)
|
| 22 |
+
video_id: str
|
| 23 |
+
user_id: str
|
| 24 |
+
file_path: str # MinIO path or local path
|
| 25 |
+
|
| 26 |
+
# Optional fields (from schema)
|
| 27 |
+
minio_object_key: Optional[str] = None
|
| 28 |
+
minio_bucket: Optional[str] = None
|
| 29 |
+
codec: Optional[str] = None
|
| 30 |
+
fps: Optional[float] = 30.0 # bsonType: double - must be float
|
| 31 |
+
upload_date: Optional[datetime] = None
|
| 32 |
+
duration_secs: Optional[int] = None # bsonType: int - must be INTEGER not float
|
| 33 |
+
file_size_bytes: Optional[int] = None # bsonType: long
|
| 34 |
+
meta_data: Optional[Dict] = None # Store ALL extra fields here (processing_status, resolution, etc.)
|
| 35 |
+
|
| 36 |
+
_id: Optional[ObjectId] = None
|
| 37 |
+
|
| 38 |
+
def to_dict(self) -> Dict:
|
| 39 |
+
"""Convert to dictionary for MongoDB insertion with proper type conversion"""
|
| 40 |
+
data = asdict(self)
|
| 41 |
+
|
| 42 |
+
# Set defaults
|
| 43 |
+
if data.get('upload_date') is None:
|
| 44 |
+
data['upload_date'] = datetime.utcnow()
|
| 45 |
+
if data.get('fps') is None:
|
| 46 |
+
data['fps'] = 30.0
|
| 47 |
+
|
| 48 |
+
# Ensure duration is integer (MongoDB schema requires int)
|
| 49 |
+
if data.get('duration_secs') is not None:
|
| 50 |
+
data['duration_secs'] = int(data['duration_secs'])
|
| 51 |
+
|
| 52 |
+
# Ensure file_size is integer (MongoDB schema requires long)
|
| 53 |
+
if data.get('file_size_bytes') is not None:
|
| 54 |
+
data['file_size_bytes'] = int(data['file_size_bytes'])
|
| 55 |
+
|
| 56 |
+
# Ensure fps is float (MongoDB schema requires double)
|
| 57 |
+
if data.get('fps') is not None:
|
| 58 |
+
data['fps'] = float(data['fps'])
|
| 59 |
+
|
| 60 |
+
return data
|
| 61 |
+
|
| 62 |
+
@dataclass
|
| 63 |
+
class DetectedFaceModel:
|
| 64 |
+
"""Maps to existing detected_faces collection"""
|
| 65 |
+
video_id: str
|
| 66 |
+
frame_timestamp: float
|
| 67 |
+
face_bbox: List[float] # [x1, y1, x2, y2]
|
| 68 |
+
confidence: float
|
| 69 |
+
face_encoding: Optional[List[float]] = None
|
| 70 |
+
keyframe_minio_path: Optional[str] = None
|
| 71 |
+
keyframe_id: Optional[ObjectId] = None
|
| 72 |
+
person_id: Optional[str] = None
|
| 73 |
+
is_suspicious: bool = False
|
| 74 |
+
_id: Optional[ObjectId] = None
|
| 75 |
+
|
| 76 |
+
def to_dict(self) -> Dict:
|
| 77 |
+
return asdict(self)
|
| 78 |
+
|
| 79 |
+
@dataclass
|
| 80 |
+
class EventModel:
|
| 81 |
+
"""Maps EXACTLY to event collection schema in MongoDB Atlas"""
|
| 82 |
+
# Required fields (from schema)
|
| 83 |
+
event_id: str
|
| 84 |
+
video_id: str
|
| 85 |
+
start_timestamp_ms: int # bsonType: long - MUST be milliseconds as INTEGER
|
| 86 |
+
end_timestamp_ms: int # bsonType: long - MUST be milliseconds as INTEGER
|
| 87 |
+
|
| 88 |
+
# Optional fields (from schema)
|
| 89 |
+
event_type: Optional[str] = None # 'object_detection', 'motion', 'fire', 'weapon', etc.
|
| 90 |
+
confidence_score: Optional[float] = None # bsonType: double (NOT 'confidence')
|
| 91 |
+
is_verified: bool = False
|
| 92 |
+
is_false_positive: bool = False
|
| 93 |
+
verified_at: Optional[datetime] = None
|
| 94 |
+
verified_by: Optional[str] = None
|
| 95 |
+
visual_embedding: Optional[List[float]] = None # For future FAISS integration
|
| 96 |
+
bounding_boxes: Optional[Dict] = None # Store detection bboxes here as object
|
| 97 |
+
|
| 98 |
+
_id: Optional[ObjectId] = None
|
| 99 |
+
|
| 100 |
+
def to_dict(self) -> Dict:
|
| 101 |
+
"""Convert to dictionary for MongoDB insertion with proper type conversion"""
|
| 102 |
+
data = asdict(self)
|
| 103 |
+
|
| 104 |
+
# Ensure timestamps are integers (milliseconds) - CRITICAL for MongoDB long type
|
| 105 |
+
data['start_timestamp_ms'] = int(data['start_timestamp_ms'])
|
| 106 |
+
data['end_timestamp_ms'] = int(data['end_timestamp_ms'])
|
| 107 |
+
|
| 108 |
+
# Ensure confidence_score is float
|
| 109 |
+
if data.get('confidence_score') is not None:
|
| 110 |
+
data['confidence_score'] = float(data['confidence_score'])
|
| 111 |
+
|
| 112 |
+
# Set default empty arrays/objects for schema compliance
|
| 113 |
+
if data.get('visual_embedding') is None:
|
| 114 |
+
data['visual_embedding'] = []
|
| 115 |
+
if data.get('bounding_boxes') is None:
|
| 116 |
+
data['bounding_boxes'] = {}
|
| 117 |
+
|
| 118 |
+
return data
|
| 119 |
+
|
| 120 |
+
@dataclass
|
| 121 |
+
class EventCaptionModel:
|
| 122 |
+
"""Maps to existing event_caption collection"""
|
| 123 |
+
event_id: ObjectId
|
| 124 |
+
video_id: str
|
| 125 |
+
caption_text: str
|
| 126 |
+
generated_by: str = "system" # system, user, ai
|
| 127 |
+
confidence: Optional[float] = None
|
| 128 |
+
created_at: Optional[datetime] = None
|
| 129 |
+
_id: Optional[ObjectId] = None
|
| 130 |
+
|
| 131 |
+
def to_dict(self) -> Dict:
|
| 132 |
+
data = asdict(self)
|
| 133 |
+
if data.get('created_at') is None:
|
| 134 |
+
data['created_at'] = datetime.utcnow()
|
| 135 |
+
return data
|
| 136 |
+
|
| 137 |
+
@dataclass
|
| 138 |
+
class EventClipModel:
|
| 139 |
+
"""Maps to existing event_clip collection"""
|
| 140 |
+
event_id: ObjectId
|
| 141 |
+
video_id: str
|
| 142 |
+
clip_start_timestamp: float
|
| 143 |
+
clip_end_timestamp: float
|
| 144 |
+
minio_clip_path: str
|
| 145 |
+
clip_duration: float
|
| 146 |
+
frame_count: int
|
| 147 |
+
created_at: Optional[datetime] = None
|
| 148 |
+
_id: Optional[ObjectId] = None
|
| 149 |
+
|
| 150 |
+
def to_dict(self) -> Dict:
|
| 151 |
+
data = asdict(self)
|
| 152 |
+
if data.get('created_at') is None:
|
| 153 |
+
data['created_at'] = datetime.utcnow()
|
| 154 |
+
return data
|
| 155 |
+
|
| 156 |
+
@dataclass
|
| 157 |
+
class EventDescriptionModel:
|
| 158 |
+
"""Maps to existing event_description collection"""
|
| 159 |
+
event_id: ObjectId
|
| 160 |
+
video_id: str
|
| 161 |
+
description_text: str
|
| 162 |
+
description_type: str = "automatic" # automatic, manual, ai_generated
|
| 163 |
+
tags: Optional[List[str]] = None
|
| 164 |
+
created_at: Optional[datetime] = None
|
| 165 |
+
_id: Optional[ObjectId] = None
|
| 166 |
+
|
| 167 |
+
def to_dict(self) -> Dict:
|
| 168 |
+
data = asdict(self)
|
| 169 |
+
if data.get('created_at') is None:
|
| 170 |
+
data['created_at'] = datetime.utcnow()
|
| 171 |
+
return data
|
| 172 |
+
|
| 173 |
+
@dataclass
|
| 174 |
+
class FaceMatchModel:
|
| 175 |
+
"""Maps to existing face_matches collection"""
|
| 176 |
+
video_id: str
|
| 177 |
+
face_1_id: ObjectId
|
| 178 |
+
face_2_id: ObjectId
|
| 179 |
+
similarity_score: float
|
| 180 |
+
match_confidence: float
|
| 181 |
+
is_match: bool
|
| 182 |
+
person_id: Optional[str] = None
|
| 183 |
+
created_at: Optional[datetime] = None
|
| 184 |
+
_id: Optional[ObjectId] = None
|
| 185 |
+
|
| 186 |
+
def to_dict(self) -> Dict:
|
| 187 |
+
data = asdict(self)
|
| 188 |
+
if data.get('created_at') is None:
|
| 189 |
+
data['created_at'] = datetime.utcnow()
|
| 190 |
+
return data
|
| 191 |
+
|
| 192 |
+
# New models for video processing pipeline
|
| 193 |
+
|
| 194 |
+
@dataclass
|
| 195 |
+
class KeyframeModel:
|
| 196 |
+
"""New collection for extracted keyframes"""
|
| 197 |
+
video_id: str
|
| 198 |
+
frame_number: int
|
| 199 |
+
timestamp: float
|
| 200 |
+
quality_score: float
|
| 201 |
+
motion_score: float
|
| 202 |
+
minio_path: str
|
| 203 |
+
enhancement_applied: bool = False
|
| 204 |
+
face_count: int = 0
|
| 205 |
+
object_detections: Optional[List[Dict]] = None
|
| 206 |
+
processing_metadata: Optional[Dict] = None
|
| 207 |
+
created_at: Optional[datetime] = None
|
| 208 |
+
_id: Optional[ObjectId] = None
|
| 209 |
+
|
| 210 |
+
def to_dict(self) -> Dict:
|
| 211 |
+
data = asdict(self)
|
| 212 |
+
if data.get('created_at') is None:
|
| 213 |
+
data['created_at'] = datetime.utcnow()
|
| 214 |
+
if data.get('object_detections') is None:
|
| 215 |
+
data['object_detections'] = []
|
| 216 |
+
return data
|
| 217 |
+
|
| 218 |
+
@dataclass
|
| 219 |
+
class VideoSegmentModel:
|
| 220 |
+
"""New collection for video segments"""
|
| 221 |
+
video_id: str
|
| 222 |
+
segment_id: int
|
| 223 |
+
start_timestamp: float
|
| 224 |
+
end_timestamp: float
|
| 225 |
+
duration: float
|
| 226 |
+
start_frame: int
|
| 227 |
+
end_frame: int
|
| 228 |
+
keyframe_ids: List[ObjectId]
|
| 229 |
+
activity_level: str # low, medium, high
|
| 230 |
+
motion_statistics: Optional[Dict] = None
|
| 231 |
+
segment_minio_path: Optional[str] = None
|
| 232 |
+
created_at: Optional[datetime] = None
|
| 233 |
+
_id: Optional[ObjectId] = None
|
| 234 |
+
|
| 235 |
+
def to_dict(self) -> Dict:
|
| 236 |
+
data = asdict(self)
|
| 237 |
+
if data.get('created_at') is None:
|
| 238 |
+
data['created_at'] = datetime.utcnow()
|
| 239 |
+
return data
|
| 240 |
+
|
| 241 |
+
@dataclass
|
| 242 |
+
class ProcessingJobModel:
|
| 243 |
+
"""New collection for tracking processing jobs"""
|
| 244 |
+
video_id: str
|
| 245 |
+
job_type: str = "complete_processing" # complete_processing, keyframe_extraction, object_detection
|
| 246 |
+
status: str = "queued" # queued, processing, completed, failed
|
| 247 |
+
progress: int = 0 # 0-100
|
| 248 |
+
message: str = ""
|
| 249 |
+
started_at: Optional[datetime] = None
|
| 250 |
+
completed_at: Optional[datetime] = None
|
| 251 |
+
processing_stats: Optional[Dict] = None
|
| 252 |
+
error_details: Optional[Dict] = None
|
| 253 |
+
created_at: Optional[datetime] = None
|
| 254 |
+
_id: Optional[ObjectId] = None
|
| 255 |
+
|
| 256 |
+
def to_dict(self) -> Dict:
|
| 257 |
+
data = asdict(self)
|
| 258 |
+
if data.get('created_at') is None:
|
| 259 |
+
data['created_at'] = datetime.utcnow()
|
| 260 |
+
return data
|
| 261 |
+
|
| 262 |
+
@dataclass
|
| 263 |
+
class ObjectDetectionModel:
|
| 264 |
+
"""Detailed object detection results"""
|
| 265 |
+
video_id: str
|
| 266 |
+
keyframe_id: ObjectId
|
| 267 |
+
detection_id: str
|
| 268 |
+
class_name: str # fire, smoke, knife, gun
|
| 269 |
+
confidence: float
|
| 270 |
+
bbox: List[float] # [x1, y1, x2, y2]
|
| 271 |
+
center_point: List[float] # [x, y]
|
| 272 |
+
area: float
|
| 273 |
+
frame_timestamp: float
|
| 274 |
+
detection_model: str # 'fire' for fire_YOLO11.pt, 'weapon' for weapon_YOLO11.pt
|
| 275 |
+
threat_level: str = "low"
|
| 276 |
+
created_at: Optional[datetime] = None
|
| 277 |
+
_id: Optional[ObjectId] = None
|
| 278 |
+
|
| 279 |
+
def to_dict(self) -> Dict:
|
| 280 |
+
data = asdict(self)
|
| 281 |
+
if data.get('created_at') is None:
|
| 282 |
+
data['created_at'] = datetime.utcnow()
|
| 283 |
+
return data
|
| 284 |
+
|
| 285 |
+
class ModelFactory:
|
| 286 |
+
"""Factory class for creating model instances from database documents"""
|
| 287 |
+
|
| 288 |
+
@staticmethod
|
| 289 |
+
def create_video_file(doc: Dict) -> VideoFileModel:
|
| 290 |
+
"""Create VideoFileModel from MongoDB document"""
|
| 291 |
+
return VideoFileModel(**doc)
|
| 292 |
+
|
| 293 |
+
@staticmethod
|
| 294 |
+
def create_keyframe(doc: Dict) -> KeyframeModel:
|
| 295 |
+
"""Create KeyframeModel from MongoDB document"""
|
| 296 |
+
return KeyframeModel(**doc)
|
| 297 |
+
|
| 298 |
+
@staticmethod
|
| 299 |
+
def create_event(doc: Dict) -> EventModel:
|
| 300 |
+
"""Create EventModel from MongoDB document"""
|
| 301 |
+
return EventModel(**doc)
|
| 302 |
+
|
| 303 |
+
@staticmethod
|
| 304 |
+
def create_processing_job(doc: Dict) -> ProcessingJobModel:
|
| 305 |
+
"""Create ProcessingJobModel from MongoDB document"""
|
| 306 |
+
return ProcessingJobModel(**doc)
|
| 307 |
+
|
| 308 |
+
# Helper functions for database operations
|
| 309 |
+
|
| 310 |
+
def prepare_for_mongodb(data: Dict) -> Dict:
|
| 311 |
+
"""Prepare data dictionary for MongoDB insertion"""
|
| 312 |
+
# Remove None ObjectId fields
|
| 313 |
+
cleaned_data = {}
|
| 314 |
+
for key, value in data.items():
|
| 315 |
+
if key == '_id' and value is None:
|
| 316 |
+
continue
|
| 317 |
+
cleaned_data[key] = value
|
| 318 |
+
return cleaned_data
|
| 319 |
+
|
| 320 |
+
def convert_objectid_to_string(doc: Dict) -> Dict:
|
| 321 |
+
"""Convert ObjectId fields to strings for JSON serialization"""
|
| 322 |
+
if isinstance(doc, dict):
|
| 323 |
+
for key, value in doc.items():
|
| 324 |
+
if isinstance(value, ObjectId):
|
| 325 |
+
doc[key] = str(value)
|
| 326 |
+
elif isinstance(value, list):
|
| 327 |
+
doc[key] = [convert_objectid_to_string(item) if isinstance(item, dict) else str(item) if isinstance(item, ObjectId) else item for item in value]
|
| 328 |
+
elif isinstance(value, dict):
|
| 329 |
+
doc[key] = convert_objectid_to_string(value)
|
| 330 |
+
return doc
|
database/repositories.py
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Repository Classes for DetectifAI Database Operations
|
| 3 |
+
|
| 4 |
+
This module provides data access layer for MongoDB and MinIO operations.
|
| 5 |
+
Each repository handles CRUD operations for specific collections.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import io
|
| 10 |
+
from typing import List, Dict, Any, Optional
|
| 11 |
+
from datetime import datetime, timedelta
|
| 12 |
+
from bson import ObjectId
|
| 13 |
+
from pymongo.collection import Collection
|
| 14 |
+
from minio import Minio
|
| 15 |
+
from minio.error import S3Error
|
| 16 |
+
import logging
|
| 17 |
+
|
| 18 |
+
from .models import (
|
| 19 |
+
VideoFileModel, EventModel, EventDescriptionModel, DetectedFaceModel,
|
| 20 |
+
prepare_for_mongodb, convert_objectid_to_string, convert_numpy_types,
|
| 21 |
+
seconds_to_milliseconds
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
class BaseRepository:
|
| 27 |
+
"""Base repository class with common functionality"""
|
| 28 |
+
|
| 29 |
+
def __init__(self, db_manager):
|
| 30 |
+
self.db = db_manager.db
|
| 31 |
+
self._db_manager = db_manager
|
| 32 |
+
self.video_bucket = db_manager.config.minio_video_bucket
|
| 33 |
+
self.keyframe_bucket = db_manager.config.minio_keyframe_bucket
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def minio(self):
|
| 37 |
+
"""Lazy access to S3 storage — tolerates unavailable storage"""
|
| 38 |
+
return self._db_manager.minio_client
|
| 39 |
+
|
| 40 |
+
class VideoRepository(BaseRepository):
|
| 41 |
+
"""Repository for video_file collection operations"""
|
| 42 |
+
|
| 43 |
+
def __init__(self, db_manager):
|
| 44 |
+
super().__init__(db_manager)
|
| 45 |
+
self.collection = self.db.video_file
|
| 46 |
+
|
| 47 |
+
def create_video_record(self, video_data: Dict) -> str:
|
| 48 |
+
"""Create new video record matching MongoDB schema exactly"""
|
| 49 |
+
try:
|
| 50 |
+
# Extract required fields
|
| 51 |
+
video_id = video_data.get('video_id')
|
| 52 |
+
user_id = video_data.get('user_id', 'system')
|
| 53 |
+
file_path = video_data.get('file_path', f"videos/{video_id}.mp4")
|
| 54 |
+
|
| 55 |
+
# Build schema-compliant record
|
| 56 |
+
record = {
|
| 57 |
+
"video_id": video_id,
|
| 58 |
+
"user_id": user_id,
|
| 59 |
+
"file_path": file_path,
|
| 60 |
+
"upload_date": datetime.utcnow()
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
# Add optional schema fields
|
| 64 |
+
if 'fps' in video_data:
|
| 65 |
+
record['fps'] = float(video_data['fps']) # Ensure double type
|
| 66 |
+
else:
|
| 67 |
+
record['fps'] = 30.0 # Default
|
| 68 |
+
|
| 69 |
+
if 'duration' in video_data or 'duration_secs' in video_data:
|
| 70 |
+
duration = video_data.get('duration_secs') or video_data.get('duration', 0)
|
| 71 |
+
record['duration_secs'] = int(duration) # Ensure integer
|
| 72 |
+
|
| 73 |
+
if 'file_size' in video_data or 'file_size_bytes' in video_data:
|
| 74 |
+
file_size = video_data.get('file_size_bytes') or video_data.get('file_size', 0)
|
| 75 |
+
record['file_size_bytes'] = int(file_size) # Ensure long
|
| 76 |
+
|
| 77 |
+
if 'codec' in video_data:
|
| 78 |
+
record['codec'] = str(video_data['codec'])
|
| 79 |
+
|
| 80 |
+
if 'minio_object_key' in video_data:
|
| 81 |
+
record['minio_object_key'] = video_data['minio_object_key']
|
| 82 |
+
|
| 83 |
+
if 'minio_bucket' in video_data:
|
| 84 |
+
record['minio_bucket'] = video_data['minio_bucket']
|
| 85 |
+
|
| 86 |
+
# Build meta_data object for extra fields
|
| 87 |
+
meta_data = {}
|
| 88 |
+
extra_fields = [
|
| 89 |
+
'processing_status', 'resolution', 'filename', 'keyframe_count',
|
| 90 |
+
'event_count', 'compression_applied', 'enhancement_applied',
|
| 91 |
+
'error_message', 'processing_config'
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
+
for field in extra_fields:
|
| 95 |
+
if field in video_data:
|
| 96 |
+
meta_data[field] = video_data[field]
|
| 97 |
+
|
| 98 |
+
if meta_data:
|
| 99 |
+
record['meta_data'] = meta_data
|
| 100 |
+
|
| 101 |
+
# Convert numpy types and prepare for MongoDB
|
| 102 |
+
record = prepare_for_mongodb(record)
|
| 103 |
+
|
| 104 |
+
result = self.collection.insert_one(record)
|
| 105 |
+
logger.info(f"✅ Created video record: {video_id}")
|
| 106 |
+
return str(result.inserted_id)
|
| 107 |
+
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error(f"❌ Failed to create video record: {e}")
|
| 110 |
+
raise
|
| 111 |
+
|
| 112 |
+
def get_video_by_id(self, video_id: str) -> Optional[Dict]:
|
| 113 |
+
"""Get video record by video_id"""
|
| 114 |
+
try:
|
| 115 |
+
doc = self.collection.find_one({"video_id": video_id})
|
| 116 |
+
if doc:
|
| 117 |
+
return convert_objectid_to_string(doc)
|
| 118 |
+
return None
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(f"❌ Failed to get video {video_id}: {e}")
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
def update_processing_status(self, video_id: str, status: str, metadata: Dict = None):
|
| 124 |
+
"""Update video processing status in meta_data field"""
|
| 125 |
+
try:
|
| 126 |
+
# Get current meta_data
|
| 127 |
+
video = self.collection.find_one({"video_id": video_id})
|
| 128 |
+
if not video:
|
| 129 |
+
logger.warning(f"⚠️ Video not found for status update: {video_id}")
|
| 130 |
+
return
|
| 131 |
+
|
| 132 |
+
current_meta = video.get('meta_data', {})
|
| 133 |
+
current_meta['processing_status'] = status
|
| 134 |
+
current_meta['last_updated'] = datetime.utcnow().isoformat()
|
| 135 |
+
|
| 136 |
+
# Add any additional metadata
|
| 137 |
+
if metadata:
|
| 138 |
+
current_meta.update(metadata)
|
| 139 |
+
|
| 140 |
+
result = self.collection.update_one(
|
| 141 |
+
{"video_id": video_id},
|
| 142 |
+
{"$set": {"meta_data": current_meta}}
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
if result.matched_count > 0:
|
| 146 |
+
logger.info(f"✅ Updated video status: {video_id} -> {status}")
|
| 147 |
+
else:
|
| 148 |
+
logger.warning(f"⚠️ Video not found for status update: {video_id}")
|
| 149 |
+
|
| 150 |
+
except Exception as e:
|
| 151 |
+
logger.error(f"❌ Failed to update video status: {e}")
|
| 152 |
+
raise
|
| 153 |
+
|
| 154 |
+
def update_metadata(self, video_id: str, metadata: Dict):
|
| 155 |
+
"""Update video meta_data field with processing information"""
|
| 156 |
+
try:
|
| 157 |
+
# Get current meta_data
|
| 158 |
+
video = self.collection.find_one({"video_id": video_id})
|
| 159 |
+
if not video:
|
| 160 |
+
logger.warning(f"⚠️ Video not found: {video_id}")
|
| 161 |
+
return
|
| 162 |
+
|
| 163 |
+
current_meta = video.get('meta_data', {})
|
| 164 |
+
current_meta.update(metadata)
|
| 165 |
+
|
| 166 |
+
result = self.collection.update_one(
|
| 167 |
+
{"video_id": video_id},
|
| 168 |
+
{"$set": {"meta_data": current_meta}}
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
logger.info(f"✅ Updated video metadata: {video_id}")
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
logger.error(f"❌ Failed to update video metadata: {e}")
|
| 175 |
+
raise
|
| 176 |
+
|
| 177 |
+
def upload_video_to_minio(self, local_path: str, video_id: str) -> str:
|
| 178 |
+
"""Upload video file to S3 storage"""
|
| 179 |
+
if self.minio is None:
|
| 180 |
+
logger.warning("S3 storage unavailable — skipping video upload to object storage")
|
| 181 |
+
return f"local://{local_path}"
|
| 182 |
+
try:
|
| 183 |
+
minio_path = f"original/{video_id}/video.mp4"
|
| 184 |
+
|
| 185 |
+
with open(local_path, 'rb') as file_data:
|
| 186 |
+
file_info = os.stat(local_path)
|
| 187 |
+
self.minio.put_object(
|
| 188 |
+
self.video_bucket,
|
| 189 |
+
minio_path,
|
| 190 |
+
file_data,
|
| 191 |
+
length=file_info.st_size,
|
| 192 |
+
content_type='video/mp4'
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
logger.info(f"✅ Uploaded video to S3: {minio_path}")
|
| 196 |
+
return minio_path
|
| 197 |
+
|
| 198 |
+
except Exception as e:
|
| 199 |
+
logger.error(f"❌ Failed to upload video to S3: {e}")
|
| 200 |
+
raise
|
| 201 |
+
|
| 202 |
+
def get_video_presigned_url(self, minio_path: str, expires: timedelta = timedelta(hours=1)) -> str:
|
| 203 |
+
"""Generate presigned URL for video access"""
|
| 204 |
+
if self.minio is None:
|
| 205 |
+
return None
|
| 206 |
+
try:
|
| 207 |
+
return self.minio.presigned_get_object(self.video_bucket, minio_path, expires=expires)
|
| 208 |
+
except S3Error as e:
|
| 209 |
+
logger.error(f"❌ Failed to generate presigned URL: {e}")
|
| 210 |
+
return None
|
| 211 |
+
|
| 212 |
+
def get_compressed_video_presigned_url(self, video_id: str, expires: timedelta = timedelta(hours=1)) -> str:
|
| 213 |
+
"""Generate presigned URL for compressed video access"""
|
| 214 |
+
if self.minio is None:
|
| 215 |
+
return None
|
| 216 |
+
try:
|
| 217 |
+
minio_path = f"compressed/{video_id}/video.mp4"
|
| 218 |
+
return self.minio.presigned_get_object(self.video_bucket, minio_path, expires=expires)
|
| 219 |
+
except S3Error as e:
|
| 220 |
+
logger.error(f"❌ Failed to generate presigned URL for compressed video: {e}")
|
| 221 |
+
return None
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ========================================
|
| 225 |
+
# Event Repository (Schema-Compliant)
|
| 226 |
+
# ========================================
|
| 227 |
+
|
| 228 |
+
class EventRepository(BaseRepository):
|
| 229 |
+
"""Repository for event collection operations - Schema Compliant"""
|
| 230 |
+
|
| 231 |
+
def __init__(self, db_manager):
|
| 232 |
+
super().__init__(db_manager)
|
| 233 |
+
self.collection = self.db.event
|
| 234 |
+
self.event_description_collection = self.db.event_description
|
| 235 |
+
|
| 236 |
+
def create_event(self, event_data: Dict) -> str:
|
| 237 |
+
"""Create event - alias for save_event for compatibility"""
|
| 238 |
+
return self.save_event(event_data)
|
| 239 |
+
|
| 240 |
+
def save_event(self, event_data: Dict) -> str:
|
| 241 |
+
"""Save event matching MongoDB schema exactly"""
|
| 242 |
+
try:
|
| 243 |
+
import uuid
|
| 244 |
+
|
| 245 |
+
# Extract required fields
|
| 246 |
+
event_id = event_data.get('event_id', str(uuid.uuid4()))
|
| 247 |
+
video_id = event_data.get('video_id', event_data.get('camera_id', 'unknown'))
|
| 248 |
+
|
| 249 |
+
# Convert timestamps: seconds (float) -> milliseconds (int)
|
| 250 |
+
start_time = event_data.get('start_timestamp', 0.0)
|
| 251 |
+
end_time = event_data.get('end_timestamp', 0.0)
|
| 252 |
+
start_timestamp_ms = seconds_to_milliseconds(start_time)
|
| 253 |
+
end_timestamp_ms = seconds_to_milliseconds(end_time)
|
| 254 |
+
|
| 255 |
+
# Build schema-compliant event document
|
| 256 |
+
event_doc = {
|
| 257 |
+
"event_id": event_id,
|
| 258 |
+
"video_id": video_id,
|
| 259 |
+
"start_timestamp_ms": int(start_timestamp_ms),
|
| 260 |
+
"end_timestamp_ms": int(end_timestamp_ms),
|
| 261 |
+
"event_type": event_data.get('event_type', 'motion'),
|
| 262 |
+
"confidence_score": float(event_data.get('confidence', 0.0)),
|
| 263 |
+
"is_verified": False,
|
| 264 |
+
"is_false_positive": False,
|
| 265 |
+
"verified_at": None,
|
| 266 |
+
"verified_by": None,
|
| 267 |
+
"visual_embedding": [],
|
| 268 |
+
"bounding_boxes": event_data.get('bounding_boxes', {})
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
# Convert numpy types
|
| 272 |
+
event_doc = convert_numpy_types(event_doc)
|
| 273 |
+
event_doc = prepare_for_mongodb(event_doc)
|
| 274 |
+
|
| 275 |
+
result = self.collection.insert_one(event_doc)
|
| 276 |
+
logger.info(f"✅ Saved event: {event_id} ({event_data.get('event_type')})")
|
| 277 |
+
|
| 278 |
+
# If there's additional description info, save to event_description
|
| 279 |
+
if event_data.get('description') or event_data.get('caption'):
|
| 280 |
+
self._save_event_description(event_id, event_data)
|
| 281 |
+
|
| 282 |
+
return str(result.inserted_id)
|
| 283 |
+
|
| 284 |
+
except Exception as e:
|
| 285 |
+
logger.error(f"❌ Failed to save event: {e}")
|
| 286 |
+
raise
|
| 287 |
+
|
| 288 |
+
def save_detection_events(self, video_id: str, detection_groups: List[Dict]) -> List[str]:
|
| 289 |
+
"""Save object detection events with proper schema compliance"""
|
| 290 |
+
event_ids = []
|
| 291 |
+
|
| 292 |
+
try:
|
| 293 |
+
for group in detection_groups:
|
| 294 |
+
# Build bounding_boxes object
|
| 295 |
+
bboxes = {
|
| 296 |
+
"detections": [
|
| 297 |
+
{
|
| 298 |
+
"class": det.get('class_name', ''),
|
| 299 |
+
"confidence": float(det.get('confidence', 0.0)),
|
| 300 |
+
"bbox": [float(x) for x in det.get('bbox', [0, 0, 0, 0])],
|
| 301 |
+
"timestamp": float(det.get('frame_timestamp', 0.0)),
|
| 302 |
+
"model": det.get('detection_model', '')
|
| 303 |
+
}
|
| 304 |
+
for det in group.get('detections', [])
|
| 305 |
+
]
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
event_data = {
|
| 309 |
+
"video_id": video_id,
|
| 310 |
+
"start_timestamp": group.get('start_timestamp', 0.0),
|
| 311 |
+
"end_timestamp": group.get('end_timestamp', 0.0),
|
| 312 |
+
"event_type": f"object_detection_{group.get('class', 'unknown')}",
|
| 313 |
+
"confidence": group.get('max_confidence', 0.0),
|
| 314 |
+
"bounding_boxes": bboxes,
|
| 315 |
+
"description": f"Detected {len(group.get('detections', []))} {group.get('class', 'object')}(s)"
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
event_id = self.save_event(event_data)
|
| 319 |
+
event_ids.append(event_id)
|
| 320 |
+
|
| 321 |
+
logger.info(f"✅ Saved {len(event_ids)} detection events for video {video_id}")
|
| 322 |
+
return event_ids
|
| 323 |
+
|
| 324 |
+
except Exception as e:
|
| 325 |
+
logger.error(f"❌ Failed to save detection events: {e}")
|
| 326 |
+
raise
|
| 327 |
+
|
| 328 |
+
def _save_event_description(self, event_id: str, event_data: Dict):
|
| 329 |
+
"""Save detailed event description to event_description collection.
|
| 330 |
+
|
| 331 |
+
Generates real text embeddings using SentenceTransformer (all-mpnet-base-v2)
|
| 332 |
+
for compatibility with NLP search in query_retreival.py.
|
| 333 |
+
"""
|
| 334 |
+
try:
|
| 335 |
+
import uuid
|
| 336 |
+
|
| 337 |
+
description_text = event_data.get('description') or event_data.get('caption', '')
|
| 338 |
+
|
| 339 |
+
if not description_text:
|
| 340 |
+
return
|
| 341 |
+
|
| 342 |
+
# Generate real text embedding for NLP search
|
| 343 |
+
text_embedding = self._generate_text_embedding(description_text)
|
| 344 |
+
|
| 345 |
+
description_doc = {
|
| 346 |
+
"description_id": str(uuid.uuid4()),
|
| 347 |
+
"event_id": event_id,
|
| 348 |
+
"caption": description_text,
|
| 349 |
+
"text_embedding": text_embedding,
|
| 350 |
+
"confidence": float(event_data.get('confidence', 0.0)),
|
| 351 |
+
"created_at": datetime.utcnow(),
|
| 352 |
+
"updated_at": datetime.utcnow()
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
description_doc = prepare_for_mongodb(description_doc)
|
| 356 |
+
self.event_description_collection.insert_one(description_doc)
|
| 357 |
+
logger.info(f"✅ Saved event description for {event_id} (embedding: {len(text_embedding)}-dim)")
|
| 358 |
+
|
| 359 |
+
except Exception as e:
|
| 360 |
+
logger.error(f"❌ Failed to save event description: {e}")
|
| 361 |
+
|
| 362 |
+
def _generate_text_embedding(self, text: str) -> list:
|
| 363 |
+
"""Generate text embedding using SentenceTransformer.
|
| 364 |
+
|
| 365 |
+
Lazy-loads the model on first call and caches it as a class attribute.
|
| 366 |
+
Uses all-mpnet-base-v2 (768-dim) for NLP search compatibility.
|
| 367 |
+
"""
|
| 368 |
+
# Lazy-load and cache the model at class level
|
| 369 |
+
if not hasattr(EventRepository, '_embedding_model'):
|
| 370 |
+
EventRepository._embedding_model = None
|
| 371 |
+
|
| 372 |
+
if EventRepository._embedding_model is None:
|
| 373 |
+
try:
|
| 374 |
+
from sentence_transformers import SentenceTransformer
|
| 375 |
+
EventRepository._embedding_model = SentenceTransformer('all-mpnet-base-v2')
|
| 376 |
+
logger.info("✅ Loaded SentenceTransformer (all-mpnet-base-v2) for event embeddings")
|
| 377 |
+
except Exception as e:
|
| 378 |
+
logger.error(f"Failed to load SentenceTransformer: {e}")
|
| 379 |
+
return []
|
| 380 |
+
|
| 381 |
+
try:
|
| 382 |
+
import numpy as np
|
| 383 |
+
embedding = EventRepository._embedding_model.encode(text, normalize_embeddings=True)
|
| 384 |
+
return embedding.astype(np.float32).tolist()
|
| 385 |
+
except Exception as e:
|
| 386 |
+
logger.error(f"Failed to generate text embedding: {e}")
|
| 387 |
+
return []
|
| 388 |
+
|
| 389 |
+
def get_events_by_video_id(self, video_id: str, event_type: str = None) -> List[Dict]:
|
| 390 |
+
"""Get events for a video with optional type filtering"""
|
| 391 |
+
try:
|
| 392 |
+
query = {"video_id": video_id}
|
| 393 |
+
if event_type:
|
| 394 |
+
query["event_type"] = event_type
|
| 395 |
+
|
| 396 |
+
events = list(self.collection.find(query).sort("start_timestamp_ms", 1))
|
| 397 |
+
|
| 398 |
+
# Convert ObjectIds to strings
|
| 399 |
+
for event in events:
|
| 400 |
+
event = convert_objectid_to_string(event)
|
| 401 |
+
|
| 402 |
+
return events
|
| 403 |
+
|
| 404 |
+
except Exception as e:
|
| 405 |
+
logger.error(f"❌ Failed to get events for video {video_id}: {e}")
|
| 406 |
+
return []
|
| 407 |
+
|
| 408 |
+
def mark_as_false_positive(self, event_id: str):
|
| 409 |
+
"""Mark event as false positive (for deduplication)"""
|
| 410 |
+
try:
|
| 411 |
+
self.collection.update_one(
|
| 412 |
+
{"event_id": event_id},
|
| 413 |
+
{"$set": {"is_false_positive": True}}
|
| 414 |
+
)
|
| 415 |
+
logger.info(f"✅ Marked event {event_id} as false positive")
|
| 416 |
+
except Exception as e:
|
| 417 |
+
logger.error(f"❌ Failed to mark event as false positive: {e}")
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
# ========================================
|
| 421 |
+
# Report Repository
|
| 422 |
+
# ========================================
|
| 423 |
+
|
| 424 |
+
class ReportRepository(BaseRepository):
|
| 425 |
+
"""Repository for report storage and retrieval operations"""
|
| 426 |
+
|
| 427 |
+
def __init__(self, db_manager):
|
| 428 |
+
super().__init__(db_manager)
|
| 429 |
+
self.reports_bucket = db_manager.config.minio_reports_bucket
|
| 430 |
+
|
| 431 |
+
def upload_report_to_minio(self, local_path: str, video_id: str, filename: str) -> str:
|
| 432 |
+
"""
|
| 433 |
+
Upload report file to S3 storage
|
| 434 |
+
|
| 435 |
+
Args:
|
| 436 |
+
local_path: Path to local report file
|
| 437 |
+
video_id: Video identifier
|
| 438 |
+
filename: Report filename (e.g., report_20260130_123456.html)
|
| 439 |
+
|
| 440 |
+
Returns:
|
| 441 |
+
S3 object path
|
| 442 |
+
"""
|
| 443 |
+
if self.minio is None:
|
| 444 |
+
logger.warning("S3 storage unavailable — skipping report upload to object storage")
|
| 445 |
+
return f"local://{local_path}"
|
| 446 |
+
try:
|
| 447 |
+
minio_path = f"reports/{video_id}/{filename}"
|
| 448 |
+
|
| 449 |
+
# Determine content type based on file extension
|
| 450 |
+
content_type = 'text/html' if filename.endswith('.html') else 'application/pdf'
|
| 451 |
+
|
| 452 |
+
with open(local_path, 'rb') as file_data:
|
| 453 |
+
file_info = os.stat(local_path)
|
| 454 |
+
self.minio.put_object(
|
| 455 |
+
self.reports_bucket,
|
| 456 |
+
minio_path,
|
| 457 |
+
file_data,
|
| 458 |
+
length=file_info.st_size,
|
| 459 |
+
content_type=content_type
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
logger.info(f"✅ Uploaded report to S3: {minio_path}")
|
| 463 |
+
return minio_path
|
| 464 |
+
|
| 465 |
+
except Exception as e:
|
| 466 |
+
logger.error(f"❌ Failed to upload report to S3: {e}")
|
| 467 |
+
raise
|
| 468 |
+
|
| 469 |
+
def get_report_presigned_url(self, video_id: str, filename: str, expires: timedelta = timedelta(hours=24)) -> str:
|
| 470 |
+
"""
|
| 471 |
+
Generate presigned URL for report access
|
| 472 |
+
"""
|
| 473 |
+
if self.minio is None:
|
| 474 |
+
return None
|
| 475 |
+
try:
|
| 476 |
+
minio_path = f"reports/{video_id}/{filename}"
|
| 477 |
+
url = self.minio.presigned_get_object(self.reports_bucket, minio_path, expires=expires)
|
| 478 |
+
logger.info(f"✅ Generated presigned URL for report: {filename}")
|
| 479 |
+
return url
|
| 480 |
+
except S3Error as e:
|
| 481 |
+
logger.error(f"❌ Failed to generate presigned URL for report: {e}")
|
| 482 |
+
return None
|
| 483 |
+
|
| 484 |
+
def list_reports_for_video(self, video_id: str) -> List[Dict[str, Any]]:
|
| 485 |
+
"""
|
| 486 |
+
List all reports for a video
|
| 487 |
+
"""
|
| 488 |
+
if self.minio is None:
|
| 489 |
+
return []
|
| 490 |
+
try:
|
| 491 |
+
prefix = f"reports/{video_id}/"
|
| 492 |
+
objects = self.minio.list_objects(self.reports_bucket, prefix=prefix, recursive=True)
|
| 493 |
+
|
| 494 |
+
reports = []
|
| 495 |
+
for obj in objects:
|
| 496 |
+
reports.append({
|
| 497 |
+
'filename': obj.object_name.split('/')[-1],
|
| 498 |
+
'path': obj.object_name,
|
| 499 |
+
'size': obj.size,
|
| 500 |
+
'last_modified': obj.last_modified,
|
| 501 |
+
'content_type': 'text/html' if obj.object_name.endswith('.html') else 'application/pdf'
|
| 502 |
+
})
|
| 503 |
+
|
| 504 |
+
logger.info(f"✅ Found {len(reports)} reports for video {video_id}")
|
| 505 |
+
return reports
|
| 506 |
+
|
| 507 |
+
except Exception as e:
|
| 508 |
+
logger.error(f"❌ Failed to list reports for video {video_id}: {e}")
|
| 509 |
+
return []
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
# Remove KeyframeRepository - collection doesn't exist in schema
|
| 513 |
+
# Remove ProcessingJobRepository - collection doesn't exist in schema
|
| 514 |
+
# Remove ObjectDetectionRepository - collection doesn't exist in schema
|
| 515 |
+
|
| 516 |
+
# Only VideoRepository, EventRepository, and ReportRepository are schema-compliant and remain above
|
database/repositories_old.py
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Repository Classes for DetectifAI Database Operations
|
| 3 |
+
|
| 4 |
+
This module provides data access layer for MongoDB and MinIO operations.
|
| 5 |
+
Each repository handles CRUD operations for specific collections.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import io
|
| 10 |
+
from typing import List, Dict, Any, Optional
|
| 11 |
+
from datetime import datetime, timedelta
|
| 12 |
+
from bson import ObjectId
|
| 13 |
+
from pymongo.collection import Collection
|
| 14 |
+
from minio import Minio
|
| 15 |
+
from minio.error import S3Error
|
| 16 |
+
import logging
|
| 17 |
+
|
| 18 |
+
from .models import (
|
| 19 |
+
VideoFileModel, EventModel, EventDescriptionModel, DetectedFaceModel,
|
| 20 |
+
prepare_for_mongodb, convert_objectid_to_string, convert_numpy_types,
|
| 21 |
+
seconds_to_milliseconds
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
class BaseRepository:
|
| 27 |
+
"""Base repository class with common functionality"""
|
| 28 |
+
|
| 29 |
+
def __init__(self, db_manager):
|
| 30 |
+
self.db = db_manager.db
|
| 31 |
+
self.minio = db_manager.minio_client
|
| 32 |
+
self.video_bucket = db_manager.config.minio_video_bucket
|
| 33 |
+
self.keyframe_bucket = db_manager.config.minio_keyframe_bucket
|
| 34 |
+
|
| 35 |
+
class VideoRepository(BaseRepository):
|
| 36 |
+
"""Repository for video_file collection operations"""
|
| 37 |
+
|
| 38 |
+
def __init__(self, db_manager):
|
| 39 |
+
super().__init__(db_manager)
|
| 40 |
+
self.collection = self.db.video_file
|
| 41 |
+
|
| 42 |
+
def create_video_record(self, video_data: Dict) -> str:
|
| 43 |
+
"""Create new video record matching MongoDB schema exactly"""
|
| 44 |
+
try:
|
| 45 |
+
# Extract required fields
|
| 46 |
+
video_id = video_data.get('video_id')
|
| 47 |
+
user_id = video_data.get('user_id', 'system')
|
| 48 |
+
file_path = video_data.get('file_path', f"videos/{video_id}.mp4")
|
| 49 |
+
|
| 50 |
+
# Build schema-compliant record
|
| 51 |
+
record = {
|
| 52 |
+
"video_id": video_id,
|
| 53 |
+
"user_id": user_id,
|
| 54 |
+
"file_path": file_path,
|
| 55 |
+
"upload_date": datetime.utcnow()
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# Add optional schema fields
|
| 59 |
+
if 'fps' in video_data:
|
| 60 |
+
record['fps'] = float(video_data['fps']) # Ensure double type
|
| 61 |
+
else:
|
| 62 |
+
record['fps'] = 30.0 # Default
|
| 63 |
+
|
| 64 |
+
if 'duration' in video_data or 'duration_secs' in video_data:
|
| 65 |
+
duration = video_data.get('duration_secs') or video_data.get('duration', 0)
|
| 66 |
+
record['duration_secs'] = int(duration) # Ensure integer
|
| 67 |
+
|
| 68 |
+
if 'file_size' in video_data or 'file_size_bytes' in video_data:
|
| 69 |
+
file_size = video_data.get('file_size_bytes') or video_data.get('file_size', 0)
|
| 70 |
+
record['file_size_bytes'] = int(file_size) # Ensure long
|
| 71 |
+
|
| 72 |
+
if 'codec' in video_data:
|
| 73 |
+
record['codec'] = str(video_data['codec'])
|
| 74 |
+
|
| 75 |
+
if 'minio_object_key' in video_data:
|
| 76 |
+
record['minio_object_key'] = video_data['minio_object_key']
|
| 77 |
+
|
| 78 |
+
if 'minio_bucket' in video_data:
|
| 79 |
+
record['minio_bucket'] = video_data['minio_bucket']
|
| 80 |
+
|
| 81 |
+
# Build meta_data object for extra fields
|
| 82 |
+
meta_data = {}
|
| 83 |
+
extra_fields = [
|
| 84 |
+
'processing_status', 'resolution', 'filename', 'keyframe_count',
|
| 85 |
+
'event_count', 'compression_applied', 'enhancement_applied',
|
| 86 |
+
'error_message', 'processing_config'
|
| 87 |
+
]
|
| 88 |
+
|
| 89 |
+
for field in extra_fields:
|
| 90 |
+
if field in video_data:
|
| 91 |
+
meta_data[field] = video_data[field]
|
| 92 |
+
|
| 93 |
+
if meta_data:
|
| 94 |
+
record['meta_data'] = meta_data
|
| 95 |
+
|
| 96 |
+
# Convert numpy types and prepare for MongoDB
|
| 97 |
+
record = prepare_for_mongodb(record)
|
| 98 |
+
|
| 99 |
+
result = self.collection.insert_one(record)
|
| 100 |
+
logger.info(f"✅ Created video record: {video_id}")
|
| 101 |
+
return str(result.inserted_id)
|
| 102 |
+
|
| 103 |
+
except Exception as e:
|
| 104 |
+
logger.error(f"❌ Failed to create video record: {e}")
|
| 105 |
+
raise
|
| 106 |
+
|
| 107 |
+
def get_video_by_id(self, video_id: str) -> Optional[Dict]:
|
| 108 |
+
"""Get video record by video_id"""
|
| 109 |
+
try:
|
| 110 |
+
doc = self.collection.find_one({"video_id": video_id})
|
| 111 |
+
if doc:
|
| 112 |
+
return convert_objectid_to_string(doc)
|
| 113 |
+
return None
|
| 114 |
+
except Exception as e:
|
| 115 |
+
logger.error(f"❌ Failed to get video {video_id}: {e}")
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
def update_processing_status(self, video_id: str, status: str, metadata: Dict = None):
|
| 119 |
+
"""Update video processing status in meta_data field"""
|
| 120 |
+
try:
|
| 121 |
+
# Get current meta_data
|
| 122 |
+
video = self.collection.find_one({"video_id": video_id})
|
| 123 |
+
if not video:
|
| 124 |
+
logger.warning(f"⚠️ Video not found for status update: {video_id}")
|
| 125 |
+
return
|
| 126 |
+
|
| 127 |
+
current_meta = video.get('meta_data', {})
|
| 128 |
+
current_meta['processing_status'] = status
|
| 129 |
+
current_meta['last_updated'] = datetime.utcnow().isoformat()
|
| 130 |
+
|
| 131 |
+
# Add any additional metadata
|
| 132 |
+
if metadata:
|
| 133 |
+
current_meta.update(metadata)
|
| 134 |
+
|
| 135 |
+
result = self.collection.update_one(
|
| 136 |
+
{"video_id": video_id},
|
| 137 |
+
{"$set": {"meta_data": current_meta}}
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
if result.matched_count > 0:
|
| 141 |
+
logger.info(f"✅ Updated video status: {video_id} -> {status}")
|
| 142 |
+
else:
|
| 143 |
+
logger.warning(f"⚠️ Video not found for status update: {video_id}")
|
| 144 |
+
|
| 145 |
+
except Exception as e:
|
| 146 |
+
logger.error(f"❌ Failed to update video status: {e}")
|
| 147 |
+
raise
|
| 148 |
+
|
| 149 |
+
def update_metadata(self, video_id: str, metadata: Dict):
|
| 150 |
+
"""Update video meta_data field with processing information"""
|
| 151 |
+
try:
|
| 152 |
+
# Get current meta_data
|
| 153 |
+
video = self.collection.find_one({"video_id": video_id})
|
| 154 |
+
if not video:
|
| 155 |
+
logger.warning(f"⚠️ Video not found: {video_id}")
|
| 156 |
+
return
|
| 157 |
+
|
| 158 |
+
current_meta = video.get('meta_data', {})
|
| 159 |
+
current_meta.update(metadata)
|
| 160 |
+
|
| 161 |
+
result = self.collection.update_one(
|
| 162 |
+
{"video_id": video_id},
|
| 163 |
+
{"$set": {"meta_data": current_meta}}
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
logger.info(f"✅ Updated video metadata: {video_id}")
|
| 167 |
+
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.error(f"❌ Failed to update video metadata: {e}")
|
| 170 |
+
raise
|
| 171 |
+
|
| 172 |
+
def upload_video_to_minio(self, local_path: str, video_id: str) -> str:
|
| 173 |
+
"""Upload video file to MinIO storage"""
|
| 174 |
+
try:
|
| 175 |
+
minio_path = f"original/{video_id}/video.mp4"
|
| 176 |
+
|
| 177 |
+
with open(local_path, 'rb') as file_data:
|
| 178 |
+
file_info = os.stat(local_path)
|
| 179 |
+
self.minio.put_object(
|
| 180 |
+
self.video_bucket,
|
| 181 |
+
minio_path,
|
| 182 |
+
file_data,
|
| 183 |
+
length=file_info.st_size,
|
| 184 |
+
content_type='video/mp4'
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
logger.info(f"✅ Uploaded video to MinIO: {minio_path}")
|
| 188 |
+
return minio_path
|
| 189 |
+
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logger.error(f"❌ Failed to upload video to MinIO: {e}")
|
| 192 |
+
raise
|
| 193 |
+
|
| 194 |
+
def get_video_presigned_url(self, minio_path: str, expires: timedelta = timedelta(hours=1)) -> str:
|
| 195 |
+
"""Generate presigned URL for video access"""
|
| 196 |
+
try:
|
| 197 |
+
return self.minio.presigned_get_object(self.video_bucket, minio_path, expires=expires)
|
| 198 |
+
except S3Error as e:
|
| 199 |
+
logger.error(f"❌ Failed to generate presigned URL: {e}")
|
| 200 |
+
return None
|
| 201 |
+
|
| 202 |
+
class KeyframeRepository(BaseRepository):
|
| 203 |
+
"""Repository for keyframes collection operations"""
|
| 204 |
+
|
| 205 |
+
def __init__(self, db_manager):
|
| 206 |
+
super().__init__(db_manager)
|
| 207 |
+
self.collection = self.db.keyframes
|
| 208 |
+
|
| 209 |
+
def save_keyframes_batch(self, video_id: str, keyframes_data: List[Dict]) -> List[str]:
|
| 210 |
+
"""Save multiple keyframes to MinIO and MongoDB"""
|
| 211 |
+
keyframe_ids = []
|
| 212 |
+
|
| 213 |
+
try:
|
| 214 |
+
for i, kf_data in enumerate(keyframes_data):
|
| 215 |
+
# Extract frame data from keyframe result
|
| 216 |
+
frame_data = kf_data.frame_data if hasattr(kf_data, 'frame_data') else kf_data
|
| 217 |
+
|
| 218 |
+
# Upload keyframe image to MinIO using correct bucket path structure
|
| 219 |
+
minio_path = f"{video_id}/frame_{frame_data['frame_number']:06d}.jpg"
|
| 220 |
+
|
| 221 |
+
# Handle both file path and frame data scenarios
|
| 222 |
+
if 'frame_path' in frame_data:
|
| 223 |
+
local_path = frame_data['frame_path']
|
| 224 |
+
if os.path.exists(local_path):
|
| 225 |
+
with open(local_path, 'rb') as img_file:
|
| 226 |
+
file_info = os.stat(local_path)
|
| 227 |
+
self.minio.put_object(
|
| 228 |
+
self.keyframe_bucket,
|
| 229 |
+
minio_path,
|
| 230 |
+
img_file,
|
| 231 |
+
length=file_info.st_size,
|
| 232 |
+
content_type='image/jpeg'
|
| 233 |
+
)
|
| 234 |
+
else:
|
| 235 |
+
logger.warning(f"⚠️ Keyframe file not found: {local_path}")
|
| 236 |
+
continue
|
| 237 |
+
|
| 238 |
+
# Create keyframe document
|
| 239 |
+
keyframe_doc = {
|
| 240 |
+
"video_id": video_id,
|
| 241 |
+
"frame_number": frame_data.get('frame_number', i),
|
| 242 |
+
"timestamp": frame_data.get('timestamp', 0.0),
|
| 243 |
+
"quality_score": frame_data.get('quality_score', 0.0),
|
| 244 |
+
"motion_score": frame_data.get('motion_score', 0.0),
|
| 245 |
+
"minio_path": minio_path,
|
| 246 |
+
"enhancement_applied": frame_data.get('enhancement_applied', False),
|
| 247 |
+
"face_count": frame_data.get('face_count', 0),
|
| 248 |
+
"object_detections": [],
|
| 249 |
+
"created_at": datetime.utcnow()
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
result = self.collection.insert_one(keyframe_doc)
|
| 253 |
+
keyframe_ids.append(str(result.inserted_id))
|
| 254 |
+
|
| 255 |
+
logger.info(f"✅ Saved {len(keyframe_ids)} keyframes for video {video_id}")
|
| 256 |
+
return keyframe_ids
|
| 257 |
+
|
| 258 |
+
except Exception as e:
|
| 259 |
+
logger.error(f"❌ Failed to save keyframes batch: {e}")
|
| 260 |
+
raise
|
| 261 |
+
|
| 262 |
+
def get_keyframes_by_video_id(self, video_id: str, has_detections: bool = False,
|
| 263 |
+
limit: int = None) -> List[Dict]:
|
| 264 |
+
"""Get keyframes for a video with optional filtering"""
|
| 265 |
+
try:
|
| 266 |
+
query = {"video_id": video_id}
|
| 267 |
+
|
| 268 |
+
if has_detections:
|
| 269 |
+
query["object_detections"] = {"$exists": True, "$not": {"$size": 0}}
|
| 270 |
+
|
| 271 |
+
cursor = self.collection.find(query).sort("timestamp", 1)
|
| 272 |
+
|
| 273 |
+
if limit:
|
| 274 |
+
cursor = cursor.limit(limit)
|
| 275 |
+
|
| 276 |
+
keyframes = list(cursor)
|
| 277 |
+
|
| 278 |
+
# Convert ObjectIds to strings and add presigned URLs
|
| 279 |
+
for kf in keyframes:
|
| 280 |
+
kf = convert_objectid_to_string(kf)
|
| 281 |
+
kf['presigned_url'] = self.minio.presigned_get_object(
|
| 282 |
+
self.bucket,
|
| 283 |
+
kf['minio_path'],
|
| 284 |
+
expires=timedelta(hours=1)
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
return keyframes
|
| 288 |
+
|
| 289 |
+
except Exception as e:
|
| 290 |
+
logger.error(f"❌ Failed to get keyframes for video {video_id}: {e}")
|
| 291 |
+
return []
|
| 292 |
+
|
| 293 |
+
def update_keyframe_detections(self, keyframe_id: str, detections: List[Dict]):
|
| 294 |
+
"""Update keyframe with object detection results"""
|
| 295 |
+
try:
|
| 296 |
+
self.collection.update_one(
|
| 297 |
+
{"_id": ObjectId(keyframe_id)},
|
| 298 |
+
{"$set": {
|
| 299 |
+
"object_detections": detections,
|
| 300 |
+
"updated_at": datetime.utcnow()
|
| 301 |
+
}}
|
| 302 |
+
)
|
| 303 |
+
logger.info(f"✅ Updated keyframe {keyframe_id} with {len(detections)} detections")
|
| 304 |
+
except Exception as e:
|
| 305 |
+
logger.error(f"❌ Failed to update keyframe detections: {e}")
|
| 306 |
+
|
| 307 |
+
class EventRepository(BaseRepository):
|
| 308 |
+
"""Repository for event collection operations - Schema Compliant"""
|
| 309 |
+
|
| 310 |
+
def __init__(self, db_manager):
|
| 311 |
+
super().__init__(db_manager)
|
| 312 |
+
self.collection = self.db.event
|
| 313 |
+
self.event_description_collection = self.db.event_description
|
| 314 |
+
|
| 315 |
+
def save_event(self, event_data: Dict) -> str:
|
| 316 |
+
"""Save event matching MongoDB schema exactly"""
|
| 317 |
+
try:
|
| 318 |
+
import uuid
|
| 319 |
+
|
| 320 |
+
# Extract required fields
|
| 321 |
+
event_id = event_data.get('event_id', str(uuid.uuid4()))
|
| 322 |
+
video_id = event_data['video_id']
|
| 323 |
+
|
| 324 |
+
# Convert timestamps: seconds (float) -> milliseconds (int)
|
| 325 |
+
start_time = event_data.get('start_timestamp', 0.0)
|
| 326 |
+
end_time = event_data.get('end_timestamp', 0.0)
|
| 327 |
+
start_timestamp_ms = seconds_to_milliseconds(start_time)
|
| 328 |
+
end_timestamp_ms = seconds_to_milliseconds(end_time)
|
| 329 |
+
|
| 330 |
+
# Build schema-compliant event document
|
| 331 |
+
event_doc = {
|
| 332 |
+
"event_id": event_id,
|
| 333 |
+
"video_id": video_id,
|
| 334 |
+
"start_timestamp_ms": int(start_timestamp_ms),
|
| 335 |
+
"end_timestamp_ms": int(end_timestamp_ms),
|
| 336 |
+
"event_type": event_data.get('event_type', 'motion'),
|
| 337 |
+
"confidence_score": float(event_data.get('confidence', 0.0)),
|
| 338 |
+
"is_verified": False,
|
| 339 |
+
"is_false_positive": False,
|
| 340 |
+
"verified_at": None,
|
| 341 |
+
"verified_by": None,
|
| 342 |
+
"visual_embedding": [],
|
| 343 |
+
"bounding_boxes": event_data.get('bounding_boxes', {})
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
# Convert numpy types
|
| 347 |
+
event_doc = convert_numpy_types(event_doc)
|
| 348 |
+
event_doc = prepare_for_mongodb(event_doc)
|
| 349 |
+
|
| 350 |
+
result = self.collection.insert_one(event_doc)
|
| 351 |
+
logger.info(f"✅ Saved event: {event_id} ({event_data.get('event_type')})")
|
| 352 |
+
|
| 353 |
+
# If there's additional description info, save to event_description
|
| 354 |
+
if event_data.get('description') or event_data.get('caption'):
|
| 355 |
+
self._save_event_description(event_id, event_data)
|
| 356 |
+
|
| 357 |
+
return str(result.inserted_id)
|
| 358 |
+
|
| 359 |
+
except Exception as e:
|
| 360 |
+
logger.error(f"❌ Failed to save event: {e}")
|
| 361 |
+
raise
|
| 362 |
+
|
| 363 |
+
def save_detection_events(self, video_id: str, detection_groups: List[Dict]) -> List[str]:
|
| 364 |
+
"""Save object detection events with proper schema compliance"""
|
| 365 |
+
event_ids = []
|
| 366 |
+
|
| 367 |
+
try:
|
| 368 |
+
for group in detection_groups:
|
| 369 |
+
# Build bounding_boxes object
|
| 370 |
+
bboxes = {
|
| 371 |
+
"detections": [
|
| 372 |
+
{
|
| 373 |
+
"class": det.get('class_name', ''),
|
| 374 |
+
"confidence": float(det.get('confidence', 0.0)),
|
| 375 |
+
"bbox": [float(x) for x in det.get('bbox', [0, 0, 0, 0])],
|
| 376 |
+
"timestamp": float(det.get('frame_timestamp', 0.0)),
|
| 377 |
+
"model": det.get('detection_model', '')
|
| 378 |
+
}
|
| 379 |
+
for det in group.get('detections', [])
|
| 380 |
+
]
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
event_data = {
|
| 384 |
+
"video_id": video_id,
|
| 385 |
+
"start_timestamp": group.get('start_timestamp', 0.0),
|
| 386 |
+
"end_timestamp": group.get('end_timestamp', 0.0),
|
| 387 |
+
"event_type": f"object_detection_{group.get('class', 'unknown')}",
|
| 388 |
+
"confidence": group.get('max_confidence', 0.0),
|
| 389 |
+
"bounding_boxes": bboxes,
|
| 390 |
+
"description": f"Detected {len(group.get('detections', []))} {group.get('class', 'object')}(s)"
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
event_id = self.save_event(event_data)
|
| 394 |
+
event_ids.append(event_id)
|
| 395 |
+
|
| 396 |
+
logger.info(f"✅ Saved {len(event_ids)} detection events for video {video_id}")
|
| 397 |
+
return event_ids
|
| 398 |
+
|
| 399 |
+
except Exception as e:
|
| 400 |
+
logger.error(f"❌ Failed to save detection events: {e}")
|
| 401 |
+
raise
|
| 402 |
+
|
| 403 |
+
def _save_event_description(self, event_id: str, event_data: Dict):
|
| 404 |
+
"""Save detailed event description to event_description collection"""
|
| 405 |
+
try:
|
| 406 |
+
import uuid
|
| 407 |
+
|
| 408 |
+
description_text = event_data.get('description') or event_data.get('caption', '')
|
| 409 |
+
|
| 410 |
+
if not description_text:
|
| 411 |
+
return
|
| 412 |
+
|
| 413 |
+
description_doc = {
|
| 414 |
+
"description_id": str(uuid.uuid4()),
|
| 415 |
+
"event_id": event_id,
|
| 416 |
+
"caption": description_text,
|
| 417 |
+
"text_embedding": [], # TODO: Generate embedding in future
|
| 418 |
+
"confidence": float(event_data.get('confidence', 0.0)),
|
| 419 |
+
"created_at": datetime.utcnow(),
|
| 420 |
+
"updated_at": datetime.utcnow()
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
description_doc = prepare_for_mongodb(description_doc)
|
| 424 |
+
self.event_description_collection.insert_one(description_doc)
|
| 425 |
+
logger.info(f"✅ Saved event description for {event_id}")
|
| 426 |
+
|
| 427 |
+
except Exception as e:
|
| 428 |
+
logger.error(f"❌ Failed to save event description: {e}")
|
| 429 |
+
|
| 430 |
+
def get_events_by_video_id(self, video_id: str, event_type: str = None) -> List[Dict]:
|
| 431 |
+
"""Get events for a video with optional type filtering"""
|
| 432 |
+
try:
|
| 433 |
+
query = {"video_id": video_id}
|
| 434 |
+
if event_type:
|
| 435 |
+
query["event_type"] = event_type
|
| 436 |
+
|
| 437 |
+
events = list(self.collection.find(query).sort("start_timestamp_ms", 1))
|
| 438 |
+
|
| 439 |
+
# Convert ObjectIds to strings
|
| 440 |
+
for event in events:
|
| 441 |
+
event = convert_objectid_to_string(event)
|
| 442 |
+
|
| 443 |
+
return events
|
| 444 |
+
|
| 445 |
+
except Exception as e:
|
| 446 |
+
logger.error(f"❌ Failed to get events for video {video_id}: {e}")
|
| 447 |
+
return []
|
| 448 |
+
|
| 449 |
+
def mark_as_false_positive(self, event_id: str):
|
| 450 |
+
"""Mark event as false positive (for deduplication)"""
|
| 451 |
+
try:
|
| 452 |
+
self.collection.update_one(
|
| 453 |
+
{"event_id": event_id},
|
| 454 |
+
{"$set": {"is_false_positive": True}}
|
| 455 |
+
)
|
| 456 |
+
logger.info(f"✅ Marked event {event_id} as false positive")
|
| 457 |
+
except Exception as e:
|
| 458 |
+
logger.error(f"❌ Failed to mark event as false positive: {e}")
|
| 459 |
+
|
| 460 |
+
# Remove KeyframeRepository - collection doesn't exist in schema
|
| 461 |
+
# Remove ProcessingJobRepository - collection doesn't exist in schema
|
| 462 |
+
# Remove ObjectDetectionRepository - collection doesn't exist in schema
|
| 463 |
+
|
| 464 |
+
# Keeping only repositories for schema-defined collections below:
|
| 465 |
+
|
| 466 |
+
event_ids = []
|
| 467 |
+
|
| 468 |
+
try:
|
| 469 |
+
for event_data in detection_events:
|
| 470 |
+
# Calculate threat level based on detected objects
|
| 471 |
+
threat_level = self._calculate_threat_level(event_data.get('object_class', ''))
|
| 472 |
+
|
| 473 |
+
event_doc = {
|
| 474 |
+
"video_id": video_id,
|
| 475 |
+
"event_type": "object_detection",
|
| 476 |
+
"start_timestamp": event_data.get('start_timestamp', 0.0),
|
| 477 |
+
"end_timestamp": event_data.get('end_timestamp', 0.0),
|
| 478 |
+
"confidence": event_data.get('confidence', 0.0),
|
| 479 |
+
"importance_score": event_data.get('importance_score', 0.0),
|
| 480 |
+
"threat_level": threat_level,
|
| 481 |
+
"object_detections": event_data.get('detections', []),
|
| 482 |
+
"keyframe_paths": event_data.get('keyframe_paths', []),
|
| 483 |
+
"is_canonical": False,
|
| 484 |
+
"created_at": datetime.utcnow()
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
result = self.collection.insert_one(event_doc)
|
| 488 |
+
event_ids.append(str(result.inserted_id))
|
| 489 |
+
|
| 490 |
+
logger.info(f"✅ Saved {len(event_ids)} object detection events for video {video_id}")
|
| 491 |
+
return event_ids
|
| 492 |
+
|
| 493 |
+
except Exception as e:
|
| 494 |
+
logger.error(f"❌ Failed to save object detection events: {e}")
|
| 495 |
+
raise
|
| 496 |
+
|
| 497 |
+
def get_events_by_video_id(self, video_id: str, event_type: str = None) -> List[Dict]:
|
| 498 |
+
"""Get events for a video with optional type filtering"""
|
| 499 |
+
try:
|
| 500 |
+
query = {"video_id": video_id}
|
| 501 |
+
if event_type:
|
| 502 |
+
query["event_type"] = event_type
|
| 503 |
+
|
| 504 |
+
events = list(self.collection.find(query).sort("start_timestamp", 1))
|
| 505 |
+
|
| 506 |
+
# Convert ObjectIds to strings
|
| 507 |
+
for event in events:
|
| 508 |
+
event = convert_objectid_to_string(event)
|
| 509 |
+
|
| 510 |
+
return events
|
| 511 |
+
|
| 512 |
+
except Exception as e:
|
| 513 |
+
logger.error(f"❌ Failed to get events for video {video_id}: {e}")
|
| 514 |
+
return []
|
| 515 |
+
|
| 516 |
+
def _calculate_threat_level(self, object_class: str) -> str:
|
| 517 |
+
"""Calculate threat level based on detected object class"""
|
| 518 |
+
threat_map = {
|
| 519 |
+
'fire': 'critical',
|
| 520 |
+
'gun': 'critical',
|
| 521 |
+
'knife': 'high',
|
| 522 |
+
'smoke': 'medium'
|
| 523 |
+
}
|
| 524 |
+
return threat_map.get(object_class.lower(), 'low')
|
| 525 |
+
|
| 526 |
+
class ProcessingJobRepository(BaseRepository):
|
| 527 |
+
"""Repository for processing_jobs collection operations"""
|
| 528 |
+
|
| 529 |
+
def __init__(self, db_manager):
|
| 530 |
+
super().__init__(db_manager)
|
| 531 |
+
self.collection = self.db.processing_jobs
|
| 532 |
+
|
| 533 |
+
def create_processing_job(self, video_id: str, job_type: str = "complete_processing") -> str:
|
| 534 |
+
"""Create new processing job record"""
|
| 535 |
+
try:
|
| 536 |
+
job_doc = {
|
| 537 |
+
"video_id": video_id,
|
| 538 |
+
"job_type": job_type,
|
| 539 |
+
"status": "queued",
|
| 540 |
+
"progress": 0,
|
| 541 |
+
"message": "Processing job queued",
|
| 542 |
+
"created_at": datetime.utcnow()
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
result = self.collection.insert_one(job_doc)
|
| 546 |
+
logger.info(f"✅ Created processing job: {video_id}")
|
| 547 |
+
return str(result.inserted_id)
|
| 548 |
+
|
| 549 |
+
except Exception as e:
|
| 550 |
+
logger.error(f"❌ Failed to create processing job: {e}")
|
| 551 |
+
raise
|
| 552 |
+
|
| 553 |
+
def update_job_progress(self, video_id: str, progress: int, message: str, status: str = None):
|
| 554 |
+
"""Update processing job progress and status"""
|
| 555 |
+
try:
|
| 556 |
+
update_data = {
|
| 557 |
+
"progress": progress,
|
| 558 |
+
"message": message,
|
| 559 |
+
"updated_at": datetime.utcnow()
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
if status:
|
| 563 |
+
update_data["status"] = status
|
| 564 |
+
if status == "processing" and not self.collection.find_one({"video_id": video_id, "started_at": {"$exists": True}}):
|
| 565 |
+
update_data["started_at"] = datetime.utcnow()
|
| 566 |
+
elif status in ["completed", "failed"]:
|
| 567 |
+
update_data["completed_at"] = datetime.utcnow()
|
| 568 |
+
|
| 569 |
+
self.collection.update_one(
|
| 570 |
+
{"video_id": video_id},
|
| 571 |
+
{"$set": update_data}
|
| 572 |
+
)
|
| 573 |
+
|
| 574 |
+
except Exception as e:
|
| 575 |
+
logger.error(f"❌ Failed to update job progress: {e}")
|
| 576 |
+
|
| 577 |
+
def get_job_status(self, video_id: str) -> Optional[Dict]:
|
| 578 |
+
"""Get processing job status"""
|
| 579 |
+
try:
|
| 580 |
+
job = self.collection.find_one({"video_id": video_id})
|
| 581 |
+
if job:
|
| 582 |
+
return convert_objectid_to_string(job)
|
| 583 |
+
return None
|
| 584 |
+
except Exception as e:
|
| 585 |
+
logger.error(f"❌ Failed to get job status: {e}")
|
| 586 |
+
return None
|
| 587 |
+
|
| 588 |
+
class ObjectDetectionRepository(BaseRepository):
|
| 589 |
+
"""Repository for object detection results"""
|
| 590 |
+
|
| 591 |
+
def __init__(self, db_manager):
|
| 592 |
+
super().__init__(db_manager)
|
| 593 |
+
self.collection = self.db.object_detections
|
| 594 |
+
|
| 595 |
+
def save_detection_batch(self, video_id: str, detections: List[Dict]) -> List[str]:
|
| 596 |
+
"""Save object detection results"""
|
| 597 |
+
detection_ids = []
|
| 598 |
+
|
| 599 |
+
try:
|
| 600 |
+
for detection in detections:
|
| 601 |
+
detection_doc = {
|
| 602 |
+
"video_id": video_id,
|
| 603 |
+
"keyframe_id": ObjectId(detection.get('keyframe_id')) if detection.get('keyframe_id') else None,
|
| 604 |
+
"detection_id": f"{video_id}_{detection.get('frame_number', 0)}_{len(detection_ids)}",
|
| 605 |
+
"class_name": detection.get('class_name', ''),
|
| 606 |
+
"confidence": detection.get('confidence', 0.0),
|
| 607 |
+
"bbox": detection.get('bbox', [0, 0, 0, 0]),
|
| 608 |
+
"center_point": detection.get('center_point', [0, 0]),
|
| 609 |
+
"area": detection.get('area', 0.0),
|
| 610 |
+
"frame_timestamp": detection.get('frame_timestamp', 0.0),
|
| 611 |
+
"detection_model": detection.get('detection_model', ''),
|
| 612 |
+
"threat_level": self._calculate_threat_level(detection.get('class_name', '')),
|
| 613 |
+
"created_at": datetime.utcnow()
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
result = self.collection.insert_one(detection_doc)
|
| 617 |
+
detection_ids.append(str(result.inserted_id))
|
| 618 |
+
|
| 619 |
+
logger.info(f"✅ Saved {len(detection_ids)} detection results for video {video_id}")
|
| 620 |
+
return detection_ids
|
| 621 |
+
|
| 622 |
+
except Exception as e:
|
| 623 |
+
logger.error(f"❌ Failed to save detection results: {e}")
|
| 624 |
+
raise
|
| 625 |
+
|
| 626 |
+
def get_detections_by_video_id(self, video_id: str, class_filter: str = None) -> List[Dict]:
|
| 627 |
+
"""Get object detections for a video"""
|
| 628 |
+
try:
|
| 629 |
+
query = {"video_id": video_id}
|
| 630 |
+
if class_filter:
|
| 631 |
+
query["class_name"] = class_filter
|
| 632 |
+
|
| 633 |
+
detections = list(self.collection.find(query).sort("frame_timestamp", 1))
|
| 634 |
+
|
| 635 |
+
# Convert ObjectIds to strings
|
| 636 |
+
for detection in detections:
|
| 637 |
+
detection = convert_objectid_to_string(detection)
|
| 638 |
+
|
| 639 |
+
return detections
|
| 640 |
+
|
| 641 |
+
except Exception as e:
|
| 642 |
+
logger.error(f"❌ Failed to get detections for video {video_id}: {e}")
|
| 643 |
+
return []
|
| 644 |
+
|
| 645 |
+
def _calculate_threat_level(self, class_name: str) -> str:
|
| 646 |
+
"""Calculate threat level based on detected object class"""
|
| 647 |
+
threat_map = {
|
| 648 |
+
'fire': 'critical',
|
| 649 |
+
'gun': 'critical',
|
| 650 |
+
'knife': 'high',
|
| 651 |
+
'smoke': 'medium'
|
| 652 |
+
}
|
| 653 |
+
return threat_map.get(class_name.lower(), 'low')
|
database/storage_logger.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Storage Logging Configuration for MinIO and Database Operations
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
|
| 9 |
+
def setup_storage_logger():
|
| 10 |
+
"""Configure logger for storage operations"""
|
| 11 |
+
logger = logging.getLogger('storage_operations')
|
| 12 |
+
logger.setLevel(logging.DEBUG)
|
| 13 |
+
|
| 14 |
+
# Create logs directory if it doesn't exist
|
| 15 |
+
logs_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs')
|
| 16 |
+
os.makedirs(logs_dir, exist_ok=True)
|
| 17 |
+
|
| 18 |
+
# File handler for storage operations
|
| 19 |
+
log_file = os.path.join(logs_dir, f'storage_{datetime.now().strftime("%Y%m%d")}.log')
|
| 20 |
+
file_handler = logging.FileHandler(log_file)
|
| 21 |
+
file_handler.setLevel(logging.DEBUG)
|
| 22 |
+
|
| 23 |
+
# Console handler
|
| 24 |
+
console_handler = logging.StreamHandler()
|
| 25 |
+
console_handler.setLevel(logging.INFO)
|
| 26 |
+
|
| 27 |
+
# Create formatter
|
| 28 |
+
formatter = logging.Formatter(
|
| 29 |
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 30 |
+
)
|
| 31 |
+
file_handler.setFormatter(formatter)
|
| 32 |
+
console_handler.setFormatter(formatter)
|
| 33 |
+
|
| 34 |
+
# Add handlers
|
| 35 |
+
logger.addHandler(file_handler)
|
| 36 |
+
logger.addHandler(console_handler)
|
| 37 |
+
|
| 38 |
+
return logger
|
| 39 |
+
|
| 40 |
+
# Initialize logger
|
| 41 |
+
storage_logger = setup_storage_logger()
|
database/video_compression_service.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Video Compression and Storage Service for DetectifAI
|
| 3 |
+
|
| 4 |
+
This module handles video compression and MinIO storage for compressed videos.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import cv2
|
| 9 |
+
import subprocess
|
| 10 |
+
import logging
|
| 11 |
+
from io import BytesIO
|
| 12 |
+
from typing import Dict, Optional
|
| 13 |
+
from datetime import timedelta
|
| 14 |
+
from minio.error import S3Error
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
class VideoCompressionService:
|
| 19 |
+
"""Service for compressing videos and storing in S3-compatible storage"""
|
| 20 |
+
|
| 21 |
+
def __init__(self, db_manager, config=None):
|
| 22 |
+
self._db_manager = db_manager
|
| 23 |
+
self.bucket = db_manager.config.minio_video_bucket # Store compressed videos in the videos bucket
|
| 24 |
+
self.config = config
|
| 25 |
+
|
| 26 |
+
# Default compression settings
|
| 27 |
+
self.output_resolution = "720p" # 720p for web delivery
|
| 28 |
+
self.compression_crf = 23 # 0-51, lower = better quality (23 is default)
|
| 29 |
+
self.compression_preset = "medium" # ultrafast to veryslow
|
| 30 |
+
|
| 31 |
+
# Check if FFmpeg is available
|
| 32 |
+
self.ffmpeg_available = self._check_ffmpeg_available()
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def minio(self):
|
| 36 |
+
"""Lazy access to S3 storage — tolerates unavailable storage"""
|
| 37 |
+
return self._db_manager.minio_client
|
| 38 |
+
|
| 39 |
+
def _check_ffmpeg_available(self) -> bool:
|
| 40 |
+
"""Check if FFmpeg is available on the system"""
|
| 41 |
+
try:
|
| 42 |
+
result = subprocess.run(
|
| 43 |
+
['ffmpeg', '-version'],
|
| 44 |
+
capture_output=True,
|
| 45 |
+
text=True,
|
| 46 |
+
timeout=5
|
| 47 |
+
)
|
| 48 |
+
return result.returncode == 0
|
| 49 |
+
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
| 50 |
+
return False
|
| 51 |
+
|
| 52 |
+
def compress_and_store(self, input_path: str, video_id: str) -> Optional[Dict]:
|
| 53 |
+
"""Compress video and store in MinIO and locally"""
|
| 54 |
+
try:
|
| 55 |
+
# Create local storage directory
|
| 56 |
+
local_dir = os.path.join("video_processing_outputs", "compressed", video_id)
|
| 57 |
+
os.makedirs(local_dir, exist_ok=True)
|
| 58 |
+
local_path = os.path.join(local_dir, "video.mp4")
|
| 59 |
+
|
| 60 |
+
# Use BytesIO for in-memory compression
|
| 61 |
+
from io import BytesIO
|
| 62 |
+
compressed_buffer = BytesIO()
|
| 63 |
+
|
| 64 |
+
# Try FFmpeg first if available, otherwise use OpenCV
|
| 65 |
+
if self.ffmpeg_available:
|
| 66 |
+
success = self._compress_with_ffmpeg_to_buffer(input_path, compressed_buffer)
|
| 67 |
+
if not success:
|
| 68 |
+
logger.warning("FFmpeg compression failed, falling back to OpenCV")
|
| 69 |
+
compressed_buffer.seek(0) # Reset buffer position
|
| 70 |
+
success = self._compress_with_opencv_to_buffer(input_path, compressed_buffer)
|
| 71 |
+
else:
|
| 72 |
+
logger.info("FFmpeg not available, using OpenCV compression")
|
| 73 |
+
success = self._compress_with_opencv_to_buffer(input_path, compressed_buffer)
|
| 74 |
+
|
| 75 |
+
if not success:
|
| 76 |
+
logger.error("Both compression methods failed")
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
# Get buffer contents
|
| 80 |
+
compressed_buffer.seek(0)
|
| 81 |
+
compressed_data = compressed_buffer.getvalue()
|
| 82 |
+
compressed_size = len(compressed_data)
|
| 83 |
+
|
| 84 |
+
# Save locally
|
| 85 |
+
with open(local_path, 'wb') as f:
|
| 86 |
+
f.write(compressed_data)
|
| 87 |
+
logger.info(f"✅ Video saved locally: {local_path}")
|
| 88 |
+
|
| 89 |
+
# Calculate compression stats
|
| 90 |
+
original_size = os.path.getsize(input_path)
|
| 91 |
+
compression_ratio = ((original_size - compressed_size) / original_size) * 100
|
| 92 |
+
|
| 93 |
+
# Upload directly to S3 using consistent path structure (skip if unavailable)
|
| 94 |
+
minio_path = None
|
| 95 |
+
if self.minio is not None:
|
| 96 |
+
try:
|
| 97 |
+
minio_path = f"compressed/{video_id}/video.mp4"
|
| 98 |
+
compressed_buffer.seek(0) # Reset buffer for S3 upload
|
| 99 |
+
self.minio.put_object(
|
| 100 |
+
self.bucket,
|
| 101 |
+
minio_path,
|
| 102 |
+
compressed_buffer,
|
| 103 |
+
length=compressed_size,
|
| 104 |
+
content_type='video/mp4'
|
| 105 |
+
)
|
| 106 |
+
except Exception as s3_err:
|
| 107 |
+
logger.warning(f"⚠️ S3 upload skipped for compressed video: {s3_err}")
|
| 108 |
+
minio_path = None
|
| 109 |
+
else:
|
| 110 |
+
logger.info("S3 storage unavailable — compressed video stored locally only")
|
| 111 |
+
|
| 112 |
+
result = {
|
| 113 |
+
'success': True,
|
| 114 |
+
'minio_path': minio_path,
|
| 115 |
+
'local_path': local_path,
|
| 116 |
+
'original_size': original_size,
|
| 117 |
+
'compressed_size': compressed_size,
|
| 118 |
+
'compression_ratio': round(compression_ratio, 2),
|
| 119 |
+
'output_resolution': self.output_resolution
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
logger.info(f"✅ Video compressed and stored: {compression_ratio:.1f}% reduction")
|
| 123 |
+
return result
|
| 124 |
+
|
| 125 |
+
except Exception as e:
|
| 126 |
+
logger.error(f"❌ Compression and storage failed: {e}")
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
def get_compressed_video_presigned_url(self, video_id: str, expires: timedelta = timedelta(hours=1)) -> str:
|
| 130 |
+
"""Generate presigned URL for compressed video access"""
|
| 131 |
+
if self.minio is None:
|
| 132 |
+
return None
|
| 133 |
+
try:
|
| 134 |
+
minio_path = f"compressed/{video_id}/video.mp4"
|
| 135 |
+
return self.minio.presigned_get_object(self.bucket, minio_path, expires=expires)
|
| 136 |
+
except S3Error as e:
|
| 137 |
+
logger.error(f"❌ Failed to generate presigned URL for compressed video: {e}")
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
def _compress_with_ffmpeg(self, input_path: str, output_path: str) -> bool:
|
| 141 |
+
"""Compress video using FFmpeg"""
|
| 142 |
+
try:
|
| 143 |
+
# Build FFmpeg command
|
| 144 |
+
cmd = [
|
| 145 |
+
'ffmpeg',
|
| 146 |
+
'-i', input_path,
|
| 147 |
+
'-c:v', 'libx264', # H.264 codec
|
| 148 |
+
'-crf', str(self.compression_crf),
|
| 149 |
+
'-preset', self.compression_preset,
|
| 150 |
+
'-movflags', '+faststart', # Enable web playback
|
| 151 |
+
'-y' # Overwrite output file
|
| 152 |
+
]
|
| 153 |
+
|
| 154 |
+
# Add resolution scaling if needed
|
| 155 |
+
if self.output_resolution == "720p":
|
| 156 |
+
cmd.extend(['-vf', 'scale=1280:720:force_original_aspect_ratio=decrease']) # Scale to 720p preserving aspect ratio
|
| 157 |
+
elif self.output_resolution == "480p":
|
| 158 |
+
cmd.extend(['-vf', 'scale=854:480:force_original_aspect_ratio=decrease']) # Scale to 480p preserving aspect ratio
|
| 159 |
+
|
| 160 |
+
cmd.append(output_path)
|
| 161 |
+
|
| 162 |
+
# Run FFmpeg
|
| 163 |
+
result = subprocess.run(
|
| 164 |
+
cmd,
|
| 165 |
+
capture_output=True,
|
| 166 |
+
text=True
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
if result.returncode == 0 and os.path.exists(output_path):
|
| 170 |
+
logger.info("✅ FFmpeg compression successful")
|
| 171 |
+
return True
|
| 172 |
+
else:
|
| 173 |
+
logger.error(f"FFmpeg error: {result.stderr}")
|
| 174 |
+
return False
|
| 175 |
+
|
| 176 |
+
except Exception as e:
|
| 177 |
+
logger.error(f"FFmpeg compression failed: {e}")
|
| 178 |
+
return False
|
| 179 |
+
|
| 180 |
+
def _compress_with_ffmpeg_to_buffer(self, input_path: str, output_buffer: BytesIO) -> bool:
|
| 181 |
+
"""Compress video using FFmpeg with temporary file (more reliable than pipe)"""
|
| 182 |
+
import tempfile
|
| 183 |
+
try:
|
| 184 |
+
# Create temporary file for FFmpeg output
|
| 185 |
+
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as temp_file:
|
| 186 |
+
temp_path = temp_file.name
|
| 187 |
+
|
| 188 |
+
# Build FFmpeg command to output to temporary file
|
| 189 |
+
cmd = [
|
| 190 |
+
'ffmpeg',
|
| 191 |
+
'-i', input_path,
|
| 192 |
+
'-c:v', 'libx264', # H.264 codec
|
| 193 |
+
'-crf', str(self.compression_crf),
|
| 194 |
+
'-preset', self.compression_preset,
|
| 195 |
+
'-movflags', '+faststart', # Enable web playback (safe for file output)
|
| 196 |
+
'-y' # Overwrite output
|
| 197 |
+
]
|
| 198 |
+
|
| 199 |
+
# Add resolution scaling if needed
|
| 200 |
+
if self.output_resolution == "720p":
|
| 201 |
+
cmd.extend(['-vf', 'scale=1280:720:force_original_aspect_ratio=decrease']) # Scale to 720p preserving aspect ratio
|
| 202 |
+
elif self.output_resolution == "480p":
|
| 203 |
+
cmd.extend(['-vf', 'scale=854:480:force_original_aspect_ratio=decrease']) # Scale to 480p preserving aspect ratio
|
| 204 |
+
|
| 205 |
+
# Add output file
|
| 206 |
+
cmd.append(temp_path)
|
| 207 |
+
|
| 208 |
+
# Run FFmpeg
|
| 209 |
+
result = subprocess.run(
|
| 210 |
+
cmd,
|
| 211 |
+
capture_output=True,
|
| 212 |
+
text=True,
|
| 213 |
+
timeout=300 # 5 minute timeout
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
if result.returncode == 0 and os.path.exists(temp_path):
|
| 217 |
+
# Read temporary file into buffer
|
| 218 |
+
with open(temp_path, 'rb') as f:
|
| 219 |
+
output_buffer.write(f.read())
|
| 220 |
+
|
| 221 |
+
# Clean up temporary file
|
| 222 |
+
os.unlink(temp_path)
|
| 223 |
+
|
| 224 |
+
logger.info("✅ FFmpeg compression to buffer successful")
|
| 225 |
+
return True
|
| 226 |
+
else:
|
| 227 |
+
# Clean up temporary file on error
|
| 228 |
+
if os.path.exists(temp_path):
|
| 229 |
+
os.unlink(temp_path)
|
| 230 |
+
logger.error(f"FFmpeg error: {result.stderr}")
|
| 231 |
+
return False
|
| 232 |
+
|
| 233 |
+
except Exception as e:
|
| 234 |
+
logger.error(f"FFmpeg compression to buffer failed: {e}")
|
| 235 |
+
return False
|
| 236 |
+
|
| 237 |
+
def _compress_with_opencv_to_buffer(self, input_path: str, output_buffer: BytesIO) -> bool:
|
| 238 |
+
"""Fallback compression using OpenCV directly to a buffer"""
|
| 239 |
+
try:
|
| 240 |
+
# Open input video
|
| 241 |
+
cap = cv2.VideoCapture(input_path)
|
| 242 |
+
if not cap.isOpened():
|
| 243 |
+
logger.error(f"Cannot open input video: {input_path}")
|
| 244 |
+
return False
|
| 245 |
+
|
| 246 |
+
# Get video properties
|
| 247 |
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 248 |
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 249 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 250 |
+
|
| 251 |
+
# Calculate new dimensions
|
| 252 |
+
if self.output_resolution == "720p":
|
| 253 |
+
new_height = 720
|
| 254 |
+
new_width = int((width / height) * new_height)
|
| 255 |
+
elif self.output_resolution == "480p":
|
| 256 |
+
new_height = 480
|
| 257 |
+
new_width = int((width / height) * new_height)
|
| 258 |
+
else:
|
| 259 |
+
new_width, new_height = width, height
|
| 260 |
+
|
| 261 |
+
# Create temporary file for OpenCV (required for VideoWriter)
|
| 262 |
+
import tempfile
|
| 263 |
+
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as temp_file:
|
| 264 |
+
temp_path = temp_file.name
|
| 265 |
+
|
| 266 |
+
# Create video writer with best available codec
|
| 267 |
+
# Prioritize H.264 (avc1) for browser compatibility
|
| 268 |
+
codecs_to_try = [
|
| 269 |
+
('avc1', 'H.264'),
|
| 270 |
+
('h264', 'H.264'),
|
| 271 |
+
('X264', 'H.264'),
|
| 272 |
+
('mp4v', 'MPEG-4')
|
| 273 |
+
]
|
| 274 |
+
|
| 275 |
+
out = None
|
| 276 |
+
used_codec = None
|
| 277 |
+
|
| 278 |
+
for fourcc_code, name in codecs_to_try:
|
| 279 |
+
try:
|
| 280 |
+
fourcc = cv2.VideoWriter_fourcc(*fourcc_code)
|
| 281 |
+
out = cv2.VideoWriter(temp_path, fourcc, fps, (new_width, new_height))
|
| 282 |
+
if out.isOpened():
|
| 283 |
+
used_codec = name
|
| 284 |
+
logger.info(f"✅ Using codec: {name} ({fourcc_code})")
|
| 285 |
+
break
|
| 286 |
+
out.release()
|
| 287 |
+
except Exception as e:
|
| 288 |
+
logger.debug(f"Codec {fourcc_code} failed: {e}")
|
| 289 |
+
|
| 290 |
+
if not out or not out.isOpened():
|
| 291 |
+
logger.error("❌ No suitable video codec found")
|
| 292 |
+
return False
|
| 293 |
+
|
| 294 |
+
while True:
|
| 295 |
+
ret, frame = cap.read()
|
| 296 |
+
if not ret:
|
| 297 |
+
break
|
| 298 |
+
|
| 299 |
+
# Resize frame if needed
|
| 300 |
+
if (new_width, new_height) != (width, height):
|
| 301 |
+
frame = cv2.resize(frame, (new_width, new_height))
|
| 302 |
+
|
| 303 |
+
out.write(frame)
|
| 304 |
+
|
| 305 |
+
cap.release()
|
| 306 |
+
out.release()
|
| 307 |
+
|
| 308 |
+
# Read compressed file into buffer
|
| 309 |
+
if os.path.exists(temp_path):
|
| 310 |
+
with open(temp_path, 'rb') as f:
|
| 311 |
+
output_buffer.write(f.read())
|
| 312 |
+
os.unlink(temp_path) # Delete temporary file
|
| 313 |
+
logger.info("✅ OpenCV compression to buffer successful")
|
| 314 |
+
return True
|
| 315 |
+
else:
|
| 316 |
+
logger.error("OpenCV compression failed - output file not created")
|
| 317 |
+
return False
|
| 318 |
+
|
| 319 |
+
except Exception as e:
|
| 320 |
+
logger.error(f"OpenCV compression to buffer failed: {e}")
|
| 321 |
+
return False
|
| 322 |
+
|
| 323 |
+
def _compress_with_opencv(self, input_path: str, output_path: str) -> bool:
|
| 324 |
+
"""Fallback compression using OpenCV"""
|
| 325 |
+
try:
|
| 326 |
+
# Open input video
|
| 327 |
+
cap = cv2.VideoCapture(input_path)
|
| 328 |
+
if not cap.isOpened():
|
| 329 |
+
logger.error(f"Cannot open input video: {input_path}")
|
| 330 |
+
return False
|
| 331 |
+
|
| 332 |
+
# Get video properties
|
| 333 |
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 334 |
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 335 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 336 |
+
|
| 337 |
+
# Calculate new dimensions
|
| 338 |
+
if self.output_resolution == "720p":
|
| 339 |
+
new_height = 720
|
| 340 |
+
new_width = int((width / height) * new_height)
|
| 341 |
+
elif self.output_resolution == "480p":
|
| 342 |
+
new_height = 480
|
| 343 |
+
new_width = int((width / height) * new_height)
|
| 344 |
+
else:
|
| 345 |
+
new_width, new_height = width, height
|
| 346 |
+
|
| 347 |
+
# Create video writer
|
| 348 |
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
| 349 |
+
out = cv2.VideoWriter(
|
| 350 |
+
output_path,
|
| 351 |
+
fourcc,
|
| 352 |
+
fps,
|
| 353 |
+
(new_width, new_height)
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
while True:
|
| 357 |
+
ret, frame = cap.read()
|
| 358 |
+
if not ret:
|
| 359 |
+
break
|
| 360 |
+
|
| 361 |
+
# Resize frame
|
| 362 |
+
if (new_width, new_height) != (width, height):
|
| 363 |
+
frame = cv2.resize(frame, (new_width, new_height))
|
| 364 |
+
|
| 365 |
+
out.write(frame)
|
| 366 |
+
|
| 367 |
+
cap.release()
|
| 368 |
+
out.release()
|
| 369 |
+
|
| 370 |
+
if os.path.exists(output_path):
|
| 371 |
+
logger.info("✅ OpenCV compression successful")
|
| 372 |
+
return True
|
| 373 |
+
else:
|
| 374 |
+
logger.error("OpenCV compression failed - output file not created")
|
| 375 |
+
return False
|
| 376 |
+
|
| 377 |
+
except Exception as e:
|
| 378 |
+
logger.error(f"OpenCV compression failed: {e}")
|
| 379 |
+
return False
|
database_video_service.py
ADDED
|
@@ -0,0 +1,1804 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database-Integrated Video Processing Service
|
| 3 |
+
|
| 4 |
+
This service integrates the existing video processing pipeline with MongoDB and MinIO storage.
|
| 5 |
+
It replaces local file storage with database persistence while maintaining all processing capabilities.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import cv2
|
| 10 |
+
import time
|
| 11 |
+
import threading
|
| 12 |
+
from typing import Dict, List, Any, Optional
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
import logging
|
| 15 |
+
import uuid
|
| 16 |
+
import json
|
| 17 |
+
|
| 18 |
+
# Import existing processing components
|
| 19 |
+
from config import VideoProcessingConfig
|
| 20 |
+
from main_pipeline import CompleteVideoProcessingPipeline
|
| 21 |
+
from core.video_processing import OptimizedVideoProcessor
|
| 22 |
+
from object_detection import ObjectDetector
|
| 23 |
+
from behavior_analysis_integrator import BehaviorAnalysisIntegrator
|
| 24 |
+
from event_aggregation import EventDetector
|
| 25 |
+
from video_segmentation import VideoSegmentationEngine
|
| 26 |
+
|
| 27 |
+
# Import database components
|
| 28 |
+
from database.config import DatabaseManager
|
| 29 |
+
from database.repositories import VideoRepository, EventRepository
|
| 30 |
+
from database.keyframe_repository import KeyframeRepository
|
| 31 |
+
from database.video_compression_service import VideoCompressionService
|
| 32 |
+
from database.models import (
|
| 33 |
+
convert_numpy_types,
|
| 34 |
+
seconds_to_milliseconds,
|
| 35 |
+
milliseconds_to_seconds,
|
| 36 |
+
prepare_for_mongodb
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger(__name__)
|
| 40 |
+
|
| 41 |
+
class DatabaseIntegratedVideoService:
|
| 42 |
+
"""Enhanced video processing service with database integration"""
|
| 43 |
+
|
| 44 |
+
def __init__(self, config: VideoProcessingConfig = None):
|
| 45 |
+
"""Initialize service with database connections and processing components"""
|
| 46 |
+
self.config = config or VideoProcessingConfig()
|
| 47 |
+
|
| 48 |
+
# Initialize database connections
|
| 49 |
+
self.db_manager = DatabaseManager()
|
| 50 |
+
|
| 51 |
+
# Initialize repositories (including keyframe and compression)
|
| 52 |
+
self.video_repo = VideoRepository(self.db_manager)
|
| 53 |
+
self.event_repo = EventRepository(self.db_manager)
|
| 54 |
+
self.keyframe_repo = KeyframeRepository(self.db_manager)
|
| 55 |
+
self.compression_service = VideoCompressionService(self.db_manager, self.config)
|
| 56 |
+
|
| 57 |
+
# Initialize processing components
|
| 58 |
+
self.video_processor = OptimizedVideoProcessor(self.config)
|
| 59 |
+
self.event_detector = EventDetector(self.config)
|
| 60 |
+
self.segmentation_engine = VideoSegmentationEngine(self.config)
|
| 61 |
+
|
| 62 |
+
# Initialize object detector if enabled
|
| 63 |
+
self.object_detector = None
|
| 64 |
+
if self.config.enable_object_detection:
|
| 65 |
+
try:
|
| 66 |
+
self.object_detector = ObjectDetector(self.config)
|
| 67 |
+
logger.info("✅ Object detection enabled")
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.warning(f"⚠️ Object detection initialization failed: {e}")
|
| 70 |
+
self.config.enable_object_detection = False
|
| 71 |
+
|
| 72 |
+
# Initialize behavior analyzer if enabled
|
| 73 |
+
self.behavior_analyzer = None
|
| 74 |
+
if getattr(self.config, 'enable_behavior_analysis', False):
|
| 75 |
+
try:
|
| 76 |
+
self.behavior_analyzer = BehaviorAnalysisIntegrator(self.config)
|
| 77 |
+
logger.info("✅ Behavior analysis enabled")
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.warning(f"⚠️ Behavior analysis initialization failed: {e}")
|
| 80 |
+
self.config.enable_behavior_analysis = False
|
| 81 |
+
|
| 82 |
+
# Initialize video captioning if enabled
|
| 83 |
+
self.video_captioning = None
|
| 84 |
+
if getattr(self.config, 'enable_video_captioning', False):
|
| 85 |
+
try:
|
| 86 |
+
from video_captioning_integrator import VideoCaptioningIntegrator
|
| 87 |
+
self.video_captioning = VideoCaptioningIntegrator(self.config, db_manager=self.db_manager)
|
| 88 |
+
logger.info("✅ Video captioning enabled (MongoDB + FAISS)")
|
| 89 |
+
except Exception as e:
|
| 90 |
+
logger.warning(f"⚠️ Video captioning initialization failed: {e}")
|
| 91 |
+
self.config.enable_video_captioning = False
|
| 92 |
+
|
| 93 |
+
logger.info("✅ Database-integrated video service initialized")
|
| 94 |
+
|
| 95 |
+
def process_video_with_database_storage(self, video_path: str, video_id: str, user_id: str = None):
|
| 96 |
+
"""
|
| 97 |
+
Main processing pipeline with database integration
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
video_path: Path to uploaded video file
|
| 101 |
+
video_id: Unique identifier for the video
|
| 102 |
+
user_id: Optional user identifier
|
| 103 |
+
"""
|
| 104 |
+
logger.info(f"🚀 Starting database-integrated processing for video: {video_id}")
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
# Check if MongoDB record already exists (created during upload)
|
| 108 |
+
existing_video = self.video_repo.get_video_by_id(video_id)
|
| 109 |
+
if not existing_video:
|
| 110 |
+
logger.warning(f"⚠️ Video record not found in MongoDB for {video_id}, creating now...")
|
| 111 |
+
# Fallback: create record if it doesn't exist
|
| 112 |
+
video_metadata = self._extract_video_metadata(video_path)
|
| 113 |
+
video_record = {
|
| 114 |
+
"video_id": video_id,
|
| 115 |
+
"user_id": user_id or "system",
|
| 116 |
+
"file_path": f"videos/{video_id}/video.mp4",
|
| 117 |
+
"minio_object_key": f"original/{video_id}/video.mp4",
|
| 118 |
+
"minio_bucket": self.video_repo.video_bucket,
|
| 119 |
+
"codec": "h264",
|
| 120 |
+
"fps": float(video_metadata.get("fps", 30.0)),
|
| 121 |
+
"upload_date": datetime.utcnow(),
|
| 122 |
+
"duration_secs": int(video_metadata.get("duration", 0)),
|
| 123 |
+
"file_size_bytes": int(video_metadata.get("file_size", 0)),
|
| 124 |
+
"meta_data": {
|
| 125 |
+
"filename": os.path.basename(video_path),
|
| 126 |
+
"resolution": video_metadata.get("resolution"),
|
| 127 |
+
"processing_status": "processing",
|
| 128 |
+
"processing_progress": 0,
|
| 129 |
+
"processing_message": "Starting processing..."
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
self.video_repo.create_video_record(video_record)
|
| 133 |
+
else:
|
| 134 |
+
logger.info(f"✅ MongoDB record already exists for {video_id}, proceeding with processing...")
|
| 135 |
+
|
| 136 |
+
# Update status: processing started
|
| 137 |
+
self.video_repo.update_metadata(video_id, {
|
| 138 |
+
"processing_status": "processing",
|
| 139 |
+
"processing_progress": 10,
|
| 140 |
+
"processing_message": "Starting video processing pipeline..."
|
| 141 |
+
})
|
| 142 |
+
|
| 143 |
+
# Step 1: Extract keyframes and upload to MinIO
|
| 144 |
+
self.video_repo.update_metadata(video_id, {
|
| 145 |
+
"processing_progress": 15,
|
| 146 |
+
"processing_message": "Extracting and uploading keyframes..."
|
| 147 |
+
})
|
| 148 |
+
keyframes = self.video_processor.extract_keyframes(video_path)
|
| 149 |
+
|
| 150 |
+
# Process keyframes directly for MinIO upload
|
| 151 |
+
keyframe_batch = []
|
| 152 |
+
for kf in keyframes:
|
| 153 |
+
frame_data = kf.frame_data if hasattr(kf, 'frame_data') else kf
|
| 154 |
+
|
| 155 |
+
# Extract keyframe information consistently
|
| 156 |
+
keyframe_info = {
|
| 157 |
+
'frame_path': frame_data.frame_path if hasattr(frame_data, 'frame_path') else None,
|
| 158 |
+
'frame_number': frame_data.frame_number if hasattr(frame_data, 'frame_number') else 0,
|
| 159 |
+
'timestamp': frame_data.timestamp if hasattr(frame_data, 'timestamp') else 0.0,
|
| 160 |
+
'enhancement_applied': frame_data.enhancement_applied if hasattr(frame_data, 'enhancement_applied') else False
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
# If we have a numpy frame directly, we might need to save it to a file first
|
| 164 |
+
if hasattr(frame_data, 'frame') and frame_data.frame is not None:
|
| 165 |
+
# Save numpy array to temporary file for upload
|
| 166 |
+
import tempfile
|
| 167 |
+
import cv2
|
| 168 |
+
import numpy as np
|
| 169 |
+
|
| 170 |
+
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as temp_file:
|
| 171 |
+
temp_path = temp_file.name
|
| 172 |
+
cv2.imwrite(temp_path, cv2.cvtColor(frame_data.frame, cv2.COLOR_RGB2BGR))
|
| 173 |
+
keyframe_info['frame_path'] = temp_path
|
| 174 |
+
|
| 175 |
+
keyframe_batch.append(keyframe_info)
|
| 176 |
+
|
| 177 |
+
# Process and upload keyframes to MinIO
|
| 178 |
+
logger.info(f"Uploading {len(keyframe_batch)} keyframes to MinIO...")
|
| 179 |
+
|
| 180 |
+
keyframe_info = []
|
| 181 |
+
for idx, kf_info in enumerate(keyframe_batch):
|
| 182 |
+
frame_path = kf_info.get('frame_path')
|
| 183 |
+
|
| 184 |
+
if frame_path and os.path.exists(frame_path):
|
| 185 |
+
try:
|
| 186 |
+
# Create MinIO path
|
| 187 |
+
frame_number = kf_info.get('frame_number', idx)
|
| 188 |
+
timestamp = kf_info.get('timestamp', 0.0)
|
| 189 |
+
minio_path = f"{video_id}/keyframes/frame_{frame_number:06d}.jpg"
|
| 190 |
+
|
| 191 |
+
# Upload to MinIO with metadata
|
| 192 |
+
with open(frame_path, 'rb') as f:
|
| 193 |
+
file_size = os.path.getsize(frame_path)
|
| 194 |
+
metadata = {
|
| 195 |
+
"frame_number": str(frame_number),
|
| 196 |
+
"timestamp": str(timestamp),
|
| 197 |
+
"enhancement_applied": str(kf_info.get('enhancement_applied', False))
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
self.keyframe_repo.minio.put_object(
|
| 201 |
+
self.keyframe_repo.bucket,
|
| 202 |
+
minio_path,
|
| 203 |
+
f,
|
| 204 |
+
file_size,
|
| 205 |
+
content_type='image/jpeg',
|
| 206 |
+
metadata=metadata
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
keyframe_info.append({
|
| 210 |
+
"frame_number": frame_number,
|
| 211 |
+
"timestamp": timestamp,
|
| 212 |
+
"minio_path": minio_path,
|
| 213 |
+
"size_bytes": file_size,
|
| 214 |
+
"uploaded_at": datetime.utcnow().isoformat()
|
| 215 |
+
})
|
| 216 |
+
|
| 217 |
+
except Exception as e:
|
| 218 |
+
logger.error(f"Failed to upload keyframe {frame_path}: {e}")
|
| 219 |
+
continue
|
| 220 |
+
|
| 221 |
+
if (idx + 1) % 10 == 0:
|
| 222 |
+
logger.info(f"Uploaded {idx + 1}/{len(keyframe_batch)} keyframes")
|
| 223 |
+
|
| 224 |
+
# Step 2: Update MongoDB with keyframe MinIO paths (link metadata)
|
| 225 |
+
# Store each keyframe's MinIO path in MongoDB metadata
|
| 226 |
+
keyframe_metadata = []
|
| 227 |
+
for kf in keyframe_info:
|
| 228 |
+
keyframe_metadata.append({
|
| 229 |
+
"frame_number": kf["frame_number"],
|
| 230 |
+
"timestamp": kf["timestamp"],
|
| 231 |
+
"minio_path": kf["minio_path"],
|
| 232 |
+
"minio_bucket": self.keyframe_repo.bucket,
|
| 233 |
+
"size_bytes": kf["size_bytes"],
|
| 234 |
+
"uploaded_at": kf["uploaded_at"]
|
| 235 |
+
})
|
| 236 |
+
|
| 237 |
+
# Update video metadata with keyframe information and MinIO links
|
| 238 |
+
self.video_repo.update_metadata(video_id, {
|
| 239 |
+
"keyframe_info": keyframe_metadata, # Full metadata with MinIO paths
|
| 240 |
+
"keyframe_count": len(keyframe_info),
|
| 241 |
+
"keyframe_bucket": self.keyframe_repo.bucket,
|
| 242 |
+
"keyframes_minio_paths": [kf["minio_path"] for kf in keyframe_info], # Quick access list
|
| 243 |
+
"upload_stats": {
|
| 244 |
+
"total_frames": len(keyframe_batch),
|
| 245 |
+
"uploaded_frames": len(keyframe_info),
|
| 246 |
+
"upload_completed": datetime.utcnow().isoformat()
|
| 247 |
+
}
|
| 248 |
+
})
|
| 249 |
+
logger.info(f"✅ Uploaded {len(keyframe_info)} keyframes to MinIO and linked in MongoDB")
|
| 250 |
+
|
| 251 |
+
# Enrich original keyframe objects with MinIO metadata for downstream processing
|
| 252 |
+
# This ensures video captioning and other modules can access MinIO paths
|
| 253 |
+
for idx, kf in enumerate(keyframes):
|
| 254 |
+
if idx < len(keyframe_metadata):
|
| 255 |
+
kf_meta = keyframe_metadata[idx]
|
| 256 |
+
# Add MinIO metadata to keyframe object
|
| 257 |
+
if hasattr(kf, 'frame_data'):
|
| 258 |
+
kf.frame_data.minio_path = kf_meta['minio_path']
|
| 259 |
+
kf.frame_data.minio_bucket = kf_meta['minio_bucket']
|
| 260 |
+
else:
|
| 261 |
+
kf.minio_path = kf_meta['minio_path']
|
| 262 |
+
kf.minio_bucket = kf_meta['minio_bucket']
|
| 263 |
+
|
| 264 |
+
logger.info(f"✅ Enriched {len(keyframes)} keyframe objects with MinIO metadata")
|
| 265 |
+
|
| 266 |
+
# Step 2: Generate compressed video and upload to MinIO (MOVED UP - Priority for playback)
|
| 267 |
+
compressed_minio_path = None
|
| 268 |
+
if self.config.generate_compressed_video:
|
| 269 |
+
self.video_repo.update_metadata(video_id, {
|
| 270 |
+
"processing_progress": 20,
|
| 271 |
+
"processing_message": "Generating and uploading compressed video..."
|
| 272 |
+
})
|
| 273 |
+
logger.info("📦 ===== STARTING VIDEO COMPRESSION (PRIORITY) ===== ")
|
| 274 |
+
compressed_minio_path = self._generate_compressed_video(video_path, video_id)
|
| 275 |
+
if compressed_minio_path:
|
| 276 |
+
logger.info(f"✅ Compressed video uploaded to MinIO: {compressed_minio_path}")
|
| 277 |
+
# Update metadata immediately so video is playable
|
| 278 |
+
self.video_repo.update_metadata(video_id, {
|
| 279 |
+
"minio_compressed_path": compressed_minio_path
|
| 280 |
+
})
|
| 281 |
+
self.video_repo.collection.update_one(
|
| 282 |
+
{"video_id": video_id},
|
| 283 |
+
{"$set": {"meta_data.minio_compressed_path": compressed_minio_path}}
|
| 284 |
+
)
|
| 285 |
+
else:
|
| 286 |
+
logger.warning("⚠️ Video compression failed, continuing with other processing")
|
| 287 |
+
|
| 288 |
+
# Step 3: Object detection (if enabled)
|
| 289 |
+
detection_results = []
|
| 290 |
+
if self.config.enable_object_detection and self.object_detector:
|
| 291 |
+
self.video_repo.update_metadata(video_id, {
|
| 292 |
+
"processing_progress": 40,
|
| 293 |
+
"processing_message": "Running object detection..."
|
| 294 |
+
})
|
| 295 |
+
detection_results = self._run_object_detection_on_keyframes(
|
| 296 |
+
video_id, keyframes
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
# Step 4: Behavior analysis (if enabled)
|
| 300 |
+
behavior_results = []
|
| 301 |
+
behavior_events = []
|
| 302 |
+
if self.config.enable_behavior_analysis and self.behavior_analyzer:
|
| 303 |
+
self.video_repo.update_metadata(video_id, {
|
| 304 |
+
"processing_progress": 55,
|
| 305 |
+
"processing_message": "Running behavior analysis (fight/accident/climbing detection)..."
|
| 306 |
+
})
|
| 307 |
+
logger.info("🚀 ===== STARTING BEHAVIOR ANALYSIS ===== ")
|
| 308 |
+
logger.info(f"📹 Processing video: {video_path}")
|
| 309 |
+
logger.info(f"🔧 Available models: {list(self.behavior_analyzer.models.keys())}")
|
| 310 |
+
|
| 311 |
+
# Pass video_path for 3D-ResNet models (fighting, road_accident) which need 16-frame clips
|
| 312 |
+
behavior_results, behavior_events = self.behavior_analyzer.process_keyframes_with_behavior_analysis(keyframes, video_path=video_path)
|
| 313 |
+
|
| 314 |
+
# Store behavior detections in keyframes
|
| 315 |
+
for i, keyframe in enumerate(keyframes):
|
| 316 |
+
frame_path = keyframe.frame_data.frame_path if hasattr(keyframe, 'frame_data') else None
|
| 317 |
+
timestamp = keyframe.frame_data.timestamp if hasattr(keyframe, 'frame_data') else 0
|
| 318 |
+
|
| 319 |
+
# Find behavior detections for this frame
|
| 320 |
+
frame_behaviors = [r for r in behavior_results if r.frame_path == frame_path and abs(r.timestamp - timestamp) < 0.1]
|
| 321 |
+
|
| 322 |
+
if frame_behaviors:
|
| 323 |
+
for behavior in frame_behaviors:
|
| 324 |
+
if not hasattr(keyframe, 'behaviors'):
|
| 325 |
+
keyframe.behaviors = []
|
| 326 |
+
keyframe.behaviors.append({
|
| 327 |
+
"type": behavior.behavior_detected,
|
| 328 |
+
"confidence": behavior.confidence,
|
| 329 |
+
"model": behavior.model_used,
|
| 330 |
+
"timestamp": behavior.timestamp
|
| 331 |
+
})
|
| 332 |
+
|
| 333 |
+
logger.info(f"✅ Behavior analysis complete: {len(behavior_results)} detections, {len(behavior_events)} events")
|
| 334 |
+
|
| 335 |
+
# Step 5: Event detection and aggregation
|
| 336 |
+
self.video_repo.update_metadata(video_id, {
|
| 337 |
+
"processing_progress": 70,
|
| 338 |
+
"processing_message": "Detecting and aggregating events..."
|
| 339 |
+
})
|
| 340 |
+
|
| 341 |
+
# Create events from object detections
|
| 342 |
+
event_ids = []
|
| 343 |
+
object_events = []
|
| 344 |
+
if detection_results:
|
| 345 |
+
object_events = self._create_object_events_from_detections(detection_results)
|
| 346 |
+
# Save events using EventRepository
|
| 347 |
+
for event in object_events:
|
| 348 |
+
event['video_id'] = video_id # Add video_id to event data
|
| 349 |
+
event_id = self.event_repo.save_event(event)
|
| 350 |
+
event_ids.append(event_id)
|
| 351 |
+
|
| 352 |
+
# Create and save events from behavior analysis
|
| 353 |
+
if behavior_events:
|
| 354 |
+
logger.info(f"📅 Creating {len(behavior_events)} behavior-based events...")
|
| 355 |
+
for behavior_event in behavior_events:
|
| 356 |
+
event_dict = {
|
| 357 |
+
"video_id": video_id,
|
| 358 |
+
"event_type": f"behavior_{behavior_event.behavior_type}",
|
| 359 |
+
"start_timestamp": behavior_event.start_timestamp,
|
| 360 |
+
"end_timestamp": behavior_event.end_timestamp,
|
| 361 |
+
"confidence_score": float(behavior_event.confidence),
|
| 362 |
+
"keyframes": behavior_event.keyframes,
|
| 363 |
+
"importance_score": float(behavior_event.importance_score),
|
| 364 |
+
"description": f"{behavior_event.behavior_type.capitalize()} behavior detected",
|
| 365 |
+
"detection_data": {
|
| 366 |
+
"model_used": behavior_event.model_used,
|
| 367 |
+
"frame_indices": behavior_event.frame_indices,
|
| 368 |
+
"behavior_type": behavior_event.behavior_type
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
try:
|
| 372 |
+
event_id = self.event_repo.save_event(event_dict)
|
| 373 |
+
event_ids.append(event_id)
|
| 374 |
+
logger.info(f"✅ Saved behavior event: {behavior_event.behavior_type} at {behavior_event.start_timestamp:.1f}s")
|
| 375 |
+
except Exception as e:
|
| 376 |
+
logger.error(f"❌ Failed to save behavior event: {e}")
|
| 377 |
+
|
| 378 |
+
# Step 5.5: Run facial recognition on frames with detections (if enabled)
|
| 379 |
+
face_results = []
|
| 380 |
+
if self.config.enable_facial_recognition and (detection_results or behavior_results) and event_ids:
|
| 381 |
+
self.video_repo.update_metadata(video_id, {
|
| 382 |
+
"processing_progress": 75,
|
| 383 |
+
"processing_message": "Running facial recognition on suspicious frames..."
|
| 384 |
+
})
|
| 385 |
+
try:
|
| 386 |
+
from facial_recognition import FacialRecognitionIntegrated
|
| 387 |
+
face_detector = FacialRecognitionIntegrated(self.config)
|
| 388 |
+
|
| 389 |
+
# Get frames that have detections for facial recognition
|
| 390 |
+
frames_with_detections = []
|
| 391 |
+
for i, keyframe in enumerate(keyframes):
|
| 392 |
+
frame_data = keyframe.frame_data if hasattr(keyframe, 'frame_data') else keyframe
|
| 393 |
+
frame_path = (
|
| 394 |
+
frame_data.frame_path if hasattr(frame_data, 'frame_path')
|
| 395 |
+
else getattr(frame_data, 'path', None)
|
| 396 |
+
)
|
| 397 |
+
timestamp = (
|
| 398 |
+
frame_data.timestamp if hasattr(frame_data, 'timestamp')
|
| 399 |
+
else getattr(frame_data, 'timestamp', 0.0)
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
# Check if this frame has object detections
|
| 403 |
+
has_object_detection = any(
|
| 404 |
+
abs(d['frame_timestamp'] - timestamp) < 0.5
|
| 405 |
+
for d in detection_results
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
# Check if this frame has behavior detections
|
| 409 |
+
has_behavior_detection = any(
|
| 410 |
+
abs(b.timestamp - timestamp) < 0.5 and b.behavior_detected != "no_action"
|
| 411 |
+
for b in behavior_results
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
if (has_object_detection or has_behavior_detection) and frame_path and os.path.exists(frame_path):
|
| 415 |
+
frames_with_detections.append((frame_path, timestamp))
|
| 416 |
+
|
| 417 |
+
# Run facial recognition on suspicious frames
|
| 418 |
+
for frame_path, timestamp in frames_with_detections:
|
| 419 |
+
try:
|
| 420 |
+
# Find associated event_id for this timestamp
|
| 421 |
+
associated_event_id = None
|
| 422 |
+
for event_id, event in zip(event_ids, object_events):
|
| 423 |
+
if (event.get('start_timestamp', 0) <= timestamp <=
|
| 424 |
+
event.get('end_timestamp', float('inf'))):
|
| 425 |
+
associated_event_id = event_id
|
| 426 |
+
break
|
| 427 |
+
|
| 428 |
+
if not associated_event_id and event_ids:
|
| 429 |
+
associated_event_id = event_ids[0] # Fallback to first event
|
| 430 |
+
|
| 431 |
+
# Detect faces in frame
|
| 432 |
+
face_result = face_detector.detect_faces_in_frame(frame_path, timestamp)
|
| 433 |
+
|
| 434 |
+
# Convert FaceDetectionResult to list of face info dictionaries
|
| 435 |
+
if face_result and face_result.faces_detected > 0:
|
| 436 |
+
# Extract face information from FaceDetectionResult
|
| 437 |
+
for i in range(face_result.faces_detected):
|
| 438 |
+
face_id = face_result.detected_face_ids[i] if face_result.detected_face_ids and i < len(face_result.detected_face_ids) else f"face_{uuid.uuid4().hex[:8]}"
|
| 439 |
+
bounding_box = face_result.face_bounding_boxes[i] if i < len(face_result.face_bounding_boxes) else [0, 0, 0, 0]
|
| 440 |
+
confidence = face_result.face_confidence_scores[i] if i < len(face_result.face_confidence_scores) else 0.0
|
| 441 |
+
matched_person = face_result.matched_persons[i] if face_result.matched_persons and i < len(face_result.matched_persons) else None
|
| 442 |
+
|
| 443 |
+
# Construct face_info dictionary
|
| 444 |
+
face_info = {
|
| 445 |
+
'face_id': face_id,
|
| 446 |
+
'bounding_box': bounding_box,
|
| 447 |
+
'confidence': confidence,
|
| 448 |
+
'person_name': matched_person.split('(')[0].strip() if matched_person else None,
|
| 449 |
+
'face_image_path': None # Will be set if saved
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
# Try to get face image path from MongoDB if it was saved
|
| 453 |
+
try:
|
| 454 |
+
faces_collection = self.db_manager.db.detected_faces
|
| 455 |
+
existing_face = faces_collection.find_one({'face_id': face_id})
|
| 456 |
+
if existing_face:
|
| 457 |
+
face_info['face_image_path'] = existing_face.get('face_image_path')
|
| 458 |
+
except:
|
| 459 |
+
pass
|
| 460 |
+
|
| 461 |
+
# Get frame number from frame path if possible
|
| 462 |
+
frame_number = 0
|
| 463 |
+
try:
|
| 464 |
+
# Try to extract frame number from frame_path
|
| 465 |
+
import re
|
| 466 |
+
frame_match = re.search(r'frame_(\d+)', frame_path)
|
| 467 |
+
if frame_match:
|
| 468 |
+
frame_number = int(frame_match.group(1))
|
| 469 |
+
else:
|
| 470 |
+
# Estimate from timestamp (assuming 30 fps)
|
| 471 |
+
frame_number = int(timestamp * 30)
|
| 472 |
+
except:
|
| 473 |
+
frame_number = int(timestamp * 30) # Fallback estimate
|
| 474 |
+
|
| 475 |
+
# Process this face_info - Save face to MongoDB detected_faces collection
|
| 476 |
+
# Convert bounding_box array [x1, y1, x2, y2] to bounding_boxes object {x1, y1, x2, y2}
|
| 477 |
+
bounding_box_array = face_info.get('bounding_box', [])
|
| 478 |
+
bounding_boxes_obj = {}
|
| 479 |
+
if isinstance(bounding_box_array, list) and len(bounding_box_array) >= 4:
|
| 480 |
+
bounding_boxes_obj = {
|
| 481 |
+
'x1': int(bounding_box_array[0]),
|
| 482 |
+
'y1': int(bounding_box_array[1]),
|
| 483 |
+
'x2': int(bounding_box_array[2]),
|
| 484 |
+
'y2': int(bounding_box_array[3])
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
face_data = {
|
| 488 |
+
'face_id': face_info.get('face_id', f"face_{uuid.uuid4().hex[:8]}"),
|
| 489 |
+
'event_id': associated_event_id or f"event_{uuid.uuid4().hex[:8]}",
|
| 490 |
+
'detected_at': datetime.utcnow(),
|
| 491 |
+
'confidence_score': float(face_info.get('confidence', 0.0)),
|
| 492 |
+
'bounding_box': bounding_box_array, # Keep array format for backward compatibility
|
| 493 |
+
'bounding_boxes': bounding_boxes_obj, # Object format required by MongoDB schema
|
| 494 |
+
'person_name': face_info.get('person_name'),
|
| 495 |
+
'person_confidence': None,
|
| 496 |
+
'face_image_path': '', # Initialize as empty string (schema requires string)
|
| 497 |
+
'minio_object_key': None,
|
| 498 |
+
'minio_bucket': None,
|
| 499 |
+
'frame_number': frame_number, # Store frame number to link to keyframes
|
| 500 |
+
'timestamp': float(timestamp), # Store timestamp in seconds to link to keyframes
|
| 501 |
+
'video_id': video_id # Store video_id for easier querying
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
# Upload face image to MinIO if available
|
| 505 |
+
# First try to save face image from the face detection result
|
| 506 |
+
temp_face_path = None
|
| 507 |
+
try:
|
| 508 |
+
# Get face crop from the detection result
|
| 509 |
+
if i < len(face_result.face_bounding_boxes):
|
| 510 |
+
# Load frame and crop face
|
| 511 |
+
import cv2
|
| 512 |
+
frame_img = cv2.imread(frame_path)
|
| 513 |
+
if frame_img is not None:
|
| 514 |
+
box = face_result.face_bounding_boxes[i]
|
| 515 |
+
x1, y1, x2, y2 = box[0], box[1], box[2], box[3]
|
| 516 |
+
|
| 517 |
+
# Ensure valid coordinates
|
| 518 |
+
x1, y1 = max(0, x1), max(0, y1)
|
| 519 |
+
x2, y2 = min(frame_img.shape[1], x2), min(frame_img.shape[0], y2)
|
| 520 |
+
|
| 521 |
+
if x2 > x1 and y2 > y1:
|
| 522 |
+
face_crop = frame_img[y1:y2, x1:x2]
|
| 523 |
+
|
| 524 |
+
# Create temp directory if it doesn't exist
|
| 525 |
+
temp_dir = "temp_faces"
|
| 526 |
+
os.makedirs(temp_dir, exist_ok=True)
|
| 527 |
+
|
| 528 |
+
# Save face crop temporarily
|
| 529 |
+
temp_face_path = os.path.join(temp_dir, f"{face_data['face_id']}.jpg")
|
| 530 |
+
cv2.imwrite(temp_face_path, face_crop)
|
| 531 |
+
|
| 532 |
+
# Verify file was created
|
| 533 |
+
if os.path.exists(temp_face_path):
|
| 534 |
+
# Upload to MinIO
|
| 535 |
+
minio_face_path = f"{video_id}/faces/{face_data['face_id']}.jpg"
|
| 536 |
+
with open(temp_face_path, 'rb') as f:
|
| 537 |
+
file_size = os.path.getsize(temp_face_path)
|
| 538 |
+
self.keyframe_repo.minio.put_object(
|
| 539 |
+
self.keyframe_repo.bucket,
|
| 540 |
+
minio_face_path,
|
| 541 |
+
f,
|
| 542 |
+
file_size,
|
| 543 |
+
content_type='image/jpeg'
|
| 544 |
+
)
|
| 545 |
+
|
| 546 |
+
face_data['minio_object_key'] = minio_face_path
|
| 547 |
+
face_data['minio_bucket'] = self.keyframe_repo.bucket
|
| 548 |
+
face_data['face_image_path'] = minio_face_path # Store MinIO path, not temp path
|
| 549 |
+
logger.info(f"✅ Uploaded face image to MinIO: {minio_face_path}")
|
| 550 |
+
else:
|
| 551 |
+
logger.warning(f"Failed to create temp face file: {temp_face_path}")
|
| 552 |
+
else:
|
| 553 |
+
logger.warning(f"Invalid bounding box coordinates: ({x1}, {y1}, {x2}, {y2})")
|
| 554 |
+
except Exception as e:
|
| 555 |
+
logger.warning(f"Failed to upload face image to MinIO: {e}")
|
| 556 |
+
import traceback
|
| 557 |
+
logger.debug(traceback.format_exc())
|
| 558 |
+
|
| 559 |
+
# Clean up temp file AFTER MongoDB save (not before)
|
| 560 |
+
# Save to MongoDB
|
| 561 |
+
try:
|
| 562 |
+
# Ensure face_image_path is a string (not None) for schema validation
|
| 563 |
+
if not face_data.get('face_image_path'):
|
| 564 |
+
face_data['face_image_path'] = '' # Empty string is valid
|
| 565 |
+
|
| 566 |
+
faces_collection = self.db_manager.db.detected_faces
|
| 567 |
+
faces_collection.insert_one(face_data)
|
| 568 |
+
face_results.append(face_data)
|
| 569 |
+
logger.info(f"✅ Saved face to MongoDB: {face_data['face_id']}")
|
| 570 |
+
except Exception as e:
|
| 571 |
+
logger.error(f"Failed to save face to MongoDB: {e}")
|
| 572 |
+
import traceback
|
| 573 |
+
logger.debug(traceback.format_exc())
|
| 574 |
+
# Still add to results even if MongoDB save fails
|
| 575 |
+
face_results.append(face_data)
|
| 576 |
+
|
| 577 |
+
# Clean up temp file AFTER MongoDB save
|
| 578 |
+
if temp_face_path and os.path.exists(temp_face_path):
|
| 579 |
+
try:
|
| 580 |
+
os.remove(temp_face_path)
|
| 581 |
+
except Exception as e:
|
| 582 |
+
logger.warning(f"Failed to remove temp face file: {e}")
|
| 583 |
+
|
| 584 |
+
except Exception as e:
|
| 585 |
+
logger.error(f"Facial recognition error for frame {frame_path}: {e}")
|
| 586 |
+
continue
|
| 587 |
+
|
| 588 |
+
logger.info(f"✅ Facial recognition completed: {len(face_results)} faces detected")
|
| 589 |
+
|
| 590 |
+
# Update metadata with face count
|
| 591 |
+
self.video_repo.update_metadata(video_id, {
|
| 592 |
+
"face_count": len(face_results),
|
| 593 |
+
"facial_recognition_completed": True
|
| 594 |
+
})
|
| 595 |
+
|
| 596 |
+
except ImportError:
|
| 597 |
+
logger.warning("Facial recognition module not available")
|
| 598 |
+
except Exception as e:
|
| 599 |
+
logger.error(f"Facial recognition failed: {e}")
|
| 600 |
+
|
| 601 |
+
# Step 6: Video Captioning (MOVED TO END - Last step, won't block other processing)
|
| 602 |
+
captioning_results = {}
|
| 603 |
+
if self.config.enable_video_captioning and self.video_captioning:
|
| 604 |
+
self.video_repo.update_metadata(video_id, {
|
| 605 |
+
"processing_progress": 90,
|
| 606 |
+
"processing_message": "Generating video captions with AI..."
|
| 607 |
+
})
|
| 608 |
+
logger.info("🎬 ===== STARTING VIDEO CAPTIONING (FINAL STEP) ===== ")
|
| 609 |
+
logger.info(f"📹 Processing {len(keyframes)} keyframes for captioning")
|
| 610 |
+
|
| 611 |
+
try:
|
| 612 |
+
captioning_results = self.video_captioning.process_keyframes_with_captioning(
|
| 613 |
+
keyframes,
|
| 614 |
+
video_id=video_id
|
| 615 |
+
)
|
| 616 |
+
|
| 617 |
+
# Update video metadata with captioning info
|
| 618 |
+
self.video_repo.update_metadata(video_id, {
|
| 619 |
+
"total_captions": captioning_results.get('total_captions', 0),
|
| 620 |
+
"captioning_enabled": captioning_results.get('enabled', False)
|
| 621 |
+
})
|
| 622 |
+
|
| 623 |
+
logger.info(f"✅ Video captioning complete: {captioning_results.get('total_captions', 0)} captions generated")
|
| 624 |
+
logger.info(f"💾 Captions saved to MongoDB, embeddings saved to FAISS")
|
| 625 |
+
except Exception as caption_error:
|
| 626 |
+
logger.error(f"❌ Video captioning failed (non-fatal): {caption_error}")
|
| 627 |
+
# Don't fail the entire pipeline if captioning fails
|
| 628 |
+
captioning_results = {'enabled': True, 'total_captions': 0, 'errors': [str(caption_error)]}
|
| 629 |
+
|
| 630 |
+
# Step 7: Finalize processing
|
| 631 |
+
final_meta_data = {
|
| 632 |
+
"processing_status": "completed",
|
| 633 |
+
"processing_progress": 100,
|
| 634 |
+
"processing_message": "Processing completed successfully!",
|
| 635 |
+
"keyframe_count": len(keyframes),
|
| 636 |
+
"detection_count": len(detection_results),
|
| 637 |
+
"event_count": len(object_events) if detection_results else 0,
|
| 638 |
+
"face_count": len(face_results) if 'face_results' in locals() else 0,
|
| 639 |
+
"caption_count": captioning_results.get('total_captions', 0) if captioning_results else 0,
|
| 640 |
+
"processed_at": datetime.utcnow().isoformat()
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
# Compressed video path was already set in Step 2
|
| 644 |
+
# No need to update again here
|
| 645 |
+
|
| 646 |
+
self.video_repo.update_processing_status(video_id, "completed")
|
| 647 |
+
self.video_repo.update_metadata(video_id, final_meta_data)
|
| 648 |
+
|
| 649 |
+
logger.info(f"✅ Video processing completed successfully: {video_id}")
|
| 650 |
+
|
| 651 |
+
# Cleanup temporary files
|
| 652 |
+
self._cleanup_temp_files(video_path, keyframes)
|
| 653 |
+
|
| 654 |
+
except Exception as e:
|
| 655 |
+
logger.error(f"❌ Video processing failed for {video_id}: {e}")
|
| 656 |
+
|
| 657 |
+
# Update status to failed
|
| 658 |
+
self.video_repo.update_processing_status(video_id, "failed")
|
| 659 |
+
self.video_repo.update_metadata(video_id, {
|
| 660 |
+
"processing_progress": 0,
|
| 661 |
+
"processing_message": f"Processing failed: {str(e)}",
|
| 662 |
+
"error_message": str(e),
|
| 663 |
+
"failed_at": datetime.utcnow().isoformat()
|
| 664 |
+
})
|
| 665 |
+
|
| 666 |
+
raise
|
| 667 |
+
|
| 668 |
+
def _extract_video_metadata(self, video_path: str) -> Dict:
|
| 669 |
+
"""Extract metadata from video file with schema-compliant field names"""
|
| 670 |
+
try:
|
| 671 |
+
cap = cv2.VideoCapture(video_path)
|
| 672 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 673 |
+
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 674 |
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 675 |
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 676 |
+
duration = frame_count / fps if fps > 0 else 0
|
| 677 |
+
file_size = os.path.getsize(video_path)
|
| 678 |
+
cap.release()
|
| 679 |
+
|
| 680 |
+
return {
|
| 681 |
+
"duration": duration,
|
| 682 |
+
"fps": float(fps),
|
| 683 |
+
"resolution": f"{width}x{height}",
|
| 684 |
+
"file_size": int(file_size),
|
| 685 |
+
"frame_count": int(frame_count)
|
| 686 |
+
}
|
| 687 |
+
except Exception as e:
|
| 688 |
+
logger.error(f"Failed to extract video metadata: {e}")
|
| 689 |
+
return {"file_size": os.path.getsize(video_path)}
|
| 690 |
+
|
| 691 |
+
def _run_object_detection_on_keyframes(self, video_id: str, keyframes: List) -> List[Dict]:
|
| 692 |
+
"""Run object detection on extracted keyframes, create annotated frames, and upload to MinIO"""
|
| 693 |
+
detection_results = []
|
| 694 |
+
annotated_keyframes_info = [] # Store info about annotated keyframes
|
| 695 |
+
|
| 696 |
+
try:
|
| 697 |
+
for i, keyframe in enumerate(keyframes):
|
| 698 |
+
# Get frame data
|
| 699 |
+
frame_data = keyframe.frame_data if hasattr(keyframe, 'frame_data') else keyframe
|
| 700 |
+
|
| 701 |
+
# Get frame path depending on structure
|
| 702 |
+
frame_path = (
|
| 703 |
+
frame_data.frame_path if hasattr(frame_data, 'frame_path')
|
| 704 |
+
else getattr(frame_data, 'path', None)
|
| 705 |
+
)
|
| 706 |
+
|
| 707 |
+
if frame_path and os.path.exists(frame_path):
|
| 708 |
+
# Get timestamp from frame data
|
| 709 |
+
timestamp = (
|
| 710 |
+
frame_data.timestamp if hasattr(frame_data, 'timestamp')
|
| 711 |
+
else getattr(frame_data, 'timestamp', 0.0)
|
| 712 |
+
)
|
| 713 |
+
|
| 714 |
+
frame_number = getattr(frame_data, 'frame_number', i)
|
| 715 |
+
|
| 716 |
+
# Run detection on this keyframe
|
| 717 |
+
detection_result = self.object_detector.detect_objects_in_frame(
|
| 718 |
+
frame_path,
|
| 719 |
+
timestamp
|
| 720 |
+
)
|
| 721 |
+
|
| 722 |
+
# Process detected objects and create annotated frame if detections exist
|
| 723 |
+
annotated_minio_path = None
|
| 724 |
+
if detection_result and detection_result.detected_objects:
|
| 725 |
+
# Create annotated version of the frame
|
| 726 |
+
try:
|
| 727 |
+
annotated_path = self.object_detector.annotate_frame_with_detections(
|
| 728 |
+
frame_path,
|
| 729 |
+
detection_result
|
| 730 |
+
)
|
| 731 |
+
|
| 732 |
+
# Upload annotated frame to MinIO
|
| 733 |
+
if annotated_path and os.path.exists(annotated_path):
|
| 734 |
+
annotated_minio_path = f"{video_id}/keyframes/annotated/frame_{frame_number:06d}_annotated.jpg"
|
| 735 |
+
|
| 736 |
+
with open(annotated_path, 'rb') as f:
|
| 737 |
+
file_size = os.path.getsize(annotated_path)
|
| 738 |
+
metadata = {
|
| 739 |
+
"frame_number": str(frame_number),
|
| 740 |
+
"timestamp": str(timestamp),
|
| 741 |
+
"is_annotated": "true",
|
| 742 |
+
"detection_count": str(len(detection_result.detected_objects))
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
self.keyframe_repo.minio.put_object(
|
| 746 |
+
self.keyframe_repo.bucket,
|
| 747 |
+
annotated_minio_path,
|
| 748 |
+
f,
|
| 749 |
+
file_size,
|
| 750 |
+
content_type='image/jpeg',
|
| 751 |
+
metadata=metadata
|
| 752 |
+
)
|
| 753 |
+
|
| 754 |
+
annotated_keyframes_info.append({
|
| 755 |
+
"frame_number": frame_number,
|
| 756 |
+
"timestamp": timestamp,
|
| 757 |
+
"minio_path": annotated_minio_path,
|
| 758 |
+
"original_minio_path": f"{video_id}/keyframes/frame_{frame_number:06d}.jpg",
|
| 759 |
+
"detection_count": len(detection_result.detected_objects),
|
| 760 |
+
"objects": [obj.class_name for obj in detection_result.detected_objects],
|
| 761 |
+
"confidence_avg": sum(obj.confidence for obj in detection_result.detected_objects) / len(detection_result.detected_objects) if detection_result.detected_objects else 0.0
|
| 762 |
+
})
|
| 763 |
+
|
| 764 |
+
logger.info(f"✅ Uploaded annotated keyframe to MinIO: {annotated_minio_path}")
|
| 765 |
+
except Exception as e:
|
| 766 |
+
logger.warning(f"Failed to create/upload annotated keyframe: {e}")
|
| 767 |
+
|
| 768 |
+
# Process detected objects for detection_results
|
| 769 |
+
if detection_result and detection_result.detected_objects:
|
| 770 |
+
for obj in detection_result.detected_objects:
|
| 771 |
+
detection_data = {
|
| 772 |
+
"frame_number": frame_number,
|
| 773 |
+
"class_name": str(obj.class_name),
|
| 774 |
+
"confidence": float(obj.confidence),
|
| 775 |
+
"bbox": [int(x) for x in obj.bbox[:4]], # Convert to list of ints
|
| 776 |
+
"center_point": [float(x) for x in obj.center_point],
|
| 777 |
+
"area": float(obj.area),
|
| 778 |
+
"frame_timestamp": float(obj.frame_timestamp),
|
| 779 |
+
"detection_model": str(obj.detection_model),
|
| 780 |
+
"annotated_minio_path": annotated_minio_path # Link to annotated frame
|
| 781 |
+
}
|
| 782 |
+
# Apply numpy type conversion
|
| 783 |
+
detection_data = convert_numpy_types(detection_data)
|
| 784 |
+
detection_results.append(detection_data)
|
| 785 |
+
|
| 786 |
+
# Store annotated keyframes info in MongoDB metadata
|
| 787 |
+
if annotated_keyframes_info:
|
| 788 |
+
self.video_repo.update_metadata(video_id, {
|
| 789 |
+
"annotated_keyframes_info": annotated_keyframes_info,
|
| 790 |
+
"annotated_keyframes_count": len(annotated_keyframes_info)
|
| 791 |
+
})
|
| 792 |
+
logger.info(f"✅ Stored {len(annotated_keyframes_info)} annotated keyframes metadata")
|
| 793 |
+
|
| 794 |
+
logger.info(f"✅ Object detection completed: {len(detection_results)} detections")
|
| 795 |
+
return detection_results
|
| 796 |
+
|
| 797 |
+
except Exception as e:
|
| 798 |
+
logger.error(f"Object detection failed: {e}")
|
| 799 |
+
import traceback
|
| 800 |
+
logger.debug(traceback.format_exc())
|
| 801 |
+
return []
|
| 802 |
+
|
| 803 |
+
def _create_object_events_from_detections(self, detection_results: List[Dict]) -> List[Dict]:
|
| 804 |
+
"""Convert object detections into aggregated schema-compliant events"""
|
| 805 |
+
events = []
|
| 806 |
+
|
| 807 |
+
try:
|
| 808 |
+
# Group detections by class and temporal proximity
|
| 809 |
+
detection_groups = self._group_detections_by_class_and_time(detection_results)
|
| 810 |
+
|
| 811 |
+
for class_name, detections in detection_groups.items():
|
| 812 |
+
if not detections:
|
| 813 |
+
continue
|
| 814 |
+
|
| 815 |
+
# Create event from detection group
|
| 816 |
+
start_time_secs = min(d['frame_timestamp'] for d in detections)
|
| 817 |
+
end_time_secs = max(d['frame_timestamp'] for d in detections)
|
| 818 |
+
avg_confidence = sum(d['confidence'] for d in detections) / len(detections)
|
| 819 |
+
|
| 820 |
+
# Calculate importance score based on threat level and confidence
|
| 821 |
+
threat_multiplier = {'fire': 3.0, 'gun': 3.0, 'knife': 2.0, 'smoke': 1.5}.get(class_name, 1.0)
|
| 822 |
+
importance_score = avg_confidence * threat_multiplier
|
| 823 |
+
|
| 824 |
+
# Create schema-compliant event structure
|
| 825 |
+
event = {
|
| 826 |
+
"event_type": f"object_detection_{class_name}",
|
| 827 |
+
"start_timestamp": start_time_secs,
|
| 828 |
+
"end_timestamp": end_time_secs,
|
| 829 |
+
"confidence_score": avg_confidence,
|
| 830 |
+
"importance_score": importance_score,
|
| 831 |
+
"bounding_boxes": [
|
| 832 |
+
{
|
| 833 |
+
"x": d['bbox'][0],
|
| 834 |
+
"y": d['bbox'][1],
|
| 835 |
+
"width": d['bbox'][2] - d['bbox'][0],
|
| 836 |
+
"height": d['bbox'][3] - d['bbox'][1],
|
| 837 |
+
"confidence": d['confidence'],
|
| 838 |
+
"class_name": d['class_name']
|
| 839 |
+
}
|
| 840 |
+
for d in detections
|
| 841 |
+
],
|
| 842 |
+
"detected_object_type": class_name,
|
| 843 |
+
"detection_count": len(detections),
|
| 844 |
+
"threat_level": self._calculate_threat_level(class_name, avg_confidence)
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
events.append(event)
|
| 848 |
+
|
| 849 |
+
return events
|
| 850 |
+
|
| 851 |
+
except Exception as e:
|
| 852 |
+
logger.error(f"Failed to create object events: {e}")
|
| 853 |
+
return []
|
| 854 |
+
|
| 855 |
+
def _calculate_threat_level(self, class_name: str, confidence: float) -> str:
|
| 856 |
+
"""Calculate threat level based on object class and confidence"""
|
| 857 |
+
if class_name in ['fire', 'gun'] and confidence > 0.7:
|
| 858 |
+
return 'critical'
|
| 859 |
+
elif class_name in ['fire', 'gun', 'knife'] and confidence > 0.5:
|
| 860 |
+
return 'high'
|
| 861 |
+
elif class_name in ['smoke', 'knife']:
|
| 862 |
+
return 'medium'
|
| 863 |
+
else:
|
| 864 |
+
return 'low'
|
| 865 |
+
|
| 866 |
+
def _group_detections_by_class_and_time(self, detections: List[Dict], time_window: float = 5.0) -> Dict[str, List[Dict]]:
|
| 867 |
+
"""Group detections by object class and temporal proximity"""
|
| 868 |
+
grouped = {}
|
| 869 |
+
|
| 870 |
+
# Sort detections by timestamp
|
| 871 |
+
sorted_detections = sorted(detections, key=lambda x: x['frame_timestamp'])
|
| 872 |
+
|
| 873 |
+
for detection in sorted_detections:
|
| 874 |
+
class_name = detection['class_name']
|
| 875 |
+
|
| 876 |
+
if class_name not in grouped:
|
| 877 |
+
grouped[class_name] = []
|
| 878 |
+
|
| 879 |
+
grouped[class_name].append(detection)
|
| 880 |
+
|
| 881 |
+
return grouped
|
| 882 |
+
|
| 883 |
+
def _generate_compressed_video(self, video_path: str, video_id: str) -> Optional[str]:
|
| 884 |
+
"""Generate compressed version of video and upload to MinIO"""
|
| 885 |
+
try:
|
| 886 |
+
# Use compression service to compress and store video
|
| 887 |
+
result = self.compression_service.compress_and_store(video_path, video_id)
|
| 888 |
+
|
| 889 |
+
if result and result.get('success'):
|
| 890 |
+
compression_info = {
|
| 891 |
+
'original_size_bytes': result['original_size'],
|
| 892 |
+
'compressed_size_bytes': result['compressed_size'],
|
| 893 |
+
'compression_ratio': result['compression_ratio'],
|
| 894 |
+
'output_resolution': result['output_resolution'],
|
| 895 |
+
'local_path': result.get('local_path'), # Store local path for fallback
|
| 896 |
+
'minio_path': result.get('minio_path') # Store MinIO path
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
+
# Update video metadata with compression info (including local path)
|
| 900 |
+
self.video_repo.update_metadata(video_id, {
|
| 901 |
+
'compression_info': compression_info,
|
| 902 |
+
'minio_compressed_path': result.get('minio_path') # Also store at top level for easy access
|
| 903 |
+
})
|
| 904 |
+
|
| 905 |
+
logger.info(f"✅ Stored compression info with local path: {result.get('local_path')}")
|
| 906 |
+
return result['minio_path']
|
| 907 |
+
else:
|
| 908 |
+
logger.error("Video compression failed")
|
| 909 |
+
return None
|
| 910 |
+
|
| 911 |
+
except Exception as e:
|
| 912 |
+
logger.error(f"❌ Failed to generate compressed video: {e}")
|
| 913 |
+
return None
|
| 914 |
+
|
| 915 |
+
def _cleanup_temp_files(self, video_path: str, keyframes: List):
|
| 916 |
+
"""Clean up temporary files after processing"""
|
| 917 |
+
try:
|
| 918 |
+
# Remove uploaded video file
|
| 919 |
+
if os.path.exists(video_path):
|
| 920 |
+
os.remove(video_path)
|
| 921 |
+
|
| 922 |
+
# Remove temporary keyframe files
|
| 923 |
+
for keyframe in keyframes:
|
| 924 |
+
frame_data = keyframe.frame_data if hasattr(keyframe, 'frame_data') else keyframe
|
| 925 |
+
|
| 926 |
+
# Get frame path depending on structure
|
| 927 |
+
frame_path = (
|
| 928 |
+
frame_data.frame_path if hasattr(frame_data, 'frame_path')
|
| 929 |
+
else getattr(frame_data, 'path', None)
|
| 930 |
+
)
|
| 931 |
+
|
| 932 |
+
if frame_path and os.path.exists(frame_path):
|
| 933 |
+
os.remove(frame_path)
|
| 934 |
+
|
| 935 |
+
logger.info("✅ Temporary files cleaned up")
|
| 936 |
+
|
| 937 |
+
except Exception as e:
|
| 938 |
+
logger.error(f"⚠️ Failed to cleanup temp files: {e}")
|
| 939 |
+
|
| 940 |
+
def get_video_status(self, video_id: str) -> Dict:
|
| 941 |
+
"""Get processing status for a video"""
|
| 942 |
+
video = self.video_repo.get_video_by_id(video_id)
|
| 943 |
+
|
| 944 |
+
if not video:
|
| 945 |
+
return {"error": "Video not found"}
|
| 946 |
+
|
| 947 |
+
meta_data = video.get("meta_data", {})
|
| 948 |
+
|
| 949 |
+
status_data = {
|
| 950 |
+
"video_id": video_id,
|
| 951 |
+
"status": meta_data.get("processing_status", "unknown"),
|
| 952 |
+
"filename": meta_data.get("filename"),
|
| 953 |
+
"upload_date": video.get("upload_date"),
|
| 954 |
+
"duration": video.get("duration_secs"),
|
| 955 |
+
"fps": video.get("fps"),
|
| 956 |
+
"file_size_bytes": video.get("file_size_bytes"),
|
| 957 |
+
"resolution": meta_data.get("resolution"),
|
| 958 |
+
"keyframe_count": meta_data.get("keyframe_count", 0),
|
| 959 |
+
"detection_count": meta_data.get("detection_count", 0),
|
| 960 |
+
"event_count": meta_data.get("event_count", 0),
|
| 961 |
+
"processing_progress": meta_data.get("processing_progress", 0),
|
| 962 |
+
"processing_message": meta_data.get("processing_message", "")
|
| 963 |
+
}
|
| 964 |
+
|
| 965 |
+
# Add presigned URLs for accessing content
|
| 966 |
+
try:
|
| 967 |
+
# Original video URL
|
| 968 |
+
minio_original_path = meta_data.get("minio_original_path")
|
| 969 |
+
if minio_original_path:
|
| 970 |
+
status_data["original_video_url"] = self.video_repo.get_video_presigned_url(minio_original_path)
|
| 971 |
+
|
| 972 |
+
# Compressed video URL (if available)
|
| 973 |
+
minio_compressed_path = meta_data.get("minio_compressed_path")
|
| 974 |
+
if minio_compressed_path:
|
| 975 |
+
# Always use the API endpoint which will handle MinIO/local fallback
|
| 976 |
+
status_data["compressed_video_url"] = f"/api/video/compressed/{video_id}"
|
| 977 |
+
# Also try to get presigned URL as alternative
|
| 978 |
+
try:
|
| 979 |
+
presigned_url = self.compression_service.get_compressed_video_presigned_url(video_id)
|
| 980 |
+
if presigned_url:
|
| 981 |
+
status_data["compressed_video_presigned_url"] = presigned_url
|
| 982 |
+
except:
|
| 983 |
+
pass
|
| 984 |
+
else:
|
| 985 |
+
# Check if compression was completed but path not set
|
| 986 |
+
if meta_data.get("processing_status") == "completed":
|
| 987 |
+
# Try to construct path and use API endpoint
|
| 988 |
+
status_data["compressed_video_url"] = f"/api/video/compressed/{video_id}"
|
| 989 |
+
|
| 990 |
+
# Keyframes URLs (if available)
|
| 991 |
+
if meta_data.get("keyframe_count", 0) > 0:
|
| 992 |
+
try:
|
| 993 |
+
keyframes_urls = self.keyframe_repo.get_video_keyframes_presigned_urls(video_id)
|
| 994 |
+
# If no URLs from MinIO, try to get from MongoDB metadata
|
| 995 |
+
if not keyframes_urls and meta_data.get("keyframe_info"):
|
| 996 |
+
# Generate URLs from stored metadata
|
| 997 |
+
keyframes_urls = []
|
| 998 |
+
for kf_info in meta_data.get("keyframe_info", []):
|
| 999 |
+
minio_path = kf_info.get("minio_path")
|
| 1000 |
+
if minio_path:
|
| 1001 |
+
presigned_url = self.keyframe_repo.get_keyframe_presigned_url(minio_path)
|
| 1002 |
+
# Also provide API endpoint URL
|
| 1003 |
+
api_url = f"/api/minio/image/{self.keyframe_repo.bucket}/{minio_path}"
|
| 1004 |
+
if presigned_url:
|
| 1005 |
+
keyframes_urls.append({
|
| 1006 |
+
'frame_number': kf_info.get("frame_number", 0),
|
| 1007 |
+
'timestamp': kf_info.get("timestamp", 0.0),
|
| 1008 |
+
'minio_path': minio_path,
|
| 1009 |
+
'presigned_url': presigned_url,
|
| 1010 |
+
'url': api_url, # Use API endpoint for better reliability
|
| 1011 |
+
'api_url': api_url,
|
| 1012 |
+
'filename': minio_path.split('/')[-1]
|
| 1013 |
+
})
|
| 1014 |
+
status_data["keyframes_urls"] = keyframes_urls
|
| 1015 |
+
except Exception as e:
|
| 1016 |
+
logger.warning(f"Failed to get keyframes URLs: {e}")
|
| 1017 |
+
status_data["keyframes_urls"] = []
|
| 1018 |
+
|
| 1019 |
+
except Exception as e:
|
| 1020 |
+
logger.warning(f"Failed to generate presigned URLs for video {video_id}: {e}")
|
| 1021 |
+
|
| 1022 |
+
return status_data
|
| 1023 |
+
|
| 1024 |
+
def get_video_keyframes(self, video_id: str, filter_detections: bool = False, limit: int = None) -> Dict:
|
| 1025 |
+
"""Get keyframes for a video with optional filtering and presigned URLs"""
|
| 1026 |
+
try:
|
| 1027 |
+
# Get video record to check if it exists
|
| 1028 |
+
video = self.video_repo.get_video_by_id(video_id)
|
| 1029 |
+
if not video:
|
| 1030 |
+
return {"error": "Video not found"}
|
| 1031 |
+
|
| 1032 |
+
# Get keyframes with presigned URLs from keyframe repository
|
| 1033 |
+
keyframes_urls = self.keyframe_repo.get_video_keyframes_presigned_urls(video_id)
|
| 1034 |
+
|
| 1035 |
+
# Fallback: If no keyframes from MinIO, try to get from MongoDB metadata
|
| 1036 |
+
if not keyframes_urls:
|
| 1037 |
+
meta_data = video.get("meta_data", {})
|
| 1038 |
+
keyframe_info = meta_data.get("keyframe_info", [])
|
| 1039 |
+
if keyframe_info:
|
| 1040 |
+
logger.info(f"Using MongoDB metadata for keyframes: {len(keyframe_info)} keyframes")
|
| 1041 |
+
for kf_info in keyframe_info:
|
| 1042 |
+
minio_path = kf_info.get("minio_path")
|
| 1043 |
+
if minio_path:
|
| 1044 |
+
try:
|
| 1045 |
+
presigned_url = self.keyframe_repo.get_keyframe_presigned_url(minio_path)
|
| 1046 |
+
if presigned_url:
|
| 1047 |
+
keyframes_urls.append({
|
| 1048 |
+
'frame_number': kf_info.get("frame_number", 0),
|
| 1049 |
+
'timestamp': kf_info.get("timestamp", 0.0),
|
| 1050 |
+
'minio_path': minio_path,
|
| 1051 |
+
'presigned_url': presigned_url,
|
| 1052 |
+
'url': presigned_url,
|
| 1053 |
+
'filename': minio_path.split('/')[-1]
|
| 1054 |
+
})
|
| 1055 |
+
except Exception as e:
|
| 1056 |
+
logger.warning(f"Failed to generate presigned URL for {minio_path}: {e}")
|
| 1057 |
+
|
| 1058 |
+
# Get events to determine which keyframes have detections
|
| 1059 |
+
events = self.event_repo.get_events_by_video_id(video_id)
|
| 1060 |
+
detection_events = [e for e in events if e.get("event_type", "").startswith("object_detection_")]
|
| 1061 |
+
|
| 1062 |
+
# Create a map of timestamps that have detections
|
| 1063 |
+
detection_timestamps = set()
|
| 1064 |
+
for event in detection_events:
|
| 1065 |
+
start_ms = event.get("start_timestamp_ms", 0)
|
| 1066 |
+
end_ms = event.get("end_timestamp_ms", 0)
|
| 1067 |
+
# Convert milliseconds to seconds and create range
|
| 1068 |
+
start_sec = start_ms / 1000.0
|
| 1069 |
+
end_sec = end_ms / 1000.0
|
| 1070 |
+
# Add timestamps in 1-second intervals
|
| 1071 |
+
for t in range(int(start_sec), int(end_sec) + 1):
|
| 1072 |
+
detection_timestamps.add(t)
|
| 1073 |
+
|
| 1074 |
+
# Get annotated keyframes info from metadata
|
| 1075 |
+
meta_data = video.get("meta_data", {})
|
| 1076 |
+
annotated_keyframes_info = meta_data.get("annotated_keyframes_info", [])
|
| 1077 |
+
annotated_lookup = {kf.get("frame_number"): kf for kf in annotated_keyframes_info}
|
| 1078 |
+
|
| 1079 |
+
# Get faces for this video to check which keyframes have faces
|
| 1080 |
+
faces_data = self.get_video_faces(video_id)
|
| 1081 |
+
faces = faces_data.get("faces", [])
|
| 1082 |
+
|
| 1083 |
+
# Create a map of frame_numbers and timestamps that have faces
|
| 1084 |
+
frames_with_faces = set()
|
| 1085 |
+
timestamps_with_faces = set()
|
| 1086 |
+
for face in faces:
|
| 1087 |
+
face_frame = face.get('frame_number', 0)
|
| 1088 |
+
face_timestamp = face.get('timestamp', 0)
|
| 1089 |
+
if face_frame:
|
| 1090 |
+
frames_with_faces.add(face_frame)
|
| 1091 |
+
if face_timestamp:
|
| 1092 |
+
timestamps_with_faces.add(face_timestamp)
|
| 1093 |
+
|
| 1094 |
+
# Enhance keyframes with detection info and annotated URLs
|
| 1095 |
+
enhanced_keyframes = []
|
| 1096 |
+
for kf in keyframes_urls:
|
| 1097 |
+
timestamp_sec = kf.get('timestamp', 0)
|
| 1098 |
+
frame_number = kf.get('frame_number', 0)
|
| 1099 |
+
|
| 1100 |
+
# Check if this timestamp has detections (within 1 second tolerance)
|
| 1101 |
+
has_detections = any(abs(timestamp_sec - dt) < 1.0 for dt in detection_timestamps)
|
| 1102 |
+
|
| 1103 |
+
# Check if this keyframe has faces (by frame_number or timestamp)
|
| 1104 |
+
has_faces = (
|
| 1105 |
+
frame_number in frames_with_faces or
|
| 1106 |
+
any(abs(timestamp_sec - ft) < 0.5 for ft in timestamps_with_faces)
|
| 1107 |
+
)
|
| 1108 |
+
|
| 1109 |
+
enhanced_kf = {
|
| 1110 |
+
**kf,
|
| 1111 |
+
'has_detections': has_detections,
|
| 1112 |
+
'has_faces': has_faces, # Add face detection flag
|
| 1113 |
+
'url': kf.get('presigned_url'), # Add url alias for compatibility
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
# Add annotated frame info if available
|
| 1117 |
+
if frame_number in annotated_lookup:
|
| 1118 |
+
annotated_info = annotated_lookup[frame_number]
|
| 1119 |
+
# Generate presigned URL for annotated frame
|
| 1120 |
+
try:
|
| 1121 |
+
annotated_presigned_url = self.keyframe_repo.get_keyframe_presigned_url(
|
| 1122 |
+
annotated_info.get("minio_path")
|
| 1123 |
+
)
|
| 1124 |
+
if annotated_presigned_url:
|
| 1125 |
+
enhanced_kf['annotated_url'] = annotated_presigned_url
|
| 1126 |
+
enhanced_kf['annotated_presigned_url'] = annotated_presigned_url
|
| 1127 |
+
enhanced_kf['detection_count'] = annotated_info.get("detection_count", 0)
|
| 1128 |
+
enhanced_kf['objects'] = annotated_info.get("objects", [])
|
| 1129 |
+
enhanced_kf['confidence_avg'] = annotated_info.get("confidence_avg", 0.0)
|
| 1130 |
+
enhanced_kf['has_detections'] = True # Override if annotated frame exists
|
| 1131 |
+
except Exception as e:
|
| 1132 |
+
logger.warning(f"Failed to get presigned URL for annotated keyframe: {e}")
|
| 1133 |
+
|
| 1134 |
+
# If this keyframe has faces, prioritize showing "Face Detected" over object names
|
| 1135 |
+
if has_faces:
|
| 1136 |
+
# Count faces for this keyframe
|
| 1137 |
+
face_count = sum(
|
| 1138 |
+
1 for face in faces
|
| 1139 |
+
if (face.get('frame_number') == frame_number or
|
| 1140 |
+
abs(face.get('timestamp', 0) - timestamp_sec) < 0.5)
|
| 1141 |
+
)
|
| 1142 |
+
enhanced_kf['face_count'] = face_count
|
| 1143 |
+
# Add "Face Detected" to objects list if not already present, and prioritize it
|
| 1144 |
+
if enhanced_kf.get('objects'):
|
| 1145 |
+
# Check if "Face" is already in objects
|
| 1146 |
+
has_face_in_objects = any('face' in str(obj).lower() for obj in enhanced_kf['objects'])
|
| 1147 |
+
if not has_face_in_objects:
|
| 1148 |
+
# Add "Face Detected" at the beginning
|
| 1149 |
+
enhanced_kf['objects'] = ['Face Detected'] + enhanced_kf['objects']
|
| 1150 |
+
else:
|
| 1151 |
+
# Move "Face Detected" to front, remove duplicates
|
| 1152 |
+
face_objects = [obj for obj in enhanced_kf['objects'] if 'face' in str(obj).lower()]
|
| 1153 |
+
other_objects = [obj for obj in enhanced_kf['objects'] if 'face' not in str(obj).lower()]
|
| 1154 |
+
enhanced_kf['objects'] = ['Face Detected'] + other_objects
|
| 1155 |
+
else:
|
| 1156 |
+
enhanced_kf['objects'] = ['Face Detected']
|
| 1157 |
+
# Update detection count to include faces
|
| 1158 |
+
enhanced_kf['detection_count'] = enhanced_kf.get('detection_count', 0) + face_count
|
| 1159 |
+
|
| 1160 |
+
enhanced_keyframes.append(enhanced_kf)
|
| 1161 |
+
|
| 1162 |
+
# Apply filtering if requested
|
| 1163 |
+
if filter_detections:
|
| 1164 |
+
filtered_keyframes = [kf for kf in enhanced_keyframes if kf.get('has_detections', False)]
|
| 1165 |
+
else:
|
| 1166 |
+
filtered_keyframes = enhanced_keyframes
|
| 1167 |
+
|
| 1168 |
+
# Apply limit if specified
|
| 1169 |
+
if limit and limit > 0:
|
| 1170 |
+
filtered_keyframes = filtered_keyframes[:limit]
|
| 1171 |
+
|
| 1172 |
+
# Get video metadata for additional context
|
| 1173 |
+
meta_data = video.get("meta_data", {})
|
| 1174 |
+
keyframe_count = meta_data.get("keyframe_count", 0)
|
| 1175 |
+
|
| 1176 |
+
return {
|
| 1177 |
+
"video_id": video_id,
|
| 1178 |
+
"keyframes": filtered_keyframes,
|
| 1179 |
+
"total_keyframes": len(filtered_keyframes),
|
| 1180 |
+
"filter_applied": filter_detections,
|
| 1181 |
+
"limit_applied": limit if limit and limit > 0 else None,
|
| 1182 |
+
"keyframe_count": keyframe_count
|
| 1183 |
+
}
|
| 1184 |
+
|
| 1185 |
+
except Exception as e:
|
| 1186 |
+
logger.error(f"Failed to get keyframes for video {video_id}: {e}")
|
| 1187 |
+
return {"error": str(e)}
|
| 1188 |
+
|
| 1189 |
+
def get_video_events(self, video_id: str, event_type: str = None) -> Dict:
|
| 1190 |
+
"""Get events for a video"""
|
| 1191 |
+
events = self.event_repo.get_events_by_video_id(video_id)
|
| 1192 |
+
|
| 1193 |
+
# Filter by event type if specified
|
| 1194 |
+
if event_type:
|
| 1195 |
+
events = [e for e in events if e.get("event_type") == event_type]
|
| 1196 |
+
|
| 1197 |
+
return {
|
| 1198 |
+
"video_id": video_id,
|
| 1199 |
+
"events": events,
|
| 1200 |
+
"total_events": len(events)
|
| 1201 |
+
}
|
| 1202 |
+
|
| 1203 |
+
def get_video_detections(self, video_id: str, class_filter: str = None) -> Dict:
|
| 1204 |
+
"""Get object detections for a video from events"""
|
| 1205 |
+
try:
|
| 1206 |
+
# Get all events for this video
|
| 1207 |
+
events = self.event_repo.get_events_by_video_id(video_id)
|
| 1208 |
+
|
| 1209 |
+
# Filter events that are object detection events
|
| 1210 |
+
detection_events = [e for e in events if e.get("event_type", "").startswith("object_detection_")]
|
| 1211 |
+
|
| 1212 |
+
# Apply class filter if specified
|
| 1213 |
+
if class_filter:
|
| 1214 |
+
detection_events = [e for e in detection_events if e.get("event_type") == f"object_detection_{class_filter}"]
|
| 1215 |
+
|
| 1216 |
+
# Extract detections from bounding_boxes
|
| 1217 |
+
detections = []
|
| 1218 |
+
for event in detection_events:
|
| 1219 |
+
bboxes = event.get("bounding_boxes", {})
|
| 1220 |
+
|
| 1221 |
+
# Handle different bounding_boxes structures
|
| 1222 |
+
event_detections = []
|
| 1223 |
+
if isinstance(bboxes, dict):
|
| 1224 |
+
event_detections = bboxes.get("detections", [])
|
| 1225 |
+
elif isinstance(bboxes, list):
|
| 1226 |
+
# If bounding_boxes is a list directly
|
| 1227 |
+
event_detections = bboxes
|
| 1228 |
+
|
| 1229 |
+
# Also check if detections are stored directly in event
|
| 1230 |
+
if not event_detections:
|
| 1231 |
+
event_detections = event.get("detections", [])
|
| 1232 |
+
|
| 1233 |
+
for det in event_detections:
|
| 1234 |
+
# Handle both dict and list formats
|
| 1235 |
+
if isinstance(det, dict):
|
| 1236 |
+
detection = {
|
| 1237 |
+
"class_name": det.get("class", det.get("class_name", "unknown")),
|
| 1238 |
+
"confidence": float(det.get("confidence", 0.0)),
|
| 1239 |
+
"bbox": det.get("bbox", [0, 0, 0, 0]),
|
| 1240 |
+
"timestamp": float(det.get("timestamp", event.get("start_timestamp_ms", 0) / 1000.0)),
|
| 1241 |
+
"event_id": event.get("event_id"),
|
| 1242 |
+
"model": det.get("model", "unknown")
|
| 1243 |
+
}
|
| 1244 |
+
detections.append(detection)
|
| 1245 |
+
elif isinstance(det, list) and len(det) >= 4:
|
| 1246 |
+
# Handle list format [x, y, width, height, class, confidence]
|
| 1247 |
+
detection = {
|
| 1248 |
+
"class_name": str(det[4]) if len(det) > 4 else "unknown",
|
| 1249 |
+
"confidence": float(det[5]) if len(det) > 5 else 0.0,
|
| 1250 |
+
"bbox": [int(det[0]), int(det[1]), int(det[0] + det[2]), int(det[1] + det[3])] if len(det) >= 4 else [0, 0, 0, 0],
|
| 1251 |
+
"timestamp": float(event.get("start_timestamp_ms", 0) / 1000.0),
|
| 1252 |
+
"event_id": event.get("event_id"),
|
| 1253 |
+
"model": "unknown"
|
| 1254 |
+
}
|
| 1255 |
+
detections.append(detection)
|
| 1256 |
+
|
| 1257 |
+
# Also extract from event_type if no detections found
|
| 1258 |
+
if not detections and event.get("event_type"):
|
| 1259 |
+
event_type = event.get("event_type", "")
|
| 1260 |
+
if event_type.startswith("object_detection_"):
|
| 1261 |
+
class_name = event_type.replace("object_detection_", "")
|
| 1262 |
+
detection = {
|
| 1263 |
+
"class_name": class_name,
|
| 1264 |
+
"confidence": float(event.get("confidence_score", 0.0)),
|
| 1265 |
+
"bbox": [0, 0, 0, 0], # No bbox info available
|
| 1266 |
+
"timestamp": float(event.get("start_timestamp_ms", 0) / 1000.0),
|
| 1267 |
+
"event_id": event.get("event_id"),
|
| 1268 |
+
"model": "unknown"
|
| 1269 |
+
}
|
| 1270 |
+
detections.append(detection)
|
| 1271 |
+
|
| 1272 |
+
return {
|
| 1273 |
+
"video_id": video_id,
|
| 1274 |
+
"detections": detections,
|
| 1275 |
+
"total_detections": len(detections)
|
| 1276 |
+
}
|
| 1277 |
+
|
| 1278 |
+
except Exception as e:
|
| 1279 |
+
logger.error(f"Failed to get detections for video {video_id}: {e}")
|
| 1280 |
+
return {
|
| 1281 |
+
"video_id": video_id,
|
| 1282 |
+
"detections": [],
|
| 1283 |
+
"total_detections": 0,
|
| 1284 |
+
"error": str(e)
|
| 1285 |
+
}
|
| 1286 |
+
|
| 1287 |
+
def get_video_faces(self, video_id: str) -> Dict:
|
| 1288 |
+
"""Get detected faces for a video (through events)"""
|
| 1289 |
+
try:
|
| 1290 |
+
# Get all events for this video
|
| 1291 |
+
events = self.event_repo.get_events_by_video_id(video_id)
|
| 1292 |
+
event_ids = [e.get('event_id') for e in events if e.get('event_id')]
|
| 1293 |
+
|
| 1294 |
+
if not event_ids:
|
| 1295 |
+
return {
|
| 1296 |
+
"video_id": video_id,
|
| 1297 |
+
"faces": [],
|
| 1298 |
+
"total_faces": 0
|
| 1299 |
+
}
|
| 1300 |
+
|
| 1301 |
+
# Query detected_faces collection for faces associated with these events
|
| 1302 |
+
faces_collection = self.db_manager.db.detected_faces
|
| 1303 |
+
faces = list(faces_collection.find({"event_id": {"$in": event_ids}}))
|
| 1304 |
+
|
| 1305 |
+
# Convert ObjectIds to strings
|
| 1306 |
+
from database.models import convert_objectid_to_string
|
| 1307 |
+
faces = [convert_objectid_to_string(face) for face in faces]
|
| 1308 |
+
|
| 1309 |
+
return {
|
| 1310 |
+
"video_id": video_id,
|
| 1311 |
+
"faces": faces,
|
| 1312 |
+
"total_faces": len(faces)
|
| 1313 |
+
}
|
| 1314 |
+
|
| 1315 |
+
except Exception as e:
|
| 1316 |
+
logger.error(f"Failed to get faces for video {video_id}: {e}")
|
| 1317 |
+
return {
|
| 1318 |
+
"video_id": video_id,
|
| 1319 |
+
"faces": [],
|
| 1320 |
+
"total_faces": 0,
|
| 1321 |
+
"error": str(e)
|
| 1322 |
+
}
|
| 1323 |
+
|
| 1324 |
+
def process_video_complete(self, video_path: str, video_id: str, user_id: str = None,
|
| 1325 |
+
upload_to_minio: bool = True, enable_compression: bool = True,
|
| 1326 |
+
enable_object_detection: bool = True, enable_behavior_analysis: bool = True,
|
| 1327 |
+
enable_event_aggregation: bool = True,
|
| 1328 |
+
enable_deduplication: bool = True) -> Dict:
|
| 1329 |
+
"""
|
| 1330 |
+
Complete video processing pipeline with all features
|
| 1331 |
+
|
| 1332 |
+
Args:
|
| 1333 |
+
video_path: Path to the video file
|
| 1334 |
+
video_id: Unique identifier for the video
|
| 1335 |
+
user_id: User identifier
|
| 1336 |
+
upload_to_minio: Whether to upload to MinIO storage
|
| 1337 |
+
enable_compression: Whether to compress the video
|
| 1338 |
+
enable_object_detection: Whether to run object detection
|
| 1339 |
+
enable_event_aggregation: Whether to aggregate events
|
| 1340 |
+
enable_deduplication: Whether to deduplicate similar events
|
| 1341 |
+
|
| 1342 |
+
Returns:
|
| 1343 |
+
Dict with processing results and statistics
|
| 1344 |
+
"""
|
| 1345 |
+
logger.info(f"🔥 Starting complete pipeline processing for {video_id}")
|
| 1346 |
+
|
| 1347 |
+
start_time = time.time()
|
| 1348 |
+
results = {
|
| 1349 |
+
"video_id": video_id,
|
| 1350 |
+
"status": "processing",
|
| 1351 |
+
"minio_uploaded": False,
|
| 1352 |
+
"processing_stats": {}
|
| 1353 |
+
}
|
| 1354 |
+
|
| 1355 |
+
try:
|
| 1356 |
+
# Step 1: Create video record with metadata
|
| 1357 |
+
logger.info("📝 Creating video record...")
|
| 1358 |
+
video_metadata = self._extract_video_metadata(video_path)
|
| 1359 |
+
|
| 1360 |
+
# Create schema-compliant video record
|
| 1361 |
+
video_record = {
|
| 1362 |
+
"video_id": video_id,
|
| 1363 |
+
"user_id": user_id or "system",
|
| 1364 |
+
"file_path": f"videos/{video_id}.mp4",
|
| 1365 |
+
"fps": video_metadata.get("fps", 30.0),
|
| 1366 |
+
"duration_secs": int(video_metadata.get("duration", 0)),
|
| 1367 |
+
"file_size_bytes": video_metadata.get("file_size", 0),
|
| 1368 |
+
"codec": "h264", # default codec
|
| 1369 |
+
"meta_data": {
|
| 1370 |
+
"processing_status": "processing",
|
| 1371 |
+
"filename": os.path.basename(video_path),
|
| 1372 |
+
"resolution": video_metadata.get("resolution"),
|
| 1373 |
+
"frame_count": video_metadata.get("frame_count")
|
| 1374 |
+
}
|
| 1375 |
+
}
|
| 1376 |
+
|
| 1377 |
+
video_doc_id = self.video_repo.create_video_record(video_record)
|
| 1378 |
+
logger.info(f"✅ Created video record: {video_id}")
|
| 1379 |
+
|
| 1380 |
+
# Step 2: Upload to MinIO (if enabled and available)
|
| 1381 |
+
minio_uploaded = False
|
| 1382 |
+
if upload_to_minio:
|
| 1383 |
+
try:
|
| 1384 |
+
logger.info("☁️ Uploading to MinIO...")
|
| 1385 |
+
minio_path = self.video_repo.upload_video_to_minio(video_path, video_id)
|
| 1386 |
+
minio_uploaded = True
|
| 1387 |
+
self.video_repo.update_metadata(video_id, {"minio_original_path": minio_path})
|
| 1388 |
+
logger.info(f"✅ Video uploaded to MinIO: {minio_path}")
|
| 1389 |
+
except Exception as e:
|
| 1390 |
+
logger.warning(f"⚠️ MinIO upload failed (graceful fallback): {e}")
|
| 1391 |
+
|
| 1392 |
+
results["minio_uploaded"] = minio_uploaded
|
| 1393 |
+
|
| 1394 |
+
# Step 3: Process keyframes with object detection
|
| 1395 |
+
logger.info("🔑 Processing keyframes...")
|
| 1396 |
+
keyframes = self.video_processor.extract_keyframes(video_path)
|
| 1397 |
+
logger.info(f"✅ Extracted {len(keyframes)} keyframes")
|
| 1398 |
+
|
| 1399 |
+
# Run object detection on keyframes if enabled
|
| 1400 |
+
detection_results = []
|
| 1401 |
+
if enable_object_detection and self.object_detector:
|
| 1402 |
+
logger.info("🎯 Running object detection...")
|
| 1403 |
+
for i, keyframe in enumerate(keyframes):
|
| 1404 |
+
# Handle KeyframeResult objects correctly
|
| 1405 |
+
frame_path = keyframe.frame_data.frame_path if hasattr(keyframe, 'frame_data') else None
|
| 1406 |
+
timestamp = keyframe.frame_data.timestamp if hasattr(keyframe, 'frame_data') else 0
|
| 1407 |
+
|
| 1408 |
+
if frame_path and os.path.exists(frame_path):
|
| 1409 |
+
result = self.object_detector.detect_objects_in_frame(frame_path, timestamp)
|
| 1410 |
+
detections = []
|
| 1411 |
+
|
| 1412 |
+
if result and result.detected_objects:
|
| 1413 |
+
for obj in result.detected_objects:
|
| 1414 |
+
detection_dict = {
|
| 1415 |
+
"class_name": str(obj.class_name),
|
| 1416 |
+
"confidence": float(obj.confidence),
|
| 1417 |
+
"bbox": [int(x) for x in obj.bbox[:4]],
|
| 1418 |
+
"frame_timestamp": float(timestamp),
|
| 1419 |
+
"annotated_path": getattr(obj, 'annotated_path', None)
|
| 1420 |
+
}
|
| 1421 |
+
# Apply numpy type conversion
|
| 1422 |
+
detection_dict = convert_numpy_types(detection_dict)
|
| 1423 |
+
detections.append(detection_dict)
|
| 1424 |
+
|
| 1425 |
+
# Store detections in keyframe (add as attribute)
|
| 1426 |
+
keyframe.object_detections = detections
|
| 1427 |
+
detection_results.extend(detections)
|
| 1428 |
+
|
| 1429 |
+
# Log fire detections specifically
|
| 1430 |
+
fire_detections = [d for d in detections if d.get('class_name') == 'fire']
|
| 1431 |
+
if fire_detections:
|
| 1432 |
+
logger.info(f"🔥 Fire detected at {timestamp:.1f}s (confidence: {fire_detections[0].get('confidence', 0):.2f})")
|
| 1433 |
+
|
| 1434 |
+
logger.info(f"✅ Found {len(detection_results)} object detections")
|
| 1435 |
+
|
| 1436 |
+
# Step 3b: Run behavior analysis on keyframes if enabled
|
| 1437 |
+
behavior_results = []
|
| 1438 |
+
behavior_events = []
|
| 1439 |
+
if enable_behavior_analysis and self.behavior_analyzer:
|
| 1440 |
+
logger.info("🔍 Running behavior analysis...")
|
| 1441 |
+
# Pass video_path for 3D-ResNet models (fighting, road_accident) which need 16-frame clips
|
| 1442 |
+
behavior_results, behavior_events = self.behavior_analyzer.process_keyframes_with_behavior_analysis(keyframes, video_path=video_path)
|
| 1443 |
+
|
| 1444 |
+
# Store behavior detections in keyframes
|
| 1445 |
+
for i, keyframe in enumerate(keyframes):
|
| 1446 |
+
frame_path = keyframe.frame_data.frame_path if hasattr(keyframe, 'frame_data') else None
|
| 1447 |
+
timestamp = keyframe.frame_data.timestamp if hasattr(keyframe, 'frame_data') else 0
|
| 1448 |
+
|
| 1449 |
+
# Find behavior detections for this frame
|
| 1450 |
+
frame_behaviors = [r for r in behavior_results if r.frame_path == frame_path and abs(r.timestamp - timestamp) < 0.1]
|
| 1451 |
+
if frame_behaviors:
|
| 1452 |
+
behavior_detections = []
|
| 1453 |
+
for behavior in frame_behaviors:
|
| 1454 |
+
behavior_dict = {
|
| 1455 |
+
"behavior_type": behavior.behavior_detected,
|
| 1456 |
+
"confidence": float(behavior.confidence),
|
| 1457 |
+
"frame_timestamp": float(behavior.timestamp),
|
| 1458 |
+
"model_used": behavior.model_used
|
| 1459 |
+
}
|
| 1460 |
+
behavior_dict = convert_numpy_types(behavior_dict)
|
| 1461 |
+
behavior_detections.append(behavior_dict)
|
| 1462 |
+
|
| 1463 |
+
keyframe.behavior_detections = behavior_detections
|
| 1464 |
+
|
| 1465 |
+
logger.info(f"✅ Found {len(behavior_results)} behavior detections, {len(behavior_events)} behavior events")
|
| 1466 |
+
|
| 1467 |
+
# Step 4: Event aggregation and deduplication
|
| 1468 |
+
events = []
|
| 1469 |
+
if enable_event_aggregation:
|
| 1470 |
+
logger.info("📅 Performing event aggregation...")
|
| 1471 |
+
|
| 1472 |
+
# Group detections by type and time proximity
|
| 1473 |
+
detection_events = self._aggregate_detection_events(keyframes, video_id)
|
| 1474 |
+
events.extend(detection_events)
|
| 1475 |
+
|
| 1476 |
+
# Add behavior events
|
| 1477 |
+
if behavior_events:
|
| 1478 |
+
for behavior_event in behavior_events:
|
| 1479 |
+
event_dict = {
|
| 1480 |
+
"event_type": f"behavior_{behavior_event.behavior_type}",
|
| 1481 |
+
"start_timestamp": behavior_event.start_timestamp,
|
| 1482 |
+
"end_timestamp": behavior_event.end_timestamp,
|
| 1483 |
+
"confidence_score": float(behavior_event.confidence),
|
| 1484 |
+
"keyframes": behavior_event.keyframes,
|
| 1485 |
+
"importance_score": float(behavior_event.importance_score),
|
| 1486 |
+
"description": f"{behavior_event.behavior_type.capitalize()} detected",
|
| 1487 |
+
"detection_data": {
|
| 1488 |
+
"model_used": behavior_event.model_used,
|
| 1489 |
+
"frame_indices": behavior_event.frame_indices
|
| 1490 |
+
}
|
| 1491 |
+
}
|
| 1492 |
+
event_dict = convert_numpy_types(event_dict)
|
| 1493 |
+
events.append(event_dict)
|
| 1494 |
+
|
| 1495 |
+
if enable_deduplication:
|
| 1496 |
+
logger.info("🔄 Deduplicating similar events...")
|
| 1497 |
+
events = self._deduplicate_events(events)
|
| 1498 |
+
|
| 1499 |
+
# Store events in database using EventRepository
|
| 1500 |
+
logger.info(f"💾 Saving {len(events)} events to database...")
|
| 1501 |
+
for event in events:
|
| 1502 |
+
try:
|
| 1503 |
+
# EventRepository.save_event expects event dict with proper structure
|
| 1504 |
+
# It will handle timestamp conversion and field mapping
|
| 1505 |
+
event['video_id'] = video_id # Add video_id to event data
|
| 1506 |
+
self.event_repo.save_event(event)
|
| 1507 |
+
except Exception as e:
|
| 1508 |
+
logger.error(f"Failed to save event: {e}")
|
| 1509 |
+
|
| 1510 |
+
logger.info(f"✅ Stored {len(events)} events in database")
|
| 1511 |
+
|
| 1512 |
+
# Step 5: Create annotated video with bounding boxes (if detections exist)
|
| 1513 |
+
annotated_video_path = None
|
| 1514 |
+
annotated_minio_path = None
|
| 1515 |
+
if enable_object_detection and detection_results and self.object_detector:
|
| 1516 |
+
try:
|
| 1517 |
+
logger.info("🎨 Creating annotated video with bounding boxes...")
|
| 1518 |
+
|
| 1519 |
+
# Convert keyframes to detection results format for annotation
|
| 1520 |
+
detection_result_objects = []
|
| 1521 |
+
for keyframe in keyframes:
|
| 1522 |
+
if hasattr(keyframe, 'object_detections') and keyframe.object_detections:
|
| 1523 |
+
# Create ObjectDetectionResult-like object
|
| 1524 |
+
from object_detection import ObjectDetectionResult, DetectedObject
|
| 1525 |
+
from core.video_processing import FrameData
|
| 1526 |
+
|
| 1527 |
+
detected_objects = []
|
| 1528 |
+
for det in keyframe.object_detections:
|
| 1529 |
+
detected_objects.append(DetectedObject(
|
| 1530 |
+
class_name=det['class_name'],
|
| 1531 |
+
confidence=det['confidence'],
|
| 1532 |
+
bbox=det['bbox']
|
| 1533 |
+
))
|
| 1534 |
+
|
| 1535 |
+
if detected_objects:
|
| 1536 |
+
frame_data = keyframe.frame_data if hasattr(keyframe, 'frame_data') else None
|
| 1537 |
+
frame_path = frame_data.frame_path if frame_data else None
|
| 1538 |
+
timestamp = frame_data.timestamp if frame_data else 0
|
| 1539 |
+
|
| 1540 |
+
if frame_path:
|
| 1541 |
+
detection_result_objects.append(ObjectDetectionResult(
|
| 1542 |
+
frame_path=frame_path,
|
| 1543 |
+
timestamp=timestamp,
|
| 1544 |
+
detected_objects=detected_objects,
|
| 1545 |
+
total_detections=len(detected_objects)
|
| 1546 |
+
))
|
| 1547 |
+
|
| 1548 |
+
if detection_result_objects:
|
| 1549 |
+
# Create annotated video
|
| 1550 |
+
annotated_video_path = f"video_processing_outputs/annotated/{video_id}_annotated.mp4"
|
| 1551 |
+
os.makedirs(os.path.dirname(annotated_video_path), exist_ok=True)
|
| 1552 |
+
|
| 1553 |
+
annotated_path = self.object_detector.create_annotated_video(
|
| 1554 |
+
video_path,
|
| 1555 |
+
detection_result_objects,
|
| 1556 |
+
annotated_video_path
|
| 1557 |
+
)
|
| 1558 |
+
|
| 1559 |
+
if annotated_path and os.path.exists(annotated_path):
|
| 1560 |
+
annotated_video_path = annotated_path
|
| 1561 |
+
|
| 1562 |
+
# Upload annotated video to MinIO
|
| 1563 |
+
try:
|
| 1564 |
+
annotated_minio_path = f"annotated/{video_id}/video_annotated.mp4"
|
| 1565 |
+
with open(annotated_video_path, 'rb') as file_data:
|
| 1566 |
+
file_info = os.stat(annotated_video_path)
|
| 1567 |
+
self.video_repo.minio.put_object(
|
| 1568 |
+
self.video_repo.video_bucket,
|
| 1569 |
+
annotated_minio_path,
|
| 1570 |
+
file_data,
|
| 1571 |
+
length=file_info.st_size,
|
| 1572 |
+
content_type='video/mp4'
|
| 1573 |
+
)
|
| 1574 |
+
logger.info(f"✅ Uploaded annotated video to MinIO: {annotated_minio_path}")
|
| 1575 |
+
|
| 1576 |
+
# Update metadata with annotated video path
|
| 1577 |
+
self.video_repo.update_metadata(video_id, {
|
| 1578 |
+
"minio_annotated_path": annotated_minio_path,
|
| 1579 |
+
"annotated_video_path": annotated_video_path
|
| 1580 |
+
})
|
| 1581 |
+
except Exception as e:
|
| 1582 |
+
logger.warning(f"⚠️ Failed to upload annotated video to MinIO: {e}")
|
| 1583 |
+
|
| 1584 |
+
logger.info(f"✅ Annotated video created: {annotated_video_path}")
|
| 1585 |
+
else:
|
| 1586 |
+
logger.warning("⚠️ Annotated video creation returned no path")
|
| 1587 |
+
else:
|
| 1588 |
+
logger.info("ℹ️ No detections found, skipping annotated video creation")
|
| 1589 |
+
|
| 1590 |
+
except Exception as e:
|
| 1591 |
+
logger.warning(f"⚠️ Annotated video creation failed: {e}")
|
| 1592 |
+
import traceback
|
| 1593 |
+
logger.error(traceback.format_exc())
|
| 1594 |
+
|
| 1595 |
+
# Step 6: Video compression (if enabled)
|
| 1596 |
+
compression_info = {}
|
| 1597 |
+
if enable_compression:
|
| 1598 |
+
try:
|
| 1599 |
+
logger.info("📦 Compressing video...")
|
| 1600 |
+
from video_compression import OptimizedVideoCompressor
|
| 1601 |
+
compressor = OptimizedVideoCompressor()
|
| 1602 |
+
|
| 1603 |
+
compressed_path = f"video_processing_outputs/compressed/{video_id}_compressed.mp4"
|
| 1604 |
+
os.makedirs(os.path.dirname(compressed_path), exist_ok=True)
|
| 1605 |
+
|
| 1606 |
+
compression_result = compressor.compress_video(video_path, compressed_path)
|
| 1607 |
+
|
| 1608 |
+
if compression_result.get('success'):
|
| 1609 |
+
original_size = os.path.getsize(video_path) / (1024 * 1024) # MB
|
| 1610 |
+
compressed_size = os.path.getsize(compressed_path) / (1024 * 1024) # MB
|
| 1611 |
+
compression_ratio = (1 - compressed_size / original_size) * 100 if original_size > 0 else 0
|
| 1612 |
+
|
| 1613 |
+
compression_info = {
|
| 1614 |
+
"original_size_mb": round(original_size, 2),
|
| 1615 |
+
"compressed_size_mb": round(compressed_size, 2),
|
| 1616 |
+
"compression_ratio": round(compression_ratio, 1),
|
| 1617 |
+
"compressed_path": compressed_path
|
| 1618 |
+
}
|
| 1619 |
+
|
| 1620 |
+
self.video_repo.update_metadata(video_id, {"minio_compressed_path": compressed_path})
|
| 1621 |
+
logger.info(f"✅ Video compressed: {compression_ratio:.1f}% reduction")
|
| 1622 |
+
|
| 1623 |
+
except Exception as e:
|
| 1624 |
+
logger.warning(f"⚠️ Video compression failed: {e}")
|
| 1625 |
+
|
| 1626 |
+
# Step 7: Update final status
|
| 1627 |
+
processing_time = time.time() - start_time
|
| 1628 |
+
|
| 1629 |
+
final_meta_data = {
|
| 1630 |
+
"processing_status": "completed",
|
| 1631 |
+
"keyframe_count": len(keyframes),
|
| 1632 |
+
"detection_count": len(detection_results),
|
| 1633 |
+
"behavior_detection_count": len(behavior_results),
|
| 1634 |
+
"behavior_event_count": len(behavior_events),
|
| 1635 |
+
"event_count": len(events),
|
| 1636 |
+
"processing_time_seconds": round(processing_time, 2),
|
| 1637 |
+
"processed_at": datetime.utcnow().isoformat(),
|
| 1638 |
+
"compressed_video_info": compression_info,
|
| 1639 |
+
"annotated_video_available": bool(annotated_minio_path),
|
| 1640 |
+
"annotated_video_path": annotated_minio_path
|
| 1641 |
+
}
|
| 1642 |
+
|
| 1643 |
+
self.video_repo.update_processing_status(video_id, "completed")
|
| 1644 |
+
self.video_repo.update_metadata(video_id, final_meta_data)
|
| 1645 |
+
|
| 1646 |
+
results.update({
|
| 1647 |
+
"status": "completed",
|
| 1648 |
+
"processing_stats": final_meta_data,
|
| 1649 |
+
"keyframes_extracted": len(keyframes),
|
| 1650 |
+
"objects_detected": len(detection_results),
|
| 1651 |
+
"behaviors_detected": len(behavior_results),
|
| 1652 |
+
"behavior_events": len(behavior_events),
|
| 1653 |
+
"events_created": len(events),
|
| 1654 |
+
"processing_time": processing_time
|
| 1655 |
+
})
|
| 1656 |
+
|
| 1657 |
+
logger.info(f"🎉 Complete pipeline processing finished for {video_id} in {processing_time:.1f}s")
|
| 1658 |
+
return results
|
| 1659 |
+
|
| 1660 |
+
except Exception as e:
|
| 1661 |
+
logger.error(f"❌ Processing failed for {video_id}: {e}")
|
| 1662 |
+
|
| 1663 |
+
# Update status to failed
|
| 1664 |
+
try:
|
| 1665 |
+
self.video_repo.update_processing_status(video_id, "failed")
|
| 1666 |
+
self.video_repo.update_metadata(video_id, {
|
| 1667 |
+
"error_message": str(e),
|
| 1668 |
+
"failed_at": datetime.utcnow().isoformat()
|
| 1669 |
+
})
|
| 1670 |
+
except:
|
| 1671 |
+
pass
|
| 1672 |
+
|
| 1673 |
+
results.update({
|
| 1674 |
+
"status": "failed",
|
| 1675 |
+
"error": str(e)
|
| 1676 |
+
})
|
| 1677 |
+
|
| 1678 |
+
raise e
|
| 1679 |
+
|
| 1680 |
+
def _aggregate_detection_events(self, keyframes, video_id):
|
| 1681 |
+
"""Aggregate object detections into schema-compliant events"""
|
| 1682 |
+
events = []
|
| 1683 |
+
|
| 1684 |
+
# Group keyframes with detections by detection type
|
| 1685 |
+
detection_groups = {}
|
| 1686 |
+
for keyframe in keyframes:
|
| 1687 |
+
# Handle KeyframeResult objects
|
| 1688 |
+
detections = getattr(keyframe, 'object_detections', [])
|
| 1689 |
+
frame_data = keyframe.frame_data if hasattr(keyframe, 'frame_data') else keyframe
|
| 1690 |
+
|
| 1691 |
+
for detection in detections:
|
| 1692 |
+
class_name = detection.get('class_name', 'unknown')
|
| 1693 |
+
if class_name not in detection_groups:
|
| 1694 |
+
detection_groups[class_name] = []
|
| 1695 |
+
detection_groups[class_name].append({
|
| 1696 |
+
'keyframe': keyframe,
|
| 1697 |
+
'detection': detection,
|
| 1698 |
+
'timestamp': frame_data.timestamp if hasattr(frame_data, 'timestamp') else 0
|
| 1699 |
+
})
|
| 1700 |
+
|
| 1701 |
+
# Create events for each detection type
|
| 1702 |
+
for class_name, detections in detection_groups.items():
|
| 1703 |
+
if not detections:
|
| 1704 |
+
continue
|
| 1705 |
+
|
| 1706 |
+
# Sort by timestamp
|
| 1707 |
+
detections.sort(key=lambda x: x['timestamp'])
|
| 1708 |
+
|
| 1709 |
+
# Group nearby detections into events (within 3 seconds)
|
| 1710 |
+
current_event = None
|
| 1711 |
+
|
| 1712 |
+
for det_info in detections:
|
| 1713 |
+
timestamp = det_info['timestamp']
|
| 1714 |
+
confidence = det_info['detection'].get('confidence', 0)
|
| 1715 |
+
bbox = det_info['detection'].get('bbox', [0, 0, 0, 0])
|
| 1716 |
+
|
| 1717 |
+
# Check if this detection belongs to current event
|
| 1718 |
+
if current_event and timestamp - current_event['end_timestamp'] <= 3.0:
|
| 1719 |
+
# Extend current event
|
| 1720 |
+
current_event['end_timestamp'] = timestamp
|
| 1721 |
+
current_event['confidence_score'] = max(current_event['confidence_score'], confidence)
|
| 1722 |
+
current_event['bounding_boxes'].append({
|
| 1723 |
+
"x": int(bbox[0]),
|
| 1724 |
+
"y": int(bbox[1]),
|
| 1725 |
+
"width": int(bbox[2] - bbox[0]),
|
| 1726 |
+
"height": int(bbox[3] - bbox[1]),
|
| 1727 |
+
"confidence": float(confidence),
|
| 1728 |
+
"class_name": class_name
|
| 1729 |
+
})
|
| 1730 |
+
else:
|
| 1731 |
+
# Start new event
|
| 1732 |
+
if current_event:
|
| 1733 |
+
events.append(current_event)
|
| 1734 |
+
|
| 1735 |
+
threat_level = self._calculate_threat_level(class_name, confidence)
|
| 1736 |
+
importance_score = 0.9 if class_name == 'fire' else 0.7 if class_name in ['knife', 'gun'] else 0.5
|
| 1737 |
+
|
| 1738 |
+
current_event = {
|
| 1739 |
+
'event_type': f'object_detection_{class_name}',
|
| 1740 |
+
'start_timestamp': timestamp,
|
| 1741 |
+
'end_timestamp': timestamp,
|
| 1742 |
+
'confidence_score': confidence,
|
| 1743 |
+
'importance_score': importance_score,
|
| 1744 |
+
'threat_level': threat_level,
|
| 1745 |
+
'bounding_boxes': [{
|
| 1746 |
+
"x": int(bbox[0]),
|
| 1747 |
+
"y": int(bbox[1]),
|
| 1748 |
+
"width": int(bbox[2] - bbox[0]),
|
| 1749 |
+
"height": int(bbox[3] - bbox[1]),
|
| 1750 |
+
"confidence": float(confidence),
|
| 1751 |
+
"class_name": class_name
|
| 1752 |
+
}],
|
| 1753 |
+
'detected_object_type': class_name
|
| 1754 |
+
}
|
| 1755 |
+
|
| 1756 |
+
# Add final event
|
| 1757 |
+
if current_event:
|
| 1758 |
+
events.append(current_event)
|
| 1759 |
+
|
| 1760 |
+
return events
|
| 1761 |
+
|
| 1762 |
+
def _deduplicate_events(self, events):
|
| 1763 |
+
"""Remove duplicate or very similar events and mark them as false positives"""
|
| 1764 |
+
if len(events) <= 1:
|
| 1765 |
+
return events
|
| 1766 |
+
|
| 1767 |
+
# Sort events by start timestamp
|
| 1768 |
+
events.sort(key=lambda x: x.get('start_timestamp', 0))
|
| 1769 |
+
|
| 1770 |
+
deduplicated = []
|
| 1771 |
+
|
| 1772 |
+
for event in events:
|
| 1773 |
+
# Check if this event is too similar to recent events
|
| 1774 |
+
is_duplicate = False
|
| 1775 |
+
|
| 1776 |
+
for recent_event in deduplicated[-3:]: # Check last 3 events
|
| 1777 |
+
# Same type and overlapping time window
|
| 1778 |
+
if (event.get('event_type') == recent_event.get('event_type') and
|
| 1779 |
+
abs(event.get('start_timestamp', 0) - recent_event.get('end_timestamp', 0)) <= 5.0):
|
| 1780 |
+
|
| 1781 |
+
# Check if same object types detected
|
| 1782 |
+
event_objects = {event.get('detected_object_type')}
|
| 1783 |
+
recent_objects = {recent_event.get('detected_object_type')}
|
| 1784 |
+
|
| 1785 |
+
if event_objects & recent_objects: # Common objects
|
| 1786 |
+
is_duplicate = True
|
| 1787 |
+
|
| 1788 |
+
# Merge into the existing event (extend time window, keep highest confidence)
|
| 1789 |
+
recent_event['end_timestamp'] = max(
|
| 1790 |
+
recent_event.get('end_timestamp', 0),
|
| 1791 |
+
event.get('end_timestamp', 0)
|
| 1792 |
+
)
|
| 1793 |
+
recent_event['confidence_score'] = max(
|
| 1794 |
+
recent_event.get('confidence_score', 0),
|
| 1795 |
+
event.get('confidence_score', 0)
|
| 1796 |
+
)
|
| 1797 |
+
recent_event['bounding_boxes'].extend(event.get('bounding_boxes', []))
|
| 1798 |
+
break
|
| 1799 |
+
|
| 1800 |
+
if not is_duplicate:
|
| 1801 |
+
deduplicated.append(event)
|
| 1802 |
+
|
| 1803 |
+
logger.info(f"🔄 Deduplication: {len(events)} → {len(deduplicated)} events")
|
| 1804 |
+
return deduplicated
|
detectifai_events.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DetectifAI Security Event System
|
| 3 |
+
|
| 4 |
+
This module defines the specific security event types and processing logic
|
| 5 |
+
according to DetectifAI's scope: assault/fighting, weapons, fire, jumping over wall,
|
| 6 |
+
road accidents, and suspicious person re-occurrence.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
import logging
|
| 12 |
+
from typing import Dict, List, Tuple, Optional, Any
|
| 13 |
+
from dataclasses import dataclass, asdict
|
| 14 |
+
from enum import Enum
|
| 15 |
+
import json
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
class DetectifAIEventType(Enum):
|
| 20 |
+
"""DetectifAI-specific security event types"""
|
| 21 |
+
FIRE_DETECTION = "fire_detection"
|
| 22 |
+
WEAPON_DETECTION = "weapon_detection" # knife, gun
|
| 23 |
+
PHYSICAL_ASSAULT = "physical_assault" # fighting, violence
|
| 24 |
+
WALL_JUMPING = "wall_jumping" # perimeter breach
|
| 25 |
+
ROAD_ACCIDENT = "road_accident" # vehicle collision
|
| 26 |
+
SUSPICIOUS_PERSON_REOCCURRENCE = "suspicious_person_reoccurrence"
|
| 27 |
+
GENERAL_MOTION = "general_motion" # fallback for unclassified motion
|
| 28 |
+
|
| 29 |
+
class ThreatLevel(Enum):
|
| 30 |
+
"""Security threat levels for DetectifAI events"""
|
| 31 |
+
CRITICAL = "critical" # Immediate response required (fire, weapons)
|
| 32 |
+
HIGH = "high" # Urgent attention needed (assault, suspicious person)
|
| 33 |
+
MEDIUM = "medium" # Monitor closely (wall jumping, accidents)
|
| 34 |
+
LOW = "low" # General awareness (motion)
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class DetectifAIEvent:
|
| 38 |
+
"""Enhanced event structure specific to DetectifAI security requirements"""
|
| 39 |
+
event_id: str
|
| 40 |
+
event_type: DetectifAIEventType
|
| 41 |
+
threat_level: ThreatLevel
|
| 42 |
+
start_timestamp: float
|
| 43 |
+
end_timestamp: float
|
| 44 |
+
duration: float
|
| 45 |
+
confidence: float
|
| 46 |
+
|
| 47 |
+
# Location and detection details
|
| 48 |
+
keyframes: List[str]
|
| 49 |
+
detection_details: Dict[str, Any] # Specific to event type
|
| 50 |
+
|
| 51 |
+
# Security-specific fields
|
| 52 |
+
requires_immediate_response: bool
|
| 53 |
+
investigation_priority: int # 1-10 scale
|
| 54 |
+
|
| 55 |
+
# Person tracking (for applicable events)
|
| 56 |
+
persons_detected: List[Dict] = None
|
| 57 |
+
is_person_reoccurrence: bool = False
|
| 58 |
+
|
| 59 |
+
# Context and description
|
| 60 |
+
description: str = ""
|
| 61 |
+
security_notes: str = ""
|
| 62 |
+
|
| 63 |
+
# Metadata
|
| 64 |
+
processing_timestamp: float = None
|
| 65 |
+
detection_model_used: str = ""
|
| 66 |
+
|
| 67 |
+
@dataclass
|
| 68 |
+
class DetectifAICanonicalEvent:
|
| 69 |
+
"""Canonical representation of aggregated DetectifAI security events"""
|
| 70 |
+
canonical_id: str
|
| 71 |
+
event_type: DetectifAIEventType
|
| 72 |
+
threat_level: ThreatLevel
|
| 73 |
+
|
| 74 |
+
# Temporal information
|
| 75 |
+
start_time: float
|
| 76 |
+
end_time: float
|
| 77 |
+
total_duration: float
|
| 78 |
+
|
| 79 |
+
# Aggregation details
|
| 80 |
+
aggregated_events_count: int
|
| 81 |
+
aggregated_event_ids: List[str]
|
| 82 |
+
representative_frame: str
|
| 83 |
+
all_keyframes: List[str]
|
| 84 |
+
|
| 85 |
+
# Security assessment
|
| 86 |
+
max_confidence: float
|
| 87 |
+
average_confidence: float
|
| 88 |
+
investigation_priority: int
|
| 89 |
+
requires_immediate_response: bool
|
| 90 |
+
|
| 91 |
+
# Detection summary
|
| 92 |
+
total_detections: int
|
| 93 |
+
detection_summary: Dict[str, Any]
|
| 94 |
+
|
| 95 |
+
# Person tracking summary
|
| 96 |
+
unique_persons_count: int = 0
|
| 97 |
+
suspicious_persons: List[Dict] = None
|
| 98 |
+
person_reoccurrences: int = 0
|
| 99 |
+
|
| 100 |
+
# Investigation details
|
| 101 |
+
description: str = ""
|
| 102 |
+
security_assessment: str = ""
|
| 103 |
+
recommended_actions: List[str] = None
|
| 104 |
+
|
| 105 |
+
class DetectifAIEventProcessor:
|
| 106 |
+
"""Process and classify events according to DetectifAI security requirements"""
|
| 107 |
+
|
| 108 |
+
def __init__(self, config):
|
| 109 |
+
self.config = config
|
| 110 |
+
|
| 111 |
+
# DetectifAI-specific thresholds
|
| 112 |
+
self.threat_thresholds = {
|
| 113 |
+
DetectifAIEventType.FIRE_DETECTION: {
|
| 114 |
+
ThreatLevel.CRITICAL: 0.7,
|
| 115 |
+
ThreatLevel.HIGH: 0.5,
|
| 116 |
+
ThreatLevel.MEDIUM: 0.3,
|
| 117 |
+
ThreatLevel.LOW: 0.1
|
| 118 |
+
},
|
| 119 |
+
DetectifAIEventType.WEAPON_DETECTION: {
|
| 120 |
+
ThreatLevel.CRITICAL: 0.8,
|
| 121 |
+
ThreatLevel.HIGH: 0.6,
|
| 122 |
+
ThreatLevel.MEDIUM: 0.4,
|
| 123 |
+
ThreatLevel.LOW: 0.2
|
| 124 |
+
},
|
| 125 |
+
DetectifAIEventType.PHYSICAL_ASSAULT: {
|
| 126 |
+
ThreatLevel.CRITICAL: 0.9,
|
| 127 |
+
ThreatLevel.HIGH: 0.7,
|
| 128 |
+
ThreatLevel.MEDIUM: 0.5,
|
| 129 |
+
ThreatLevel.LOW: 0.3
|
| 130 |
+
},
|
| 131 |
+
DetectifAIEventType.WALL_JUMPING: {
|
| 132 |
+
ThreatLevel.HIGH: 0.8,
|
| 133 |
+
ThreatLevel.MEDIUM: 0.6,
|
| 134 |
+
ThreatLevel.LOW: 0.4
|
| 135 |
+
},
|
| 136 |
+
DetectifAIEventType.ROAD_ACCIDENT: {
|
| 137 |
+
ThreatLevel.HIGH: 0.8,
|
| 138 |
+
ThreatLevel.MEDIUM: 0.6,
|
| 139 |
+
ThreatLevel.LOW: 0.4
|
| 140 |
+
},
|
| 141 |
+
DetectifAIEventType.SUSPICIOUS_PERSON_REOCCURRENCE: {
|
| 142 |
+
ThreatLevel.HIGH: 0.9,
|
| 143 |
+
ThreatLevel.MEDIUM: 0.7,
|
| 144 |
+
ThreatLevel.LOW: 0.5
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
# Processing statistics
|
| 149 |
+
self.processing_stats = {
|
| 150 |
+
'motion_events_processed': 0,
|
| 151 |
+
'object_events_processed': 0,
|
| 152 |
+
'detectifai_events_created': 0,
|
| 153 |
+
'facial_recognition_events': 0,
|
| 154 |
+
'placeholder_events_created': 0
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
logger.info("DetectifAI Event Processor initialized")
|
| 158 |
+
|
| 159 |
+
def process_security_events(self, keyframes: List, motion_events: List, object_events: List = None) -> List[DetectifAIEvent]:
|
| 160 |
+
"""Main method to process all security events and convert to DetectifAI format"""
|
| 161 |
+
logger.info("🔍 Processing security events for DetectifAI system")
|
| 162 |
+
|
| 163 |
+
detectifai_events = []
|
| 164 |
+
|
| 165 |
+
# Convert object detection events
|
| 166 |
+
if object_events:
|
| 167 |
+
object_detectifai_events = self.convert_object_detection_to_detectifai_events(object_events)
|
| 168 |
+
detectifai_events.extend(object_detectifai_events)
|
| 169 |
+
self.processing_stats['object_events_processed'] = len(object_events)
|
| 170 |
+
|
| 171 |
+
# Create placeholder events from motion
|
| 172 |
+
placeholder_events = self.create_placeholder_events(keyframes, motion_events)
|
| 173 |
+
detectifai_events.extend(placeholder_events)
|
| 174 |
+
self.processing_stats['motion_events_processed'] = len(motion_events)
|
| 175 |
+
self.processing_stats['placeholder_events_created'] = len(placeholder_events)
|
| 176 |
+
|
| 177 |
+
# Update final count
|
| 178 |
+
self.processing_stats['detectifai_events_created'] = len(detectifai_events)
|
| 179 |
+
|
| 180 |
+
logger.info(f"✅ DetectifAI processing complete: {len(detectifai_events)} security events created")
|
| 181 |
+
return detectifai_events
|
| 182 |
+
|
| 183 |
+
def get_processing_stats(self) -> Dict[str, Any]:
|
| 184 |
+
"""Get processing statistics"""
|
| 185 |
+
return self.processing_stats.copy()
|
| 186 |
+
|
| 187 |
+
def convert_object_detection_to_detectifai_events(self, object_events: List[Dict]) -> List[DetectifAIEvent]:
|
| 188 |
+
"""Convert object detection events to DetectifAI security events"""
|
| 189 |
+
detectifai_events = []
|
| 190 |
+
|
| 191 |
+
for obj_event in object_events:
|
| 192 |
+
# Determine DetectifAI event type
|
| 193 |
+
object_class = obj_event.get('object_class', '').lower()
|
| 194 |
+
|
| 195 |
+
if object_class == 'fire':
|
| 196 |
+
event_type = DetectifAIEventType.FIRE_DETECTION
|
| 197 |
+
elif object_class in ['knife', 'gun']:
|
| 198 |
+
event_type = DetectifAIEventType.WEAPON_DETECTION
|
| 199 |
+
else:
|
| 200 |
+
event_type = DetectifAIEventType.GENERAL_MOTION
|
| 201 |
+
|
| 202 |
+
# Assess threat level
|
| 203 |
+
confidence = obj_event.get('confidence', 0.0)
|
| 204 |
+
threat_level = self._assess_threat_level(event_type, confidence)
|
| 205 |
+
|
| 206 |
+
# Create DetectifAI event
|
| 207 |
+
detectifai_event = DetectifAIEvent(
|
| 208 |
+
event_id=f"detectifai_{obj_event['event_id']}",
|
| 209 |
+
event_type=event_type,
|
| 210 |
+
threat_level=threat_level,
|
| 211 |
+
start_timestamp=obj_event['start_timestamp'],
|
| 212 |
+
end_timestamp=obj_event['end_timestamp'],
|
| 213 |
+
duration=obj_event['end_timestamp'] - obj_event['start_timestamp'],
|
| 214 |
+
confidence=confidence,
|
| 215 |
+
keyframes=obj_event.get('keyframes', []),
|
| 216 |
+
detection_details={
|
| 217 |
+
'object_class': object_class,
|
| 218 |
+
'detection_count': obj_event.get('detection_count', 0),
|
| 219 |
+
'max_confidence': obj_event.get('max_confidence', confidence),
|
| 220 |
+
'detection_data': obj_event.get('detection_details', [])
|
| 221 |
+
},
|
| 222 |
+
requires_immediate_response=threat_level in [ThreatLevel.CRITICAL, ThreatLevel.HIGH],
|
| 223 |
+
investigation_priority=self._calculate_investigation_priority(event_type, threat_level, confidence),
|
| 224 |
+
description=self._generate_detectifai_description(event_type, object_class, confidence),
|
| 225 |
+
processing_timestamp=time.time(),
|
| 226 |
+
detection_model_used=f"object_detection_{object_class}"
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
detectifai_events.append(detectifai_event)
|
| 230 |
+
|
| 231 |
+
logger.info(f"Converted {len(object_events)} object events to {len(detectifai_events)} DetectifAI events")
|
| 232 |
+
return detectifai_events
|
| 233 |
+
|
| 234 |
+
def create_placeholder_events(self, keyframes: List, motion_events: List) -> List[DetectifAIEvent]:
|
| 235 |
+
"""Create placeholder events for unimplemented DetectifAI modules"""
|
| 236 |
+
placeholder_events = []
|
| 237 |
+
|
| 238 |
+
# Convert high-motion events to potential security events (placeholders)
|
| 239 |
+
for motion_event in motion_events:
|
| 240 |
+
if hasattr(motion_event, 'motion_intensity') and motion_event.motion_intensity > 0.015:
|
| 241 |
+
# High motion could be assault/fighting (placeholder)
|
| 242 |
+
placeholder_event = DetectifAIEvent(
|
| 243 |
+
event_id=f"placeholder_assault_{motion_event.event_id}",
|
| 244 |
+
event_type=DetectifAIEventType.PHYSICAL_ASSAULT,
|
| 245 |
+
threat_level=ThreatLevel.MEDIUM, # Conservative for placeholder
|
| 246 |
+
start_timestamp=motion_event.start_timestamp,
|
| 247 |
+
end_timestamp=motion_event.end_timestamp,
|
| 248 |
+
duration=motion_event.end_timestamp - motion_event.start_timestamp,
|
| 249 |
+
confidence=0.5, # Placeholder confidence
|
| 250 |
+
keyframes=motion_event.keyframes,
|
| 251 |
+
detection_details={
|
| 252 |
+
'placeholder': True,
|
| 253 |
+
'motion_intensity': motion_event.motion_intensity,
|
| 254 |
+
'original_event_type': motion_event.event_type
|
| 255 |
+
},
|
| 256 |
+
requires_immediate_response=False,
|
| 257 |
+
investigation_priority=5,
|
| 258 |
+
description=f"Potential physical assault detected (placeholder) - High motion intensity: {motion_event.motion_intensity:.3f}",
|
| 259 |
+
security_notes="PLACEHOLDER: Requires fight detection module implementation",
|
| 260 |
+
processing_timestamp=time.time(),
|
| 261 |
+
detection_model_used="placeholder_fight_detection"
|
| 262 |
+
)
|
| 263 |
+
placeholder_events.append(placeholder_event)
|
| 264 |
+
|
| 265 |
+
# Add other placeholder event types based on analysis
|
| 266 |
+
# Wall jumping, road accidents, etc. can be added here based on scene analysis
|
| 267 |
+
|
| 268 |
+
logger.info(f"Created {len(placeholder_events)} placeholder DetectifAI events")
|
| 269 |
+
return placeholder_events
|
| 270 |
+
|
| 271 |
+
def _assess_threat_level(self, event_type: DetectifAIEventType, confidence: float) -> ThreatLevel:
|
| 272 |
+
"""Assess threat level based on event type and confidence"""
|
| 273 |
+
if event_type not in self.threat_thresholds:
|
| 274 |
+
return ThreatLevel.LOW
|
| 275 |
+
|
| 276 |
+
thresholds = self.threat_thresholds[event_type]
|
| 277 |
+
|
| 278 |
+
for threat_level in [ThreatLevel.CRITICAL, ThreatLevel.HIGH, ThreatLevel.MEDIUM, ThreatLevel.LOW]:
|
| 279 |
+
if threat_level in thresholds and confidence >= thresholds[threat_level]:
|
| 280 |
+
return threat_level
|
| 281 |
+
|
| 282 |
+
return ThreatLevel.LOW
|
| 283 |
+
|
| 284 |
+
def _calculate_investigation_priority(self, event_type: DetectifAIEventType,
|
| 285 |
+
threat_level: ThreatLevel, confidence: float) -> int:
|
| 286 |
+
"""Calculate investigation priority (1-10 scale)"""
|
| 287 |
+
base_priorities = {
|
| 288 |
+
DetectifAIEventType.FIRE_DETECTION: 9,
|
| 289 |
+
DetectifAIEventType.WEAPON_DETECTION: 8,
|
| 290 |
+
DetectifAIEventType.PHYSICAL_ASSAULT: 7,
|
| 291 |
+
DetectifAIEventType.SUSPICIOUS_PERSON_REOCCURRENCE: 6,
|
| 292 |
+
DetectifAIEventType.WALL_JUMPING: 5,
|
| 293 |
+
DetectifAIEventType.ROAD_ACCIDENT: 4,
|
| 294 |
+
DetectifAIEventType.GENERAL_MOTION: 2
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
base_priority = base_priorities.get(event_type, 2)
|
| 298 |
+
|
| 299 |
+
# Adjust based on threat level
|
| 300 |
+
threat_multipliers = {
|
| 301 |
+
ThreatLevel.CRITICAL: 1.0,
|
| 302 |
+
ThreatLevel.HIGH: 0.9,
|
| 303 |
+
ThreatLevel.MEDIUM: 0.7,
|
| 304 |
+
ThreatLevel.LOW: 0.5
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
adjusted_priority = int(base_priority * threat_multipliers[threat_level])
|
| 308 |
+
|
| 309 |
+
# Boost for high confidence
|
| 310 |
+
if confidence > 0.8:
|
| 311 |
+
adjusted_priority = min(10, adjusted_priority + 1)
|
| 312 |
+
|
| 313 |
+
return max(1, min(10, adjusted_priority))
|
| 314 |
+
|
| 315 |
+
def _generate_detectifai_description(self, event_type: DetectifAIEventType,
|
| 316 |
+
object_class: str, confidence: float) -> str:
|
| 317 |
+
"""Generate DetectifAI-specific event descriptions"""
|
| 318 |
+
descriptions = {
|
| 319 |
+
DetectifAIEventType.FIRE_DETECTION: f"🔥 Fire detected with {confidence:.1%} confidence - Immediate evacuation may be required",
|
| 320 |
+
DetectifAIEventType.WEAPON_DETECTION: f"⚠️ Weapon ({object_class}) detected with {confidence:.1%} confidence - Security alert triggered",
|
| 321 |
+
DetectifAIEventType.PHYSICAL_ASSAULT: f"👊 Physical assault detected with {confidence:.1%} confidence - Intervention may be needed",
|
| 322 |
+
DetectifAIEventType.WALL_JUMPING: f"🧗 Perimeter breach (wall jumping) detected with {confidence:.1%} confidence",
|
| 323 |
+
DetectifAIEventType.ROAD_ACCIDENT: f"🚗 Road accident detected with {confidence:.1%} confidence - Emergency services may be needed",
|
| 324 |
+
DetectifAIEventType.SUSPICIOUS_PERSON_REOCCURRENCE: f"👤 Suspicious person re-occurrence detected with {confidence:.1%} confidence",
|
| 325 |
+
DetectifAIEventType.GENERAL_MOTION: f"📊 General motion activity detected"
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
return descriptions.get(event_type, f"Security event detected: {event_type.value}")
|
| 329 |
+
|
| 330 |
+
class DetectifAIEventAggregator:
|
| 331 |
+
"""Simplified event aggregation focused on DetectifAI security requirements"""
|
| 332 |
+
|
| 333 |
+
def __init__(self, config):
|
| 334 |
+
self.config = config
|
| 335 |
+
self.temporal_window = getattr(config, 'detectifai_temporal_window', 10.0) # seconds
|
| 336 |
+
|
| 337 |
+
def aggregate_detectifai_events(self, events: List[DetectifAIEvent]) -> List[DetectifAICanonicalEvent]:
|
| 338 |
+
"""Aggregate DetectifAI events into canonical security events"""
|
| 339 |
+
logger.info(f"Aggregating {len(events)} DetectifAI events")
|
| 340 |
+
|
| 341 |
+
if not events:
|
| 342 |
+
return []
|
| 343 |
+
|
| 344 |
+
# Group events by type for focused aggregation
|
| 345 |
+
events_by_type = {}
|
| 346 |
+
for event in events:
|
| 347 |
+
if event.event_type not in events_by_type:
|
| 348 |
+
events_by_type[event.event_type] = []
|
| 349 |
+
events_by_type[event.event_type].append(event)
|
| 350 |
+
|
| 351 |
+
canonical_events = []
|
| 352 |
+
canonical_id_counter = 1
|
| 353 |
+
|
| 354 |
+
# Process each event type separately with DetectifAI-specific logic
|
| 355 |
+
for event_type, type_events in events_by_type.items():
|
| 356 |
+
type_canonical = self._aggregate_by_detectifai_type(
|
| 357 |
+
event_type, type_events, canonical_id_counter
|
| 358 |
+
)
|
| 359 |
+
canonical_events.extend(type_canonical)
|
| 360 |
+
canonical_id_counter += len(type_canonical)
|
| 361 |
+
|
| 362 |
+
# Sort by investigation priority
|
| 363 |
+
canonical_events.sort(key=lambda e: e.investigation_priority, reverse=True)
|
| 364 |
+
|
| 365 |
+
logger.info(f"Created {len(canonical_events)} canonical DetectifAI events")
|
| 366 |
+
return canonical_events
|
| 367 |
+
|
| 368 |
+
def _aggregate_by_detectifai_type(self, event_type: DetectifAIEventType,
|
| 369 |
+
events: List[DetectifAIEvent],
|
| 370 |
+
start_id: int) -> List[DetectifAICanonicalEvent]:
|
| 371 |
+
"""Aggregate events of specific DetectifAI type"""
|
| 372 |
+
if not events:
|
| 373 |
+
return []
|
| 374 |
+
|
| 375 |
+
# Sort events by timestamp
|
| 376 |
+
events.sort(key=lambda e: e.start_timestamp)
|
| 377 |
+
|
| 378 |
+
# Group events within temporal window
|
| 379 |
+
clusters = []
|
| 380 |
+
current_cluster = [events[0]]
|
| 381 |
+
|
| 382 |
+
for i in range(1, len(events)):
|
| 383 |
+
current_event = events[i]
|
| 384 |
+
last_in_cluster = current_cluster[-1]
|
| 385 |
+
|
| 386 |
+
# Check if events should be clustered
|
| 387 |
+
time_gap = current_event.start_timestamp - last_in_cluster.end_timestamp
|
| 388 |
+
|
| 389 |
+
if time_gap <= self.temporal_window:
|
| 390 |
+
current_cluster.append(current_event)
|
| 391 |
+
else:
|
| 392 |
+
clusters.append(current_cluster)
|
| 393 |
+
current_cluster = [current_event]
|
| 394 |
+
|
| 395 |
+
# Don't forget the last cluster
|
| 396 |
+
if current_cluster:
|
| 397 |
+
clusters.append(current_cluster)
|
| 398 |
+
|
| 399 |
+
# Create canonical events from clusters
|
| 400 |
+
canonical_events = []
|
| 401 |
+
for i, cluster in enumerate(clusters):
|
| 402 |
+
canonical_event = self._create_detectifai_canonical_event(
|
| 403 |
+
event_type, cluster, start_id + i
|
| 404 |
+
)
|
| 405 |
+
canonical_events.append(canonical_event)
|
| 406 |
+
|
| 407 |
+
return canonical_events
|
| 408 |
+
|
| 409 |
+
def _create_detectifai_canonical_event(self, event_type: DetectifAIEventType,
|
| 410 |
+
cluster: List[DetectifAIEvent],
|
| 411 |
+
canonical_id: int) -> DetectifAICanonicalEvent:
|
| 412 |
+
"""Create canonical event from DetectifAI event cluster"""
|
| 413 |
+
# Find highest priority event as representative
|
| 414 |
+
representative = max(cluster, key=lambda e: e.investigation_priority)
|
| 415 |
+
|
| 416 |
+
# Aggregate temporal information
|
| 417 |
+
start_time = min(e.start_timestamp for e in cluster)
|
| 418 |
+
end_time = max(e.end_timestamp for e in cluster)
|
| 419 |
+
total_duration = end_time - start_time
|
| 420 |
+
|
| 421 |
+
# Aggregate confidence and priority
|
| 422 |
+
max_confidence = max(e.confidence for e in cluster)
|
| 423 |
+
avg_confidence = sum(e.confidence for e in cluster) / len(cluster)
|
| 424 |
+
max_priority = max(e.investigation_priority for e in cluster)
|
| 425 |
+
|
| 426 |
+
# Collect all keyframes
|
| 427 |
+
all_keyframes = []
|
| 428 |
+
for event in cluster:
|
| 429 |
+
all_keyframes.extend(event.keyframes)
|
| 430 |
+
unique_keyframes = list(set(all_keyframes))
|
| 431 |
+
|
| 432 |
+
# Aggregate detection information
|
| 433 |
+
total_detections = sum(
|
| 434 |
+
event.detection_details.get('detection_count', 1) for event in cluster
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
# Determine if immediate response required
|
| 438 |
+
requires_immediate_response = any(e.requires_immediate_response for e in cluster)
|
| 439 |
+
|
| 440 |
+
# Get highest threat level
|
| 441 |
+
threat_levels = [ThreatLevel.LOW, ThreatLevel.MEDIUM, ThreatLevel.HIGH, ThreatLevel.CRITICAL]
|
| 442 |
+
max_threat_level = max((e.threat_level for e in cluster), key=lambda t: threat_levels.index(t))
|
| 443 |
+
|
| 444 |
+
# Create detection summary
|
| 445 |
+
detection_summary = {
|
| 446 |
+
'total_events_aggregated': len(cluster),
|
| 447 |
+
'detection_methods': list(set(e.detection_model_used for e in cluster)),
|
| 448 |
+
'confidence_range': {
|
| 449 |
+
'min': min(e.confidence for e in cluster),
|
| 450 |
+
'max': max_confidence,
|
| 451 |
+
'average': avg_confidence
|
| 452 |
+
},
|
| 453 |
+
'detection_details': [e.detection_details for e in cluster]
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
# Generate description and assessment
|
| 457 |
+
description = self._generate_canonical_description(event_type, cluster, max_confidence)
|
| 458 |
+
security_assessment = self._generate_security_assessment(event_type, max_threat_level, len(cluster))
|
| 459 |
+
recommended_actions = self._get_recommended_actions(event_type, max_threat_level)
|
| 460 |
+
|
| 461 |
+
canonical_event = DetectifAICanonicalEvent(
|
| 462 |
+
canonical_id=f"detectifai_canonical_{canonical_id:04d}",
|
| 463 |
+
event_type=event_type,
|
| 464 |
+
threat_level=max_threat_level,
|
| 465 |
+
start_time=start_time,
|
| 466 |
+
end_time=end_time,
|
| 467 |
+
total_duration=total_duration,
|
| 468 |
+
aggregated_events_count=len(cluster),
|
| 469 |
+
aggregated_event_ids=[e.event_id for e in cluster],
|
| 470 |
+
representative_frame=representative.keyframes[0] if representative.keyframes else "",
|
| 471 |
+
all_keyframes=unique_keyframes,
|
| 472 |
+
max_confidence=max_confidence,
|
| 473 |
+
average_confidence=avg_confidence,
|
| 474 |
+
investigation_priority=max_priority,
|
| 475 |
+
requires_immediate_response=requires_immediate_response,
|
| 476 |
+
total_detections=total_detections,
|
| 477 |
+
detection_summary=detection_summary,
|
| 478 |
+
description=description,
|
| 479 |
+
security_assessment=security_assessment,
|
| 480 |
+
recommended_actions=recommended_actions
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
return canonical_event
|
| 484 |
+
|
| 485 |
+
def _generate_canonical_description(self, event_type: DetectifAIEventType,
|
| 486 |
+
cluster: List[DetectifAIEvent], confidence: float) -> str:
|
| 487 |
+
"""Generate description for canonical DetectifAI event"""
|
| 488 |
+
event_count = len(cluster)
|
| 489 |
+
duration = max(e.end_timestamp for e in cluster) - min(e.start_timestamp for e in cluster)
|
| 490 |
+
|
| 491 |
+
base_descriptions = {
|
| 492 |
+
DetectifAIEventType.FIRE_DETECTION: f"Fire incident - {event_count} detections over {duration:.1f}s",
|
| 493 |
+
DetectifAIEventType.WEAPON_DETECTION: f"Weapon threat - {event_count} detections over {duration:.1f}s",
|
| 494 |
+
DetectifAIEventType.PHYSICAL_ASSAULT: f"Physical assault incident - {event_count} events over {duration:.1f}s",
|
| 495 |
+
DetectifAIEventType.WALL_JUMPING: f"Perimeter breach - {event_count} wall jumping events over {duration:.1f}s",
|
| 496 |
+
DetectifAIEventType.ROAD_ACCIDENT: f"Road accident - {event_count} incidents over {duration:.1f}s",
|
| 497 |
+
DetectifAIEventType.SUSPICIOUS_PERSON_REOCCURRENCE: f"Suspicious person alert - {event_count} re-occurrences",
|
| 498 |
+
DetectifAIEventType.GENERAL_MOTION: f"Motion activity - {event_count} events over {duration:.1f}s"
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
return base_descriptions.get(event_type, f"Security event: {event_type.value}")
|
| 502 |
+
|
| 503 |
+
def _generate_security_assessment(self, event_type: DetectifAIEventType,
|
| 504 |
+
threat_level: ThreatLevel, event_count: int) -> str:
|
| 505 |
+
"""Generate security assessment for canonical event"""
|
| 506 |
+
assessments = {
|
| 507 |
+
(DetectifAIEventType.FIRE_DETECTION, ThreatLevel.CRITICAL): "CRITICAL: Immediate evacuation and fire response required",
|
| 508 |
+
(DetectifAIEventType.WEAPON_DETECTION, ThreatLevel.CRITICAL): "CRITICAL: Armed threat present - immediate security intervention",
|
| 509 |
+
(DetectifAIEventType.PHYSICAL_ASSAULT, ThreatLevel.HIGH): "HIGH: Violence in progress - security response needed",
|
| 510 |
+
(DetectifAIEventType.SUSPICIOUS_PERSON_REOCCURRENCE, ThreatLevel.HIGH): "HIGH: Known suspicious individual returned - monitor closely"
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
specific_assessment = assessments.get((event_type, threat_level))
|
| 514 |
+
if specific_assessment:
|
| 515 |
+
return specific_assessment
|
| 516 |
+
|
| 517 |
+
# Generic assessment based on threat level
|
| 518 |
+
generic_assessments = {
|
| 519 |
+
ThreatLevel.CRITICAL: f"CRITICAL threat level - immediate response required",
|
| 520 |
+
ThreatLevel.HIGH: f"HIGH priority security event - urgent attention needed",
|
| 521 |
+
ThreatLevel.MEDIUM: f"MEDIUM priority - monitor and assess situation",
|
| 522 |
+
ThreatLevel.LOW: f"LOW priority - general awareness sufficient"
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
return generic_assessments.get(threat_level, "Security event requires assessment")
|
| 526 |
+
|
| 527 |
+
def _get_recommended_actions(self, event_type: DetectifAIEventType,
|
| 528 |
+
threat_level: ThreatLevel) -> List[str]:
|
| 529 |
+
"""Get recommended actions for DetectifAI event types"""
|
| 530 |
+
actions_map = {
|
| 531 |
+
DetectifAIEventType.FIRE_DETECTION: [
|
| 532 |
+
"Verify fire location and extent",
|
| 533 |
+
"Initiate evacuation procedures if confirmed",
|
| 534 |
+
"Contact fire department",
|
| 535 |
+
"Monitor spread and safety of personnel"
|
| 536 |
+
],
|
| 537 |
+
DetectifAIEventType.WEAPON_DETECTION: [
|
| 538 |
+
"Verify weapon type and threat level",
|
| 539 |
+
"Alert security personnel immediately",
|
| 540 |
+
"Consider lockdown procedures",
|
| 541 |
+
"Contact law enforcement if confirmed threat"
|
| 542 |
+
],
|
| 543 |
+
DetectifAIEventType.PHYSICAL_ASSAULT: [
|
| 544 |
+
"Assess severity of altercation",
|
| 545 |
+
"Dispatch security to location",
|
| 546 |
+
"Consider medical assistance",
|
| 547 |
+
"Document incident for investigation"
|
| 548 |
+
],
|
| 549 |
+
DetectifAIEventType.WALL_JUMPING: [
|
| 550 |
+
"Verify perimeter breach",
|
| 551 |
+
"Check intruder location and intent",
|
| 552 |
+
"Review security footage",
|
| 553 |
+
"Assess security protocol effectiveness"
|
| 554 |
+
],
|
| 555 |
+
DetectifAIEventType.ROAD_ACCIDENT: [
|
| 556 |
+
"Assess severity of accident",
|
| 557 |
+
"Check for injuries",
|
| 558 |
+
"Contact emergency services if needed",
|
| 559 |
+
"Manage traffic flow around incident"
|
| 560 |
+
],
|
| 561 |
+
DetectifAIEventType.SUSPICIOUS_PERSON_REOCCURRENCE: [
|
| 562 |
+
"Review person's previous incidents",
|
| 563 |
+
"Monitor current activities closely",
|
| 564 |
+
"Alert security personnel",
|
| 565 |
+
"Consider preventive measures"
|
| 566 |
+
]
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
base_actions = actions_map.get(event_type, ["Monitor situation", "Assess threat level", "Take appropriate action"])
|
| 570 |
+
|
| 571 |
+
# Add threat-level specific actions
|
| 572 |
+
if threat_level == ThreatLevel.CRITICAL:
|
| 573 |
+
base_actions.insert(0, "IMMEDIATE ACTION REQUIRED")
|
| 574 |
+
elif threat_level == ThreatLevel.HIGH:
|
| 575 |
+
base_actions.insert(0, "URGENT: Prioritize response")
|
| 576 |
+
|
| 577 |
+
return base_actions
|
event_aggregation.py
ADDED
|
@@ -0,0 +1,819 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Event Aggregation and Deduplication Module
|
| 3 |
+
|
| 4 |
+
This module handles:
|
| 5 |
+
- Event detection and clustering
|
| 6 |
+
- Temporal aggregation of related events
|
| 7 |
+
- Duplicate frame removal using similarity detection
|
| 8 |
+
- Canonical event generation
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import cv2
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
from typing import List, Dict, Tuple, Set, Any, Optional
|
| 16 |
+
from dataclasses import dataclass, asdict
|
| 17 |
+
import imagehash
|
| 18 |
+
from PIL import Image
|
| 19 |
+
from collections import defaultdict
|
| 20 |
+
import logging
|
| 21 |
+
from datetime import datetime
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class Event:
|
| 27 |
+
"""Represents a detected event"""
|
| 28 |
+
event_id: str
|
| 29 |
+
start_timestamp: float
|
| 30 |
+
end_timestamp: float
|
| 31 |
+
event_type: str
|
| 32 |
+
confidence: float
|
| 33 |
+
keyframes: List[str] # Frame paths
|
| 34 |
+
importance_score: float
|
| 35 |
+
motion_intensity: float
|
| 36 |
+
description: str = ""
|
| 37 |
+
# Object detection specific fields
|
| 38 |
+
object_class: str = "" # For object-based events (fire, knife, gun)
|
| 39 |
+
detection_count: int = 0 # Number of detections in this event
|
| 40 |
+
max_confidence: float = 0.0 # Highest confidence detection
|
| 41 |
+
is_object_event: bool = False # Flag to identify object-based events
|
| 42 |
+
detection_details: List = None # Raw detection data
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class CanonicalEvent:
|
| 46 |
+
"""Canonical representation of aggregated events"""
|
| 47 |
+
canonical_id: str
|
| 48 |
+
event_type: str
|
| 49 |
+
representative_frame: str
|
| 50 |
+
start_time: float
|
| 51 |
+
end_time: float
|
| 52 |
+
duration: float
|
| 53 |
+
confidence: float
|
| 54 |
+
frame_count: int
|
| 55 |
+
aggregated_events: List[str] # Event IDs
|
| 56 |
+
description: str
|
| 57 |
+
similarity_cluster: int
|
| 58 |
+
# Enhanced object detection fields
|
| 59 |
+
contains_objects: bool = False # Whether this canonical event has object detections
|
| 60 |
+
detected_object_classes: List[str] = None # List of detected object classes
|
| 61 |
+
object_detection_summary: Dict = None # Summary of object detections
|
| 62 |
+
threat_level: str = "low" # Threat assessment: low, medium, high, critical
|
| 63 |
+
|
| 64 |
+
class SimilarityCalculator:
|
| 65 |
+
"""Calculate similarity between frames using multiple methods"""
|
| 66 |
+
|
| 67 |
+
def __init__(self, similarity_threshold: float = 0.85):
|
| 68 |
+
self.similarity_threshold = similarity_threshold
|
| 69 |
+
|
| 70 |
+
def calculate_histogram_similarity(self, frame1: np.ndarray, frame2: np.ndarray) -> float:
|
| 71 |
+
"""Calculate histogram-based similarity"""
|
| 72 |
+
try:
|
| 73 |
+
# Convert to HSV for better color comparison
|
| 74 |
+
hsv1 = cv2.cvtColor(frame1, cv2.COLOR_BGR2HSV)
|
| 75 |
+
hsv2 = cv2.cvtColor(frame2, cv2.COLOR_BGR2HSV)
|
| 76 |
+
|
| 77 |
+
# Calculate histograms
|
| 78 |
+
hist1 = cv2.calcHist([hsv1], [0, 1, 2], None, [50, 60, 60], [0, 180, 0, 256, 0, 256])
|
| 79 |
+
hist2 = cv2.calcHist([hsv2], [0, 1, 2], None, [50, 60, 60], [0, 180, 0, 256, 0, 256])
|
| 80 |
+
|
| 81 |
+
# Calculate correlation
|
| 82 |
+
correlation = cv2.compareHist(hist1, hist2, cv2.HISTCMP_CORREL)
|
| 83 |
+
return max(0.0, correlation)
|
| 84 |
+
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.error(f"Histogram similarity calculation failed: {e}")
|
| 87 |
+
return 0.0
|
| 88 |
+
|
| 89 |
+
def calculate_perceptual_hash_similarity(self, frame1_path: str, frame2_path: str) -> float:
|
| 90 |
+
"""Calculate perceptual hash similarity"""
|
| 91 |
+
try:
|
| 92 |
+
# Load images with PIL for imagehash
|
| 93 |
+
img1 = Image.open(frame1_path)
|
| 94 |
+
img2 = Image.open(frame2_path)
|
| 95 |
+
|
| 96 |
+
# Calculate perceptual hashes
|
| 97 |
+
hash1 = imagehash.phash(img1)
|
| 98 |
+
hash2 = imagehash.phash(img2)
|
| 99 |
+
|
| 100 |
+
# Calculate similarity (lower hash difference = higher similarity)
|
| 101 |
+
hash_diff = hash1 - hash2
|
| 102 |
+
similarity = 1.0 - (hash_diff / 64.0) # Normalize to 0-1
|
| 103 |
+
|
| 104 |
+
return max(0.0, similarity)
|
| 105 |
+
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error(f"Perceptual hash similarity calculation failed: {e}")
|
| 108 |
+
return 0.0
|
| 109 |
+
|
| 110 |
+
def calculate_structural_similarity(self, frame1: np.ndarray, frame2: np.ndarray) -> float:
|
| 111 |
+
"""Calculate structural similarity using template matching"""
|
| 112 |
+
try:
|
| 113 |
+
# Convert to grayscale
|
| 114 |
+
gray1 = cv2.cvtColor(frame1, cv2.COLOR_BGR2GRAY)
|
| 115 |
+
gray2 = cv2.cvtColor(frame2, cv2.COLOR_BGR2GRAY)
|
| 116 |
+
|
| 117 |
+
# Resize to same dimensions if needed
|
| 118 |
+
if gray1.shape != gray2.shape:
|
| 119 |
+
h, w = min(gray1.shape[0], gray2.shape[0]), min(gray1.shape[1], gray2.shape[1])
|
| 120 |
+
gray1 = cv2.resize(gray1, (w, h))
|
| 121 |
+
gray2 = cv2.resize(gray2, (w, h))
|
| 122 |
+
|
| 123 |
+
# Calculate normalized cross-correlation
|
| 124 |
+
result = cv2.matchTemplate(gray1, gray2, cv2.TM_CCOEFF_NORMED)
|
| 125 |
+
similarity = result[0, 0]
|
| 126 |
+
|
| 127 |
+
return max(0.0, similarity)
|
| 128 |
+
|
| 129 |
+
except Exception as e:
|
| 130 |
+
logger.error(f"Structural similarity calculation failed: {e}")
|
| 131 |
+
return 0.0
|
| 132 |
+
|
| 133 |
+
def calculate_combined_similarity(self, frame1_path: str, frame2_path: str) -> float:
|
| 134 |
+
"""Calculate combined similarity score using multiple methods"""
|
| 135 |
+
try:
|
| 136 |
+
# Load frames
|
| 137 |
+
frame1 = cv2.imread(frame1_path)
|
| 138 |
+
frame2 = cv2.imread(frame2_path)
|
| 139 |
+
|
| 140 |
+
if frame1 is None or frame2 is None:
|
| 141 |
+
return 0.0
|
| 142 |
+
|
| 143 |
+
# Calculate different similarity metrics
|
| 144 |
+
hist_sim = self.calculate_histogram_similarity(frame1, frame2)
|
| 145 |
+
hash_sim = self.calculate_perceptual_hash_similarity(frame1_path, frame2_path)
|
| 146 |
+
struct_sim = self.calculate_structural_similarity(frame1, frame2)
|
| 147 |
+
|
| 148 |
+
# Weighted combination
|
| 149 |
+
combined_similarity = (
|
| 150 |
+
hist_sim * 0.4 + # Histogram similarity
|
| 151 |
+
hash_sim * 0.4 + # Perceptual hash similarity
|
| 152 |
+
struct_sim * 0.2 # Structural similarity
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
return min(1.0, combined_similarity)
|
| 156 |
+
|
| 157 |
+
except Exception as e:
|
| 158 |
+
logger.error(f"Combined similarity calculation failed: {e}")
|
| 159 |
+
return 0.0
|
| 160 |
+
|
| 161 |
+
class EventDetector:
|
| 162 |
+
"""Detect events from keyframes"""
|
| 163 |
+
|
| 164 |
+
def __init__(self, config):
|
| 165 |
+
self.config = config
|
| 166 |
+
self.event_types = {
|
| 167 |
+
'high_motion': {'motion_threshold': config.motion_threshold * 2},
|
| 168 |
+
'burst_activity': {'requires_burst': True},
|
| 169 |
+
'scene_change': {'change_threshold': config.scene_change_threshold},
|
| 170 |
+
'quality_peak': {'quality_threshold': config.base_quality_threshold * 1.5}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
def detect_events(self, keyframes: List) -> List[Event]:
|
| 174 |
+
"""Detect events from keyframes"""
|
| 175 |
+
logger.info(f"Detecting events from {len(keyframes)} keyframes")
|
| 176 |
+
|
| 177 |
+
events = []
|
| 178 |
+
event_id_counter = 1
|
| 179 |
+
|
| 180 |
+
# Temporal clustering for event detection
|
| 181 |
+
clusters = self._create_temporal_clusters(keyframes)
|
| 182 |
+
|
| 183 |
+
for cluster in clusters:
|
| 184 |
+
if len(cluster) == 0:
|
| 185 |
+
continue
|
| 186 |
+
|
| 187 |
+
# Analyze cluster for event types
|
| 188 |
+
cluster_events = self._analyze_cluster_for_events(cluster, event_id_counter)
|
| 189 |
+
events.extend(cluster_events)
|
| 190 |
+
event_id_counter += len(cluster_events)
|
| 191 |
+
|
| 192 |
+
logger.info(f"Detected {len(events)} events")
|
| 193 |
+
return events
|
| 194 |
+
|
| 195 |
+
def _create_temporal_clusters(self, keyframes: List) -> List[List]:
|
| 196 |
+
"""Create temporal clusters of keyframes"""
|
| 197 |
+
if not keyframes:
|
| 198 |
+
return []
|
| 199 |
+
|
| 200 |
+
# Sort keyframes by timestamp
|
| 201 |
+
sorted_keyframes = sorted(keyframes, key=lambda x: x.frame_data.timestamp)
|
| 202 |
+
|
| 203 |
+
clusters = []
|
| 204 |
+
current_cluster = [sorted_keyframes[0]]
|
| 205 |
+
|
| 206 |
+
for i in range(1, len(sorted_keyframes)):
|
| 207 |
+
current_kf = sorted_keyframes[i]
|
| 208 |
+
last_kf = current_cluster[-1]
|
| 209 |
+
|
| 210 |
+
time_gap = current_kf.frame_data.timestamp - last_kf.frame_data.timestamp
|
| 211 |
+
|
| 212 |
+
# If gap is within clustering window, add to current cluster
|
| 213 |
+
if time_gap <= self.config.temporal_clustering_window:
|
| 214 |
+
current_cluster.append(current_kf)
|
| 215 |
+
else:
|
| 216 |
+
# Start new cluster
|
| 217 |
+
if len(current_cluster) > 0:
|
| 218 |
+
clusters.append(current_cluster)
|
| 219 |
+
current_cluster = [current_kf]
|
| 220 |
+
|
| 221 |
+
# Don't forget the last cluster
|
| 222 |
+
if len(current_cluster) > 0:
|
| 223 |
+
clusters.append(current_cluster)
|
| 224 |
+
|
| 225 |
+
return clusters
|
| 226 |
+
|
| 227 |
+
def _analyze_cluster_for_events(self, cluster: List, start_event_id: int) -> List[Event]:
|
| 228 |
+
"""Analyze a temporal cluster for different event types"""
|
| 229 |
+
events = []
|
| 230 |
+
|
| 231 |
+
if not cluster:
|
| 232 |
+
return events
|
| 233 |
+
|
| 234 |
+
# Calculate cluster metrics
|
| 235 |
+
motion_scores = [kf.frame_data.motion_score for kf in cluster]
|
| 236 |
+
quality_scores = [kf.frame_data.quality_score for kf in cluster]
|
| 237 |
+
burst_frames = [kf for kf in cluster if kf.frame_data.burst_active]
|
| 238 |
+
|
| 239 |
+
start_time = min(kf.frame_data.timestamp for kf in cluster)
|
| 240 |
+
end_time = max(kf.frame_data.timestamp for kf in cluster)
|
| 241 |
+
|
| 242 |
+
max_motion = max(motion_scores) if motion_scores else 0
|
| 243 |
+
avg_motion = sum(motion_scores) / len(motion_scores) if motion_scores else 0
|
| 244 |
+
max_quality = max(quality_scores) if quality_scores else 0
|
| 245 |
+
|
| 246 |
+
# High motion event
|
| 247 |
+
if max_motion > self.config.motion_threshold * 2:
|
| 248 |
+
event = Event(
|
| 249 |
+
event_id=f"event_{start_event_id:04d}",
|
| 250 |
+
start_timestamp=start_time,
|
| 251 |
+
end_timestamp=end_time,
|
| 252 |
+
event_type="high_motion",
|
| 253 |
+
confidence=min(max_motion * 2, 1.0),
|
| 254 |
+
keyframes=[kf.frame_data.frame_path for kf in cluster],
|
| 255 |
+
importance_score=max_motion + (avg_motion * 0.5),
|
| 256 |
+
motion_intensity=max_motion,
|
| 257 |
+
description=f"High motion event with peak intensity {max_motion:.3f}"
|
| 258 |
+
)
|
| 259 |
+
events.append(event)
|
| 260 |
+
start_event_id += 1
|
| 261 |
+
|
| 262 |
+
# Burst activity event
|
| 263 |
+
if len(burst_frames) >= 2:
|
| 264 |
+
event = Event(
|
| 265 |
+
event_id=f"event_{start_event_id:04d}",
|
| 266 |
+
start_timestamp=start_time,
|
| 267 |
+
end_timestamp=end_time,
|
| 268 |
+
event_type="burst_activity",
|
| 269 |
+
confidence=min(len(burst_frames) / len(cluster), 1.0),
|
| 270 |
+
keyframes=[kf.frame_data.frame_path for kf in burst_frames],
|
| 271 |
+
importance_score=len(burst_frames) * 0.3 + avg_motion,
|
| 272 |
+
motion_intensity=max_motion,
|
| 273 |
+
description=f"Burst activity with {len(burst_frames)} active frames"
|
| 274 |
+
)
|
| 275 |
+
events.append(event)
|
| 276 |
+
start_event_id += 1
|
| 277 |
+
|
| 278 |
+
# Quality peak event
|
| 279 |
+
if max_quality > self.config.base_quality_threshold * 1.5:
|
| 280 |
+
high_quality_frames = [kf for kf in cluster if kf.frame_data.quality_score > self.config.base_quality_threshold * 1.3]
|
| 281 |
+
if high_quality_frames:
|
| 282 |
+
event = Event(
|
| 283 |
+
event_id=f"event_{start_event_id:04d}",
|
| 284 |
+
start_timestamp=start_time,
|
| 285 |
+
end_timestamp=end_time,
|
| 286 |
+
event_type="quality_peak",
|
| 287 |
+
confidence=max_quality,
|
| 288 |
+
keyframes=[kf.frame_data.frame_path for kf in high_quality_frames],
|
| 289 |
+
importance_score=max_quality + (len(high_quality_frames) * 0.1),
|
| 290 |
+
motion_intensity=max_motion,
|
| 291 |
+
description=f"High quality event with peak score {max_quality:.3f}"
|
| 292 |
+
)
|
| 293 |
+
events.append(event)
|
| 294 |
+
|
| 295 |
+
return events
|
| 296 |
+
|
| 297 |
+
def convert_object_events_to_standard_format(self, object_events: List[Dict]) -> List[Event]:
|
| 298 |
+
"""Convert object events from object detection module to standard Event format"""
|
| 299 |
+
standard_events = []
|
| 300 |
+
|
| 301 |
+
for obj_event in object_events:
|
| 302 |
+
# Convert object event dict to Event dataclass
|
| 303 |
+
event = Event(
|
| 304 |
+
event_id=obj_event['event_id'],
|
| 305 |
+
start_timestamp=obj_event['start_timestamp'],
|
| 306 |
+
end_timestamp=obj_event['end_timestamp'],
|
| 307 |
+
event_type=obj_event['event_type'],
|
| 308 |
+
confidence=obj_event['confidence'],
|
| 309 |
+
keyframes=obj_event['keyframes'],
|
| 310 |
+
importance_score=obj_event['importance_score'],
|
| 311 |
+
motion_intensity=obj_event.get('motion_intensity', 0.0),
|
| 312 |
+
description=obj_event['description'],
|
| 313 |
+
# Object-specific fields
|
| 314 |
+
object_class=obj_event.get('object_class', ''),
|
| 315 |
+
detection_count=obj_event.get('detection_count', 0),
|
| 316 |
+
max_confidence=obj_event.get('max_confidence', obj_event['confidence']),
|
| 317 |
+
is_object_event=True,
|
| 318 |
+
detection_details=obj_event.get('detection_details', [])
|
| 319 |
+
)
|
| 320 |
+
standard_events.append(event)
|
| 321 |
+
|
| 322 |
+
return standard_events
|
| 323 |
+
|
| 324 |
+
def convert_behavior_events_to_standard_format(self, behavior_events: List) -> List[Event]:
|
| 325 |
+
"""Convert behavior events from behavior analysis module to standard Event format"""
|
| 326 |
+
standard_events = []
|
| 327 |
+
|
| 328 |
+
for behavior_event in behavior_events:
|
| 329 |
+
# Handle both dataclass and dict formats
|
| 330 |
+
if hasattr(behavior_event, 'behavior_type'):
|
| 331 |
+
# Dataclass format (from BehaviorEvent)
|
| 332 |
+
event = Event(
|
| 333 |
+
event_id=behavior_event.event_id,
|
| 334 |
+
start_timestamp=behavior_event.start_timestamp,
|
| 335 |
+
end_timestamp=behavior_event.end_timestamp,
|
| 336 |
+
event_type=f"behavior_{behavior_event.behavior_type}",
|
| 337 |
+
confidence=behavior_event.confidence,
|
| 338 |
+
keyframes=behavior_event.keyframes,
|
| 339 |
+
importance_score=behavior_event.importance_score,
|
| 340 |
+
motion_intensity=0.0, # Behavior events don't have motion intensity
|
| 341 |
+
description=f"{behavior_event.behavior_type.capitalize()} detected (confidence: {behavior_event.confidence:.2f})",
|
| 342 |
+
# Use object_class field to store behavior type for consistency
|
| 343 |
+
object_class=behavior_event.behavior_type,
|
| 344 |
+
detection_count=len(behavior_event.frame_indices),
|
| 345 |
+
max_confidence=behavior_event.confidence,
|
| 346 |
+
is_object_event=False, # Behavior events are separate from object events
|
| 347 |
+
detection_details=[{
|
| 348 |
+
'model_used': behavior_event.model_used,
|
| 349 |
+
'frame_indices': behavior_event.frame_indices
|
| 350 |
+
}]
|
| 351 |
+
)
|
| 352 |
+
else:
|
| 353 |
+
# Dict format (fallback)
|
| 354 |
+
event = Event(
|
| 355 |
+
event_id=behavior_event.get('event_id', f"behavior_{len(standard_events)}"),
|
| 356 |
+
start_timestamp=behavior_event.get('start_timestamp', 0.0),
|
| 357 |
+
end_timestamp=behavior_event.get('end_timestamp', 0.0),
|
| 358 |
+
event_type=f"behavior_{behavior_event.get('behavior_type', 'unknown')}",
|
| 359 |
+
confidence=behavior_event.get('confidence', 0.0),
|
| 360 |
+
keyframes=behavior_event.get('keyframes', []),
|
| 361 |
+
importance_score=behavior_event.get('importance_score', 0.0),
|
| 362 |
+
motion_intensity=0.0,
|
| 363 |
+
description=behavior_event.get('description', 'Behavior detected'),
|
| 364 |
+
object_class=behavior_event.get('behavior_type', ''),
|
| 365 |
+
detection_count=len(behavior_event.get('frame_indices', [])),
|
| 366 |
+
max_confidence=behavior_event.get('confidence', 0.0),
|
| 367 |
+
is_object_event=False,
|
| 368 |
+
detection_details=[{
|
| 369 |
+
'model_used': behavior_event.get('model_used', 'unknown'),
|
| 370 |
+
'frame_indices': behavior_event.get('frame_indices', [])
|
| 371 |
+
}]
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
standard_events.append(event)
|
| 375 |
+
|
| 376 |
+
return standard_events
|
| 377 |
+
|
| 378 |
+
def assess_threat_level(self, event: Event) -> str:
|
| 379 |
+
"""Assess threat level for events, particularly object-based events"""
|
| 380 |
+
if not event.is_object_event:
|
| 381 |
+
# For motion events, use motion intensity and burst activity
|
| 382 |
+
if event.event_type == "high_motion" and event.motion_intensity > 0.015:
|
| 383 |
+
return "medium"
|
| 384 |
+
elif event.event_type == "burst_activity":
|
| 385 |
+
return "medium"
|
| 386 |
+
else:
|
| 387 |
+
return "low"
|
| 388 |
+
|
| 389 |
+
# Object-based threat assessment
|
| 390 |
+
threat_map = {
|
| 391 |
+
'fire': {
|
| 392 |
+
'low': 0.3, # Confidence thresholds
|
| 393 |
+
'medium': 0.5,
|
| 394 |
+
'high': 0.7,
|
| 395 |
+
'critical': 0.85
|
| 396 |
+
},
|
| 397 |
+
'gun': {
|
| 398 |
+
'low': 0.4,
|
| 399 |
+
'medium': 0.6,
|
| 400 |
+
'high': 0.8,
|
| 401 |
+
'critical': 0.9
|
| 402 |
+
},
|
| 403 |
+
'knife': {
|
| 404 |
+
'low': 0.4,
|
| 405 |
+
'medium': 0.6,
|
| 406 |
+
'high': 0.75,
|
| 407 |
+
'critical': 0.85
|
| 408 |
+
}
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
obj_class = event.object_class.lower()
|
| 412 |
+
confidence = event.max_confidence
|
| 413 |
+
|
| 414 |
+
if obj_class in threat_map:
|
| 415 |
+
thresholds = threat_map[obj_class]
|
| 416 |
+
if confidence >= thresholds['critical']:
|
| 417 |
+
return "critical"
|
| 418 |
+
elif confidence >= thresholds['high']:
|
| 419 |
+
return "high"
|
| 420 |
+
elif confidence >= thresholds['medium']:
|
| 421 |
+
return "medium"
|
| 422 |
+
else:
|
| 423 |
+
return "low"
|
| 424 |
+
|
| 425 |
+
return "medium" # Default for unknown object types
|
| 426 |
+
|
| 427 |
+
class EventDeduplicationEngine:
|
| 428 |
+
"""Remove duplicate events and create canonical representations"""
|
| 429 |
+
|
| 430 |
+
def __init__(self, config):
|
| 431 |
+
self.config = config
|
| 432 |
+
self.similarity_calculator = SimilarityCalculator(config.similarity_threshold)
|
| 433 |
+
|
| 434 |
+
def deduplicate_events(self, events: List[Event]) -> Tuple[List[CanonicalEvent], Dict[str, Any]]:
|
| 435 |
+
"""
|
| 436 |
+
Deduplicate events and create canonical representations
|
| 437 |
+
|
| 438 |
+
Returns:
|
| 439 |
+
Tuple of (canonical_events, deduplication_stats)
|
| 440 |
+
"""
|
| 441 |
+
logger.info(f"Deduplicating {len(events)} events")
|
| 442 |
+
|
| 443 |
+
if not events:
|
| 444 |
+
return [], {}
|
| 445 |
+
|
| 446 |
+
# Group events by type first
|
| 447 |
+
events_by_type = defaultdict(list)
|
| 448 |
+
for event in events:
|
| 449 |
+
events_by_type[event.event_type].append(event)
|
| 450 |
+
|
| 451 |
+
canonical_events = []
|
| 452 |
+
dedup_stats = {
|
| 453 |
+
'original_events': len(events),
|
| 454 |
+
'canonical_events': 0,
|
| 455 |
+
'duplicates_removed': 0,
|
| 456 |
+
'similarity_clusters': 0
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
canonical_id_counter = 1
|
| 460 |
+
|
| 461 |
+
# Process each event type separately
|
| 462 |
+
for event_type, type_events in events_by_type.items():
|
| 463 |
+
type_canonical = self._deduplicate_events_by_type(
|
| 464 |
+
type_events, event_type, canonical_id_counter
|
| 465 |
+
)
|
| 466 |
+
canonical_events.extend(type_canonical)
|
| 467 |
+
canonical_id_counter += len(type_canonical)
|
| 468 |
+
|
| 469 |
+
# Update stats
|
| 470 |
+
dedup_stats['canonical_events'] = len(canonical_events)
|
| 471 |
+
dedup_stats['duplicates_removed'] = dedup_stats['original_events'] - dedup_stats['canonical_events']
|
| 472 |
+
dedup_stats['similarity_clusters'] = len(canonical_events)
|
| 473 |
+
|
| 474 |
+
logger.info(f"Deduplication complete: {len(canonical_events)} canonical events created")
|
| 475 |
+
return canonical_events, dedup_stats
|
| 476 |
+
|
| 477 |
+
def _deduplicate_events_by_type(self, events: List[Event], event_type: str,
|
| 478 |
+
start_canonical_id: int) -> List[CanonicalEvent]:
|
| 479 |
+
"""Deduplicate events of the same type"""
|
| 480 |
+
if not events:
|
| 481 |
+
return []
|
| 482 |
+
|
| 483 |
+
# Create similarity matrix
|
| 484 |
+
similarity_matrix = self._create_similarity_matrix(events)
|
| 485 |
+
|
| 486 |
+
# Cluster similar events
|
| 487 |
+
clusters = self._cluster_similar_events(events, similarity_matrix)
|
| 488 |
+
|
| 489 |
+
# Create canonical events from clusters
|
| 490 |
+
canonical_events = []
|
| 491 |
+
for i, cluster in enumerate(clusters):
|
| 492 |
+
canonical_event = self._create_canonical_event(
|
| 493 |
+
cluster, event_type, start_canonical_id + i, i
|
| 494 |
+
)
|
| 495 |
+
canonical_events.append(canonical_event)
|
| 496 |
+
|
| 497 |
+
return canonical_events
|
| 498 |
+
|
| 499 |
+
def _create_similarity_matrix(self, events: List[Event]) -> np.ndarray:
|
| 500 |
+
"""Create similarity matrix between events"""
|
| 501 |
+
n = len(events)
|
| 502 |
+
similarity_matrix = np.zeros((n, n))
|
| 503 |
+
|
| 504 |
+
for i in range(n):
|
| 505 |
+
for j in range(i, n):
|
| 506 |
+
if i == j:
|
| 507 |
+
similarity_matrix[i, j] = 1.0
|
| 508 |
+
else:
|
| 509 |
+
# Calculate similarity between representative frames
|
| 510 |
+
sim_score = self._calculate_event_similarity(events[i], events[j])
|
| 511 |
+
similarity_matrix[i, j] = sim_score
|
| 512 |
+
similarity_matrix[j, i] = sim_score
|
| 513 |
+
|
| 514 |
+
return similarity_matrix
|
| 515 |
+
|
| 516 |
+
def _calculate_event_similarity(self, event1: Event, event2: Event) -> float:
|
| 517 |
+
"""Calculate similarity between two events (enhanced for object events)"""
|
| 518 |
+
try:
|
| 519 |
+
# Object events similarity
|
| 520 |
+
if event1.is_object_event and event2.is_object_event:
|
| 521 |
+
return self._calculate_object_event_similarity(event1, event2)
|
| 522 |
+
elif event1.is_object_event != event2.is_object_event:
|
| 523 |
+
# Different event types (object vs motion) - lower similarity
|
| 524 |
+
return 0.1
|
| 525 |
+
|
| 526 |
+
# Motion events similarity (original logic)
|
| 527 |
+
# Time overlap similarity
|
| 528 |
+
time_overlap = self._calculate_time_overlap(event1, event2)
|
| 529 |
+
|
| 530 |
+
# Frame content similarity (use representative frames)
|
| 531 |
+
frame1 = event1.keyframes[0] if event1.keyframes else None
|
| 532 |
+
frame2 = event2.keyframes[0] if event2.keyframes else None
|
| 533 |
+
|
| 534 |
+
content_similarity = 0.0
|
| 535 |
+
if frame1 and frame2 and os.path.exists(frame1) and os.path.exists(frame2):
|
| 536 |
+
content_similarity = self.similarity_calculator.calculate_combined_similarity(frame1, frame2)
|
| 537 |
+
|
| 538 |
+
# Motion intensity similarity
|
| 539 |
+
motion_sim = 1.0 - abs(event1.motion_intensity - event2.motion_intensity)
|
| 540 |
+
|
| 541 |
+
# Combined similarity
|
| 542 |
+
combined_similarity = (
|
| 543 |
+
time_overlap * 0.3 +
|
| 544 |
+
content_similarity * 0.5 +
|
| 545 |
+
motion_sim * 0.2
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
return combined_similarity
|
| 549 |
+
|
| 550 |
+
except Exception as e:
|
| 551 |
+
logger.error(f"Event similarity calculation failed: {e}")
|
| 552 |
+
return 0.0
|
| 553 |
+
|
| 554 |
+
def _calculate_object_event_similarity(self, event1: Event, event2: Event) -> float:
|
| 555 |
+
"""Calculate similarity between two object events"""
|
| 556 |
+
try:
|
| 557 |
+
# Object class similarity (must be same class)
|
| 558 |
+
if event1.object_class != event2.object_class:
|
| 559 |
+
return 0.0 # Different object types are not similar
|
| 560 |
+
|
| 561 |
+
# Time proximity
|
| 562 |
+
time_gap = abs(event1.start_timestamp - event2.start_timestamp)
|
| 563 |
+
time_similarity = max(0.0, 1.0 - (time_gap / self.config.object_event_temporal_window))
|
| 564 |
+
|
| 565 |
+
# Confidence similarity
|
| 566 |
+
conf_diff = abs(event1.confidence - event2.confidence)
|
| 567 |
+
conf_similarity = max(0.0, 1.0 - conf_diff)
|
| 568 |
+
|
| 569 |
+
# Detection count similarity
|
| 570 |
+
count_diff = abs(event1.detection_count - event2.detection_count)
|
| 571 |
+
count_similarity = max(0.0, 1.0 - (count_diff / max(event1.detection_count, event2.detection_count, 1)))
|
| 572 |
+
|
| 573 |
+
# Frame content similarity
|
| 574 |
+
frame1 = event1.keyframes[0] if event1.keyframes else None
|
| 575 |
+
frame2 = event2.keyframes[0] if event2.keyframes else None
|
| 576 |
+
|
| 577 |
+
content_similarity = 0.0
|
| 578 |
+
if frame1 and frame2 and os.path.exists(frame1) and os.path.exists(frame2):
|
| 579 |
+
content_similarity = self.similarity_calculator.calculate_combined_similarity(frame1, frame2)
|
| 580 |
+
|
| 581 |
+
# Combined similarity for object events
|
| 582 |
+
combined_similarity = (
|
| 583 |
+
time_similarity * 0.4 + # Time proximity is important
|
| 584 |
+
content_similarity * 0.3 + # Visual similarity
|
| 585 |
+
conf_similarity * 0.2 + # Confidence similarity
|
| 586 |
+
count_similarity * 0.1 # Detection count similarity
|
| 587 |
+
)
|
| 588 |
+
|
| 589 |
+
return combined_similarity
|
| 590 |
+
|
| 591 |
+
except Exception as e:
|
| 592 |
+
logger.error(f"Object event similarity calculation failed: {e}")
|
| 593 |
+
return 0.0
|
| 594 |
+
|
| 595 |
+
def _calculate_time_overlap(self, event1: Event, event2: Event) -> float:
|
| 596 |
+
"""Calculate temporal overlap between events"""
|
| 597 |
+
start1, end1 = event1.start_timestamp, event1.end_timestamp
|
| 598 |
+
start2, end2 = event2.start_timestamp, event2.end_timestamp
|
| 599 |
+
|
| 600 |
+
# Calculate overlap
|
| 601 |
+
overlap_start = max(start1, start2)
|
| 602 |
+
overlap_end = min(end1, end2)
|
| 603 |
+
|
| 604 |
+
if overlap_start >= overlap_end:
|
| 605 |
+
return 0.0
|
| 606 |
+
|
| 607 |
+
overlap_duration = overlap_end - overlap_start
|
| 608 |
+
total_duration = max(end1, end2) - min(start1, start2)
|
| 609 |
+
|
| 610 |
+
return overlap_duration / total_duration if total_duration > 0 else 0.0
|
| 611 |
+
|
| 612 |
+
def _cluster_similar_events(self, events: List[Event], similarity_matrix: np.ndarray) -> List[List[Event]]:
|
| 613 |
+
"""Cluster similar events using similarity threshold"""
|
| 614 |
+
n = len(events)
|
| 615 |
+
visited = [False] * n
|
| 616 |
+
clusters = []
|
| 617 |
+
|
| 618 |
+
for i in range(n):
|
| 619 |
+
if visited[i]:
|
| 620 |
+
continue
|
| 621 |
+
|
| 622 |
+
# Start new cluster
|
| 623 |
+
cluster = [events[i]]
|
| 624 |
+
visited[i] = True
|
| 625 |
+
|
| 626 |
+
# Find similar events
|
| 627 |
+
for j in range(i + 1, n):
|
| 628 |
+
if not visited[j] and similarity_matrix[i, j] >= self.config.similarity_threshold:
|
| 629 |
+
cluster.append(events[j])
|
| 630 |
+
visited[j] = True
|
| 631 |
+
|
| 632 |
+
clusters.append(cluster)
|
| 633 |
+
|
| 634 |
+
return clusters
|
| 635 |
+
|
| 636 |
+
def _create_canonical_event(self, cluster: List[Event], event_type: str,
|
| 637 |
+
canonical_id: int, cluster_id: int) -> CanonicalEvent:
|
| 638 |
+
"""Create canonical event from cluster of similar events"""
|
| 639 |
+
if not cluster:
|
| 640 |
+
raise ValueError("Cannot create canonical event from empty cluster")
|
| 641 |
+
|
| 642 |
+
# Find representative event (highest importance score)
|
| 643 |
+
representative = max(cluster, key=lambda e: e.importance_score)
|
| 644 |
+
|
| 645 |
+
# Aggregate properties
|
| 646 |
+
start_time = min(e.start_timestamp for e in cluster)
|
| 647 |
+
end_time = max(e.end_timestamp for e in cluster)
|
| 648 |
+
duration = end_time - start_time
|
| 649 |
+
|
| 650 |
+
avg_confidence = sum(e.confidence for e in cluster) / len(cluster)
|
| 651 |
+
|
| 652 |
+
# Collect all keyframes
|
| 653 |
+
all_keyframes = []
|
| 654 |
+
for event in cluster:
|
| 655 |
+
all_keyframes.extend(event.keyframes)
|
| 656 |
+
|
| 657 |
+
# Remove duplicate frame paths
|
| 658 |
+
unique_keyframes = list(set(all_keyframes))
|
| 659 |
+
|
| 660 |
+
# Check if this cluster contains object events
|
| 661 |
+
object_events = [e for e in cluster if e.is_object_event]
|
| 662 |
+
contains_objects = len(object_events) > 0
|
| 663 |
+
|
| 664 |
+
# Object detection summary
|
| 665 |
+
detected_classes = []
|
| 666 |
+
object_summary = None
|
| 667 |
+
threat_level = "low"
|
| 668 |
+
|
| 669 |
+
if contains_objects:
|
| 670 |
+
# Collect detected object classes
|
| 671 |
+
detected_classes = list(set(e.object_class for e in object_events if e.object_class))
|
| 672 |
+
|
| 673 |
+
# Calculate object detection summary
|
| 674 |
+
total_detections = sum(e.detection_count for e in object_events)
|
| 675 |
+
max_confidence = max(e.max_confidence for e in object_events)
|
| 676 |
+
avg_obj_confidence = sum(e.confidence for e in object_events) / len(object_events)
|
| 677 |
+
|
| 678 |
+
object_summary = {
|
| 679 |
+
'total_detections': total_detections,
|
| 680 |
+
'max_confidence': max_confidence,
|
| 681 |
+
'average_confidence': avg_obj_confidence,
|
| 682 |
+
'detected_classes': detected_classes,
|
| 683 |
+
'object_events_count': len(object_events)
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
# Assess threat level based on object classes and confidence
|
| 687 |
+
threat_level = self._assess_canonical_threat_level(object_events)
|
| 688 |
+
|
| 689 |
+
# Create enhanced description
|
| 690 |
+
if contains_objects:
|
| 691 |
+
objects_str = ", ".join(detected_classes)
|
| 692 |
+
description = f"{event_type.replace('_', ' ').title()} with {objects_str} detected - {len(cluster)} events aggregated"
|
| 693 |
+
else:
|
| 694 |
+
description = f"{event_type.replace('_', ' ').title()} event aggregated from {len(cluster)} similar events"
|
| 695 |
+
|
| 696 |
+
canonical_event = CanonicalEvent(
|
| 697 |
+
canonical_id=f"canonical_{canonical_id:04d}",
|
| 698 |
+
event_type=event_type,
|
| 699 |
+
representative_frame=representative.keyframes[0] if representative.keyframes else "",
|
| 700 |
+
start_time=start_time,
|
| 701 |
+
end_time=end_time,
|
| 702 |
+
duration=duration,
|
| 703 |
+
confidence=avg_confidence,
|
| 704 |
+
frame_count=len(unique_keyframes),
|
| 705 |
+
aggregated_events=[e.event_id for e in cluster],
|
| 706 |
+
description=description,
|
| 707 |
+
similarity_cluster=cluster_id,
|
| 708 |
+
# Enhanced object detection fields
|
| 709 |
+
contains_objects=contains_objects,
|
| 710 |
+
detected_object_classes=detected_classes,
|
| 711 |
+
object_detection_summary=object_summary,
|
| 712 |
+
threat_level=threat_level
|
| 713 |
+
)
|
| 714 |
+
|
| 715 |
+
return canonical_event
|
| 716 |
+
|
| 717 |
+
def _assess_canonical_threat_level(self, object_events: List[Event]) -> str:
|
| 718 |
+
"""Assess threat level for canonical event containing object events"""
|
| 719 |
+
if not object_events:
|
| 720 |
+
return "low"
|
| 721 |
+
|
| 722 |
+
# Get highest threat level from individual events
|
| 723 |
+
threat_levels = ["low", "medium", "high", "critical"]
|
| 724 |
+
max_threat_index = 0
|
| 725 |
+
|
| 726 |
+
for event in object_events:
|
| 727 |
+
event_threat = self._assess_individual_threat_level(event)
|
| 728 |
+
threat_index = threat_levels.index(event_threat) if event_threat in threat_levels else 0
|
| 729 |
+
max_threat_index = max(max_threat_index, threat_index)
|
| 730 |
+
|
| 731 |
+
# Additional factors for canonical events
|
| 732 |
+
max_confidence = max(e.max_confidence for e in object_events)
|
| 733 |
+
total_detections = sum(e.detection_count for e in object_events)
|
| 734 |
+
unique_classes = len(set(e.object_class for e in object_events))
|
| 735 |
+
|
| 736 |
+
# Escalate threat if multiple factors present
|
| 737 |
+
if unique_classes > 1: # Multiple types of objects detected
|
| 738 |
+
max_threat_index = min(max_threat_index + 1, len(threat_levels) - 1)
|
| 739 |
+
|
| 740 |
+
if total_detections > 10: # Many detections
|
| 741 |
+
max_threat_index = min(max_threat_index + 1, len(threat_levels) - 1)
|
| 742 |
+
|
| 743 |
+
if max_confidence > 0.9: # Very high confidence
|
| 744 |
+
max_threat_index = min(max_threat_index + 1, len(threat_levels) - 1)
|
| 745 |
+
|
| 746 |
+
return threat_levels[max_threat_index]
|
| 747 |
+
|
| 748 |
+
def _assess_individual_threat_level(self, event: Event) -> str:
|
| 749 |
+
"""Assess threat level for individual event (duplicate of EventDetector method)"""
|
| 750 |
+
if not event.is_object_event:
|
| 751 |
+
# For motion events, use motion intensity and burst activity
|
| 752 |
+
if event.event_type == "high_motion" and event.motion_intensity > 0.015:
|
| 753 |
+
return "medium"
|
| 754 |
+
elif event.event_type == "burst_activity":
|
| 755 |
+
return "medium"
|
| 756 |
+
else:
|
| 757 |
+
return "low"
|
| 758 |
+
|
| 759 |
+
# Object-based threat assessment
|
| 760 |
+
threat_map = {
|
| 761 |
+
'fire': {
|
| 762 |
+
'low': 0.3, # Confidence thresholds
|
| 763 |
+
'medium': 0.5,
|
| 764 |
+
'high': 0.7,
|
| 765 |
+
'critical': 0.85
|
| 766 |
+
},
|
| 767 |
+
'gun': {
|
| 768 |
+
'low': 0.4,
|
| 769 |
+
'medium': 0.6,
|
| 770 |
+
'high': 0.8,
|
| 771 |
+
'critical': 0.9
|
| 772 |
+
},
|
| 773 |
+
'knife': {
|
| 774 |
+
'low': 0.4,
|
| 775 |
+
'medium': 0.6,
|
| 776 |
+
'high': 0.75,
|
| 777 |
+
'critical': 0.85
|
| 778 |
+
}
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
obj_class = event.object_class.lower()
|
| 782 |
+
confidence = event.max_confidence
|
| 783 |
+
|
| 784 |
+
if obj_class in threat_map:
|
| 785 |
+
thresholds = threat_map[obj_class]
|
| 786 |
+
if confidence >= thresholds['critical']:
|
| 787 |
+
return "critical"
|
| 788 |
+
elif confidence >= thresholds['high']:
|
| 789 |
+
return "high"
|
| 790 |
+
elif confidence >= thresholds['medium']:
|
| 791 |
+
return "medium"
|
| 792 |
+
else:
|
| 793 |
+
return "low"
|
| 794 |
+
|
| 795 |
+
return "medium" # Default for unknown object types
|
| 796 |
+
|
| 797 |
+
def save_canonical_events(self, canonical_events: List[CanonicalEvent],
|
| 798 |
+
output_path: str) -> bool:
|
| 799 |
+
"""Save canonical events to JSON file"""
|
| 800 |
+
try:
|
| 801 |
+
# Convert to serializable format
|
| 802 |
+
events_data = {
|
| 803 |
+
'metadata': {
|
| 804 |
+
'total_canonical_events': len(canonical_events),
|
| 805 |
+
'generation_timestamp': datetime.now().isoformat(),
|
| 806 |
+
'deduplication_threshold': self.config.similarity_threshold
|
| 807 |
+
},
|
| 808 |
+
'canonical_events': [asdict(event) for event in canonical_events]
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
with open(output_path, 'w') as f:
|
| 812 |
+
json.dump(events_data, f, indent=2)
|
| 813 |
+
|
| 814 |
+
logger.info(f"Canonical events saved to: {output_path}")
|
| 815 |
+
return True
|
| 816 |
+
|
| 817 |
+
except Exception as e:
|
| 818 |
+
logger.error(f"Failed to save canonical events: {e}")
|
| 819 |
+
return False
|
event_clip_generator.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Event Clip Generator
|
| 3 |
+
|
| 4 |
+
Generates video clips from events for viewing, playing, and downloading.
|
| 5 |
+
Extracts clips from the original or compressed video based on event timestamps.
|
| 6 |
+
Supports annotation with face bounding boxes for person search results.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import cv2
|
| 11 |
+
import subprocess
|
| 12 |
+
import logging
|
| 13 |
+
import uuid
|
| 14 |
+
from typing import Optional, Dict, Any, List, Tuple
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class EventClipGenerator:
|
| 22 |
+
"""Generate video clips from events"""
|
| 23 |
+
|
| 24 |
+
def __init__(self, output_dir: str = "video_processing_outputs/clips"):
|
| 25 |
+
self.output_dir = output_dir
|
| 26 |
+
os.makedirs(self.output_dir, exist_ok=True)
|
| 27 |
+
|
| 28 |
+
def extract_clip(self, video_path: str, start_time: float, end_time: float,
|
| 29 |
+
event_id: str, video_id: str = None) -> Optional[str]:
|
| 30 |
+
"""
|
| 31 |
+
Extract a video clip from a video file
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
video_path: Path to source video
|
| 35 |
+
start_time: Start timestamp in seconds
|
| 36 |
+
end_time: End timestamp in seconds
|
| 37 |
+
event_id: Event identifier
|
| 38 |
+
video_id: Optional video identifier for organizing clips
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
Path to extracted clip file, or None if extraction failed
|
| 42 |
+
"""
|
| 43 |
+
if not os.path.exists(video_path):
|
| 44 |
+
logger.error(f"Video file not found: {video_path}")
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
# Create clip filename
|
| 49 |
+
clip_id = f"{event_id}_{uuid.uuid4().hex[:8]}"
|
| 50 |
+
clip_filename = f"{clip_id}.mp4"
|
| 51 |
+
|
| 52 |
+
# Create output directory for this video if video_id provided
|
| 53 |
+
if video_id:
|
| 54 |
+
clip_dir = os.path.join(self.output_dir, video_id)
|
| 55 |
+
os.makedirs(clip_dir, exist_ok=True)
|
| 56 |
+
clip_path = os.path.join(clip_dir, clip_filename)
|
| 57 |
+
else:
|
| 58 |
+
clip_path = os.path.join(self.output_dir, clip_filename)
|
| 59 |
+
|
| 60 |
+
# Calculate duration
|
| 61 |
+
duration = end_time - start_time
|
| 62 |
+
|
| 63 |
+
# Use ffmpeg to extract clip (more reliable than OpenCV)
|
| 64 |
+
try:
|
| 65 |
+
# Try ffmpeg first (faster and more reliable)
|
| 66 |
+
cmd = [
|
| 67 |
+
'ffmpeg',
|
| 68 |
+
'-i', video_path,
|
| 69 |
+
'-ss', str(start_time),
|
| 70 |
+
'-t', str(duration),
|
| 71 |
+
'-c', 'copy', # Copy codec (fast, no re-encoding)
|
| 72 |
+
'-avoid_negative_ts', 'make_zero',
|
| 73 |
+
'-y', # Overwrite output file
|
| 74 |
+
clip_path
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
result = subprocess.run(
|
| 78 |
+
cmd,
|
| 79 |
+
capture_output=True,
|
| 80 |
+
text=True,
|
| 81 |
+
timeout=60 # 60 second timeout
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
if result.returncode == 0 and os.path.exists(clip_path):
|
| 85 |
+
logger.info(f"✅ Extracted clip: {clip_path} ({duration:.2f}s)")
|
| 86 |
+
return clip_path
|
| 87 |
+
else:
|
| 88 |
+
logger.warning(f"FFmpeg extraction failed, trying OpenCV fallback: {result.stderr}")
|
| 89 |
+
# Fallback to OpenCV
|
| 90 |
+
return self._extract_clip_opencv(video_path, start_time, end_time, clip_path)
|
| 91 |
+
|
| 92 |
+
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError) as e:
|
| 93 |
+
logger.warning(f"FFmpeg not available or failed: {e}, using OpenCV fallback")
|
| 94 |
+
# Fallback to OpenCV
|
| 95 |
+
return self._extract_clip_opencv(video_path, start_time, end_time, clip_path)
|
| 96 |
+
|
| 97 |
+
except Exception as e:
|
| 98 |
+
logger.error(f"Error extracting clip: {e}")
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
def _extract_clip_opencv(self, video_path: str, start_time: float,
|
| 102 |
+
end_time: float, output_path: str) -> Optional[str]:
|
| 103 |
+
"""Extract clip using OpenCV (fallback method)"""
|
| 104 |
+
try:
|
| 105 |
+
cap = cv2.VideoCapture(video_path)
|
| 106 |
+
if not cap.isOpened():
|
| 107 |
+
logger.error(f"Could not open video: {video_path}")
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 111 |
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 112 |
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 113 |
+
|
| 114 |
+
# Calculate frame numbers
|
| 115 |
+
start_frame = int(start_time * fps)
|
| 116 |
+
end_frame = int(end_time * fps)
|
| 117 |
+
|
| 118 |
+
# Set starting position
|
| 119 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
| 120 |
+
|
| 121 |
+
# Create video writer
|
| 122 |
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
| 123 |
+
out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
| 124 |
+
|
| 125 |
+
frame_count = start_frame
|
| 126 |
+
while frame_count <= end_frame:
|
| 127 |
+
ret, frame = cap.read()
|
| 128 |
+
if not ret:
|
| 129 |
+
break
|
| 130 |
+
|
| 131 |
+
out.write(frame)
|
| 132 |
+
frame_count += 1
|
| 133 |
+
|
| 134 |
+
cap.release()
|
| 135 |
+
out.release()
|
| 136 |
+
|
| 137 |
+
# Convert to browser-compatible format using ffmpeg
|
| 138 |
+
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
|
| 139 |
+
try:
|
| 140 |
+
browser_compatible_path = output_path.replace('.mp4', '_h264.mp4')
|
| 141 |
+
cmd = [
|
| 142 |
+
'ffmpeg',
|
| 143 |
+
'-i', output_path,
|
| 144 |
+
'-c:v', 'libx264', # H.264 codec for browser compatibility
|
| 145 |
+
'-preset', 'fast',
|
| 146 |
+
'-crf', '23',
|
| 147 |
+
'-c:a', 'aac', # AAC audio codec
|
| 148 |
+
'-movflags', '+faststart', # Enable streaming
|
| 149 |
+
'-y',
|
| 150 |
+
browser_compatible_path
|
| 151 |
+
]
|
| 152 |
+
|
| 153 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
| 154 |
+
|
| 155 |
+
if result.returncode == 0 and os.path.exists(browser_compatible_path):
|
| 156 |
+
# Remove the original mp4v file and rename
|
| 157 |
+
os.remove(output_path)
|
| 158 |
+
os.rename(browser_compatible_path, output_path)
|
| 159 |
+
logger.info(f"✅ Extracted clip using OpenCV (H.264): {output_path}")
|
| 160 |
+
return output_path
|
| 161 |
+
else:
|
| 162 |
+
logger.warning(f"FFmpeg conversion failed: {result.stderr}")
|
| 163 |
+
logger.info(f"✅ Extracted clip using OpenCV (mp4v): {output_path}")
|
| 164 |
+
return output_path
|
| 165 |
+
except Exception as e:
|
| 166 |
+
logger.warning(f"FFmpeg not available for conversion: {e}")
|
| 167 |
+
logger.info(f"✅ Extracted clip using OpenCV: {output_path}")
|
| 168 |
+
return output_path
|
| 169 |
+
else:
|
| 170 |
+
logger.error(f"OpenCV extraction failed: output file is empty or missing")
|
| 171 |
+
return None
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
logger.error(f"OpenCV clip extraction error: {e}")
|
| 175 |
+
return None
|
| 176 |
+
|
| 177 |
+
def extract_annotated_clip(self, video_path: str, start_time: float, end_time: float,
|
| 178 |
+
face_id: str, face_detections: List[Dict[str, Any]],
|
| 179 |
+
video_id: str = None, person_name: str = None) -> Optional[str]:
|
| 180 |
+
"""
|
| 181 |
+
Extract and annotate a video clip with bounding boxes for a specific person
|
| 182 |
+
|
| 183 |
+
Args:
|
| 184 |
+
video_path: Path to source video
|
| 185 |
+
start_time: Start timestamp in seconds
|
| 186 |
+
end_time: End timestamp in seconds
|
| 187 |
+
face_id: Face identifier to highlight
|
| 188 |
+
face_detections: List of face detection records with bounding boxes and timestamps
|
| 189 |
+
video_id: Optional video identifier
|
| 190 |
+
person_name: Optional person name to display on annotations
|
| 191 |
+
|
| 192 |
+
Returns:
|
| 193 |
+
Path to annotated clip file, or None if extraction failed
|
| 194 |
+
"""
|
| 195 |
+
if not os.path.exists(video_path):
|
| 196 |
+
logger.error(f"Video file not found: {video_path}")
|
| 197 |
+
return None
|
| 198 |
+
|
| 199 |
+
try:
|
| 200 |
+
# Create annotated clip filename
|
| 201 |
+
clip_id = f"annotated_{face_id}_{uuid.uuid4().hex[:8]}"
|
| 202 |
+
clip_filename = f"{clip_id}.mp4"
|
| 203 |
+
|
| 204 |
+
# Create output directory
|
| 205 |
+
if video_id:
|
| 206 |
+
clip_dir = os.path.join(self.output_dir, video_id, "annotated")
|
| 207 |
+
os.makedirs(clip_dir, exist_ok=True)
|
| 208 |
+
clip_path = os.path.join(clip_dir, clip_filename)
|
| 209 |
+
else:
|
| 210 |
+
annotated_dir = os.path.join(self.output_dir, "annotated")
|
| 211 |
+
os.makedirs(annotated_dir, exist_ok=True)
|
| 212 |
+
clip_path = os.path.join(annotated_dir, clip_filename)
|
| 213 |
+
|
| 214 |
+
# Open video
|
| 215 |
+
cap = cv2.VideoCapture(video_path)
|
| 216 |
+
if not cap.isOpened():
|
| 217 |
+
logger.error(f"Could not open video: {video_path}")
|
| 218 |
+
return None
|
| 219 |
+
|
| 220 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 221 |
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 222 |
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 223 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 224 |
+
|
| 225 |
+
# Calculate frame numbers
|
| 226 |
+
start_frame = int(start_time * fps)
|
| 227 |
+
end_frame = min(int(end_time * fps), total_frames - 1)
|
| 228 |
+
|
| 229 |
+
# Create a map of frame_number -> bounding boxes for quick lookup
|
| 230 |
+
frame_bbox_map = {}
|
| 231 |
+
for detection in face_detections:
|
| 232 |
+
if detection.get('face_id') == face_id:
|
| 233 |
+
# Try multiple timestamp fields
|
| 234 |
+
timestamp = (
|
| 235 |
+
detection.get('timestamp') or
|
| 236 |
+
detection.get('detected_at') or
|
| 237 |
+
(detection.get('detected_at').timestamp() if isinstance(detection.get('detected_at'), type(datetime.now())) else 0) or
|
| 238 |
+
0
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# If timestamp is a datetime object, convert to seconds
|
| 242 |
+
if hasattr(timestamp, 'timestamp'):
|
| 243 |
+
timestamp = timestamp.timestamp()
|
| 244 |
+
|
| 245 |
+
frame_num = int(timestamp * fps) if timestamp > 0 else 0
|
| 246 |
+
|
| 247 |
+
# Try multiple bbox field names
|
| 248 |
+
bbox = (
|
| 249 |
+
detection.get('bounding_box') or
|
| 250 |
+
detection.get('bounding_boxes') or
|
| 251 |
+
None
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
if bbox:
|
| 255 |
+
# Handle different bbox formats: [x1, y1, x2, y2] or {"x1": ..., "y1": ..., ...}
|
| 256 |
+
try:
|
| 257 |
+
if isinstance(bbox, dict):
|
| 258 |
+
x1 = int(bbox.get('x1', bbox.get(0, 0)))
|
| 259 |
+
y1 = int(bbox.get('y1', bbox.get(1, 0)))
|
| 260 |
+
x2 = int(bbox.get('x2', bbox.get(2, 0)))
|
| 261 |
+
y2 = int(bbox.get('y2', bbox.get(3, 0)))
|
| 262 |
+
elif isinstance(bbox, list) and len(bbox) >= 4:
|
| 263 |
+
x1, y1, x2, y2 = int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])
|
| 264 |
+
else:
|
| 265 |
+
continue
|
| 266 |
+
|
| 267 |
+
# Validate bounding box coordinates
|
| 268 |
+
if x1 >= 0 and y1 >= 0 and x2 > x1 and y2 > y1:
|
| 269 |
+
# Store for multiple nearby frames to handle timestamp inaccuracies
|
| 270 |
+
for offset in range(-2, 3): # ±2 frames tolerance
|
| 271 |
+
frame_bbox_map[frame_num + offset] = (x1, y1, x2, y2)
|
| 272 |
+
except (ValueError, TypeError) as e:
|
| 273 |
+
logger.warning(f"Invalid bounding box format: {bbox}, error: {e}")
|
| 274 |
+
continue
|
| 275 |
+
|
| 276 |
+
# Set starting position
|
| 277 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
| 278 |
+
|
| 279 |
+
# Create video writer
|
| 280 |
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
| 281 |
+
out = cv2.VideoWriter(clip_path, fourcc, fps, (width, height))
|
| 282 |
+
|
| 283 |
+
frame_count = start_frame
|
| 284 |
+
frames_annotated = 0
|
| 285 |
+
|
| 286 |
+
while frame_count <= end_frame:
|
| 287 |
+
ret, frame = cap.read()
|
| 288 |
+
if not ret:
|
| 289 |
+
break
|
| 290 |
+
|
| 291 |
+
# Check if this frame has a bounding box for this face
|
| 292 |
+
if frame_count in frame_bbox_map:
|
| 293 |
+
x1, y1, x2, y2 = frame_bbox_map[frame_count]
|
| 294 |
+
|
| 295 |
+
# Draw bounding box (green for person detection)
|
| 296 |
+
color = (0, 255, 0) # Green in BGR
|
| 297 |
+
thickness = 3
|
| 298 |
+
cv2.rectangle(frame, (x1, y1), (x2, y2), color, thickness)
|
| 299 |
+
|
| 300 |
+
# Draw label
|
| 301 |
+
label = person_name if person_name else "Detected Person"
|
| 302 |
+
label_size = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2)[0]
|
| 303 |
+
|
| 304 |
+
# Draw label background
|
| 305 |
+
cv2.rectangle(frame, (x1, y1 - label_size[1] - 10),
|
| 306 |
+
(x1 + label_size[0] + 10, y1), color, -1)
|
| 307 |
+
|
| 308 |
+
# Draw label text
|
| 309 |
+
cv2.putText(frame, label, (x1 + 5, y1 - 5),
|
| 310 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
|
| 311 |
+
|
| 312 |
+
frames_annotated += 1
|
| 313 |
+
|
| 314 |
+
out.write(frame)
|
| 315 |
+
frame_count += 1
|
| 316 |
+
|
| 317 |
+
cap.release()
|
| 318 |
+
out.release()
|
| 319 |
+
|
| 320 |
+
# Convert to browser-compatible format using ffmpeg
|
| 321 |
+
if os.path.exists(clip_path) and os.path.getsize(clip_path) > 0:
|
| 322 |
+
try:
|
| 323 |
+
browser_compatible_path = clip_path.replace('.mp4', '_h264.mp4')
|
| 324 |
+
cmd = [
|
| 325 |
+
'ffmpeg',
|
| 326 |
+
'-i', clip_path,
|
| 327 |
+
'-c:v', 'libx264', # H.264 codec for browser compatibility
|
| 328 |
+
'-preset', 'fast',
|
| 329 |
+
'-crf', '23',
|
| 330 |
+
'-c:a', 'aac', # AAC audio codec
|
| 331 |
+
'-movflags', '+faststart', # Enable streaming
|
| 332 |
+
'-y',
|
| 333 |
+
browser_compatible_path
|
| 334 |
+
]
|
| 335 |
+
|
| 336 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
| 337 |
+
|
| 338 |
+
if result.returncode == 0 and os.path.exists(browser_compatible_path):
|
| 339 |
+
# Remove the original mp4v file and rename
|
| 340 |
+
os.remove(clip_path)
|
| 341 |
+
os.rename(browser_compatible_path, clip_path)
|
| 342 |
+
logger.info(f"✅ Created annotated clip: {clip_path} ({frames_annotated} frames annotated)")
|
| 343 |
+
return clip_path
|
| 344 |
+
else:
|
| 345 |
+
logger.warning(f"FFmpeg conversion failed, returning OpenCV output: {result.stderr}")
|
| 346 |
+
logger.info(f"✅ Created annotated clip (mp4v): {clip_path} ({frames_annotated} frames annotated)")
|
| 347 |
+
return clip_path
|
| 348 |
+
except Exception as e:
|
| 349 |
+
logger.warning(f"FFmpeg not available for conversion: {e}")
|
| 350 |
+
logger.info(f"✅ Created annotated clip (mp4v): {clip_path} ({frames_annotated} frames annotated)")
|
| 351 |
+
return clip_path
|
| 352 |
+
else:
|
| 353 |
+
logger.error(f"Annotated clip creation failed: output file is empty or missing")
|
| 354 |
+
return None
|
| 355 |
+
|
| 356 |
+
except Exception as e:
|
| 357 |
+
logger.error(f"Error creating annotated clip: {e}")
|
| 358 |
+
return None
|
| 359 |
+
|
| 360 |
+
def get_clip_info(self, clip_path: str) -> Dict[str, Any]:
|
| 361 |
+
"""Get information about a clip file"""
|
| 362 |
+
if not os.path.exists(clip_path):
|
| 363 |
+
return {}
|
| 364 |
+
|
| 365 |
+
try:
|
| 366 |
+
cap = cv2.VideoCapture(clip_path)
|
| 367 |
+
if not cap.isOpened():
|
| 368 |
+
return {}
|
| 369 |
+
|
| 370 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 371 |
+
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 372 |
+
duration = frame_count / fps if fps > 0 else 0
|
| 373 |
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 374 |
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 375 |
+
file_size = os.path.getsize(clip_path)
|
| 376 |
+
|
| 377 |
+
cap.release()
|
| 378 |
+
|
| 379 |
+
return {
|
| 380 |
+
'duration': duration,
|
| 381 |
+
'fps': fps,
|
| 382 |
+
'frame_count': frame_count,
|
| 383 |
+
'resolution': f"{width}x{height}",
|
| 384 |
+
'file_size': file_size,
|
| 385 |
+
'file_size_mb': round(file_size / (1024 * 1024), 2)
|
| 386 |
+
}
|
| 387 |
+
except Exception as e:
|
| 388 |
+
logger.error(f"Error getting clip info: {e}")
|
| 389 |
+
return {}
|
| 390 |
+
|
extract_upload_keyframes.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Extract keyframes from videos and upload to S3-compatible storage (Backblaze B2).
|
| 3 |
+
|
| 4 |
+
For each video that has captions but no keyframes in storage:
|
| 5 |
+
1. Get the frame_ids from video_captions
|
| 6 |
+
2. Get the video source (local file or S3)
|
| 7 |
+
3. Extract those exact frames using OpenCV
|
| 8 |
+
4. Upload to S3 at {video_id}/frame_XXXXXX.jpg
|
| 9 |
+
"""
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
import io
|
| 13 |
+
import tempfile
|
| 14 |
+
import cv2
|
| 15 |
+
from pymongo import MongoClient
|
| 16 |
+
from minio import Minio
|
| 17 |
+
from dotenv import load_dotenv
|
| 18 |
+
|
| 19 |
+
load_dotenv()
|
| 20 |
+
|
| 21 |
+
MONGO_URI = os.getenv("MONGO_URI", "mongodb+srv://detectifai_user:DetectifAI123@cluster0.6f9uj.mongodb.net/detectifai?retryWrites=true&w=majority&appName=Cluster0")
|
| 22 |
+
client = MongoClient(MONGO_URI)
|
| 23 |
+
db = client.detectifai
|
| 24 |
+
|
| 25 |
+
minio_client = Minio(
|
| 26 |
+
os.getenv('MINIO_ENDPOINT', 's3.eu-central-003.backblazeb2.com'),
|
| 27 |
+
access_key=os.getenv('MINIO_ACCESS_KEY', '00367479ffb7e4e0000000001'),
|
| 28 |
+
secret_key=os.getenv('MINIO_SECRET_KEY', 'K003opTvf92ijRj5dM7H1dgrlwcGTdA'),
|
| 29 |
+
secure=os.getenv('MINIO_SECURE', 'true').lower() == 'true',
|
| 30 |
+
region=os.getenv('MINIO_REGION', 'eu-central-003') or None
|
| 31 |
+
)
|
| 32 |
+
KEYFRAME_BUCKET = os.getenv('MINIO_KEYFRAME_BUCKET', 'detectifai-keyframes')
|
| 33 |
+
VIDEO_BUCKET = os.getenv('MINIO_VIDEO_BUCKET', 'detectifai-videos')
|
| 34 |
+
|
| 35 |
+
BASE_DIR = os.getenv('BASE_DIR', r"d:\FAST\Final Year Project\sem1_finalized_malaika\sem1")
|
| 36 |
+
|
| 37 |
+
def get_video_source(video_id):
|
| 38 |
+
"""Return path to video file. Download from MinIO if not local."""
|
| 39 |
+
# Check local uploads first
|
| 40 |
+
local_path = os.path.join(BASE_DIR, "uploads", video_id, "video.mp4")
|
| 41 |
+
if os.path.isfile(local_path) and os.path.getsize(local_path) > 0:
|
| 42 |
+
print(f" Using local file: {local_path}")
|
| 43 |
+
return local_path
|
| 44 |
+
|
| 45 |
+
# Check MinIO
|
| 46 |
+
rec = db.video_file.find_one({"video_id": video_id}, {"minio_object_key": 1, "minio_bucket": 1})
|
| 47 |
+
if rec and rec.get("minio_object_key"):
|
| 48 |
+
bucket = rec.get("minio_bucket", VIDEO_BUCKET)
|
| 49 |
+
obj_key = rec["minio_object_key"]
|
| 50 |
+
|
| 51 |
+
# Verify the object actually exists before downloading
|
| 52 |
+
try:
|
| 53 |
+
minio_client.stat_object(bucket, obj_key)
|
| 54 |
+
except Exception:
|
| 55 |
+
print(f" MinIO object not found: {bucket}/{obj_key}")
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
print(f" Downloading from MinIO: {bucket}/{obj_key}")
|
| 59 |
+
tmp_path = os.path.join(tempfile.gettempdir(), f"{video_id}.mp4")
|
| 60 |
+
minio_client.fget_object(bucket, obj_key, tmp_path)
|
| 61 |
+
print(f" Downloaded to: {tmp_path}")
|
| 62 |
+
return tmp_path
|
| 63 |
+
|
| 64 |
+
return None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
import numpy as np
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def upload_placeholder_keyframes(video_id, frame_ids):
|
| 71 |
+
"""Generate and upload placeholder keyframe images for videos whose source is gone."""
|
| 72 |
+
uploaded = 0
|
| 73 |
+
|
| 74 |
+
for frame_id in frame_ids:
|
| 75 |
+
# Get the caption text for this frame to display on placeholder
|
| 76 |
+
caption_doc = db.video_captions.find_one(
|
| 77 |
+
{"video_id": video_id, "frame_id": frame_id},
|
| 78 |
+
{"caption": 1, "_id": 0}
|
| 79 |
+
)
|
| 80 |
+
caption_text = caption_doc.get("caption", "No caption") if caption_doc else "No caption"
|
| 81 |
+
|
| 82 |
+
# Create a 640x360 dark gradient placeholder image
|
| 83 |
+
img = np.zeros((360, 640, 3), dtype=np.uint8)
|
| 84 |
+
# Dark blue gradient
|
| 85 |
+
for y in range(360):
|
| 86 |
+
val = int(30 + (y / 360) * 40)
|
| 87 |
+
img[y, :] = [val, int(val * 0.8), int(val * 0.5)]
|
| 88 |
+
|
| 89 |
+
# Add text
|
| 90 |
+
font = cv2.FONT_HERSHEY_SIMPLEX
|
| 91 |
+
# Video ID
|
| 92 |
+
cv2.putText(img, video_id, (20, 40), font, 0.5, (150, 150, 150), 1)
|
| 93 |
+
# Frame ID
|
| 94 |
+
cv2.putText(img, frame_id, (20, 70), font, 0.5, (150, 150, 150), 1)
|
| 95 |
+
# Camera icon placeholder
|
| 96 |
+
cv2.rectangle(img, (270, 130), (370, 210), (80, 80, 80), 2)
|
| 97 |
+
cv2.putText(img, "VIDEO", (284, 178), font, 0.6, (120, 120, 120), 1)
|
| 98 |
+
# Caption (wrap if long)
|
| 99 |
+
words = caption_text[:80].split()
|
| 100 |
+
line = ""
|
| 101 |
+
y_pos = 250
|
| 102 |
+
for w in words:
|
| 103 |
+
test = line + " " + w if line else w
|
| 104 |
+
if len(test) > 50:
|
| 105 |
+
cv2.putText(img, line, (20, y_pos), font, 0.4, (200, 200, 200), 1)
|
| 106 |
+
y_pos += 22
|
| 107 |
+
line = w
|
| 108 |
+
else:
|
| 109 |
+
line = test
|
| 110 |
+
if line:
|
| 111 |
+
cv2.putText(img, line, (20, y_pos), font, 0.4, (200, 200, 200), 1)
|
| 112 |
+
|
| 113 |
+
# Encode as JPEG
|
| 114 |
+
success, buffer = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
| 115 |
+
if not success:
|
| 116 |
+
continue
|
| 117 |
+
|
| 118 |
+
minio_path = f"{video_id}/{frame_id}.jpg"
|
| 119 |
+
data = io.BytesIO(buffer.tobytes())
|
| 120 |
+
minio_client.put_object(
|
| 121 |
+
KEYFRAME_BUCKET, minio_path, data,
|
| 122 |
+
length=len(buffer.tobytes()),
|
| 123 |
+
content_type='image/jpeg'
|
| 124 |
+
)
|
| 125 |
+
uploaded += 1
|
| 126 |
+
|
| 127 |
+
return uploaded
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def extract_and_upload_keyframes(video_id, frame_ids):
|
| 131 |
+
"""Extract specific frames from video and upload to MinIO."""
|
| 132 |
+
video_path = get_video_source(video_id)
|
| 133 |
+
if not video_path:
|
| 134 |
+
print(f" No video source found — generating placeholder keyframes")
|
| 135 |
+
return upload_placeholder_keyframes(video_id, frame_ids)
|
| 136 |
+
|
| 137 |
+
# Parse frame numbers from frame_ids like "frame_000060"
|
| 138 |
+
frame_numbers = {}
|
| 139 |
+
for fid in frame_ids:
|
| 140 |
+
try:
|
| 141 |
+
num = int(fid.replace("frame_", ""))
|
| 142 |
+
frame_numbers[num] = fid
|
| 143 |
+
except ValueError:
|
| 144 |
+
print(f" WARNING: Could not parse frame_id: {fid}")
|
| 145 |
+
|
| 146 |
+
if not frame_numbers:
|
| 147 |
+
print(f" No valid frame numbers to extract")
|
| 148 |
+
return 0
|
| 149 |
+
|
| 150 |
+
cap = cv2.VideoCapture(video_path)
|
| 151 |
+
if not cap.isOpened():
|
| 152 |
+
print(f" ERROR: Could not open video: {video_path}")
|
| 153 |
+
return 0
|
| 154 |
+
|
| 155 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 156 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 157 |
+
print(f" Video: {total_frames} frames, {fps:.1f} fps")
|
| 158 |
+
|
| 159 |
+
uploaded = 0
|
| 160 |
+
max_frame = max(frame_numbers.keys())
|
| 161 |
+
|
| 162 |
+
for frame_num in sorted(frame_numbers.keys()):
|
| 163 |
+
if frame_num >= total_frames:
|
| 164 |
+
# Use last available frame
|
| 165 |
+
frame_num_actual = total_frames - 1
|
| 166 |
+
print(f" Frame {frame_num} beyond total ({total_frames}), using frame {frame_num_actual}")
|
| 167 |
+
else:
|
| 168 |
+
frame_num_actual = frame_num
|
| 169 |
+
|
| 170 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num_actual)
|
| 171 |
+
ret, frame = cap.read()
|
| 172 |
+
if not ret:
|
| 173 |
+
print(f" ERROR: Could not read frame {frame_num_actual}")
|
| 174 |
+
continue
|
| 175 |
+
|
| 176 |
+
# Encode as JPEG
|
| 177 |
+
success, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
| 178 |
+
if not success:
|
| 179 |
+
print(f" ERROR: Could not encode frame {frame_num}")
|
| 180 |
+
continue
|
| 181 |
+
|
| 182 |
+
frame_id = frame_numbers[frame_num]
|
| 183 |
+
minio_path = f"{video_id}/{frame_id}.jpg"
|
| 184 |
+
|
| 185 |
+
# Upload to MinIO
|
| 186 |
+
data = io.BytesIO(buffer.tobytes())
|
| 187 |
+
minio_client.put_object(
|
| 188 |
+
KEYFRAME_BUCKET,
|
| 189 |
+
minio_path,
|
| 190 |
+
data,
|
| 191 |
+
length=len(buffer.tobytes()),
|
| 192 |
+
content_type='image/jpeg'
|
| 193 |
+
)
|
| 194 |
+
uploaded += 1
|
| 195 |
+
|
| 196 |
+
cap.release()
|
| 197 |
+
|
| 198 |
+
# Clean up temp file if downloaded from MinIO
|
| 199 |
+
tmp_path = os.path.join(tempfile.gettempdir(), f"{video_id}.mp4")
|
| 200 |
+
if os.path.exists(tmp_path) and video_path == tmp_path:
|
| 201 |
+
os.remove(tmp_path)
|
| 202 |
+
|
| 203 |
+
return uploaded
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def main():
|
| 207 |
+
# Get all video_ids with captions
|
| 208 |
+
caption_vids = db.video_captions.distinct("video_id")
|
| 209 |
+
|
| 210 |
+
for video_id in caption_vids:
|
| 211 |
+
if video_id.startswith("test_"):
|
| 212 |
+
continue
|
| 213 |
+
|
| 214 |
+
# Check if keyframes already exist in MinIO
|
| 215 |
+
existing = list(minio_client.list_objects(KEYFRAME_BUCKET, prefix=f"{video_id}/", recursive=True))
|
| 216 |
+
if len(existing) > 0:
|
| 217 |
+
print(f"SKIP {video_id}: already has {len(existing)} keyframes in MinIO")
|
| 218 |
+
continue
|
| 219 |
+
|
| 220 |
+
# Get frame_ids from captions
|
| 221 |
+
frame_ids = db.video_captions.distinct("frame_id", {"video_id": video_id})
|
| 222 |
+
if not frame_ids:
|
| 223 |
+
print(f"SKIP {video_id}: no frame_ids in captions")
|
| 224 |
+
continue
|
| 225 |
+
|
| 226 |
+
print(f"\nPROCESSING {video_id}: {len(frame_ids)} frames to extract")
|
| 227 |
+
uploaded = extract_and_upload_keyframes(video_id, frame_ids)
|
| 228 |
+
print(f" Uploaded {uploaded}/{len(frame_ids)} keyframes to MinIO")
|
| 229 |
+
|
| 230 |
+
print("\n=== DONE ===")
|
| 231 |
+
# Final check
|
| 232 |
+
for video_id in caption_vids:
|
| 233 |
+
if video_id.startswith("test_"):
|
| 234 |
+
continue
|
| 235 |
+
objs = list(minio_client.list_objects(KEYFRAME_BUCKET, prefix=f"{video_id}/", recursive=True))
|
| 236 |
+
print(f" {video_id}: {len(objs)} keyframes in MinIO")
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
if __name__ == "__main__":
|
| 240 |
+
main()
|
facial_recognition.py
ADDED
|
@@ -0,0 +1,926 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Facial Recognition Module for DetectifAI
|
| 3 |
+
|
| 4 |
+
This module handles facial recognition for suspicious activity frames:
|
| 5 |
+
- Face detection using MTCNN (primary) or OpenCV Haar cascades (fallback)
|
| 6 |
+
- Face embeddings using FaceNet (primary) or histogram-based (fallback)
|
| 7 |
+
- FAISS vector similarity search (primary) or cosine similarity (fallback)
|
| 8 |
+
- MongoDB metadata storage with local JSON fallback
|
| 9 |
+
- Integration with suspicious activity detection pipeline
|
| 10 |
+
|
| 11 |
+
Workflow (matches activity diagram):
|
| 12 |
+
1. Receive frame from suspicious event (object detection)
|
| 13 |
+
2. Run face detection
|
| 14 |
+
3. If faces detected: crop faces, generate embeddings, store in FAISS/index
|
| 15 |
+
4. Upload face crops to storage, save metadata to MongoDB/JSON
|
| 16 |
+
5. Search for similar embeddings, link with previous incidents
|
| 17 |
+
6. Assign new person ID if no match found
|
| 18 |
+
|
| 19 |
+
Author: DetectifAI Team
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import os
|
| 23 |
+
import cv2
|
| 24 |
+
import numpy as np
|
| 25 |
+
import logging
|
| 26 |
+
import json
|
| 27 |
+
import uuid
|
| 28 |
+
import time
|
| 29 |
+
import warnings
|
| 30 |
+
from typing import List, Tuple, Optional, Dict, Any
|
| 31 |
+
from dataclasses import dataclass
|
| 32 |
+
from datetime import datetime
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
# Advanced imports (with fallbacks)
|
| 36 |
+
try:
|
| 37 |
+
import torch
|
| 38 |
+
from facenet_pytorch import MTCNN, InceptionResnetV1
|
| 39 |
+
import faiss
|
| 40 |
+
from pymongo import MongoClient
|
| 41 |
+
from dotenv import load_dotenv
|
| 42 |
+
import joblib
|
| 43 |
+
ADVANCED_AVAILABLE = True
|
| 44 |
+
load_dotenv()
|
| 45 |
+
except ImportError:
|
| 46 |
+
ADVANCED_AVAILABLE = False
|
| 47 |
+
|
| 48 |
+
warnings.filterwarnings('ignore')
|
| 49 |
+
logger = logging.getLogger(__name__)
|
| 50 |
+
|
| 51 |
+
# ========================================
|
| 52 |
+
# Configuration
|
| 53 |
+
# ========================================
|
| 54 |
+
|
| 55 |
+
# MongoDB Configuration
|
| 56 |
+
MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/") if ADVANCED_AVAILABLE else None
|
| 57 |
+
MONGO_DB_NAME = "detectifai"
|
| 58 |
+
|
| 59 |
+
# FAISS Configuration
|
| 60 |
+
FAISS_INDEX_PATH = "model/faiss_face_index.bin"
|
| 61 |
+
FAISS_ID_MAP_PATH = "model/faiss_id_map.json"
|
| 62 |
+
EMBEDDING_DIM = 512 # InceptionResnetV1 produces 512-dim embeddings
|
| 63 |
+
|
| 64 |
+
# Trained Models Configuration
|
| 65 |
+
TRAINED_MODEL_DIR = "model/trained_models"
|
| 66 |
+
CLASSIFIER_PATH = os.path.join(TRAINED_MODEL_DIR, "classifier_svm.pkl")
|
| 67 |
+
ENCODER_PATH = os.path.join(TRAINED_MODEL_DIR, "label_encoder.pkl")
|
| 68 |
+
|
| 69 |
+
# Simple fallback configuration
|
| 70 |
+
SIMPLE_INDEX_PATH = "model/simple_face_index.json"
|
| 71 |
+
|
| 72 |
+
# Face storage
|
| 73 |
+
FACES_DIR = "model/faces"
|
| 74 |
+
|
| 75 |
+
# ========================================
|
| 76 |
+
# Data Models
|
| 77 |
+
# ========================================
|
| 78 |
+
|
| 79 |
+
@dataclass
|
| 80 |
+
class FaceDetectionResult:
|
| 81 |
+
"""Result of face detection in a frame"""
|
| 82 |
+
frame_path: str
|
| 83 |
+
timestamp: float
|
| 84 |
+
faces_detected: int
|
| 85 |
+
face_embeddings: List[np.ndarray]
|
| 86 |
+
face_bounding_boxes: List[Tuple[int, int, int, int]]
|
| 87 |
+
face_confidence_scores: List[float]
|
| 88 |
+
processing_time: float
|
| 89 |
+
detected_face_ids: List[str] = None
|
| 90 |
+
matched_persons: List[str] = None
|
| 91 |
+
|
| 92 |
+
@dataclass
|
| 93 |
+
class SuspiciousPerson:
|
| 94 |
+
"""Information about a suspicious person"""
|
| 95 |
+
person_id: str
|
| 96 |
+
first_detected: float # timestamp
|
| 97 |
+
last_seen: float # timestamp
|
| 98 |
+
face_embedding: Optional[np.ndarray]
|
| 99 |
+
associated_events: List[str] # event IDs where this person appeared
|
| 100 |
+
threat_level: str
|
| 101 |
+
notes: str
|
| 102 |
+
detection_count: int
|
| 103 |
+
face_id: str = "" # Primary face_id
|
| 104 |
+
|
| 105 |
+
# ========================================
|
| 106 |
+
# Advanced Implementation (FAISS + FaceNet)
|
| 107 |
+
# ========================================
|
| 108 |
+
|
| 109 |
+
class AdvancedFaceDetector:
|
| 110 |
+
"""Advanced face detector using MTCNN"""
|
| 111 |
+
|
| 112 |
+
def __init__(self, device='cpu', min_face_size=60): # Increased from 40 to 60 for stricter filtering
|
| 113 |
+
self.device = torch.device(device)
|
| 114 |
+
self.mtcnn = MTCNN(
|
| 115 |
+
image_size=160,
|
| 116 |
+
margin=20,
|
| 117 |
+
min_face_size=min_face_size, # Larger minimum to reject small circular objects
|
| 118 |
+
thresholds=[0.8, 0.9, 0.9], # Very strict thresholds (was [0.7, 0.8, 0.8]) to eliminate false positives
|
| 119 |
+
factor=0.709,
|
| 120 |
+
keep_all=True,
|
| 121 |
+
device=self.device
|
| 122 |
+
)
|
| 123 |
+
logger.info(f"[AdvancedFaceDetector] Initialized MTCNN on {device} with min_face_size={min_face_size}, strict thresholds=[0.8, 0.9, 0.9]")
|
| 124 |
+
|
| 125 |
+
def detect_faces(self, frame: np.ndarray) -> Tuple[List[np.ndarray], List[np.ndarray], List[float]]:
|
| 126 |
+
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
| 127 |
+
boxes, probs = self.mtcnn.detect(rgb_frame, landmarks=False)
|
| 128 |
+
|
| 129 |
+
if boxes is None:
|
| 130 |
+
return [], [], []
|
| 131 |
+
|
| 132 |
+
faces = self.mtcnn.extract(rgb_frame, boxes, save_path=None)
|
| 133 |
+
if faces is None:
|
| 134 |
+
return [], [], []
|
| 135 |
+
|
| 136 |
+
valid_faces, valid_boxes, valid_probs = [], [], []
|
| 137 |
+
for face, prob, box in zip(faces, probs, boxes):
|
| 138 |
+
# Very strict probability threshold (increased from 0.85 to 0.90)
|
| 139 |
+
if face is not None and prob > 0.90:
|
| 140 |
+
# Additional validation to filter false positives (e.g., tires, wheels)
|
| 141 |
+
if self._is_valid_face(face, box):
|
| 142 |
+
valid_faces.append(face)
|
| 143 |
+
valid_boxes.append(box)
|
| 144 |
+
valid_probs.append(prob)
|
| 145 |
+
else:
|
| 146 |
+
logger.debug(f"Rejected detection (prob={prob:.3f}) - failed quality validation")
|
| 147 |
+
|
| 148 |
+
return valid_faces, valid_boxes, valid_probs
|
| 149 |
+
|
| 150 |
+
def _is_valid_face(self, face_tensor: torch.Tensor, box: np.ndarray) -> bool:
|
| 151 |
+
"""Validate detected face to filter out false positives like tires, wheels, circular objects"""
|
| 152 |
+
try:
|
| 153 |
+
# 1. Check bounding box aspect ratio (faces should be ~1:1.2, not perfectly circular like tires)
|
| 154 |
+
x1, y1, x2, y2 = box
|
| 155 |
+
width = x2 - x1
|
| 156 |
+
height = y2 - y1
|
| 157 |
+
|
| 158 |
+
if width <= 0 or height <= 0:
|
| 159 |
+
return False
|
| 160 |
+
|
| 161 |
+
aspect_ratio = width / height
|
| 162 |
+
# Reject if too circular (like tires) or too elongated - tightened range
|
| 163 |
+
if aspect_ratio < 0.7 or aspect_ratio > 1.5:
|
| 164 |
+
logger.debug(f"Rejected: aspect_ratio={aspect_ratio:.2f} (tires ~1.0, faces 0.75-1.35)")
|
| 165 |
+
return False
|
| 166 |
+
|
| 167 |
+
# 2. Check minimum face size (reject small detections) - increased to 60px
|
| 168 |
+
if width < 60 or height < 60:
|
| 169 |
+
logger.debug(f"Rejected: too small ({width}x{height}) - minimum is 60x60")
|
| 170 |
+
return False
|
| 171 |
+
|
| 172 |
+
# 3. Check face tensor for quality (reject blurry or low-contrast images like tire treads)
|
| 173 |
+
face_np = face_tensor.permute(1, 2, 0).cpu().numpy()
|
| 174 |
+
|
| 175 |
+
# Check variance (faces should have good contrast, tires are uniform) - increased threshold
|
| 176 |
+
variance = np.var(face_np)
|
| 177 |
+
if variance < 0.02: # Increased from 0.01 to 0.02 for stricter filtering
|
| 178 |
+
logger.debug(f"Rejected: low variance={variance:.4f} (uniform object, likely tire)")
|
| 179 |
+
return False
|
| 180 |
+
|
| 181 |
+
# 4. Check edge density (faces have more complex edges than smooth tire surfaces)
|
| 182 |
+
gray = cv2.cvtColor((face_np * 255).astype(np.uint8), cv2.COLOR_RGB2GRAY)
|
| 183 |
+
edges = cv2.Canny(gray, 50, 150)
|
| 184 |
+
edge_density = np.sum(edges > 0) / (edges.shape[0] * edges.shape[1])
|
| 185 |
+
|
| 186 |
+
# Tires have uniform circular edges, faces have complex features - tightened range
|
| 187 |
+
if edge_density < 0.08 or edge_density > 0.35: # Narrowed from (0.05, 0.4) to (0.08, 0.35)
|
| 188 |
+
logger.debug(f"Rejected: edge_density={edge_density:.3f} (abnormal edge pattern)")
|
| 189 |
+
return False
|
| 190 |
+
|
| 191 |
+
return True
|
| 192 |
+
|
| 193 |
+
except Exception as e:
|
| 194 |
+
logger.warning(f"Face validation error: {e}")
|
| 195 |
+
return False # Reject on error to be safe
|
| 196 |
+
|
| 197 |
+
class AdvancedFaceEmbedder:
|
| 198 |
+
"""Advanced face embedder using FaceNet"""
|
| 199 |
+
|
| 200 |
+
def __init__(self, device='cpu', weights='vggface2'):
|
| 201 |
+
self.device = torch.device(device)
|
| 202 |
+
self.model = InceptionResnetV1(pretrained=weights).eval().to(self.device)
|
| 203 |
+
logger.info(f"[AdvancedFaceEmbedder] Loaded InceptionResnetV1 on {device}")
|
| 204 |
+
|
| 205 |
+
def generate_embedding(self, face_tensor: torch.Tensor) -> np.ndarray:
|
| 206 |
+
with torch.no_grad():
|
| 207 |
+
face_tensor = face_tensor.to(self.device).unsqueeze(0)
|
| 208 |
+
embedding = self.model(face_tensor).cpu().numpy().flatten()
|
| 209 |
+
return embedding
|
| 210 |
+
|
| 211 |
+
class PersonClassifier:
|
| 212 |
+
"""Person identification using trained SVM classifier"""
|
| 213 |
+
|
| 214 |
+
def __init__(self, classifier_path: str = CLASSIFIER_PATH, encoder_path: str = ENCODER_PATH,
|
| 215 |
+
confidence_threshold: float = 0.5):
|
| 216 |
+
self.confidence_threshold = confidence_threshold
|
| 217 |
+
self.enabled = False
|
| 218 |
+
|
| 219 |
+
if ADVANCED_AVAILABLE and os.path.exists(classifier_path) and os.path.exists(encoder_path):
|
| 220 |
+
try:
|
| 221 |
+
self.classifier = joblib.load(classifier_path)
|
| 222 |
+
self.label_encoder = joblib.load(encoder_path)
|
| 223 |
+
self.enabled = True
|
| 224 |
+
logger.info(f"[PersonClassifier] ✅ Model loaded, {len(self.label_encoder.classes_)} identities recognized.")
|
| 225 |
+
except Exception as e:
|
| 226 |
+
logger.warning(f"[PersonClassifier] ⚠️ Failed to load model: {e}")
|
| 227 |
+
else:
|
| 228 |
+
logger.info("[PersonClassifier] Trained models not available, using generic face tracking")
|
| 229 |
+
|
| 230 |
+
def identify_person(self, embedding: np.ndarray) -> Tuple[Optional[str], float]:
|
| 231 |
+
"""Identify person from face embedding using SVM classifier"""
|
| 232 |
+
if not self.enabled:
|
| 233 |
+
return None, 0.0
|
| 234 |
+
|
| 235 |
+
try:
|
| 236 |
+
probs = self.classifier.predict_proba(embedding.reshape(1, -1))[0]
|
| 237 |
+
best_idx = np.argmax(probs)
|
| 238 |
+
conf = probs[best_idx]
|
| 239 |
+
|
| 240 |
+
if conf >= self.confidence_threshold:
|
| 241 |
+
return self.label_encoder.classes_[best_idx], float(conf)
|
| 242 |
+
return None, float(conf)
|
| 243 |
+
except Exception as e:
|
| 244 |
+
logger.error(f"[PersonClassifier] Error: {e}")
|
| 245 |
+
return None, 0.0
|
| 246 |
+
|
| 247 |
+
class FAISSFaceIndex:
|
| 248 |
+
"""FAISS index manager for fast similarity search"""
|
| 249 |
+
|
| 250 |
+
def __init__(self, embedding_dim: int = 512, index_path: str = FAISS_INDEX_PATH,
|
| 251 |
+
id_map_path: str = FAISS_ID_MAP_PATH):
|
| 252 |
+
self.embedding_dim = embedding_dim
|
| 253 |
+
self.index_path = index_path
|
| 254 |
+
self.id_map_path = id_map_path
|
| 255 |
+
self.index = None
|
| 256 |
+
self.id_map = {}
|
| 257 |
+
self.reverse_map = {}
|
| 258 |
+
|
| 259 |
+
os.makedirs(os.path.dirname(index_path), exist_ok=True)
|
| 260 |
+
self._load_or_create_index()
|
| 261 |
+
|
| 262 |
+
def _load_or_create_index(self):
|
| 263 |
+
if os.path.exists(self.index_path) and os.path.exists(self.id_map_path):
|
| 264 |
+
try:
|
| 265 |
+
self.index = faiss.read_index(self.index_path)
|
| 266 |
+
with open(self.id_map_path, 'r') as f:
|
| 267 |
+
data = json.load(f)
|
| 268 |
+
self.id_map = {int(k): v for k, v in data.items()}
|
| 269 |
+
self.reverse_map = {v: int(k) for k, v in self.id_map.items()}
|
| 270 |
+
logger.info(f"[FAISS] Loaded index with {self.index.ntotal} embeddings")
|
| 271 |
+
except Exception as e:
|
| 272 |
+
logger.warning(f"[FAISS] Error loading index: {e}")
|
| 273 |
+
self._create_new_index()
|
| 274 |
+
else:
|
| 275 |
+
self._create_new_index()
|
| 276 |
+
|
| 277 |
+
def _create_new_index(self):
|
| 278 |
+
self.index = faiss.IndexFlatIP(self.embedding_dim)
|
| 279 |
+
self.id_map = {}
|
| 280 |
+
self.reverse_map = {}
|
| 281 |
+
logger.info(f"[FAISS] Created new index (dim={self.embedding_dim})")
|
| 282 |
+
|
| 283 |
+
def add_embedding(self, face_id: str, embedding: np.ndarray) -> int:
|
| 284 |
+
if face_id in self.reverse_map:
|
| 285 |
+
return self.reverse_map[face_id]
|
| 286 |
+
|
| 287 |
+
embedding = embedding.astype('float32').reshape(1, -1)
|
| 288 |
+
embedding = embedding / np.linalg.norm(embedding)
|
| 289 |
+
|
| 290 |
+
idx = self.index.ntotal
|
| 291 |
+
self.index.add(embedding)
|
| 292 |
+
|
| 293 |
+
self.id_map[idx] = face_id
|
| 294 |
+
self.reverse_map[face_id] = idx
|
| 295 |
+
|
| 296 |
+
return idx
|
| 297 |
+
|
| 298 |
+
def search(self, query_embedding: np.ndarray, k: int = 5, threshold: float = 0.6) -> List[Tuple[str, float]]:
|
| 299 |
+
if self.index.ntotal == 0:
|
| 300 |
+
return []
|
| 301 |
+
|
| 302 |
+
query_embedding = query_embedding.astype('float32').reshape(1, -1)
|
| 303 |
+
query_embedding = query_embedding / np.linalg.norm(query_embedding)
|
| 304 |
+
|
| 305 |
+
similarities, indices = self.index.search(query_embedding, min(k, self.index.ntotal))
|
| 306 |
+
|
| 307 |
+
results = []
|
| 308 |
+
for sim, idx in zip(similarities[0], indices[0]):
|
| 309 |
+
if idx in self.id_map and sim >= threshold:
|
| 310 |
+
results.append((self.id_map[idx], float(sim)))
|
| 311 |
+
|
| 312 |
+
return results
|
| 313 |
+
|
| 314 |
+
def save(self):
|
| 315 |
+
os.makedirs(os.path.dirname(self.index_path), exist_ok=True)
|
| 316 |
+
faiss.write_index(self.index, self.index_path)
|
| 317 |
+
with open(self.id_map_path, 'w') as f:
|
| 318 |
+
json.dump(self.id_map, f)
|
| 319 |
+
|
| 320 |
+
class MongoDBFaceStorage:
|
| 321 |
+
"""MongoDB storage for face metadata"""
|
| 322 |
+
|
| 323 |
+
def __init__(self, mongo_uri: str, db_name: str = MONGO_DB_NAME):
|
| 324 |
+
try:
|
| 325 |
+
self.client = MongoClient(mongo_uri, serverSelectionTimeoutMS=5000)
|
| 326 |
+
self.db = self.client[db_name]
|
| 327 |
+
self.faces_collection = self.db['detected_faces']
|
| 328 |
+
self.client.server_info() # Test connection
|
| 329 |
+
self.enabled = True
|
| 330 |
+
logger.info("[MongoDB] Connected successfully")
|
| 331 |
+
except Exception as e:
|
| 332 |
+
logger.warning(f"[MongoDB] Connection failed: {e}")
|
| 333 |
+
self.enabled = False
|
| 334 |
+
|
| 335 |
+
def save_face(self, data: Dict) -> str:
|
| 336 |
+
if not self.enabled:
|
| 337 |
+
return ""
|
| 338 |
+
|
| 339 |
+
data['detected_at'] = datetime.utcnow()
|
| 340 |
+
if 'face_embedding' in data:
|
| 341 |
+
del data['face_embedding'] # Don't store embeddings in MongoDB
|
| 342 |
+
data['face_embedding'] = []
|
| 343 |
+
|
| 344 |
+
try:
|
| 345 |
+
result = self.faces_collection.insert_one(data)
|
| 346 |
+
return str(result.inserted_id)
|
| 347 |
+
except Exception as e:
|
| 348 |
+
logger.error(f"[MongoDB] Error saving face: {e}")
|
| 349 |
+
return ""
|
| 350 |
+
|
| 351 |
+
def close(self):
|
| 352 |
+
if hasattr(self, 'client'):
|
| 353 |
+
self.client.close()
|
| 354 |
+
|
| 355 |
+
# ========================================
|
| 356 |
+
# Simple Implementation (OpenCV + Histograms)
|
| 357 |
+
# ========================================
|
| 358 |
+
|
| 359 |
+
class SimpleFaceDetector:
|
| 360 |
+
"""Simple face detector using OpenCV Haar cascades"""
|
| 361 |
+
|
| 362 |
+
def __init__(self, device='cpu'):
|
| 363 |
+
self.device = device
|
| 364 |
+
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
| 365 |
+
self.face_cascade = cv2.CascadeClassifier(cascade_path)
|
| 366 |
+
logger.info(f"[SimpleFaceDetector] Initialized with OpenCV Haar cascades")
|
| 367 |
+
|
| 368 |
+
def detect_faces(self, frame: np.ndarray) -> Tuple[List[np.ndarray], List[np.ndarray], List[float]]:
|
| 369 |
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
| 370 |
+
faces = self.face_cascade.detectMultiScale(gray, 1.1, 4, minSize=(30, 30))
|
| 371 |
+
|
| 372 |
+
face_crops = []
|
| 373 |
+
boxes = []
|
| 374 |
+
confidences = []
|
| 375 |
+
|
| 376 |
+
for (x, y, w, h) in faces:
|
| 377 |
+
face_crop = frame[y:y+h, x:x+w]
|
| 378 |
+
face_crops.append(face_crop)
|
| 379 |
+
boxes.append([x, y, x+w, y+h])
|
| 380 |
+
confidences.append(0.8)
|
| 381 |
+
|
| 382 |
+
return face_crops, boxes, confidences
|
| 383 |
+
|
| 384 |
+
class SimpleFaceEmbedder:
|
| 385 |
+
"""Simple face embedder using histograms"""
|
| 386 |
+
|
| 387 |
+
def __init__(self, device='cpu'):
|
| 388 |
+
self.device = device
|
| 389 |
+
logger.info(f"[SimpleFaceEmbedder] Using histogram-based embeddings")
|
| 390 |
+
|
| 391 |
+
def generate_embedding(self, face_crop: np.ndarray) -> np.ndarray:
|
| 392 |
+
if isinstance(face_crop, np.ndarray) and len(face_crop.shape) == 3:
|
| 393 |
+
face_resized = cv2.resize(face_crop, (64, 64))
|
| 394 |
+
hsv = cv2.cvtColor(face_resized, cv2.COLOR_BGR2HSV)
|
| 395 |
+
|
| 396 |
+
hist_h = cv2.calcHist([hsv], [0], None, [16], [0, 180])
|
| 397 |
+
hist_s = cv2.calcHist([hsv], [1], None, [16], [0, 256])
|
| 398 |
+
hist_v = cv2.calcHist([hsv], [2], None, [16], [0, 256])
|
| 399 |
+
|
| 400 |
+
embedding = np.concatenate([hist_h.flatten(), hist_s.flatten(), hist_v.flatten()])
|
| 401 |
+
return embedding / np.linalg.norm(embedding)
|
| 402 |
+
else:
|
| 403 |
+
return np.random.rand(48) / np.linalg.norm(np.random.rand(48))
|
| 404 |
+
|
| 405 |
+
class SimpleFaceIndex:
|
| 406 |
+
"""Simple face index using cosine similarity"""
|
| 407 |
+
|
| 408 |
+
def __init__(self, index_path: str = SIMPLE_INDEX_PATH):
|
| 409 |
+
self.index_path = index_path
|
| 410 |
+
self.faces_db = {}
|
| 411 |
+
|
| 412 |
+
os.makedirs(os.path.dirname(index_path), exist_ok=True)
|
| 413 |
+
self._load_index()
|
| 414 |
+
|
| 415 |
+
def _load_index(self):
|
| 416 |
+
if os.path.exists(self.index_path):
|
| 417 |
+
try:
|
| 418 |
+
with open(self.index_path, 'r') as f:
|
| 419 |
+
data = json.load(f)
|
| 420 |
+
self.faces_db = {face_id: np.array(embedding)
|
| 421 |
+
for face_id, embedding in data.items()}
|
| 422 |
+
logger.info(f"[SimpleFaceIndex] Loaded {len(self.faces_db)} faces")
|
| 423 |
+
except Exception as e:
|
| 424 |
+
logger.warning(f"[SimpleFaceIndex] Error loading: {e}")
|
| 425 |
+
self.faces_db = {}
|
| 426 |
+
else:
|
| 427 |
+
self.faces_db = {}
|
| 428 |
+
|
| 429 |
+
def add_embedding(self, face_id: str, embedding: np.ndarray) -> int:
|
| 430 |
+
if face_id in self.faces_db:
|
| 431 |
+
return len(self.faces_db)
|
| 432 |
+
|
| 433 |
+
self.faces_db[face_id] = embedding
|
| 434 |
+
return len(self.faces_db)
|
| 435 |
+
|
| 436 |
+
def search(self, query_embedding: np.ndarray, k: int = 5, threshold: float = 0.6) -> List[Tuple[str, float]]:
|
| 437 |
+
if not self.faces_db:
|
| 438 |
+
return []
|
| 439 |
+
|
| 440 |
+
similarities = []
|
| 441 |
+
for face_id, stored_embedding in self.faces_db.items():
|
| 442 |
+
similarity = np.dot(query_embedding, stored_embedding) / (
|
| 443 |
+
np.linalg.norm(query_embedding) * np.linalg.norm(stored_embedding))
|
| 444 |
+
|
| 445 |
+
if similarity >= threshold:
|
| 446 |
+
similarities.append((face_id, float(similarity)))
|
| 447 |
+
|
| 448 |
+
similarities.sort(key=lambda x: x[1], reverse=True)
|
| 449 |
+
return similarities[:k]
|
| 450 |
+
|
| 451 |
+
def save(self):
|
| 452 |
+
try:
|
| 453 |
+
data = {face_id: embedding.tolist()
|
| 454 |
+
for face_id, embedding in self.faces_db.items()}
|
| 455 |
+
|
| 456 |
+
with open(self.index_path, 'w') as f:
|
| 457 |
+
json.dump(data, f)
|
| 458 |
+
|
| 459 |
+
logger.debug(f"[SimpleFaceIndex] Saved {len(self.faces_db)} faces")
|
| 460 |
+
except Exception as e:
|
| 461 |
+
logger.error(f"[SimpleFaceIndex] Error saving: {e}")
|
| 462 |
+
|
| 463 |
+
# ========================================
|
| 464 |
+
# Main Facial Recognition Class
|
| 465 |
+
# ========================================
|
| 466 |
+
|
| 467 |
+
class FacialRecognitionIntegrated:
|
| 468 |
+
"""
|
| 469 |
+
Unified facial recognition system for DetectifAI.
|
| 470 |
+
|
| 471 |
+
Automatically uses advanced implementation (MTCNN + FaceNet + FAISS + MongoDB)
|
| 472 |
+
if available, otherwise falls back to simple implementation (OpenCV + Histograms + JSON).
|
| 473 |
+
|
| 474 |
+
Applies facial recognition ONLY to suspicious frames detected by object detection.
|
| 475 |
+
"""
|
| 476 |
+
|
| 477 |
+
def __init__(self, config):
|
| 478 |
+
self.config = config
|
| 479 |
+
self.enabled = getattr(config, 'enable_facial_recognition', False)
|
| 480 |
+
self.confidence_threshold = getattr(config, 'face_recognition_confidence', 0.7)
|
| 481 |
+
self.similarity_threshold = 0.6
|
| 482 |
+
self.device = 'cuda' if torch.cuda.is_available() and getattr(config, 'use_gpu_acceleration', False) else 'cpu'
|
| 483 |
+
|
| 484 |
+
# Create faces directory
|
| 485 |
+
self.faces_dir = Path(FACES_DIR)
|
| 486 |
+
self.faces_dir.mkdir(exist_ok=True, parents=True)
|
| 487 |
+
|
| 488 |
+
# Determine implementation mode
|
| 489 |
+
self.advanced_mode = ADVANCED_AVAILABLE and self.enabled
|
| 490 |
+
|
| 491 |
+
# Initialize components only if enabled
|
| 492 |
+
if self.enabled:
|
| 493 |
+
self._initialize_components()
|
| 494 |
+
|
| 495 |
+
# Detection statistics
|
| 496 |
+
self.detection_stats = {
|
| 497 |
+
'implementation_mode': 'advanced' if self.advanced_mode else 'simple',
|
| 498 |
+
'frames_processed': 0,
|
| 499 |
+
'faces_detected': 0,
|
| 500 |
+
'suspicious_persons_tracked': 0,
|
| 501 |
+
'reoccurrences_detected': 0,
|
| 502 |
+
'new_faces_added': 0,
|
| 503 |
+
'face_matches_found': 0
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
# Suspicious persons database
|
| 507 |
+
self.suspicious_persons_db = {}
|
| 508 |
+
|
| 509 |
+
if not self.enabled:
|
| 510 |
+
logger.info("[FacialRecognition] Disabled - skipping initialization")
|
| 511 |
+
else:
|
| 512 |
+
mode = "Advanced (MTCNN + FaceNet + FAISS)" if self.advanced_mode else "Simple (OpenCV + Histograms)"
|
| 513 |
+
logger.info(f"[FacialRecognition] ✅ Initialized in {mode} mode")
|
| 514 |
+
|
| 515 |
+
def _initialize_components(self):
|
| 516 |
+
"""Initialize facial recognition components based on available dependencies"""
|
| 517 |
+
try:
|
| 518 |
+
if self.advanced_mode:
|
| 519 |
+
# Advanced implementation
|
| 520 |
+
self.detector = AdvancedFaceDetector(self.device)
|
| 521 |
+
self.embedder = AdvancedFaceEmbedder(self.device)
|
| 522 |
+
self.face_index = FAISSFaceIndex()
|
| 523 |
+
self.person_classifier = PersonClassifier() # Add trained SVM classifier
|
| 524 |
+
|
| 525 |
+
# MongoDB storage (optional)
|
| 526 |
+
if MONGO_URI:
|
| 527 |
+
self.mongodb_storage = MongoDBFaceStorage(MONGO_URI)
|
| 528 |
+
else:
|
| 529 |
+
self.mongodb_storage = None
|
| 530 |
+
logger.info("[FacialRecognition] MongoDB not configured, using local storage only")
|
| 531 |
+
|
| 532 |
+
else:
|
| 533 |
+
# Simple implementation
|
| 534 |
+
self.detector = SimpleFaceDetector()
|
| 535 |
+
self.embedder = SimpleFaceEmbedder()
|
| 536 |
+
self.face_index = SimpleFaceIndex()
|
| 537 |
+
self.person_classifier = None # No classifier in simple mode
|
| 538 |
+
self.mongodb_storage = None
|
| 539 |
+
|
| 540 |
+
except Exception as e:
|
| 541 |
+
logger.error(f"[FacialRecognition] ❌ Initialization failed: {e}")
|
| 542 |
+
self.enabled = False
|
| 543 |
+
raise
|
| 544 |
+
|
| 545 |
+
def _generate_face_id(self, frame_number: int, face_index: int, person_name: Optional[str] = None, event_id: str = "unknown") -> str:
|
| 546 |
+
"""Generate unique face ID"""
|
| 547 |
+
prefix = f"{person_name.replace(' ', '_')}" if person_name else "unknown"
|
| 548 |
+
unique_id = str(uuid.uuid4())[:8]
|
| 549 |
+
return f"face_{prefix}_event_{event_id}_{frame_number:06d}_{face_index:02d}_{unique_id}"
|
| 550 |
+
|
| 551 |
+
def _save_face_image(self, face_data, face_id: str) -> str:
|
| 552 |
+
"""Save face image to disk"""
|
| 553 |
+
try:
|
| 554 |
+
path = self.faces_dir / f"{face_id}.jpg"
|
| 555 |
+
|
| 556 |
+
if self.advanced_mode and isinstance(face_data, torch.Tensor):
|
| 557 |
+
# Convert tensor to numpy array (MTCNN returns normalized tensors in range [0, 1])
|
| 558 |
+
face_np = face_data.permute(1, 2, 0).cpu().numpy()
|
| 559 |
+
# Convert from [0,1] float to [0,255] uint8
|
| 560 |
+
face_np = (face_np * 128 + 127.5).clip(0, 255).astype(np.uint8)
|
| 561 |
+
# MTCNN outputs RGB, convert to BGR for OpenCV
|
| 562 |
+
face_bgr = cv2.cvtColor(face_np, cv2.COLOR_RGB2BGR)
|
| 563 |
+
# Resize to reasonable display size (e.g., 160x160)
|
| 564 |
+
face_bgr = cv2.resize(face_bgr, (160, 160))
|
| 565 |
+
cv2.imwrite(str(path), face_bgr)
|
| 566 |
+
logger.debug(f"Saved advanced face image to {path}")
|
| 567 |
+
elif isinstance(face_data, np.ndarray):
|
| 568 |
+
# Direct numpy array (from simple mode or already processed)
|
| 569 |
+
# Ensure it's in proper format
|
| 570 |
+
if face_data.dtype != np.uint8:
|
| 571 |
+
face_data = (face_data * 255).astype(np.uint8) if face_data.max() <= 1.0 else face_data.astype(np.uint8)
|
| 572 |
+
# Resize if too large
|
| 573 |
+
if face_data.shape[0] > 300 or face_data.shape[1] > 300:
|
| 574 |
+
face_data = cv2.resize(face_data, (160, 160))
|
| 575 |
+
cv2.imwrite(str(path), face_data)
|
| 576 |
+
logger.debug(f"Saved simple face image to {path}")
|
| 577 |
+
else:
|
| 578 |
+
logger.error(f"Unknown face_data type: {type(face_data)}")
|
| 579 |
+
return ""
|
| 580 |
+
|
| 581 |
+
return str(path)
|
| 582 |
+
except Exception as e:
|
| 583 |
+
logger.error(f"[FacialRecognition] Error saving face image: {e}")
|
| 584 |
+
import traceback
|
| 585 |
+
traceback.print_exc()
|
| 586 |
+
return ""
|
| 587 |
+
|
| 588 |
+
def detect_faces_in_frame(self, frame_path: str, timestamp: float) -> FaceDetectionResult:
|
| 589 |
+
"""
|
| 590 |
+
Detect faces in a single frame (for suspicious frames only).
|
| 591 |
+
|
| 592 |
+
Args:
|
| 593 |
+
frame_path: Path to the frame image
|
| 594 |
+
timestamp: Timestamp of the frame in video
|
| 595 |
+
|
| 596 |
+
Returns:
|
| 597 |
+
FaceDetectionResult with detected faces and metadata
|
| 598 |
+
"""
|
| 599 |
+
if not self.enabled:
|
| 600 |
+
return FaceDetectionResult(
|
| 601 |
+
frame_path=frame_path,
|
| 602 |
+
timestamp=timestamp,
|
| 603 |
+
faces_detected=0,
|
| 604 |
+
face_embeddings=[],
|
| 605 |
+
face_bounding_boxes=[],
|
| 606 |
+
face_confidence_scores=[],
|
| 607 |
+
processing_time=0.0
|
| 608 |
+
)
|
| 609 |
+
|
| 610 |
+
start_time = time.time()
|
| 611 |
+
|
| 612 |
+
try:
|
| 613 |
+
# Load frame
|
| 614 |
+
frame = cv2.imread(frame_path)
|
| 615 |
+
if frame is None:
|
| 616 |
+
logger.error(f"Could not load frame: {frame_path}")
|
| 617 |
+
return FaceDetectionResult(
|
| 618 |
+
frame_path=frame_path,
|
| 619 |
+
timestamp=timestamp,
|
| 620 |
+
faces_detected=0,
|
| 621 |
+
face_embeddings=[],
|
| 622 |
+
face_bounding_boxes=[],
|
| 623 |
+
face_confidence_scores=[],
|
| 624 |
+
processing_time=0.0
|
| 625 |
+
)
|
| 626 |
+
|
| 627 |
+
# Detect faces
|
| 628 |
+
faces, boxes, probs = self.detector.detect_faces(frame)
|
| 629 |
+
|
| 630 |
+
# Generate embeddings and process faces
|
| 631 |
+
face_embeddings = []
|
| 632 |
+
detected_face_ids = []
|
| 633 |
+
matched_persons = []
|
| 634 |
+
|
| 635 |
+
for i, (face, box, prob) in enumerate(zip(faces, boxes, probs)):
|
| 636 |
+
# Generate embedding
|
| 637 |
+
embedding = self.embedder.generate_embedding(face)
|
| 638 |
+
face_embeddings.append(embedding)
|
| 639 |
+
|
| 640 |
+
# Try person identification using trained classifier
|
| 641 |
+
person_name, person_confidence = None, 0.0
|
| 642 |
+
if self.person_classifier and self.person_classifier.enabled:
|
| 643 |
+
person_name, person_confidence = self.person_classifier.identify_person(embedding)
|
| 644 |
+
|
| 645 |
+
# Search for similar faces in FAISS index
|
| 646 |
+
matches = self.face_index.search(embedding, k=1, threshold=self.similarity_threshold)
|
| 647 |
+
|
| 648 |
+
if matches:
|
| 649 |
+
# Found matching face
|
| 650 |
+
matched_face_id, similarity = matches[0]
|
| 651 |
+
detected_face_ids.append(matched_face_id)
|
| 652 |
+
|
| 653 |
+
if person_name:
|
| 654 |
+
matched_persons.append(f"{person_name} (confidence: {person_confidence:.2f})")
|
| 655 |
+
logger.info(f"👤 Known person identified: {person_name} (confidence: {person_confidence:.2f}, face similarity: {similarity:.3f})")
|
| 656 |
+
else:
|
| 657 |
+
matched_persons.append(f"person_{matched_face_id}")
|
| 658 |
+
logger.info(f"👤 Face match found: {matched_face_id} (similarity: {similarity:.3f})")
|
| 659 |
+
|
| 660 |
+
self.detection_stats['face_matches_found'] += 1
|
| 661 |
+
else:
|
| 662 |
+
# New face - save to index
|
| 663 |
+
frame_number = int(timestamp * 30) # Estimate frame number
|
| 664 |
+
new_face_id = self._generate_face_id(frame_number, i, person_name, event_id=f"obj_detection_{int(timestamp)}")
|
| 665 |
+
|
| 666 |
+
# Add to FAISS index
|
| 667 |
+
self.face_index.add_embedding(new_face_id, embedding)
|
| 668 |
+
|
| 669 |
+
# Save face image
|
| 670 |
+
face_path = self._save_face_image(face, new_face_id)
|
| 671 |
+
|
| 672 |
+
# Save metadata to MongoDB if available
|
| 673 |
+
if self.mongodb_storage and self.mongodb_storage.enabled:
|
| 674 |
+
face_metadata = {
|
| 675 |
+
'face_id': new_face_id,
|
| 676 |
+
'frame_path': frame_path,
|
| 677 |
+
'timestamp': timestamp,
|
| 678 |
+
'confidence': float(prob),
|
| 679 |
+
'person_name': person_name,
|
| 680 |
+
'person_confidence': float(person_confidence) if person_name else None,
|
| 681 |
+
'bounding_box': [int(x) for x in box],
|
| 682 |
+
'face_image_path': face_path
|
| 683 |
+
}
|
| 684 |
+
self.mongodb_storage.save_face(face_metadata)
|
| 685 |
+
|
| 686 |
+
detected_face_ids.append(new_face_id)
|
| 687 |
+
|
| 688 |
+
if person_name:
|
| 689 |
+
matched_persons.append(f"{person_name} (NEW, confidence: {person_confidence:.2f})")
|
| 690 |
+
logger.info(f"👤 NEW known person detected: {person_name} (confidence: {person_confidence:.2f})")
|
| 691 |
+
else:
|
| 692 |
+
matched_persons.append(f"new_unknown_person_{new_face_id}")
|
| 693 |
+
logger.info(f"👤 NEW unknown face detected: {new_face_id}")
|
| 694 |
+
|
| 695 |
+
self.detection_stats['new_faces_added'] += 1
|
| 696 |
+
|
| 697 |
+
# Save face index
|
| 698 |
+
self.face_index.save()
|
| 699 |
+
|
| 700 |
+
processing_time = time.time() - start_time
|
| 701 |
+
self.detection_stats['frames_processed'] += 1
|
| 702 |
+
self.detection_stats['faces_detected'] += len(faces)
|
| 703 |
+
|
| 704 |
+
# Convert boxes to expected format
|
| 705 |
+
face_bounding_boxes = [(int(box[0]), int(box[1]), int(box[2]), int(box[3])) for box in boxes]
|
| 706 |
+
|
| 707 |
+
result = FaceDetectionResult(
|
| 708 |
+
frame_path=frame_path,
|
| 709 |
+
timestamp=timestamp,
|
| 710 |
+
faces_detected=len(faces),
|
| 711 |
+
face_embeddings=face_embeddings,
|
| 712 |
+
face_bounding_boxes=face_bounding_boxes,
|
| 713 |
+
face_confidence_scores=probs,
|
| 714 |
+
processing_time=processing_time,
|
| 715 |
+
detected_face_ids=detected_face_ids,
|
| 716 |
+
matched_persons=matched_persons
|
| 717 |
+
)
|
| 718 |
+
|
| 719 |
+
if faces:
|
| 720 |
+
logger.info(f"👤 Processed {len(faces)} faces in suspicious frame at {timestamp:.2f}s")
|
| 721 |
+
|
| 722 |
+
return result
|
| 723 |
+
|
| 724 |
+
except Exception as e:
|
| 725 |
+
logger.error(f"[FacialRecognition] Error processing frame {frame_path}: {e}")
|
| 726 |
+
return FaceDetectionResult(
|
| 727 |
+
frame_path=frame_path,
|
| 728 |
+
timestamp=timestamp,
|
| 729 |
+
faces_detected=0,
|
| 730 |
+
face_embeddings=[],
|
| 731 |
+
face_bounding_boxes=[],
|
| 732 |
+
face_confidence_scores=[],
|
| 733 |
+
processing_time=time.time() - start_time
|
| 734 |
+
)
|
| 735 |
+
|
| 736 |
+
def track_suspicious_persons(self, face_results: List[FaceDetectionResult],
|
| 737 |
+
detectifai_events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 738 |
+
"""Track suspicious persons and detect re-occurrences."""
|
| 739 |
+
if not self.enabled or not face_results:
|
| 740 |
+
logger.info("👤 Facial recognition disabled or no face results - skipping person tracking")
|
| 741 |
+
return []
|
| 742 |
+
|
| 743 |
+
logger.info(f"👤 Tracking suspicious persons across {len(face_results)} face detection results")
|
| 744 |
+
|
| 745 |
+
reoccurrence_events = []
|
| 746 |
+
person_timeline = {} # face_id -> list of timestamps
|
| 747 |
+
|
| 748 |
+
# Build person timeline from face results
|
| 749 |
+
for face_result in face_results:
|
| 750 |
+
if face_result.detected_face_ids:
|
| 751 |
+
for face_id in face_result.detected_face_ids:
|
| 752 |
+
if face_id not in person_timeline:
|
| 753 |
+
person_timeline[face_id] = []
|
| 754 |
+
person_timeline[face_id].append(face_result.timestamp)
|
| 755 |
+
|
| 756 |
+
# Look for re-occurrences (same person appearing multiple times)
|
| 757 |
+
for face_id, timestamps in person_timeline.items():
|
| 758 |
+
if len(timestamps) > 1:
|
| 759 |
+
# Create re-occurrence event
|
| 760 |
+
timestamps.sort()
|
| 761 |
+
reoccurrence_event = {
|
| 762 |
+
'event_id': f"reoccurrence_{face_id}_{int(timestamps[-1])}",
|
| 763 |
+
'start_timestamp': timestamps[0],
|
| 764 |
+
'end_timestamp': timestamps[-1],
|
| 765 |
+
'event_type': 'suspicious_person_reoccurrence',
|
| 766 |
+
'confidence': 0.85,
|
| 767 |
+
'max_confidence': 0.85,
|
| 768 |
+
'keyframes': [r.frame_path for r in face_results if face_id in (r.detected_face_ids or [])],
|
| 769 |
+
'importance_score': 4.0,
|
| 770 |
+
'description': f"Suspicious person {face_id} appeared {len(timestamps)} times",
|
| 771 |
+
'detection_details': {
|
| 772 |
+
'person_id': face_id,
|
| 773 |
+
'appearances': len(timestamps),
|
| 774 |
+
'time_span': timestamps[-1] - timestamps[0],
|
| 775 |
+
'timestamps': timestamps
|
| 776 |
+
}
|
| 777 |
+
}
|
| 778 |
+
reoccurrence_events.append(reoccurrence_event)
|
| 779 |
+
self.detection_stats['reoccurrences_detected'] += 1
|
| 780 |
+
|
| 781 |
+
# Save face index
|
| 782 |
+
if self.face_index:
|
| 783 |
+
self.face_index.save()
|
| 784 |
+
|
| 785 |
+
# Update statistics
|
| 786 |
+
self.detection_stats['suspicious_persons_tracked'] = len(person_timeline)
|
| 787 |
+
|
| 788 |
+
logger.info(f"👤 Person tracking complete: {len(person_timeline)} unique persons, {len(reoccurrence_events)} re-occurrences")
|
| 789 |
+
|
| 790 |
+
return reoccurrence_events
|
| 791 |
+
|
| 792 |
+
def search_person_by_image(self, image_path: str, k: int = 10, threshold: float = 0.6) -> List[Dict[str, Any]]:
|
| 793 |
+
"""
|
| 794 |
+
Search for a person by uploading their image.
|
| 795 |
+
|
| 796 |
+
Args:
|
| 797 |
+
image_path: Path to the uploaded image
|
| 798 |
+
k: Number of top matches to return
|
| 799 |
+
threshold: Similarity threshold for matches
|
| 800 |
+
|
| 801 |
+
Returns:
|
| 802 |
+
List of matched persons with their occurrences
|
| 803 |
+
"""
|
| 804 |
+
if not self.enabled:
|
| 805 |
+
logger.warning("[FacialRecognition] System not enabled")
|
| 806 |
+
return []
|
| 807 |
+
|
| 808 |
+
try:
|
| 809 |
+
# Load the uploaded image
|
| 810 |
+
frame = cv2.imread(image_path)
|
| 811 |
+
if frame is None:
|
| 812 |
+
logger.error(f"Could not load image: {image_path}")
|
| 813 |
+
return []
|
| 814 |
+
|
| 815 |
+
# Detect faces in the uploaded image
|
| 816 |
+
faces, boxes, probs = self.detector.detect_faces(frame)
|
| 817 |
+
|
| 818 |
+
if not faces:
|
| 819 |
+
logger.info("No faces detected in uploaded image")
|
| 820 |
+
return []
|
| 821 |
+
|
| 822 |
+
# Use the first detected face for search
|
| 823 |
+
query_face = faces[0]
|
| 824 |
+
query_embedding = self.embedder.generate_embedding(query_face)
|
| 825 |
+
|
| 826 |
+
# Search for similar faces in the database
|
| 827 |
+
matches = self.face_index.search(query_embedding, k=k, threshold=threshold)
|
| 828 |
+
|
| 829 |
+
if not matches:
|
| 830 |
+
logger.info("No similar faces found in database")
|
| 831 |
+
return []
|
| 832 |
+
|
| 833 |
+
# Group matches by person/event and gather occurrence information
|
| 834 |
+
search_results = []
|
| 835 |
+
|
| 836 |
+
for face_id, similarity in matches:
|
| 837 |
+
# Parse face_id to extract information
|
| 838 |
+
# face_id format: face_{person}_{event}_{frame}_{face_index}_{unique_id}
|
| 839 |
+
parts = face_id.split('_')
|
| 840 |
+
if len(parts) >= 6:
|
| 841 |
+
person_part = parts[1] if parts[1] != 'unknown' else 'Unknown Person'
|
| 842 |
+
event_part = '_'.join(parts[2:4]) # event_obj_detection or similar
|
| 843 |
+
|
| 844 |
+
# Check if we have face image saved
|
| 845 |
+
face_image_path = str(self.faces_dir / f"{face_id}.jpg")
|
| 846 |
+
has_face_image = os.path.exists(face_image_path)
|
| 847 |
+
|
| 848 |
+
# Try to get person identification from trained classifier
|
| 849 |
+
person_name, person_confidence = None, 0.0
|
| 850 |
+
if self.person_classifier and self.person_classifier.enabled:
|
| 851 |
+
person_name, person_confidence = self.person_classifier.identify_person(query_embedding)
|
| 852 |
+
|
| 853 |
+
result = {
|
| 854 |
+
'face_id': face_id,
|
| 855 |
+
'person_name': person_name if person_name else person_part.replace('_', ' ').title(),
|
| 856 |
+
'person_confidence': person_confidence,
|
| 857 |
+
'similarity_score': similarity,
|
| 858 |
+
'event_context': event_part,
|
| 859 |
+
'face_image_path': face_image_path if has_face_image else None,
|
| 860 |
+
'timestamp': self._extract_timestamp_from_face_id(face_id),
|
| 861 |
+
'detection_context': 'Suspicious Activity Detection'
|
| 862 |
+
}
|
| 863 |
+
search_results.append(result)
|
| 864 |
+
|
| 865 |
+
else:
|
| 866 |
+
# Fallback for differently formatted face_ids
|
| 867 |
+
person_name, person_confidence = None, 0.0
|
| 868 |
+
if self.person_classifier and self.person_classifier.enabled:
|
| 869 |
+
person_name, person_confidence = self.person_classifier.identify_person(query_embedding)
|
| 870 |
+
|
| 871 |
+
result = {
|
| 872 |
+
'face_id': face_id,
|
| 873 |
+
'person_name': person_name if person_name else 'Unknown Person',
|
| 874 |
+
'person_confidence': person_confidence,
|
| 875 |
+
'similarity_score': similarity,
|
| 876 |
+
'event_context': 'security_event',
|
| 877 |
+
'face_image_path': str(self.faces_dir / f"{face_id}.jpg") if os.path.exists(self.faces_dir / f"{face_id}.jpg") else None,
|
| 878 |
+
'timestamp': 0.0,
|
| 879 |
+
'detection_context': 'Security Event'
|
| 880 |
+
}
|
| 881 |
+
search_results.append(result)
|
| 882 |
+
|
| 883 |
+
# Sort by similarity score (highest first)
|
| 884 |
+
search_results.sort(key=lambda x: x['similarity_score'], reverse=True)
|
| 885 |
+
|
| 886 |
+
logger.info(f"👤 Image search complete: Found {len(search_results)} matches with similarity >= {threshold}")
|
| 887 |
+
|
| 888 |
+
return search_results
|
| 889 |
+
|
| 890 |
+
except Exception as e:
|
| 891 |
+
logger.error(f"[FacialRecognition] Error in image search: {e}")
|
| 892 |
+
return []
|
| 893 |
+
|
| 894 |
+
def _extract_timestamp_from_face_id(self, face_id: str) -> float:
|
| 895 |
+
"""Extract timestamp from face_id format"""
|
| 896 |
+
try:
|
| 897 |
+
parts = face_id.split('_')
|
| 898 |
+
if len(parts) >= 6:
|
| 899 |
+
# Try to extract from event part (e.g., event_obj_detection_123)
|
| 900 |
+
for part in parts:
|
| 901 |
+
if part.isdigit():
|
| 902 |
+
return float(part)
|
| 903 |
+
return 0.0
|
| 904 |
+
except:
|
| 905 |
+
return 0.0
|
| 906 |
+
|
| 907 |
+
def get_detection_stats(self) -> Dict[str, Any]:
|
| 908 |
+
"""Get facial recognition detection statistics"""
|
| 909 |
+
stats = self.detection_stats.copy()
|
| 910 |
+
if hasattr(self, 'face_index'):
|
| 911 |
+
if self.advanced_mode:
|
| 912 |
+
stats['total_faces_in_database'] = self.face_index.index.ntotal if self.face_index.index else 0
|
| 913 |
+
else:
|
| 914 |
+
stats['total_faces_in_database'] = len(self.face_index.faces_db) if self.face_index else 0
|
| 915 |
+
return stats
|
| 916 |
+
|
| 917 |
+
def cleanup(self):
|
| 918 |
+
"""Cleanup resources"""
|
| 919 |
+
if hasattr(self, 'face_index'):
|
| 920 |
+
self.face_index.save()
|
| 921 |
+
if hasattr(self, 'mongodb_storage') and self.mongodb_storage:
|
| 922 |
+
self.mongodb_storage.close()
|
| 923 |
+
logger.info("[FacialRecognition] Cleanup completed")
|
| 924 |
+
|
| 925 |
+
# For backward compatibility
|
| 926 |
+
FacialRecognitionPlaceholder = FacialRecognitionIntegrated
|
highlight_reel.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Highlight Reel Generation Module
|
| 3 |
+
|
| 4 |
+
This module creates video summaries and highlight reels using various strategies:
|
| 5 |
+
- Event-aware summarization
|
| 6 |
+
- Ultra-comprehensive coverage
|
| 7 |
+
- Quality-focused highlights
|
| 8 |
+
- Motion-based highlights
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import cv2
|
| 12 |
+
import os
|
| 13 |
+
import numpy as np
|
| 14 |
+
from typing import List, Dict, Any, Tuple, Optional
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
+
from datetime import datetime
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
class HighlightReelGenerator:
|
| 22 |
+
"""Generate highlight reels from processed video segments"""
|
| 23 |
+
|
| 24 |
+
def __init__(self, config):
|
| 25 |
+
self.config = config
|
| 26 |
+
self.highlights_dir = os.path.join(config.output_base_dir, "highlights")
|
| 27 |
+
os.makedirs(self.highlights_dir, exist_ok=True)
|
| 28 |
+
|
| 29 |
+
def create_event_aware_highlight_reel(self, segments: List, canonical_events: List = None) -> str:
|
| 30 |
+
"""
|
| 31 |
+
Create highlight reel focusing on detected events
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
segments: List of video segments
|
| 35 |
+
canonical_events: List of canonical events (optional)
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
Path to generated highlight reel
|
| 39 |
+
"""
|
| 40 |
+
logger.info("Creating event-aware highlight reel")
|
| 41 |
+
|
| 42 |
+
output_path = os.path.join(self.highlights_dir, "event_aware_highlights.mp4")
|
| 43 |
+
|
| 44 |
+
# Detect event segments
|
| 45 |
+
event_segments = self._detect_event_segments(segments)
|
| 46 |
+
|
| 47 |
+
# Select keyframes with event priority
|
| 48 |
+
selected_keyframes = self._select_event_aware_keyframes(
|
| 49 |
+
segments, event_segments, canonical_events
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Create video
|
| 53 |
+
success = self._create_highlight_video(
|
| 54 |
+
selected_keyframes,
|
| 55 |
+
output_path,
|
| 56 |
+
"Event-Aware Highlights"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
if success:
|
| 60 |
+
logger.info(f"Event-aware highlight reel created: {output_path}")
|
| 61 |
+
return output_path
|
| 62 |
+
else:
|
| 63 |
+
logger.error("Failed to create event-aware highlight reel")
|
| 64 |
+
return ""
|
| 65 |
+
|
| 66 |
+
def create_ultra_comprehensive_highlight_reel(self, segments: List) -> str:
|
| 67 |
+
"""
|
| 68 |
+
Create comprehensive highlight reel capturing maximum important moments
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
segments: List of video segments
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
Path to generated highlight reel
|
| 75 |
+
"""
|
| 76 |
+
logger.info("Creating ultra-comprehensive highlight reel")
|
| 77 |
+
|
| 78 |
+
output_path = os.path.join(self.highlights_dir, "ultra_comprehensive_highlights.mp4")
|
| 79 |
+
|
| 80 |
+
# Use ultra-sensitive selection
|
| 81 |
+
selected_keyframes = self._select_ultra_comprehensive_keyframes(segments)
|
| 82 |
+
|
| 83 |
+
# Create video
|
| 84 |
+
success = self._create_highlight_video(
|
| 85 |
+
selected_keyframes,
|
| 86 |
+
output_path,
|
| 87 |
+
"Ultra-Comprehensive Highlights"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
if success:
|
| 91 |
+
logger.info(f"Ultra-comprehensive highlight reel created: {output_path}")
|
| 92 |
+
return output_path
|
| 93 |
+
else:
|
| 94 |
+
logger.error("Failed to create ultra-comprehensive highlight reel")
|
| 95 |
+
return ""
|
| 96 |
+
|
| 97 |
+
def create_quality_focused_highlight_reel(self, segments: List) -> str:
|
| 98 |
+
"""
|
| 99 |
+
Create highlight reel focusing on highest quality frames
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
segments: List of video segments
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
Path to generated highlight reel
|
| 106 |
+
"""
|
| 107 |
+
logger.info("Creating quality-focused highlight reel")
|
| 108 |
+
|
| 109 |
+
output_path = os.path.join(self.highlights_dir, "quality_focused_highlights.mp4")
|
| 110 |
+
|
| 111 |
+
# Select highest quality keyframes
|
| 112 |
+
selected_keyframes = self._select_quality_focused_keyframes(segments)
|
| 113 |
+
|
| 114 |
+
# Create video
|
| 115 |
+
success = self._create_highlight_video(
|
| 116 |
+
selected_keyframes,
|
| 117 |
+
output_path,
|
| 118 |
+
"Quality-Focused Highlights"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
if success:
|
| 122 |
+
logger.info(f"Quality-focused highlight reel created: {output_path}")
|
| 123 |
+
return output_path
|
| 124 |
+
else:
|
| 125 |
+
logger.error("Failed to create quality-focused highlight reel")
|
| 126 |
+
return ""
|
| 127 |
+
|
| 128 |
+
def _detect_event_segments(self, segments: List) -> List[int]:
|
| 129 |
+
"""Detect which segments contain significant events"""
|
| 130 |
+
event_segments = []
|
| 131 |
+
|
| 132 |
+
for segment in segments:
|
| 133 |
+
keyframes = segment.get('keyframes', [])
|
| 134 |
+
if not keyframes:
|
| 135 |
+
continue
|
| 136 |
+
|
| 137 |
+
# Calculate segment activity metrics
|
| 138 |
+
motion_scores = [kf['frame_data']['motion_score'] for kf in keyframes]
|
| 139 |
+
burst_count = sum(1 for kf in keyframes if kf['frame_data']['burst_active'])
|
| 140 |
+
max_motion = max(motion_scores) if motion_scores else 0
|
| 141 |
+
avg_motion = sum(motion_scores) / len(motion_scores) if motion_scores else 0
|
| 142 |
+
|
| 143 |
+
# Event detection criteria
|
| 144 |
+
is_event_segment = (
|
| 145 |
+
max_motion > self.config.motion_threshold or
|
| 146 |
+
avg_motion > self.config.motion_threshold * 0.5 or
|
| 147 |
+
burst_count >= 1
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
if is_event_segment:
|
| 151 |
+
segment_id = segment.get('segment_id', len(event_segments))
|
| 152 |
+
event_segments.append(segment_id)
|
| 153 |
+
|
| 154 |
+
return event_segments
|
| 155 |
+
|
| 156 |
+
def _select_event_aware_keyframes(self, segments: List, event_segments: List[int],
|
| 157 |
+
canonical_events: List = None) -> List[Dict]:
|
| 158 |
+
"""Select keyframes with event awareness"""
|
| 159 |
+
selected_keyframes = []
|
| 160 |
+
|
| 161 |
+
for segment in segments:
|
| 162 |
+
keyframes = segment.get('keyframes', [])
|
| 163 |
+
if not keyframes:
|
| 164 |
+
continue
|
| 165 |
+
|
| 166 |
+
segment_id = segment.get('segment_id', 0)
|
| 167 |
+
|
| 168 |
+
if segment_id in event_segments:
|
| 169 |
+
# Event segment: select multiple keyframes
|
| 170 |
+
scored_keyframes = []
|
| 171 |
+
|
| 172 |
+
for kf in keyframes:
|
| 173 |
+
frame_data = kf['frame_data']
|
| 174 |
+
base_score = kf['keyframe_score']
|
| 175 |
+
motion_score = frame_data['motion_score']
|
| 176 |
+
is_burst = frame_data['burst_active']
|
| 177 |
+
|
| 178 |
+
# Event-aware scoring
|
| 179 |
+
event_score = base_score
|
| 180 |
+
if motion_score > self.config.motion_threshold:
|
| 181 |
+
event_score += motion_score * 0.5
|
| 182 |
+
if is_burst:
|
| 183 |
+
event_score *= self.config.burst_weight
|
| 184 |
+
|
| 185 |
+
scored_keyframes.append({
|
| 186 |
+
'keyframe_data': kf,
|
| 187 |
+
'event_score': event_score,
|
| 188 |
+
'timestamp': frame_data['timestamp'],
|
| 189 |
+
'is_event': True,
|
| 190 |
+
'segment_id': segment_id
|
| 191 |
+
})
|
| 192 |
+
|
| 193 |
+
# Select top keyframes from event segment
|
| 194 |
+
scored_keyframes.sort(key=lambda x: x['event_score'], reverse=True)
|
| 195 |
+
num_select = min(3, max(2, len([kf for kf in keyframes if kf['frame_data']['burst_active']])))
|
| 196 |
+
selected_keyframes.extend(scored_keyframes[:num_select])
|
| 197 |
+
|
| 198 |
+
else:
|
| 199 |
+
# Regular segment: select best keyframe
|
| 200 |
+
best_kf = max(keyframes, key=lambda x: x['keyframe_score'])
|
| 201 |
+
if best_kf['keyframe_score'] >= self.config.base_quality_threshold:
|
| 202 |
+
selected_keyframes.append({
|
| 203 |
+
'keyframe_data': best_kf,
|
| 204 |
+
'event_score': best_kf['keyframe_score'],
|
| 205 |
+
'timestamp': best_kf['frame_data']['timestamp'],
|
| 206 |
+
'is_event': False,
|
| 207 |
+
'segment_id': segment_id
|
| 208 |
+
})
|
| 209 |
+
|
| 210 |
+
# Sort by timestamp and limit
|
| 211 |
+
selected_keyframes.sort(key=lambda x: x['timestamp'])
|
| 212 |
+
|
| 213 |
+
if len(selected_keyframes) > self.config.max_summary_frames:
|
| 214 |
+
# Prioritize by event score
|
| 215 |
+
selected_keyframes.sort(key=lambda x: x['event_score'], reverse=True)
|
| 216 |
+
selected_keyframes = selected_keyframes[:self.config.max_summary_frames]
|
| 217 |
+
selected_keyframes.sort(key=lambda x: x['timestamp'])
|
| 218 |
+
|
| 219 |
+
return selected_keyframes
|
| 220 |
+
|
| 221 |
+
def _select_ultra_comprehensive_keyframes(self, segments: List) -> List[Dict]:
|
| 222 |
+
"""Select keyframes with ultra-comprehensive coverage"""
|
| 223 |
+
all_important_frames = []
|
| 224 |
+
|
| 225 |
+
# Ultra-low thresholds for comprehensive coverage
|
| 226 |
+
ultra_motion_threshold = self.config.motion_threshold * 0.5
|
| 227 |
+
ultra_quality_threshold = self.config.base_quality_threshold * 0.8
|
| 228 |
+
|
| 229 |
+
for segment in segments:
|
| 230 |
+
keyframes = segment.get('keyframes', [])
|
| 231 |
+
segment_id = segment.get('segment_id', 0)
|
| 232 |
+
|
| 233 |
+
for kf in keyframes:
|
| 234 |
+
frame_data = kf['frame_data']
|
| 235 |
+
base_score = kf['keyframe_score']
|
| 236 |
+
motion_score = frame_data['motion_score']
|
| 237 |
+
is_burst = frame_data['burst_active']
|
| 238 |
+
timestamp = frame_data['timestamp']
|
| 239 |
+
|
| 240 |
+
# Ultra-comprehensive scoring
|
| 241 |
+
importance = base_score
|
| 242 |
+
|
| 243 |
+
# Any motion is important
|
| 244 |
+
if motion_score > ultra_motion_threshold:
|
| 245 |
+
importance += motion_score * 1.0
|
| 246 |
+
elif motion_score > 0:
|
| 247 |
+
importance += motion_score * 0.5
|
| 248 |
+
|
| 249 |
+
# Burst frames are critical
|
| 250 |
+
if is_burst:
|
| 251 |
+
importance *= 3.0
|
| 252 |
+
|
| 253 |
+
# Quality bonus
|
| 254 |
+
if base_score > self.config.base_quality_threshold * 1.1:
|
| 255 |
+
importance += 0.1
|
| 256 |
+
|
| 257 |
+
# Include frame if it meets any importance criteria
|
| 258 |
+
include_frame = (
|
| 259 |
+
importance > 0.20 or
|
| 260 |
+
motion_score > ultra_motion_threshold or
|
| 261 |
+
is_burst or
|
| 262 |
+
base_score > ultra_quality_threshold
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
if include_frame:
|
| 266 |
+
all_important_frames.append({
|
| 267 |
+
'keyframe_data': kf,
|
| 268 |
+
'importance_score': importance,
|
| 269 |
+
'motion_score': motion_score,
|
| 270 |
+
'is_burst': is_burst,
|
| 271 |
+
'timestamp': timestamp,
|
| 272 |
+
'segment_id': segment_id
|
| 273 |
+
})
|
| 274 |
+
|
| 275 |
+
# Sort by importance and ensure temporal diversity
|
| 276 |
+
all_important_frames.sort(key=lambda x: x['importance_score'], reverse=True)
|
| 277 |
+
|
| 278 |
+
selected_frames = []
|
| 279 |
+
covered_timeframes = set()
|
| 280 |
+
|
| 281 |
+
for frame in all_important_frames:
|
| 282 |
+
timestamp = frame['timestamp']
|
| 283 |
+
timeframe = int(timestamp // 5) * 5 # 5-second bins
|
| 284 |
+
|
| 285 |
+
if timeframe not in covered_timeframes or len(selected_frames) < self.config.max_summary_frames:
|
| 286 |
+
selected_frames.append({
|
| 287 |
+
'keyframe_data': frame['keyframe_data'],
|
| 288 |
+
'event_score': frame['importance_score'],
|
| 289 |
+
'timestamp': timestamp,
|
| 290 |
+
'is_event': frame['is_burst'] or frame['motion_score'] > self.config.motion_threshold,
|
| 291 |
+
'segment_id': frame['segment_id']
|
| 292 |
+
})
|
| 293 |
+
covered_timeframes.add(timeframe)
|
| 294 |
+
|
| 295 |
+
if len(selected_frames) >= self.config.max_summary_frames:
|
| 296 |
+
break
|
| 297 |
+
|
| 298 |
+
# Sort by timestamp
|
| 299 |
+
selected_frames.sort(key=lambda x: x['timestamp'])
|
| 300 |
+
return selected_frames
|
| 301 |
+
|
| 302 |
+
def _select_quality_focused_keyframes(self, segments: List) -> List[Dict]:
|
| 303 |
+
"""Select keyframes focusing on quality"""
|
| 304 |
+
all_quality_frames = []
|
| 305 |
+
|
| 306 |
+
for segment in segments:
|
| 307 |
+
keyframes = segment.get('keyframes', [])
|
| 308 |
+
segment_id = segment.get('segment_id', 0)
|
| 309 |
+
|
| 310 |
+
for kf in keyframes:
|
| 311 |
+
frame_data = kf['frame_data']
|
| 312 |
+
quality_score = frame_data['quality_score']
|
| 313 |
+
|
| 314 |
+
# Only include high-quality frames
|
| 315 |
+
if quality_score >= self.config.base_quality_threshold * 1.2:
|
| 316 |
+
all_quality_frames.append({
|
| 317 |
+
'keyframe_data': kf,
|
| 318 |
+
'event_score': quality_score,
|
| 319 |
+
'timestamp': frame_data['timestamp'],
|
| 320 |
+
'is_event': False,
|
| 321 |
+
'segment_id': segment_id
|
| 322 |
+
})
|
| 323 |
+
|
| 324 |
+
# Sort by quality score and limit
|
| 325 |
+
all_quality_frames.sort(key=lambda x: x['event_score'], reverse=True)
|
| 326 |
+
|
| 327 |
+
# Ensure temporal diversity
|
| 328 |
+
selected_frames = []
|
| 329 |
+
last_timestamp = -float('inf')
|
| 330 |
+
min_gap = 3.0 # Minimum 3 seconds between frames
|
| 331 |
+
|
| 332 |
+
for frame in all_quality_frames:
|
| 333 |
+
if frame['timestamp'] - last_timestamp >= min_gap:
|
| 334 |
+
selected_frames.append(frame)
|
| 335 |
+
last_timestamp = frame['timestamp']
|
| 336 |
+
|
| 337 |
+
if len(selected_frames) >= self.config.max_summary_frames:
|
| 338 |
+
break
|
| 339 |
+
|
| 340 |
+
# Sort by timestamp
|
| 341 |
+
selected_frames.sort(key=lambda x: x['timestamp'])
|
| 342 |
+
return selected_frames
|
| 343 |
+
|
| 344 |
+
def _create_highlight_video(self, selected_keyframes: List[Dict], output_path: str,
|
| 345 |
+
title: str = "Highlight Reel") -> bool:
|
| 346 |
+
"""Create highlight video from selected keyframes"""
|
| 347 |
+
if not selected_keyframes:
|
| 348 |
+
logger.error("No keyframes selected for highlight reel")
|
| 349 |
+
return False
|
| 350 |
+
|
| 351 |
+
try:
|
| 352 |
+
# Read first frame to get dimensions
|
| 353 |
+
first_frame_path = selected_keyframes[0]['keyframe_data']['frame_data']['frame_path']
|
| 354 |
+
first_image = cv2.imread(first_frame_path)
|
| 355 |
+
|
| 356 |
+
if first_image is None:
|
| 357 |
+
logger.error(f"Cannot read first frame: {first_frame_path}")
|
| 358 |
+
return False
|
| 359 |
+
|
| 360 |
+
height, width = first_image.shape[:2]
|
| 361 |
+
|
| 362 |
+
# Set up video writer
|
| 363 |
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
| 364 |
+
fps = self.config.summary_fps
|
| 365 |
+
out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
| 366 |
+
|
| 367 |
+
if not out.isOpened():
|
| 368 |
+
logger.error("Cannot create video writer")
|
| 369 |
+
return False
|
| 370 |
+
|
| 371 |
+
# Add frames to video
|
| 372 |
+
frames_added = 0
|
| 373 |
+
logger.info(f"Creating {title} with {len(selected_keyframes)} frames")
|
| 374 |
+
|
| 375 |
+
for kf in selected_keyframes:
|
| 376 |
+
frame_path = kf['keyframe_data']['frame_data']['frame_path']
|
| 377 |
+
|
| 378 |
+
if os.path.exists(frame_path):
|
| 379 |
+
frame = cv2.imread(frame_path)
|
| 380 |
+
if frame is not None:
|
| 381 |
+
# Resize frame if needed
|
| 382 |
+
if frame.shape[:2] != (height, width):
|
| 383 |
+
frame = cv2.resize(frame, (width, height))
|
| 384 |
+
|
| 385 |
+
out.write(frame)
|
| 386 |
+
frames_added += 1
|
| 387 |
+
|
| 388 |
+
# Log frame info
|
| 389 |
+
timestamp = kf['timestamp']
|
| 390 |
+
mins = int(timestamp // 60)
|
| 391 |
+
secs = timestamp % 60
|
| 392 |
+
event_type = "EVENT" if kf['is_event'] else "QUALITY"
|
| 393 |
+
logger.debug(f"Added frame: {mins:02d}:{secs:04.1f} - {event_type}")
|
| 394 |
+
else:
|
| 395 |
+
logger.warning(f"Cannot read frame: {frame_path}")
|
| 396 |
+
else:
|
| 397 |
+
logger.warning(f"Frame not found: {frame_path}")
|
| 398 |
+
|
| 399 |
+
out.release()
|
| 400 |
+
|
| 401 |
+
# Verify output
|
| 402 |
+
if frames_added > 0 and os.path.exists(output_path):
|
| 403 |
+
file_size = os.path.getsize(output_path) / (1024*1024)
|
| 404 |
+
duration = frames_added / fps
|
| 405 |
+
|
| 406 |
+
logger.info(f"✅ {title} created successfully!")
|
| 407 |
+
logger.info(f"📁 Path: {output_path}")
|
| 408 |
+
logger.info(f"📊 {frames_added} frames, {duration:.1f}s duration, {file_size:.1f} MB")
|
| 409 |
+
|
| 410 |
+
return True
|
| 411 |
+
else:
|
| 412 |
+
logger.error("Failed to create video file")
|
| 413 |
+
return False
|
| 414 |
+
|
| 415 |
+
except Exception as e:
|
| 416 |
+
logger.error(f"Error creating highlight video: {e}")
|
| 417 |
+
return False
|
| 418 |
+
|
| 419 |
+
def create_custom_highlight_reel(self, segments: List, selection_criteria: Dict[str, Any]) -> str:
|
| 420 |
+
"""
|
| 421 |
+
Create custom highlight reel based on specific criteria
|
| 422 |
+
|
| 423 |
+
Args:
|
| 424 |
+
segments: List of video segments
|
| 425 |
+
selection_criteria: Custom criteria for frame selection
|
| 426 |
+
|
| 427 |
+
Returns:
|
| 428 |
+
Path to generated highlight reel
|
| 429 |
+
"""
|
| 430 |
+
logger.info(f"Creating custom highlight reel with criteria: {selection_criteria}")
|
| 431 |
+
|
| 432 |
+
output_path = os.path.join(self.highlights_dir, "custom_highlights.mp4")
|
| 433 |
+
|
| 434 |
+
# Apply custom selection
|
| 435 |
+
selected_keyframes = self._apply_custom_selection(segments, selection_criteria)
|
| 436 |
+
|
| 437 |
+
# Create video
|
| 438 |
+
success = self._create_highlight_video(
|
| 439 |
+
selected_keyframes,
|
| 440 |
+
output_path,
|
| 441 |
+
"Custom Highlights"
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
if success:
|
| 445 |
+
logger.info(f"Custom highlight reel created: {output_path}")
|
| 446 |
+
return output_path
|
| 447 |
+
else:
|
| 448 |
+
logger.error("Failed to create custom highlight reel")
|
| 449 |
+
return ""
|
| 450 |
+
|
| 451 |
+
def _apply_custom_selection(self, segments: List, criteria: Dict[str, Any]) -> List[Dict]:
|
| 452 |
+
"""Apply custom selection criteria"""
|
| 453 |
+
selected_keyframes = []
|
| 454 |
+
|
| 455 |
+
# Extract criteria
|
| 456 |
+
min_motion = criteria.get('min_motion_score', 0.0)
|
| 457 |
+
min_quality = criteria.get('min_quality_score', self.config.base_quality_threshold)
|
| 458 |
+
require_burst = criteria.get('require_burst', False)
|
| 459 |
+
max_frames = criteria.get('max_frames', self.config.max_summary_frames)
|
| 460 |
+
time_range = criteria.get('time_range', None) # (start, end) tuple
|
| 461 |
+
|
| 462 |
+
for segment in segments:
|
| 463 |
+
keyframes = segment.get('keyframes', [])
|
| 464 |
+
|
| 465 |
+
for kf in keyframes:
|
| 466 |
+
frame_data = kf['frame_data']
|
| 467 |
+
timestamp = frame_data['timestamp']
|
| 468 |
+
motion_score = frame_data['motion_score']
|
| 469 |
+
quality_score = frame_data['quality_score']
|
| 470 |
+
is_burst = frame_data['burst_active']
|
| 471 |
+
|
| 472 |
+
# Apply criteria
|
| 473 |
+
meets_criteria = True
|
| 474 |
+
|
| 475 |
+
if motion_score < min_motion:
|
| 476 |
+
meets_criteria = False
|
| 477 |
+
|
| 478 |
+
if quality_score < min_quality:
|
| 479 |
+
meets_criteria = False
|
| 480 |
+
|
| 481 |
+
if require_burst and not is_burst:
|
| 482 |
+
meets_criteria = False
|
| 483 |
+
|
| 484 |
+
if time_range:
|
| 485 |
+
start_time, end_time = time_range
|
| 486 |
+
if not (start_time <= timestamp <= end_time):
|
| 487 |
+
meets_criteria = False
|
| 488 |
+
|
| 489 |
+
if meets_criteria:
|
| 490 |
+
selected_keyframes.append({
|
| 491 |
+
'keyframe_data': kf,
|
| 492 |
+
'event_score': kf['keyframe_score'],
|
| 493 |
+
'timestamp': timestamp,
|
| 494 |
+
'is_event': is_burst or motion_score > self.config.motion_threshold,
|
| 495 |
+
'segment_id': segment.get('segment_id', 0)
|
| 496 |
+
})
|
| 497 |
+
|
| 498 |
+
# Sort and limit
|
| 499 |
+
selected_keyframes.sort(key=lambda x: x['event_score'], reverse=True)
|
| 500 |
+
selected_keyframes = selected_keyframes[:max_frames]
|
| 501 |
+
selected_keyframes.sort(key=lambda x: x['timestamp'])
|
| 502 |
+
|
| 503 |
+
return selected_keyframes
|
| 504 |
+
|
| 505 |
+
def generate_highlight_reel_metadata(self, selected_keyframes: List[Dict],
|
| 506 |
+
output_path: str) -> bool:
|
| 507 |
+
"""Generate metadata file for highlight reel"""
|
| 508 |
+
try:
|
| 509 |
+
metadata = {
|
| 510 |
+
'generation_info': {
|
| 511 |
+
'timestamp': datetime.now().isoformat(),
|
| 512 |
+
'total_frames': len(selected_keyframes),
|
| 513 |
+
'selection_config': {
|
| 514 |
+
'max_summary_frames': self.config.max_summary_frames,
|
| 515 |
+
'summary_fps': self.config.summary_fps,
|
| 516 |
+
'motion_threshold': self.config.motion_threshold,
|
| 517 |
+
'quality_threshold': self.config.base_quality_threshold
|
| 518 |
+
}
|
| 519 |
+
},
|
| 520 |
+
'frame_details': []
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
for i, kf in enumerate(selected_keyframes):
|
| 524 |
+
frame_detail = {
|
| 525 |
+
'sequence_number': i + 1,
|
| 526 |
+
'timestamp': kf['timestamp'],
|
| 527 |
+
'is_event_frame': kf['is_event'],
|
| 528 |
+
'segment_id': kf['segment_id'],
|
| 529 |
+
'event_score': kf['event_score'],
|
| 530 |
+
'frame_path': kf['keyframe_data']['frame_data']['frame_path']
|
| 531 |
+
}
|
| 532 |
+
metadata['frame_details'].append(frame_detail)
|
| 533 |
+
|
| 534 |
+
with open(output_path, 'w') as f:
|
| 535 |
+
json.dump(metadata, f, indent=2)
|
| 536 |
+
|
| 537 |
+
logger.info(f"Highlight reel metadata saved: {output_path}")
|
| 538 |
+
return True
|
| 539 |
+
|
| 540 |
+
except Exception as e:
|
| 541 |
+
logger.error(f"Failed to save highlight reel metadata: {e}")
|
| 542 |
+
return False
|
json_reports.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
JSON Reports Generation Module
|
| 3 |
+
|
| 4 |
+
This module handles:
|
| 5 |
+
- Processing results JSON reports
|
| 6 |
+
- Canonical events JSON
|
| 7 |
+
- Segment analysis reports
|
| 8 |
+
- Performance statistics
|
| 9 |
+
- HTML gallery generation
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import cv2
|
| 15 |
+
import base64
|
| 16 |
+
from typing import Dict, List, Any, Optional
|
| 17 |
+
from datetime import datetime
|
| 18 |
+
import logging
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
class ReportGenerator:
|
| 23 |
+
"""Generate comprehensive JSON reports and HTML galleries"""
|
| 24 |
+
|
| 25 |
+
def __init__(self, config):
|
| 26 |
+
self.config = config
|
| 27 |
+
self.reports_dir = os.path.join(config.output_base_dir, "reports")
|
| 28 |
+
os.makedirs(self.reports_dir, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
def generate_processing_results_report(self,
|
| 31 |
+
keyframes: List,
|
| 32 |
+
events: List,
|
| 33 |
+
canonical_events: List,
|
| 34 |
+
segments: List,
|
| 35 |
+
processing_stats: Dict[str, Any]) -> str:
|
| 36 |
+
"""Generate comprehensive processing results report"""
|
| 37 |
+
|
| 38 |
+
logger.info("Generating processing results report")
|
| 39 |
+
|
| 40 |
+
report = {
|
| 41 |
+
'metadata': {
|
| 42 |
+
'generation_timestamp': datetime.now().isoformat(),
|
| 43 |
+
'report_version': '1.0',
|
| 44 |
+
'processing_config': self._get_config_summary()
|
| 45 |
+
},
|
| 46 |
+
'summary': {
|
| 47 |
+
'total_keyframes_extracted': len(keyframes),
|
| 48 |
+
'total_events_detected': len(events),
|
| 49 |
+
'canonical_events_created': len(canonical_events),
|
| 50 |
+
'video_segments_created': len(segments),
|
| 51 |
+
'processing_duration': processing_stats.get('total_processing_time', 0)
|
| 52 |
+
},
|
| 53 |
+
'keyframe_analysis': self._analyze_keyframes(keyframes),
|
| 54 |
+
'event_analysis': self._analyze_events(events),
|
| 55 |
+
'canonical_event_analysis': self._analyze_canonical_events(canonical_events),
|
| 56 |
+
'segment_analysis': self._analyze_segments(segments),
|
| 57 |
+
'performance_statistics': processing_stats,
|
| 58 |
+
'quality_metrics': self._calculate_quality_metrics(keyframes, events)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
# Save report
|
| 62 |
+
output_path = os.path.join(self.reports_dir, "processing_results.json")
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
with open(output_path, 'w') as f:
|
| 66 |
+
json.dump(report, f, indent=2)
|
| 67 |
+
|
| 68 |
+
logger.info(f"Processing results report saved: {output_path}")
|
| 69 |
+
return output_path
|
| 70 |
+
|
| 71 |
+
except Exception as e:
|
| 72 |
+
logger.error(f"Failed to save processing results report: {e}")
|
| 73 |
+
return ""
|
| 74 |
+
|
| 75 |
+
def generate_canonical_events_report(self, canonical_events: List) -> str:
|
| 76 |
+
"""Generate canonical events JSON report"""
|
| 77 |
+
|
| 78 |
+
logger.info("Generating canonical events report")
|
| 79 |
+
|
| 80 |
+
report = {
|
| 81 |
+
'metadata': {
|
| 82 |
+
'generation_timestamp': datetime.now().isoformat(),
|
| 83 |
+
'total_canonical_events': len(canonical_events),
|
| 84 |
+
'deduplication_threshold': self.config.similarity_threshold
|
| 85 |
+
},
|
| 86 |
+
'canonical_events': []
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
for event in canonical_events:
|
| 90 |
+
event_data = {
|
| 91 |
+
'canonical_id': event.canonical_id,
|
| 92 |
+
'event_type': event.event_type,
|
| 93 |
+
'representative_frame': event.representative_frame,
|
| 94 |
+
'time_range': {
|
| 95 |
+
'start_time': event.start_time,
|
| 96 |
+
'end_time': event.end_time,
|
| 97 |
+
'duration': event.duration
|
| 98 |
+
},
|
| 99 |
+
'confidence': event.confidence,
|
| 100 |
+
'frame_count': event.frame_count,
|
| 101 |
+
'aggregated_events': event.aggregated_events,
|
| 102 |
+
'description': event.description,
|
| 103 |
+
'similarity_cluster': event.similarity_cluster
|
| 104 |
+
}
|
| 105 |
+
report['canonical_events'].append(event_data)
|
| 106 |
+
|
| 107 |
+
# Save report
|
| 108 |
+
output_path = os.path.join(self.reports_dir, "canonical_events.json")
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
with open(output_path, 'w') as f:
|
| 112 |
+
json.dump(report, f, indent=2)
|
| 113 |
+
|
| 114 |
+
logger.info(f"Canonical events report saved: {output_path}")
|
| 115 |
+
return output_path
|
| 116 |
+
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.error(f"Failed to save canonical events report: {e}")
|
| 119 |
+
return ""
|
| 120 |
+
|
| 121 |
+
def generate_segments_report(self, segments: List) -> str:
|
| 122 |
+
"""Generate video segments analysis report"""
|
| 123 |
+
|
| 124 |
+
logger.info("Generating video segments report")
|
| 125 |
+
|
| 126 |
+
report = {
|
| 127 |
+
'metadata': {
|
| 128 |
+
'generation_timestamp': datetime.now().isoformat(),
|
| 129 |
+
'total_segments': len(segments),
|
| 130 |
+
'segment_duration': self.config.segment_duration,
|
| 131 |
+
'keyframes_per_segment': self.config.keyframes_per_segment
|
| 132 |
+
},
|
| 133 |
+
'summary_statistics': self._get_segments_summary(segments),
|
| 134 |
+
'segments': []
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
for segment in segments:
|
| 138 |
+
segment_data = {
|
| 139 |
+
'segment_id': segment.segment_id,
|
| 140 |
+
'time_range': {
|
| 141 |
+
'start_timestamp': segment.start_timestamp,
|
| 142 |
+
'end_timestamp': segment.end_timestamp,
|
| 143 |
+
'duration': segment.duration
|
| 144 |
+
},
|
| 145 |
+
'frame_range': {
|
| 146 |
+
'start_frame': segment.start_frame,
|
| 147 |
+
'end_frame': segment.end_frame
|
| 148 |
+
},
|
| 149 |
+
'segment_classification': {
|
| 150 |
+
'segment_type': segment.segment_type,
|
| 151 |
+
'activity_level': segment.activity_level
|
| 152 |
+
},
|
| 153 |
+
'statistics': {
|
| 154 |
+
'motion_statistics': segment.motion_statistics,
|
| 155 |
+
'quality_statistics': segment.quality_statistics,
|
| 156 |
+
'keyframe_count': len(segment.keyframes)
|
| 157 |
+
},
|
| 158 |
+
'keyframes': segment.keyframes
|
| 159 |
+
}
|
| 160 |
+
report['segments'].append(segment_data)
|
| 161 |
+
|
| 162 |
+
# Save report
|
| 163 |
+
output_path = os.path.join(self.reports_dir, "video_segments.json")
|
| 164 |
+
|
| 165 |
+
try:
|
| 166 |
+
with open(output_path, 'w') as f:
|
| 167 |
+
json.dump(report, f, indent=2)
|
| 168 |
+
|
| 169 |
+
logger.info(f"Video segments report saved: {output_path}")
|
| 170 |
+
return output_path
|
| 171 |
+
|
| 172 |
+
except Exception as e:
|
| 173 |
+
logger.error(f"Failed to save video segments report: {e}")
|
| 174 |
+
return ""
|
| 175 |
+
|
| 176 |
+
def generate_html_gallery(self, keyframes: List, canonical_events: List = None,
|
| 177 |
+
segments: List = None, title: str = "Video Processing Gallery") -> str:
|
| 178 |
+
"""Generate interactive HTML gallery of keyframes and events"""
|
| 179 |
+
|
| 180 |
+
logger.info("Generating HTML gallery")
|
| 181 |
+
|
| 182 |
+
html_content = self._create_html_gallery(keyframes, canonical_events, segments, title)
|
| 183 |
+
|
| 184 |
+
# Save HTML gallery
|
| 185 |
+
output_path = os.path.join(self.reports_dir, "canonical_gallery.html")
|
| 186 |
+
|
| 187 |
+
try:
|
| 188 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
| 189 |
+
f.write(html_content)
|
| 190 |
+
|
| 191 |
+
logger.info(f"HTML gallery saved: {output_path}")
|
| 192 |
+
return output_path
|
| 193 |
+
|
| 194 |
+
except Exception as e:
|
| 195 |
+
logger.error(f"Failed to save HTML gallery: {e}")
|
| 196 |
+
return ""
|
| 197 |
+
|
| 198 |
+
def _get_config_summary(self) -> Dict[str, Any]:
|
| 199 |
+
"""Get summary of configuration settings"""
|
| 200 |
+
return {
|
| 201 |
+
'base_quality_threshold': self.config.base_quality_threshold,
|
| 202 |
+
'motion_threshold': self.config.motion_threshold,
|
| 203 |
+
'event_importance_threshold': self.config.event_importance_threshold,
|
| 204 |
+
'similarity_threshold': self.config.similarity_threshold,
|
| 205 |
+
'segment_duration': self.config.segment_duration,
|
| 206 |
+
'max_summary_frames': self.config.max_summary_frames,
|
| 207 |
+
'output_resolution': self.config.output_resolution,
|
| 208 |
+
'enable_clahe': self.config.enable_clahe,
|
| 209 |
+
'enable_denoising': self.config.enable_denoising
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
def _analyze_keyframes(self, keyframes: List) -> Dict[str, Any]:
|
| 213 |
+
"""Analyze keyframe extraction results"""
|
| 214 |
+
if not keyframes:
|
| 215 |
+
return {}
|
| 216 |
+
|
| 217 |
+
# Extract metrics
|
| 218 |
+
quality_scores = [kf.frame_data.quality_score for kf in keyframes]
|
| 219 |
+
motion_scores = [kf.frame_data.motion_score for kf in keyframes]
|
| 220 |
+
selection_reasons = [kf.selection_reason for kf in keyframes]
|
| 221 |
+
burst_frames = [kf for kf in keyframes if kf.frame_data.burst_active]
|
| 222 |
+
enhanced_frames = [kf for kf in keyframes if kf.frame_data.enhancement_applied]
|
| 223 |
+
|
| 224 |
+
# Count selection reasons
|
| 225 |
+
reason_counts = {}
|
| 226 |
+
for reason in selection_reasons:
|
| 227 |
+
reason_counts[reason] = reason_counts.get(reason, 0) + 1
|
| 228 |
+
|
| 229 |
+
# Calculate statistics
|
| 230 |
+
analysis = {
|
| 231 |
+
'total_keyframes': len(keyframes),
|
| 232 |
+
'quality_statistics': {
|
| 233 |
+
'min': float(min(quality_scores)),
|
| 234 |
+
'max': float(max(quality_scores)),
|
| 235 |
+
'mean': float(sum(quality_scores) / len(quality_scores)),
|
| 236 |
+
'std': float(np.std(quality_scores))
|
| 237 |
+
},
|
| 238 |
+
'motion_statistics': {
|
| 239 |
+
'min': float(min(motion_scores)),
|
| 240 |
+
'max': float(max(motion_scores)),
|
| 241 |
+
'mean': float(sum(motion_scores) / len(motion_scores)),
|
| 242 |
+
'std': float(np.std(motion_scores))
|
| 243 |
+
},
|
| 244 |
+
'selection_reason_distribution': reason_counts,
|
| 245 |
+
'burst_frames_count': len(burst_frames),
|
| 246 |
+
'enhanced_frames_count': len(enhanced_frames),
|
| 247 |
+
'enhancement_rate': len(enhanced_frames) / len(keyframes) * 100
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
return analysis
|
| 251 |
+
|
| 252 |
+
def _analyze_events(self, events: List) -> Dict[str, Any]:
|
| 253 |
+
"""Analyze detected events"""
|
| 254 |
+
if not events:
|
| 255 |
+
return {}
|
| 256 |
+
|
| 257 |
+
# Event type distribution
|
| 258 |
+
event_types = [event.event_type for event in events]
|
| 259 |
+
type_counts = {}
|
| 260 |
+
for event_type in event_types:
|
| 261 |
+
type_counts[event_type] = type_counts.get(event_type, 0) + 1
|
| 262 |
+
|
| 263 |
+
# Confidence statistics
|
| 264 |
+
confidences = [event.confidence for event in events]
|
| 265 |
+
importance_scores = [event.importance_score for event in events]
|
| 266 |
+
durations = [event.end_timestamp - event.start_timestamp for event in events]
|
| 267 |
+
|
| 268 |
+
analysis = {
|
| 269 |
+
'total_events': len(events),
|
| 270 |
+
'event_type_distribution': type_counts,
|
| 271 |
+
'confidence_statistics': {
|
| 272 |
+
'min': float(min(confidences)),
|
| 273 |
+
'max': float(max(confidences)),
|
| 274 |
+
'mean': float(sum(confidences) / len(confidences))
|
| 275 |
+
},
|
| 276 |
+
'importance_statistics': {
|
| 277 |
+
'min': float(min(importance_scores)),
|
| 278 |
+
'max': float(max(importance_scores)),
|
| 279 |
+
'mean': float(sum(importance_scores) / len(importance_scores))
|
| 280 |
+
},
|
| 281 |
+
'duration_statistics': {
|
| 282 |
+
'min': float(min(durations)),
|
| 283 |
+
'max': float(max(durations)),
|
| 284 |
+
'mean': float(sum(durations) / len(durations))
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
return analysis
|
| 289 |
+
|
| 290 |
+
def _analyze_canonical_events(self, canonical_events: List) -> Dict[str, Any]:
|
| 291 |
+
"""Analyze canonical events"""
|
| 292 |
+
if not canonical_events:
|
| 293 |
+
return {}
|
| 294 |
+
|
| 295 |
+
# Type distribution
|
| 296 |
+
event_types = [event.event_type for event in canonical_events]
|
| 297 |
+
type_counts = {}
|
| 298 |
+
for event_type in event_types:
|
| 299 |
+
type_counts[event_type] = type_counts.get(event_type, 0) + 1
|
| 300 |
+
|
| 301 |
+
# Statistics
|
| 302 |
+
durations = [event.duration for event in canonical_events]
|
| 303 |
+
frame_counts = [event.frame_count for event in canonical_events]
|
| 304 |
+
confidences = [event.confidence for event in canonical_events]
|
| 305 |
+
|
| 306 |
+
analysis = {
|
| 307 |
+
'total_canonical_events': len(canonical_events),
|
| 308 |
+
'event_type_distribution': type_counts,
|
| 309 |
+
'duration_statistics': {
|
| 310 |
+
'min': float(min(durations)),
|
| 311 |
+
'max': float(max(durations)),
|
| 312 |
+
'mean': float(sum(durations) / len(durations))
|
| 313 |
+
},
|
| 314 |
+
'frame_count_statistics': {
|
| 315 |
+
'min': int(min(frame_counts)),
|
| 316 |
+
'max': int(max(frame_counts)),
|
| 317 |
+
'mean': float(sum(frame_counts) / len(frame_counts))
|
| 318 |
+
},
|
| 319 |
+
'confidence_statistics': {
|
| 320 |
+
'min': float(min(confidences)),
|
| 321 |
+
'max': float(max(confidences)),
|
| 322 |
+
'mean': float(sum(confidences) / len(confidences))
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
return analysis
|
| 327 |
+
|
| 328 |
+
def _analyze_segments(self, segments: List) -> Dict[str, Any]:
|
| 329 |
+
"""Analyze video segments"""
|
| 330 |
+
if not segments:
|
| 331 |
+
return {}
|
| 332 |
+
|
| 333 |
+
# Type and activity distribution
|
| 334 |
+
segment_types = [seg.segment_type for seg in segments]
|
| 335 |
+
activity_levels = [seg.activity_level for seg in segments]
|
| 336 |
+
|
| 337 |
+
type_counts = {}
|
| 338 |
+
for seg_type in segment_types:
|
| 339 |
+
type_counts[seg_type] = type_counts.get(seg_type, 0) + 1
|
| 340 |
+
|
| 341 |
+
activity_counts = {}
|
| 342 |
+
for activity in activity_levels:
|
| 343 |
+
activity_counts[activity] = activity_counts.get(activity, 0) + 1
|
| 344 |
+
|
| 345 |
+
analysis = {
|
| 346 |
+
'total_segments': len(segments),
|
| 347 |
+
'segment_type_distribution': type_counts,
|
| 348 |
+
'activity_level_distribution': activity_counts,
|
| 349 |
+
'average_segment_duration': float(sum(seg.duration for seg in segments) / len(segments)),
|
| 350 |
+
'total_keyframes': sum(len(seg.keyframes) for seg in segments)
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
return analysis
|
| 354 |
+
|
| 355 |
+
def _calculate_quality_metrics(self, keyframes: List, events: List) -> Dict[str, Any]:
|
| 356 |
+
"""Calculate overall quality metrics"""
|
| 357 |
+
if not keyframes:
|
| 358 |
+
return {}
|
| 359 |
+
|
| 360 |
+
# Coverage metrics
|
| 361 |
+
total_frames_extracted = len(keyframes)
|
| 362 |
+
burst_frames = len([kf for kf in keyframes if kf.frame_data.burst_active])
|
| 363 |
+
high_quality_frames = len([kf for kf in keyframes if kf.frame_data.quality_score > self.config.base_quality_threshold * 1.2])
|
| 364 |
+
high_motion_frames = len([kf for kf in keyframes if kf.frame_data.motion_score > self.config.motion_threshold])
|
| 365 |
+
|
| 366 |
+
# Event coverage
|
| 367 |
+
event_coverage = len(events) / total_frames_extracted if total_frames_extracted > 0 else 0
|
| 368 |
+
|
| 369 |
+
metrics = {
|
| 370 |
+
'frame_extraction_efficiency': {
|
| 371 |
+
'total_frames_extracted': total_frames_extracted,
|
| 372 |
+
'burst_frame_rate': burst_frames / total_frames_extracted * 100,
|
| 373 |
+
'high_quality_frame_rate': high_quality_frames / total_frames_extracted * 100,
|
| 374 |
+
'high_motion_frame_rate': high_motion_frames / total_frames_extracted * 100
|
| 375 |
+
},
|
| 376 |
+
'event_detection_efficiency': {
|
| 377 |
+
'events_per_keyframe': event_coverage,
|
| 378 |
+
'total_events_detected': len(events)
|
| 379 |
+
},
|
| 380 |
+
'processing_quality_score': self._calculate_overall_quality_score(keyframes, events)
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
return metrics
|
| 384 |
+
|
| 385 |
+
def _calculate_overall_quality_score(self, keyframes: List, events: List) -> float:
|
| 386 |
+
"""Calculate overall processing quality score (0-100)"""
|
| 387 |
+
if not keyframes:
|
| 388 |
+
return 0.0
|
| 389 |
+
|
| 390 |
+
# Component scores
|
| 391 |
+
avg_quality = sum(kf.frame_data.quality_score for kf in keyframes) / len(keyframes)
|
| 392 |
+
avg_motion = sum(kf.frame_data.motion_score for kf in keyframes) / len(keyframes)
|
| 393 |
+
burst_rate = len([kf for kf in keyframes if kf.frame_data.burst_active]) / len(keyframes)
|
| 394 |
+
event_rate = len(events) / len(keyframes) if len(keyframes) > 0 else 0
|
| 395 |
+
|
| 396 |
+
# Weighted combination
|
| 397 |
+
quality_score = (
|
| 398 |
+
avg_quality * 40 + # 40% weight on frame quality
|
| 399 |
+
avg_motion * 30 + # 30% weight on motion detection
|
| 400 |
+
burst_rate * 20 + # 20% weight on burst detection
|
| 401 |
+
event_rate * 10 # 10% weight on event detection
|
| 402 |
+
) * 100
|
| 403 |
+
|
| 404 |
+
return min(100.0, quality_score)
|
| 405 |
+
|
| 406 |
+
def _get_segments_summary(self, segments: List) -> Dict[str, Any]:
|
| 407 |
+
"""Get summary statistics for segments"""
|
| 408 |
+
if not segments:
|
| 409 |
+
return {}
|
| 410 |
+
|
| 411 |
+
# Activity level distribution
|
| 412 |
+
activity_levels = [seg.activity_level for seg in segments]
|
| 413 |
+
activity_counts = {}
|
| 414 |
+
for level in activity_levels:
|
| 415 |
+
activity_counts[level] = activity_counts.get(level, 0) + 1
|
| 416 |
+
|
| 417 |
+
# Segment type distribution
|
| 418 |
+
segment_types = [seg.segment_type for seg in segments]
|
| 419 |
+
type_counts = {}
|
| 420 |
+
for seg_type in segment_types:
|
| 421 |
+
type_counts[seg_type] = type_counts.get(seg_type, 0) + 1
|
| 422 |
+
|
| 423 |
+
return {
|
| 424 |
+
'total_segments': len(segments),
|
| 425 |
+
'activity_level_distribution': activity_counts,
|
| 426 |
+
'segment_type_distribution': type_counts
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
def _create_html_gallery(self, keyframes: List, canonical_events: List = None,
|
| 430 |
+
segments: List = None, title: str = "Video Processing Gallery") -> str:
|
| 431 |
+
"""Create HTML gallery content"""
|
| 432 |
+
|
| 433 |
+
html_template = f"""
|
| 434 |
+
<!DOCTYPE html>
|
| 435 |
+
<html lang="en">
|
| 436 |
+
<head>
|
| 437 |
+
<meta charset="UTF-8">
|
| 438 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 439 |
+
<title>{title}</title>
|
| 440 |
+
<style>
|
| 441 |
+
body {{ font-family: Arial, sans-serif; margin: 20px; background-color: #f5f5f5; }}
|
| 442 |
+
.header {{ text-align: center; margin-bottom: 30px; }}
|
| 443 |
+
.stats {{ display: flex; justify-content: space-around; margin-bottom: 30px; }}
|
| 444 |
+
.stat-card {{ background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
|
| 445 |
+
.gallery {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }}
|
| 446 |
+
.frame-card {{ background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
|
| 447 |
+
.frame-image {{ width: 100%; height: 200px; object-fit: cover; }}
|
| 448 |
+
.frame-info {{ padding: 15px; }}
|
| 449 |
+
.frame-info h3 {{ margin: 0 0 10px 0; color: #333; }}
|
| 450 |
+
.frame-info p {{ margin: 5px 0; color: #666; font-size: 14px; }}
|
| 451 |
+
.event-badge {{ display: inline-block; padding: 3px 8px; border-radius: 12px; font-size: 12px; color: white; margin-right: 5px; }}
|
| 452 |
+
.burst-activity {{ background-color: #e74c3c; }}
|
| 453 |
+
.high-motion {{ background-color: #f39c12; }}
|
| 454 |
+
.high-quality {{ background-color: #27ae60; }}
|
| 455 |
+
.context-frame {{ background-color: #3498db; }}
|
| 456 |
+
.timestamp {{ font-weight: bold; color: #2c3e50; }}
|
| 457 |
+
.score {{ color: #8e44ad; font-weight: bold; }}
|
| 458 |
+
</style>
|
| 459 |
+
</head>
|
| 460 |
+
<body>
|
| 461 |
+
<div class="header">
|
| 462 |
+
<h1>{title}</h1>
|
| 463 |
+
<p>Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
| 464 |
+
</div>
|
| 465 |
+
|
| 466 |
+
<div class="stats">
|
| 467 |
+
<div class="stat-card">
|
| 468 |
+
<h3>Keyframes</h3>
|
| 469 |
+
<p>{len(keyframes)} extracted</p>
|
| 470 |
+
</div>
|
| 471 |
+
<div class="stat-card">
|
| 472 |
+
<h3>Events</h3>
|
| 473 |
+
<p>{len(canonical_events) if canonical_events else 0} canonical</p>
|
| 474 |
+
</div>
|
| 475 |
+
<div class="stat-card">
|
| 476 |
+
<h3>Segments</h3>
|
| 477 |
+
<p>{len(segments) if segments else 0} temporal</p>
|
| 478 |
+
</div>
|
| 479 |
+
</div>
|
| 480 |
+
|
| 481 |
+
<div class="gallery">
|
| 482 |
+
"""
|
| 483 |
+
|
| 484 |
+
# Add keyframes to gallery
|
| 485 |
+
for i, kf in enumerate(keyframes[:50]): # Limit to first 50 for performance
|
| 486 |
+
try:
|
| 487 |
+
frame_path = kf.frame_data.frame_path
|
| 488 |
+
|
| 489 |
+
# Convert image to base64 for embedding
|
| 490 |
+
image_data = ""
|
| 491 |
+
if os.path.exists(frame_path):
|
| 492 |
+
try:
|
| 493 |
+
with open(frame_path, 'rb') as img_file:
|
| 494 |
+
image_data = base64.b64encode(img_file.read()).decode('utf-8')
|
| 495 |
+
except Exception as e:
|
| 496 |
+
logger.warning(f"Could not encode image {frame_path}: {e}")
|
| 497 |
+
|
| 498 |
+
# Format timestamp
|
| 499 |
+
timestamp = kf.frame_data.timestamp
|
| 500 |
+
mins = int(timestamp // 60)
|
| 501 |
+
secs = timestamp % 60
|
| 502 |
+
time_str = f"{mins:02d}:{secs:04.1f}"
|
| 503 |
+
|
| 504 |
+
# Determine badge class
|
| 505 |
+
badge_class = "context-frame"
|
| 506 |
+
if kf.frame_data.burst_active:
|
| 507 |
+
badge_class = "burst-activity"
|
| 508 |
+
elif kf.frame_data.motion_score > self.config.motion_threshold:
|
| 509 |
+
badge_class = "high-motion"
|
| 510 |
+
elif kf.frame_data.quality_score > self.config.base_quality_threshold * 1.2:
|
| 511 |
+
badge_class = "high-quality"
|
| 512 |
+
|
| 513 |
+
html_template += f"""
|
| 514 |
+
<div class="frame-card">
|
| 515 |
+
{"<img class='frame-image' src='data:image/jpeg;base64," + image_data + "' alt='Keyframe " + str(i+1) + "'>" if image_data else "<div class='frame-image' style='background-color: #ddd; display: flex; align-items: center; justify-content: center;'>Image not available</div>"}
|
| 516 |
+
<div class="frame-info">
|
| 517 |
+
<h3>Frame {i+1}</h3>
|
| 518 |
+
<p><span class="timestamp">Time: {time_str}</span></p>
|
| 519 |
+
<p>Quality: <span class="score">{kf.frame_data.quality_score:.3f}</span></p>
|
| 520 |
+
<p>Motion: <span class="score">{kf.frame_data.motion_score:.4f}</span></p>
|
| 521 |
+
<p>Keyframe Score: <span class="score">{kf.keyframe_score:.3f}</span></p>
|
| 522 |
+
<p><span class="event-badge {badge_class}">{kf.selection_reason}</span></p>
|
| 523 |
+
{"<p>✨ Enhanced</p>" if kf.frame_data.enhancement_applied else ""}
|
| 524 |
+
</div>
|
| 525 |
+
</div>
|
| 526 |
+
"""
|
| 527 |
+
|
| 528 |
+
except Exception as e:
|
| 529 |
+
logger.warning(f"Error processing keyframe {i}: {e}")
|
| 530 |
+
|
| 531 |
+
html_template += """
|
| 532 |
+
</div>
|
| 533 |
+
</body>
|
| 534 |
+
</html>
|
| 535 |
+
"""
|
| 536 |
+
|
| 537 |
+
return html_template
|
| 538 |
+
|
| 539 |
+
def generate_captioning_report(self, captioning_results: Dict[str, Any], statistics: Dict[str, Any]) -> str:
|
| 540 |
+
"""Generate video captioning results report"""
|
| 541 |
+
|
| 542 |
+
logger.info("Generating video captioning report")
|
| 543 |
+
|
| 544 |
+
report = {
|
| 545 |
+
'metadata': {
|
| 546 |
+
'generation_timestamp': datetime.now().isoformat(),
|
| 547 |
+
'report_version': '1.0'
|
| 548 |
+
},
|
| 549 |
+
'summary': {
|
| 550 |
+
'captioning_enabled': captioning_results.get('enabled', False),
|
| 551 |
+
'total_captions_generated': captioning_results.get('total_captions', 0),
|
| 552 |
+
'processing_time': captioning_results.get('processing_time', 0),
|
| 553 |
+
'errors_count': len(captioning_results.get('errors', []))
|
| 554 |
+
},
|
| 555 |
+
'statistics': statistics,
|
| 556 |
+
'captions': captioning_results.get('captions', []),
|
| 557 |
+
'errors': captioning_results.get('errors', [])
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
# Save report
|
| 561 |
+
output_path = os.path.join(self.reports_dir, "video_captioning.json")
|
| 562 |
+
|
| 563 |
+
try:
|
| 564 |
+
with open(output_path, 'w') as f:
|
| 565 |
+
json.dump(report, f, indent=2)
|
| 566 |
+
|
| 567 |
+
logger.info(f"Video captioning report saved: {output_path}")
|
| 568 |
+
return output_path
|
| 569 |
+
|
| 570 |
+
except Exception as e:
|
| 571 |
+
logger.error(f"Failed to save video captioning report: {e}")
|
| 572 |
+
return ""
|
| 573 |
+
|
| 574 |
+
# Import numpy for statistics
|
| 575 |
+
import numpy as np
|
live_stream_processor.py
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Live Stream Processor for DetectifAI
|
| 3 |
+
|
| 4 |
+
Processes live webcam/CCTV footage through the same pipeline as uploaded videos:
|
| 5 |
+
- Object detection (fire, weapons)
|
| 6 |
+
- Behavior analysis (fighting, accidents, climbing)
|
| 7 |
+
- Facial recognition on suspicious frames
|
| 8 |
+
- Real-time event detection
|
| 9 |
+
- Storage in MongoDB and MinIO
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import cv2
|
| 13 |
+
import numpy as np
|
| 14 |
+
import io
|
| 15 |
+
import os
|
| 16 |
+
import time
|
| 17 |
+
import threading
|
| 18 |
+
import logging
|
| 19 |
+
import uuid
|
| 20 |
+
from datetime import datetime
|
| 21 |
+
from typing import Optional, Dict, Any, List, Tuple
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
from config import VideoProcessingConfig, get_security_focused_config
|
| 25 |
+
from object_detection import ObjectDetector
|
| 26 |
+
from behavior_analysis_integrator import BehaviorAnalysisIntegrator
|
| 27 |
+
from database.config import DatabaseManager
|
| 28 |
+
from database.repositories import VideoRepository, EventRepository
|
| 29 |
+
from database.keyframe_repository import KeyframeRepository
|
| 30 |
+
|
| 31 |
+
# Real-time alert engine
|
| 32 |
+
try:
|
| 33 |
+
from real_time_alerts import get_alert_engine, RealTimeAlertEngine
|
| 34 |
+
ALERTS_AVAILABLE = True
|
| 35 |
+
except ImportError:
|
| 36 |
+
ALERTS_AVAILABLE = False
|
| 37 |
+
logging.warning("Real-time alerts module not available")
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger(__name__)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class LiveStreamProcessor:
|
| 43 |
+
"""Process live video streams with DetectifAI pipeline"""
|
| 44 |
+
|
| 45 |
+
def __init__(self, config: VideoProcessingConfig = None, camera_id: str = "webcam_01"):
|
| 46 |
+
"""
|
| 47 |
+
Initialize live stream processor
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
config: VideoProcessingConfig object
|
| 51 |
+
camera_id: Unique identifier for the camera/stream
|
| 52 |
+
"""
|
| 53 |
+
self.config = config or get_security_focused_config()
|
| 54 |
+
self.camera_id = camera_id
|
| 55 |
+
self.is_processing = False
|
| 56 |
+
self.cap = None
|
| 57 |
+
self.camera_index = 0 # Default camera index
|
| 58 |
+
self.frame_count = 0
|
| 59 |
+
self.last_keyframe_time = 0
|
| 60 |
+
self.keyframe_interval = 1.0 # Extract keyframe every 1 second
|
| 61 |
+
|
| 62 |
+
# Initialize database connections
|
| 63 |
+
self.db_manager = DatabaseManager()
|
| 64 |
+
self.video_repo = VideoRepository(self.db_manager)
|
| 65 |
+
self.event_repo = EventRepository(self.db_manager)
|
| 66 |
+
self.keyframe_repo = KeyframeRepository(self.db_manager)
|
| 67 |
+
|
| 68 |
+
# Initialize processing components
|
| 69 |
+
self.object_detector = None
|
| 70 |
+
if self.config.enable_object_detection:
|
| 71 |
+
try:
|
| 72 |
+
self.object_detector = ObjectDetector(self.config)
|
| 73 |
+
logger.info("✅ Object detection enabled for live stream")
|
| 74 |
+
except Exception as e:
|
| 75 |
+
logger.warning(f"⚠️ Object detection initialization failed: {e}")
|
| 76 |
+
self.config.enable_object_detection = False
|
| 77 |
+
|
| 78 |
+
self.behavior_analyzer = None
|
| 79 |
+
if getattr(self.config, 'enable_behavior_analysis', False):
|
| 80 |
+
try:
|
| 81 |
+
self.behavior_analyzer = BehaviorAnalysisIntegrator(self.config)
|
| 82 |
+
logger.info("✅ Behavior analysis enabled for live stream")
|
| 83 |
+
except Exception as e:
|
| 84 |
+
logger.warning(f"⚠️ Behavior analysis initialization failed: {e}")
|
| 85 |
+
self.config.enable_behavior_analysis = False
|
| 86 |
+
|
| 87 |
+
# Initialize facial recognition if enabled
|
| 88 |
+
self.face_recognizer = None
|
| 89 |
+
if getattr(self.config, 'enable_facial_recognition', False):
|
| 90 |
+
try:
|
| 91 |
+
from facial_recognition import FacialRecognitionIntegrated
|
| 92 |
+
self.face_recognizer = FacialRecognitionIntegrated(self.config)
|
| 93 |
+
logger.info("✅ Facial recognition enabled for live stream")
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.warning(f"⚠️ Facial recognition initialization failed: {e}")
|
| 96 |
+
|
| 97 |
+
# Frame buffer for behavior analysis (needs 16 frames)
|
| 98 |
+
self.frame_buffer = []
|
| 99 |
+
self.buffer_size = 16
|
| 100 |
+
|
| 101 |
+
# Motion detection
|
| 102 |
+
self.prev_frame_gray = None
|
| 103 |
+
self.motion_threshold = 25
|
| 104 |
+
|
| 105 |
+
# Real-time alert engine
|
| 106 |
+
self.alert_engine = None
|
| 107 |
+
if ALERTS_AVAILABLE:
|
| 108 |
+
try:
|
| 109 |
+
self.alert_engine = get_alert_engine()
|
| 110 |
+
self.alert_engine.load_flagged_persons()
|
| 111 |
+
logger.info("✅ Real-time alert engine connected for live stream")
|
| 112 |
+
except Exception as e:
|
| 113 |
+
logger.warning(f"⚠️ Alert engine initialization failed: {e}")
|
| 114 |
+
|
| 115 |
+
# Statistics
|
| 116 |
+
self.stats = {
|
| 117 |
+
'frames_processed': 0,
|
| 118 |
+
'keyframes_extracted': 0,
|
| 119 |
+
'objects_detected': 0,
|
| 120 |
+
'behaviors_detected': 0,
|
| 121 |
+
'events_created': 0,
|
| 122 |
+
'alerts_generated': 0,
|
| 123 |
+
'start_time': None
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
logger.info(f"✅ Live stream processor initialized for camera: {camera_id}")
|
| 127 |
+
|
| 128 |
+
def preprocess_frame(self, frame: np.ndarray) -> Optional[np.ndarray]:
|
| 129 |
+
"""
|
| 130 |
+
Preprocess frame: resize, enhance, check quality
|
| 131 |
+
|
| 132 |
+
Args:
|
| 133 |
+
frame: Input frame from camera
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
Preprocessed frame or None if frame is too blurry
|
| 137 |
+
"""
|
| 138 |
+
if frame is None:
|
| 139 |
+
return None
|
| 140 |
+
|
| 141 |
+
# Resize to standard size for processing
|
| 142 |
+
target_size = (640, 640)
|
| 143 |
+
processed = cv2.resize(frame, target_size)
|
| 144 |
+
|
| 145 |
+
# Check for blur using Laplacian variance
|
| 146 |
+
gray = cv2.cvtColor(processed, cv2.COLOR_BGR2GRAY)
|
| 147 |
+
laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var()
|
| 148 |
+
|
| 149 |
+
# Skip blurry frames
|
| 150 |
+
if laplacian_var < 100:
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
return processed
|
| 154 |
+
|
| 155 |
+
def detect_motion(self, frame_gray: np.ndarray) -> Tuple[bool, float]:
|
| 156 |
+
"""
|
| 157 |
+
Detect motion in frame
|
| 158 |
+
|
| 159 |
+
Args:
|
| 160 |
+
frame_gray: Grayscale frame
|
| 161 |
+
|
| 162 |
+
Returns:
|
| 163 |
+
(motion_detected, motion_score)
|
| 164 |
+
"""
|
| 165 |
+
if self.prev_frame_gray is None:
|
| 166 |
+
self.prev_frame_gray = frame_gray
|
| 167 |
+
return False, 0.0
|
| 168 |
+
|
| 169 |
+
diff = cv2.absdiff(self.prev_frame_gray, frame_gray)
|
| 170 |
+
self.prev_frame_gray = frame_gray
|
| 171 |
+
|
| 172 |
+
motion_score = np.sum(diff > self.motion_threshold)
|
| 173 |
+
motion_detected = motion_score > 5000
|
| 174 |
+
|
| 175 |
+
return motion_detected, float(motion_score)
|
| 176 |
+
|
| 177 |
+
def process_frame(self, frame: np.ndarray, timestamp: float) -> Dict[str, Any]:
|
| 178 |
+
"""
|
| 179 |
+
Process a single frame through the pipeline
|
| 180 |
+
|
| 181 |
+
Args:
|
| 182 |
+
frame: Input frame
|
| 183 |
+
timestamp: Frame timestamp in seconds
|
| 184 |
+
|
| 185 |
+
Returns:
|
| 186 |
+
Processing results dictionary
|
| 187 |
+
"""
|
| 188 |
+
results = {
|
| 189 |
+
'timestamp': timestamp,
|
| 190 |
+
'frame_count': self.frame_count,
|
| 191 |
+
'objects_detected': [],
|
| 192 |
+
'behaviors_detected': [],
|
| 193 |
+
'motion_detected': False,
|
| 194 |
+
'motion_score': 0.0,
|
| 195 |
+
'events': []
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
# Preprocess frame
|
| 199 |
+
processed_frame = self.preprocess_frame(frame)
|
| 200 |
+
if processed_frame is None:
|
| 201 |
+
return results
|
| 202 |
+
|
| 203 |
+
# Detect motion
|
| 204 |
+
gray = cv2.cvtColor(processed_frame, cv2.COLOR_BGR2GRAY)
|
| 205 |
+
motion_detected, motion_score = self.detect_motion(gray)
|
| 206 |
+
results['motion_detected'] = motion_detected
|
| 207 |
+
results['motion_score'] = motion_score
|
| 208 |
+
|
| 209 |
+
# Add to frame buffer for behavior analysis
|
| 210 |
+
self.frame_buffer.append(processed_frame.copy())
|
| 211 |
+
if len(self.frame_buffer) > self.buffer_size:
|
| 212 |
+
self.frame_buffer.pop(0)
|
| 213 |
+
|
| 214 |
+
# Object detection (run on every frame with motion, or periodically)
|
| 215 |
+
# For real-time display, we want detections to show immediately
|
| 216 |
+
should_run_detection = motion_detected or (self.frame_count % 30 == 0) # Every 30 frames or on motion
|
| 217 |
+
|
| 218 |
+
if self.object_detector and should_run_detection:
|
| 219 |
+
try:
|
| 220 |
+
# Create a temporary keyframe-like object
|
| 221 |
+
from core.video_processing import KeyframeResult, FrameData
|
| 222 |
+
frame_data = FrameData(
|
| 223 |
+
frame_path=None, # Live frame, no file path
|
| 224 |
+
timestamp=timestamp,
|
| 225 |
+
frame_index=self.frame_count
|
| 226 |
+
)
|
| 227 |
+
keyframe = KeyframeResult(
|
| 228 |
+
frame_data=frame_data,
|
| 229 |
+
quality_score=0.8,
|
| 230 |
+
is_keyframe=True
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
# Store frame temporarily for detection
|
| 234 |
+
import tempfile
|
| 235 |
+
temp_dir = tempfile.gettempdir()
|
| 236 |
+
temp_frame_path = os.path.join(temp_dir, f"live_frame_{self.camera_id}_{self.frame_count}.jpg")
|
| 237 |
+
cv2.imwrite(temp_frame_path, processed_frame)
|
| 238 |
+
keyframe.frame_data.frame_path = temp_frame_path
|
| 239 |
+
|
| 240 |
+
# Run object detection
|
| 241 |
+
detection_result = self.object_detector.detect_objects_in_keyframes([keyframe])
|
| 242 |
+
if detection_result and len(detection_result) > 0:
|
| 243 |
+
detections = detection_result[0]
|
| 244 |
+
if hasattr(detections, 'total_detections') and detections.total_detections > 0:
|
| 245 |
+
results['objects_detected'] = [
|
| 246 |
+
{
|
| 247 |
+
'class': det.class_name,
|
| 248 |
+
'confidence': float(det.confidence),
|
| 249 |
+
'bbox': det.bbox
|
| 250 |
+
}
|
| 251 |
+
for det in detections.detections
|
| 252 |
+
]
|
| 253 |
+
self.stats['objects_detected'] += len(results['objects_detected'])
|
| 254 |
+
|
| 255 |
+
# Log detections in real-time
|
| 256 |
+
obj_classes = [obj['class'] for obj in results['objects_detected']]
|
| 257 |
+
logger.info(f"🎯 REAL-TIME DETECTION: {len(results['objects_detected'])} object(s) detected: {', '.join(obj_classes)} (frame {self.frame_count})")
|
| 258 |
+
|
| 259 |
+
# Generate real-time alerts for each detection
|
| 260 |
+
if self.alert_engine:
|
| 261 |
+
for det in results['objects_detected']:
|
| 262 |
+
alert = self.alert_engine.process_detection(
|
| 263 |
+
camera_id=self.camera_id,
|
| 264 |
+
detection_class=det['class'],
|
| 265 |
+
confidence=det['confidence'],
|
| 266 |
+
bounding_boxes=[det],
|
| 267 |
+
frame=processed_frame,
|
| 268 |
+
timestamp=timestamp,
|
| 269 |
+
video_id=f"live_{self.camera_id}",
|
| 270 |
+
)
|
| 271 |
+
if alert:
|
| 272 |
+
self.stats['alerts_generated'] = self.stats.get('alerts_generated', 0) + 1
|
| 273 |
+
|
| 274 |
+
# Clean up temp file
|
| 275 |
+
try:
|
| 276 |
+
os.remove(temp_frame_path)
|
| 277 |
+
except:
|
| 278 |
+
pass
|
| 279 |
+
|
| 280 |
+
except Exception as e:
|
| 281 |
+
logger.warning(f"Error in object detection: {e}")
|
| 282 |
+
|
| 283 |
+
# Behavior analysis (on frame buffer) - use frame buffer method for live streams
|
| 284 |
+
if self.behavior_analyzer and len(self.frame_buffer) >= 16 and motion_detected:
|
| 285 |
+
try:
|
| 286 |
+
# Use frame buffer method for live streams (no video file needed)
|
| 287 |
+
behavior_results = self.behavior_analyzer.detect_behavior_in_segment_from_buffer(
|
| 288 |
+
frame_buffer=self.frame_buffer,
|
| 289 |
+
start_time=timestamp - (len(self.frame_buffer) / 30.0), # Approximate start time
|
| 290 |
+
end_time=timestamp,
|
| 291 |
+
frame_indices=list(range(max(0, self.frame_count - len(self.frame_buffer) + 1), self.frame_count + 1))
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
if behavior_results:
|
| 295 |
+
results['behaviors_detected'] = [
|
| 296 |
+
{
|
| 297 |
+
'behavior_type': r.behavior_detected, # Use behavior_type for consistency
|
| 298 |
+
'behavior': r.behavior_detected, # Keep both for compatibility
|
| 299 |
+
'confidence': float(r.confidence),
|
| 300 |
+
'model': r.model_used
|
| 301 |
+
}
|
| 302 |
+
for r in behavior_results
|
| 303 |
+
]
|
| 304 |
+
self.stats['behaviors_detected'] += len(results['behaviors_detected'])
|
| 305 |
+
|
| 306 |
+
# Log behaviors in real-time
|
| 307 |
+
behavior_types = [b['behavior_type'] for b in results['behaviors_detected']]
|
| 308 |
+
logger.info(f"🎭 REAL-TIME BEHAVIOR: {len(results['behaviors_detected'])} behavior(s) detected: {', '.join(behavior_types)} (frame {self.frame_count})")
|
| 309 |
+
|
| 310 |
+
# Generate real-time alerts for each behavior
|
| 311 |
+
if self.alert_engine:
|
| 312 |
+
for beh in results['behaviors_detected']:
|
| 313 |
+
alert = self.alert_engine.process_detection(
|
| 314 |
+
camera_id=self.camera_id,
|
| 315 |
+
detection_class=beh['behavior_type'],
|
| 316 |
+
confidence=beh['confidence'],
|
| 317 |
+
frame=processed_frame,
|
| 318 |
+
timestamp=timestamp,
|
| 319 |
+
video_id=f"live_{self.camera_id}",
|
| 320 |
+
)
|
| 321 |
+
if alert:
|
| 322 |
+
self.stats['alerts_generated'] = self.stats.get('alerts_generated', 0) + 1
|
| 323 |
+
except Exception as e:
|
| 324 |
+
logger.warning(f"Error in behavior analysis: {e}")
|
| 325 |
+
|
| 326 |
+
# Facial recognition on suspicious frames
|
| 327 |
+
if self.face_recognizer and (results['objects_detected'] or results['behaviors_detected']):
|
| 328 |
+
try:
|
| 329 |
+
# Process frame for facial recognition
|
| 330 |
+
face_results = self.face_recognizer.detect_faces_in_frame(
|
| 331 |
+
processed_frame,
|
| 332 |
+
frame_number=self.frame_count,
|
| 333 |
+
timestamp=timestamp,
|
| 334 |
+
event_id=f"live_{self.camera_id}_{int(timestamp)}"
|
| 335 |
+
)
|
| 336 |
+
if face_results:
|
| 337 |
+
results['faces_detected'] = len(face_results)
|
| 338 |
+
|
| 339 |
+
# Check for suspicious person re-appearance
|
| 340 |
+
if self.alert_engine:
|
| 341 |
+
for face in face_results:
|
| 342 |
+
face_id = face.get('face_id') if isinstance(face, dict) else getattr(face, 'face_id', None)
|
| 343 |
+
match_score = face.get('confidence', 0.0) if isinstance(face, dict) else getattr(face, 'confidence_score', 0.0)
|
| 344 |
+
if face_id and match_score:
|
| 345 |
+
alert = self.alert_engine.process_suspicious_person(
|
| 346 |
+
camera_id=self.camera_id,
|
| 347 |
+
face_id=str(face_id),
|
| 348 |
+
face_match_score=float(match_score),
|
| 349 |
+
frame=processed_frame,
|
| 350 |
+
timestamp=timestamp,
|
| 351 |
+
)
|
| 352 |
+
if alert:
|
| 353 |
+
self.stats['alerts_generated'] = self.stats.get('alerts_generated', 0) + 1
|
| 354 |
+
except Exception as e:
|
| 355 |
+
logger.warning(f"Error in facial recognition: {e}")
|
| 356 |
+
|
| 357 |
+
return results
|
| 358 |
+
|
| 359 |
+
def save_keyframe(self, frame: np.ndarray, results: Dict[str, Any], timestamp: float) -> Optional[str]:
|
| 360 |
+
"""
|
| 361 |
+
Save keyframe to MinIO and MongoDB (matches uploaded video pipeline)
|
| 362 |
+
|
| 363 |
+
Args:
|
| 364 |
+
frame: Frame to save
|
| 365 |
+
results: Processing results
|
| 366 |
+
timestamp: Frame timestamp
|
| 367 |
+
|
| 368 |
+
Returns:
|
| 369 |
+
MinIO object path or None
|
| 370 |
+
"""
|
| 371 |
+
try:
|
| 372 |
+
# Encode frame as JPEG (same as uploaded video pipeline)
|
| 373 |
+
is_success, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
| 374 |
+
if not is_success:
|
| 375 |
+
logger.warning(f"⚠️ Failed to encode frame {self.frame_count} as JPEG")
|
| 376 |
+
return None
|
| 377 |
+
|
| 378 |
+
frame_bytes = buffer.tobytes()
|
| 379 |
+
frame_size = len(frame_bytes)
|
| 380 |
+
|
| 381 |
+
# Generate object name (consistent with uploaded video pipeline)
|
| 382 |
+
timestamp_str = datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f")
|
| 383 |
+
object_name = f"live/{self.camera_id}/{timestamp_str}.jpg"
|
| 384 |
+
|
| 385 |
+
# Upload to MinIO (same method as uploaded video pipeline)
|
| 386 |
+
minio_client = self.keyframe_repo.minio # Use minio client from keyframe repository
|
| 387 |
+
bucket = self.keyframe_repo.bucket # Use bucket from keyframe repository
|
| 388 |
+
|
| 389 |
+
logger.info(f"📤 Uploading keyframe to MinIO: {bucket}/{object_name} ({frame_size} bytes)")
|
| 390 |
+
|
| 391 |
+
# Use BytesIO for in-memory upload (same as uploaded video pipeline)
|
| 392 |
+
from io import BytesIO
|
| 393 |
+
frame_buffer = BytesIO(frame_bytes)
|
| 394 |
+
|
| 395 |
+
# Add metadata like uploaded video pipeline
|
| 396 |
+
metadata = {
|
| 397 |
+
"frame_index": str(self.frame_count),
|
| 398 |
+
"timestamp": str(timestamp),
|
| 399 |
+
"camera_id": self.camera_id,
|
| 400 |
+
"motion_detected": str(results.get('motion_detected', False)),
|
| 401 |
+
"motion_score": str(results.get('motion_score', 0.0))
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
minio_client.put_object(
|
| 405 |
+
bucket,
|
| 406 |
+
object_name,
|
| 407 |
+
frame_buffer,
|
| 408 |
+
length=frame_size,
|
| 409 |
+
content_type="image/jpeg",
|
| 410 |
+
metadata=metadata
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
logger.info(f"✅ Uploaded keyframe to MinIO: {bucket}/{object_name}")
|
| 414 |
+
|
| 415 |
+
# Save to MongoDB (same as uploaded video pipeline)
|
| 416 |
+
keyframe_doc = {
|
| 417 |
+
"camera_id": self.camera_id,
|
| 418 |
+
"video_id": f"live_{self.camera_id}", # Use consistent video_id format
|
| 419 |
+
"timestamp": timestamp,
|
| 420 |
+
"timestamp_ms": int(timestamp * 1000),
|
| 421 |
+
"frame_index": self.frame_count,
|
| 422 |
+
"frame_number": self.frame_count, # Also include frame_number for consistency
|
| 423 |
+
"minio_path": object_name,
|
| 424 |
+
"minio_bucket": bucket,
|
| 425 |
+
"objects_detected": results.get('objects_detected', []),
|
| 426 |
+
"behaviors_detected": results.get('behaviors_detected', []),
|
| 427 |
+
"motion_detected": results.get('motion_detected', False),
|
| 428 |
+
"motion_score": results.get('motion_score', 0.0),
|
| 429 |
+
"created_at": datetime.utcnow()
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
# Use create_keyframe method (same as uploaded video pipeline)
|
| 433 |
+
keyframe_id = self.keyframe_repo.create_keyframe(keyframe_doc)
|
| 434 |
+
if keyframe_id:
|
| 435 |
+
logger.info(f"✅ Saved keyframe metadata to MongoDB: {object_name} (ID: {keyframe_id})")
|
| 436 |
+
else:
|
| 437 |
+
logger.warning(f"⚠️ Failed to save keyframe metadata to MongoDB: {object_name}")
|
| 438 |
+
|
| 439 |
+
self.stats['keyframes_extracted'] += 1
|
| 440 |
+
|
| 441 |
+
# Return full path for URL generation
|
| 442 |
+
return f"{bucket}/{object_name}"
|
| 443 |
+
|
| 444 |
+
except Exception as e:
|
| 445 |
+
logger.error(f"❌ Error saving keyframe: {e}")
|
| 446 |
+
import traceback
|
| 447 |
+
logger.error(traceback.format_exc())
|
| 448 |
+
return None
|
| 449 |
+
|
| 450 |
+
def create_event(self, results: Dict[str, Any], start_time: float, end_time: float) -> Optional[str]:
|
| 451 |
+
"""
|
| 452 |
+
Create event from processing results (matches uploaded video pipeline)
|
| 453 |
+
|
| 454 |
+
Args:
|
| 455 |
+
results: Processing results
|
| 456 |
+
start_time: Event start time
|
| 457 |
+
end_time: Event end time
|
| 458 |
+
|
| 459 |
+
Returns:
|
| 460 |
+
Event ID or None
|
| 461 |
+
"""
|
| 462 |
+
try:
|
| 463 |
+
# Determine event type based on detections (same logic as uploaded video pipeline)
|
| 464 |
+
event_type = "motion"
|
| 465 |
+
if results.get('objects_detected'):
|
| 466 |
+
# Get the primary object class for event type
|
| 467 |
+
primary_object = results['objects_detected'][0].get('class', 'object')
|
| 468 |
+
event_type = f"object_detection_{primary_object}"
|
| 469 |
+
elif results.get('behaviors_detected'):
|
| 470 |
+
primary_behavior = results['behaviors_detected'][0].get('behavior_type', 'behavior')
|
| 471 |
+
event_type = f"behavior_detection_{primary_behavior}"
|
| 472 |
+
|
| 473 |
+
# Calculate confidence from detections (same as uploaded video pipeline)
|
| 474 |
+
confidences = []
|
| 475 |
+
if results.get('objects_detected'):
|
| 476 |
+
confidences.extend([float(r.get('confidence', 0.0)) for r in results['objects_detected']])
|
| 477 |
+
if results.get('behaviors_detected'):
|
| 478 |
+
confidences.extend([float(r.get('confidence', 0.0)) for r in results['behaviors_detected']])
|
| 479 |
+
max_confidence = max(confidences) if confidences else 0.0
|
| 480 |
+
|
| 481 |
+
# Build bounding boxes structure (same format as uploaded video pipeline)
|
| 482 |
+
bounding_boxes = {}
|
| 483 |
+
if results.get('objects_detected'):
|
| 484 |
+
bounding_boxes["detections"] = [
|
| 485 |
+
{
|
| 486 |
+
"class": det.get('class', 'unknown'),
|
| 487 |
+
"confidence": float(det.get('confidence', 0.0)),
|
| 488 |
+
"bbox": [float(x) for x in det.get('bbox', [0, 0, 0, 0])],
|
| 489 |
+
"timestamp": float(start_time),
|
| 490 |
+
"model": det.get('detection_model', 'fire' if det.get('class') == 'fire' else 'weapon')
|
| 491 |
+
|
| 492 |
+
}
|
| 493 |
+
for det in results['objects_detected']
|
| 494 |
+
]
|
| 495 |
+
|
| 496 |
+
# Create event document (matches uploaded video pipeline schema)
|
| 497 |
+
event_doc = {
|
| 498 |
+
"event_id": f"live_{self.camera_id}_{int(start_time)}_{uuid.uuid4().hex[:8]}",
|
| 499 |
+
"camera_id": self.camera_id,
|
| 500 |
+
"video_id": f"live_{self.camera_id}", # Use camera_id as video_id for live streams
|
| 501 |
+
"event_type": event_type,
|
| 502 |
+
"start_timestamp": start_time,
|
| 503 |
+
"end_timestamp": end_time,
|
| 504 |
+
"start_timestamp_ms": int(start_time * 1000),
|
| 505 |
+
"end_timestamp_ms": int(end_time * 1000),
|
| 506 |
+
"confidence": max_confidence,
|
| 507 |
+
"confidence_score": max_confidence, # Also include confidence_score for schema compliance
|
| 508 |
+
"description": f"Live stream event: {event_type} detected",
|
| 509 |
+
"bounding_boxes": bounding_boxes,
|
| 510 |
+
"metadata": {
|
| 511 |
+
"camera_id": self.camera_id,
|
| 512 |
+
"objects_detected": results.get('objects_detected', []),
|
| 513 |
+
"behaviors_detected": results.get('behaviors_detected', []),
|
| 514 |
+
"motion_score": results.get('motion_score', 0.0),
|
| 515 |
+
"source": "live_stream"
|
| 516 |
+
}
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
logger.info(f"📝 Creating event: {event_type} (confidence: {max_confidence:.2f})")
|
| 520 |
+
event_id = self.event_repo.create_event(event_doc)
|
| 521 |
+
|
| 522 |
+
if event_id:
|
| 523 |
+
logger.info(f"✅ Created event in MongoDB: {event_doc['event_id']} (MongoDB ID: {event_id})")
|
| 524 |
+
self.stats['events_created'] += 1
|
| 525 |
+
else:
|
| 526 |
+
logger.warning(f"⚠️ Failed to create event in MongoDB: {event_doc['event_id']}")
|
| 527 |
+
|
| 528 |
+
return event_id
|
| 529 |
+
|
| 530 |
+
except Exception as e:
|
| 531 |
+
logger.error(f"❌ Error creating event: {e}")
|
| 532 |
+
import traceback
|
| 533 |
+
logger.error(traceback.format_exc())
|
| 534 |
+
return None
|
| 535 |
+
|
| 536 |
+
def generate_frames(self, camera_index: int = 0):
|
| 537 |
+
"""
|
| 538 |
+
Generator function for video frames with processing
|
| 539 |
+
|
| 540 |
+
Args:
|
| 541 |
+
camera_index: Camera device index (0 for default webcam)
|
| 542 |
+
|
| 543 |
+
Yields:
|
| 544 |
+
Processed frame bytes for streaming
|
| 545 |
+
"""
|
| 546 |
+
# Release any existing camera connection
|
| 547 |
+
if self.cap is not None:
|
| 548 |
+
try:
|
| 549 |
+
self.cap.release()
|
| 550 |
+
except:
|
| 551 |
+
pass
|
| 552 |
+
|
| 553 |
+
# Try to open camera with retries
|
| 554 |
+
max_retries = 3
|
| 555 |
+
self.cap = None
|
| 556 |
+
|
| 557 |
+
for attempt in range(max_retries):
|
| 558 |
+
try:
|
| 559 |
+
logger.info(f"Attempting to open camera {camera_index} (attempt {attempt + 1}/{max_retries})")
|
| 560 |
+
self.cap = cv2.VideoCapture(camera_index)
|
| 561 |
+
|
| 562 |
+
# Give camera time to initialize
|
| 563 |
+
time.sleep(0.5)
|
| 564 |
+
|
| 565 |
+
if self.cap.isOpened():
|
| 566 |
+
# Test if we can actually read a frame
|
| 567 |
+
ret, test_frame = self.cap.read()
|
| 568 |
+
if ret and test_frame is not None:
|
| 569 |
+
logger.info(f"✅ Successfully opened camera {camera_index}")
|
| 570 |
+
break
|
| 571 |
+
else:
|
| 572 |
+
logger.warning(f"Camera {camera_index} opened but cannot read frames")
|
| 573 |
+
self.cap.release()
|
| 574 |
+
self.cap = None
|
| 575 |
+
else:
|
| 576 |
+
logger.warning(f"Camera {camera_index} failed to open")
|
| 577 |
+
if self.cap:
|
| 578 |
+
self.cap.release()
|
| 579 |
+
self.cap = None
|
| 580 |
+
except Exception as e:
|
| 581 |
+
logger.error(f"Error opening camera {camera_index}: {e}")
|
| 582 |
+
if self.cap:
|
| 583 |
+
try:
|
| 584 |
+
self.cap.release()
|
| 585 |
+
except:
|
| 586 |
+
pass
|
| 587 |
+
self.cap = None
|
| 588 |
+
|
| 589 |
+
if self.cap is None or not self.cap.isOpened():
|
| 590 |
+
error_msg = f"❌ Could not open camera {camera_index} after {max_retries} attempts"
|
| 591 |
+
logger.error(error_msg)
|
| 592 |
+
# Yield an error frame
|
| 593 |
+
error_frame = self._create_error_frame(error_msg)
|
| 594 |
+
ret, buffer = cv2.imencode('.jpg', error_frame)
|
| 595 |
+
if ret:
|
| 596 |
+
yield (b'--frame\r\n'
|
| 597 |
+
b'Content-Type: image/jpeg\r\n\r\n' + buffer.tobytes() + b'\r\n')
|
| 598 |
+
return
|
| 599 |
+
|
| 600 |
+
# Set camera properties
|
| 601 |
+
try:
|
| 602 |
+
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
| 603 |
+
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
|
| 604 |
+
self.cap.set(cv2.CAP_PROP_FPS, 30)
|
| 605 |
+
# Set buffer size to reduce latency
|
| 606 |
+
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
| 607 |
+
except Exception as e:
|
| 608 |
+
logger.warning(f"Could not set camera properties: {e}")
|
| 609 |
+
|
| 610 |
+
self.is_processing = True
|
| 611 |
+
self.stats['start_time'] = time.time()
|
| 612 |
+
self.frame_count = 0
|
| 613 |
+
self.last_keyframe_time = time.time()
|
| 614 |
+
|
| 615 |
+
logger.info(f"🎥 Started live stream processing for camera {camera_index}")
|
| 616 |
+
logger.info(f"📊 Camera properties: {self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)}x{self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)} @ {self.cap.get(cv2.CAP_PROP_FPS)} FPS")
|
| 617 |
+
logger.info(f"🔄 Entering frame generation loop...")
|
| 618 |
+
|
| 619 |
+
current_event_start = None
|
| 620 |
+
event_results = None
|
| 621 |
+
|
| 622 |
+
try:
|
| 623 |
+
consecutive_failures = 0
|
| 624 |
+
max_failures = 10
|
| 625 |
+
while self.is_processing:
|
| 626 |
+
ret, frame = self.cap.read()
|
| 627 |
+
if not ret or frame is None:
|
| 628 |
+
consecutive_failures += 1
|
| 629 |
+
if consecutive_failures >= max_failures:
|
| 630 |
+
logger.error(f"❌ Failed to read {max_failures} consecutive frames from camera")
|
| 631 |
+
break
|
| 632 |
+
logger.warning(f"⚠️ Failed to read frame from camera (failure {consecutive_failures}/{max_failures})")
|
| 633 |
+
time.sleep(0.1) # Brief pause before retry
|
| 634 |
+
continue
|
| 635 |
+
|
| 636 |
+
consecutive_failures = 0 # Reset on success
|
| 637 |
+
self.frame_count += 1
|
| 638 |
+
self.stats['frames_processed'] += 1
|
| 639 |
+
|
| 640 |
+
if self.frame_count == 1:
|
| 641 |
+
logger.info(f"✅ Successfully read first frame! Frame shape: {frame.shape}")
|
| 642 |
+
current_time = time.time()
|
| 643 |
+
timestamp = current_time - self.stats['start_time']
|
| 644 |
+
|
| 645 |
+
# Process frame
|
| 646 |
+
results = self.process_frame(frame, timestamp)
|
| 647 |
+
|
| 648 |
+
# Extract keyframe periodically or on significant events
|
| 649 |
+
should_extract_keyframe = (
|
| 650 |
+
(current_time - self.last_keyframe_time >= self.keyframe_interval) or
|
| 651 |
+
results.get('objects_detected') or
|
| 652 |
+
results.get('behaviors_detected')
|
| 653 |
+
)
|
| 654 |
+
|
| 655 |
+
if should_extract_keyframe:
|
| 656 |
+
self.save_keyframe(frame, results, timestamp)
|
| 657 |
+
self.last_keyframe_time = current_time
|
| 658 |
+
|
| 659 |
+
# Track events
|
| 660 |
+
if results.get('objects_detected') or results.get('behaviors_detected'):
|
| 661 |
+
if current_event_start is None:
|
| 662 |
+
current_event_start = timestamp
|
| 663 |
+
event_results = results
|
| 664 |
+
else:
|
| 665 |
+
# Update event results
|
| 666 |
+
event_results['objects_detected'].extend(results.get('objects_detected', []))
|
| 667 |
+
event_results['behaviors_detected'].extend(results.get('behaviors_detected', []))
|
| 668 |
+
else:
|
| 669 |
+
# End event if it exists
|
| 670 |
+
if current_event_start is not None:
|
| 671 |
+
self.create_event(event_results, current_event_start, timestamp)
|
| 672 |
+
current_event_start = None
|
| 673 |
+
event_results = None
|
| 674 |
+
|
| 675 |
+
# Draw annotations on frame
|
| 676 |
+
annotated_frame = self.annotate_frame(frame, results)
|
| 677 |
+
|
| 678 |
+
# Encode frame for streaming
|
| 679 |
+
ret, buffer = cv2.imencode('.jpg', annotated_frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
| 680 |
+
if ret:
|
| 681 |
+
frame_bytes = buffer.tobytes()
|
| 682 |
+
if self.frame_count % 30 == 0: # Log every 30 frames
|
| 683 |
+
logger.debug(f"📹 Yielding frame {self.frame_count} ({len(frame_bytes)} bytes)")
|
| 684 |
+
yield (b'--frame\r\n'
|
| 685 |
+
b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
|
| 686 |
+
else:
|
| 687 |
+
logger.warning(f"⚠️ Failed to encode frame {self.frame_count}")
|
| 688 |
+
|
| 689 |
+
# Small delay to control frame rate
|
| 690 |
+
time.sleep(0.033) # ~30 FPS
|
| 691 |
+
|
| 692 |
+
except Exception as e:
|
| 693 |
+
logger.error(f"Error in frame generation: {e}")
|
| 694 |
+
import traceback
|
| 695 |
+
logger.error(traceback.format_exc())
|
| 696 |
+
finally:
|
| 697 |
+
self.stop()
|
| 698 |
+
|
| 699 |
+
def _create_error_frame(self, error_message: str) -> np.ndarray:
|
| 700 |
+
"""Create an error frame to display when camera fails"""
|
| 701 |
+
frame = np.zeros((480, 640, 3), dtype=np.uint8)
|
| 702 |
+
frame.fill(20) # Dark background
|
| 703 |
+
|
| 704 |
+
# Add error text
|
| 705 |
+
font = cv2.FONT_HERSHEY_SIMPLEX
|
| 706 |
+
text = "Camera Error"
|
| 707 |
+
text_size = cv2.getTextSize(text, font, 1, 2)[0]
|
| 708 |
+
text_x = (640 - text_size[0]) // 2
|
| 709 |
+
text_y = 200
|
| 710 |
+
cv2.putText(frame, text, (text_x, text_y), font, 1, (0, 0, 255), 2)
|
| 711 |
+
|
| 712 |
+
# Add error message (split if too long)
|
| 713 |
+
msg_lines = error_message.split(' ')
|
| 714 |
+
line = ""
|
| 715 |
+
y_offset = 250
|
| 716 |
+
for word in msg_lines:
|
| 717 |
+
test_line = line + word + " "
|
| 718 |
+
test_size = cv2.getTextSize(test_line, font, 0.6, 1)[0]
|
| 719 |
+
if test_size[0] > 600:
|
| 720 |
+
cv2.putText(frame, line, (20, y_offset), font, 0.6, (255, 255, 255), 1)
|
| 721 |
+
line = word + " "
|
| 722 |
+
y_offset += 30
|
| 723 |
+
else:
|
| 724 |
+
line = test_line
|
| 725 |
+
if line:
|
| 726 |
+
cv2.putText(frame, line, (20, y_offset), font, 0.6, (255, 255, 255), 1)
|
| 727 |
+
|
| 728 |
+
return frame
|
| 729 |
+
|
| 730 |
+
def annotate_frame(self, frame: np.ndarray, results: Dict[str, Any]) -> np.ndarray:
|
| 731 |
+
"""
|
| 732 |
+
Draw annotations on frame (detections, behaviors, etc.) - matches uploaded video pipeline
|
| 733 |
+
|
| 734 |
+
Args:
|
| 735 |
+
frame: Input frame
|
| 736 |
+
results: Processing results
|
| 737 |
+
|
| 738 |
+
Returns:
|
| 739 |
+
Annotated frame
|
| 740 |
+
"""
|
| 741 |
+
annotated = frame.copy()
|
| 742 |
+
|
| 743 |
+
# Draw object detections with color coding (same as uploaded video pipeline)
|
| 744 |
+
for obj in results.get('objects_detected', []):
|
| 745 |
+
bbox = obj.get('bbox', [0, 0, 100, 100])
|
| 746 |
+
class_name = obj.get('class', 'object')
|
| 747 |
+
confidence = float(obj.get('confidence', 0.0))
|
| 748 |
+
|
| 749 |
+
x1, y1, x2, y2 = map(int, bbox)
|
| 750 |
+
|
| 751 |
+
# Color coding based on object class (same as uploaded video pipeline)
|
| 752 |
+
color_map = {
|
| 753 |
+
'fire': (255, 255, 0), # Cyan/Blue (BGR)
|
| 754 |
+
'knife': (0, 255, 255), # Yellow (BGR)
|
| 755 |
+
'gun': (0, 255, 0), # Green (BGR)
|
| 756 |
+
'smoke': (128, 128, 128) # Gray (BGR)
|
| 757 |
+
}
|
| 758 |
+
color = color_map.get(class_name.lower(), (0, 0, 255)) # Default red
|
| 759 |
+
|
| 760 |
+
# Draw bounding box with thicker line for visibility
|
| 761 |
+
cv2.rectangle(annotated, (x1, y1), (x2, y2), color, 3)
|
| 762 |
+
|
| 763 |
+
# Draw label with background (same style as uploaded video pipeline)
|
| 764 |
+
label = f"{class_name}: {confidence:.2f}"
|
| 765 |
+
font = cv2.FONT_HERSHEY_SIMPLEX
|
| 766 |
+
font_scale = 0.6
|
| 767 |
+
thickness = 2
|
| 768 |
+
label_size, baseline = cv2.getTextSize(label, font, font_scale, thickness)
|
| 769 |
+
|
| 770 |
+
# Draw label background
|
| 771 |
+
cv2.rectangle(annotated,
|
| 772 |
+
(x1, y1 - label_size[1] - 10),
|
| 773 |
+
(x1 + label_size[0], y1),
|
| 774 |
+
color, -1)
|
| 775 |
+
|
| 776 |
+
# Draw label text
|
| 777 |
+
cv2.putText(annotated, label, (x1, y1 - 5),
|
| 778 |
+
font, font_scale, (255, 255, 255), thickness)
|
| 779 |
+
|
| 780 |
+
# Draw behavior detections (same style as uploaded video pipeline)
|
| 781 |
+
behavior_y_offset = 30
|
| 782 |
+
for behavior in results.get('behaviors_detected', []):
|
| 783 |
+
behavior_type = behavior.get('behavior_type', behavior.get('behavior', 'unknown'))
|
| 784 |
+
confidence = float(behavior.get('confidence', 0.0))
|
| 785 |
+
label = f"{behavior_type.upper()}: {confidence:.2f}"
|
| 786 |
+
|
| 787 |
+
# Color coding for behaviors
|
| 788 |
+
behavior_colors = {
|
| 789 |
+
'fighting': (0, 0, 255), # Red
|
| 790 |
+
'road_accident': (0, 165, 255), # Orange
|
| 791 |
+
'wallclimb': (255, 0, 255) # Magenta
|
| 792 |
+
}
|
| 793 |
+
behavior_color = behavior_colors.get(behavior_type.lower(), (0, 255, 0)) # Default green
|
| 794 |
+
|
| 795 |
+
# Draw behavior label with background
|
| 796 |
+
font = cv2.FONT_HERSHEY_SIMPLEX
|
| 797 |
+
font_scale = 0.7
|
| 798 |
+
thickness = 2
|
| 799 |
+
label_size, baseline = cv2.getTextSize(label, font, font_scale, thickness)
|
| 800 |
+
|
| 801 |
+
# Background for behavior label
|
| 802 |
+
cv2.rectangle(annotated,
|
| 803 |
+
(10, behavior_y_offset - label_size[1] - 5),
|
| 804 |
+
(10 + label_size[0], behavior_y_offset + 5),
|
| 805 |
+
behavior_color, -1)
|
| 806 |
+
|
| 807 |
+
cv2.putText(annotated, label, (10, behavior_y_offset),
|
| 808 |
+
font, font_scale, (255, 255, 255), thickness)
|
| 809 |
+
behavior_y_offset += 35
|
| 810 |
+
|
| 811 |
+
# Draw motion indicator (if motion detected)
|
| 812 |
+
if results.get('motion_detected'):
|
| 813 |
+
motion_label = f"MOTION: {results.get('motion_score', 0.0):.0f}"
|
| 814 |
+
cv2.putText(annotated, motion_label, (10, behavior_y_offset),
|
| 815 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 0), 2)
|
| 816 |
+
behavior_y_offset += 30
|
| 817 |
+
|
| 818 |
+
# Draw face detection indicator
|
| 819 |
+
if results.get('faces_detected', 0) > 0:
|
| 820 |
+
face_label = f"FACES: {results['faces_detected']}"
|
| 821 |
+
cv2.putText(annotated, face_label, (10, behavior_y_offset),
|
| 822 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 192, 203), 2)
|
| 823 |
+
behavior_y_offset += 30
|
| 824 |
+
|
| 825 |
+
# Draw stats at bottom (same as uploaded video pipeline)
|
| 826 |
+
stats_text = f"Frame: {self.frame_count} | Objects: {len(results.get('objects_detected', []))} | Events: {self.stats['events_created']}"
|
| 827 |
+
cv2.putText(annotated, stats_text, (10, annotated.shape[0] - 10),
|
| 828 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
|
| 829 |
+
|
| 830 |
+
return annotated
|
| 831 |
+
|
| 832 |
+
def stop(self):
|
| 833 |
+
"""Stop processing and release resources"""
|
| 834 |
+
self.is_processing = False
|
| 835 |
+
if self.cap:
|
| 836 |
+
self.cap.release()
|
| 837 |
+
logger.info("🛑 Live stream processing stopped")
|
| 838 |
+
|
| 839 |
+
def get_stats(self) -> Dict[str, Any]:
|
| 840 |
+
"""Get processing statistics"""
|
| 841 |
+
runtime = time.time() - self.stats['start_time'] if self.stats['start_time'] else 0
|
| 842 |
+
return {
|
| 843 |
+
**self.stats,
|
| 844 |
+
'runtime_seconds': runtime,
|
| 845 |
+
'fps': self.stats['frames_processed'] / runtime if runtime > 0 else 0,
|
| 846 |
+
'is_processing': self.is_processing
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
# Global processor instances (one per camera)
|
| 851 |
+
_live_processors = {}
|
| 852 |
+
|
| 853 |
+
|
| 854 |
+
def get_live_processor(camera_id: str = "webcam_01", config: VideoProcessingConfig = None) -> LiveStreamProcessor:
|
| 855 |
+
"""Get or create a live stream processor for a camera"""
|
| 856 |
+
if camera_id not in _live_processors:
|
| 857 |
+
_live_processors[camera_id] = LiveStreamProcessor(config, camera_id)
|
| 858 |
+
return _live_processors[camera_id]
|
| 859 |
+
|
| 860 |
+
|
| 861 |
+
def stop_live_processor(camera_id: str):
|
| 862 |
+
"""Stop and remove a live stream processor"""
|
| 863 |
+
if camera_id in _live_processors:
|
| 864 |
+
_live_processors[camera_id].stop()
|
| 865 |
+
del _live_processors[camera_id]
|
| 866 |
+
|