blacksinisterx commited on
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
Files changed (50) hide show
  1. .dockerignore +74 -0
  2. DetectifAI_db/app_integrated.py +1250 -0
  3. DetectifAI_db/caption_search.py +209 -0
  4. DetectifAI_db/check_minio.py +26 -0
  5. DetectifAI_db/check_video_storage.py +191 -0
  6. DetectifAI_db/create_admin.py +120 -0
  7. DetectifAI_db/database_seed.py +212 -0
  8. DetectifAI_db/database_setup.py +375 -0
  9. DetectifAI_db/env.example +19 -0
  10. DetectifAI_db/faiss_captions.index +0 -0
  11. DetectifAI_db/faiss_captions_idmap.json +12 -0
  12. DetectifAI_db/migrate_stripe_integration.py +209 -0
  13. DetectifAI_db/minio_config.py +37 -0
  14. DetectifAI_db/requirements.txt +14 -0
  15. DetectifAI_db/reset_minio.py +104 -0
  16. DetectifAI_db/reset_users_collection.py +29 -0
  17. DetectifAI_db/seed_stripe_plans.py +141 -0
  18. DetectifAI_db/setup_database.py +44 -0
  19. DetectifAI_db/setup_minio.py +91 -0
  20. DetectifAI_db/setup_nlp_bucket.py +61 -0
  21. DetectifAI_db/upload_caption_images.py +264 -0
  22. DetectifAI_db/upload_captions.py +349 -0
  23. DetectifAI_db/vector_index.py +348 -0
  24. Dockerfile +92 -0
  25. README.md +27 -6
  26. alert_routes.py +361 -0
  27. app.py +0 -0
  28. behavior_analysis/action_recognition.py +381 -0
  29. behavior_analysis/wallclimb.pt +3 -0
  30. behavior_analysis/yolov11_wallclimb.pt +3 -0
  31. behavior_analysis_integrator.py +580 -0
  32. config.py +369 -0
  33. core/video_processing.py +384 -0
  34. database/config.py +173 -0
  35. database/keyframe_repository.py +243 -0
  36. database/models.py +432 -0
  37. database/models_backup.py +330 -0
  38. database/repositories.py +516 -0
  39. database/repositories_old.py +653 -0
  40. database/storage_logger.py +41 -0
  41. database/video_compression_service.py +379 -0
  42. database_video_service.py +1804 -0
  43. detectifai_events.py +577 -0
  44. event_aggregation.py +819 -0
  45. event_clip_generator.py +390 -0
  46. extract_upload_keyframes.py +240 -0
  47. facial_recognition.py +926 -0
  48. highlight_reel.py +542 -0
  49. json_reports.py +575 -0
  50. 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: Detectifai Backend
3
- emoji: 📚
4
- colorFrom: yellow
5
- colorTo: indigo
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+