matrice-analytics 0.1.96__py3-none-any.whl → 0.1.106__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. matrice_analytics/post_processing/__init__.py +14 -1
  2. matrice_analytics/post_processing/advanced_tracker/config.py +8 -4
  3. matrice_analytics/post_processing/advanced_tracker/track_class_aggregator.py +128 -0
  4. matrice_analytics/post_processing/advanced_tracker/tracker.py +22 -1
  5. matrice_analytics/post_processing/config.py +6 -2
  6. matrice_analytics/post_processing/core/config.py +62 -0
  7. matrice_analytics/post_processing/face_reg/face_recognition.py +706 -73
  8. matrice_analytics/post_processing/face_reg/people_activity_logging.py +25 -14
  9. matrice_analytics/post_processing/post_processor.py +8 -0
  10. matrice_analytics/post_processing/usecases/__init__.py +7 -1
  11. matrice_analytics/post_processing/usecases/footfall.py +109 -2
  12. matrice_analytics/post_processing/usecases/license_plate_monitoring.py +55 -37
  13. matrice_analytics/post_processing/usecases/vehicle_monitoring.py +14 -32
  14. matrice_analytics/post_processing/usecases/vehicle_monitoring_drone_view.py +1223 -0
  15. matrice_analytics/post_processing/usecases/vehicle_monitoring_parking_lot.py +1028 -0
  16. matrice_analytics/post_processing/utils/__init__.py +5 -0
  17. matrice_analytics/post_processing/utils/agnostic_nms.py +759 -0
  18. matrice_analytics/post_processing/utils/alert_instance_utils.py +37 -2
  19. {matrice_analytics-0.1.96.dist-info → matrice_analytics-0.1.106.dist-info}/METADATA +1 -1
  20. {matrice_analytics-0.1.96.dist-info → matrice_analytics-0.1.106.dist-info}/RECORD +23 -19
  21. {matrice_analytics-0.1.96.dist-info → matrice_analytics-0.1.106.dist-info}/WHEEL +0 -0
  22. {matrice_analytics-0.1.96.dist-info → matrice_analytics-0.1.106.dist-info}/licenses/LICENSE.txt +0 -0
  23. {matrice_analytics-0.1.96.dist-info → matrice_analytics-0.1.106.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1223 @@
1
+ from typing import Any, Dict, List, Optional, Tuple
2
+ from dataclasses import asdict
3
+ import time
4
+ from datetime import datetime, timezone
5
+
6
+ from ..core.base import BaseProcessor, ProcessingContext, ProcessingResult, ConfigProtocol, ResultFormat
7
+ from ..utils import (
8
+ filter_by_confidence,
9
+ filter_by_categories,
10
+ apply_category_mapping,
11
+ count_objects_by_category,
12
+ count_objects_in_zones,
13
+ calculate_counting_summary,
14
+ match_results_structure,
15
+ bbox_smoothing,
16
+ BBoxSmoothingConfig,
17
+ BBoxSmoothingTracker
18
+ )
19
+ from dataclasses import dataclass, field
20
+ from ..core.config import BaseConfig, AlertConfig, ZoneConfig
21
+ from ..utils.geometry_utils import get_bbox_center, point_in_polygon, get_bbox_bottom25_center
22
+ from ..utils.agnostic_nms import AgnosticNMS
23
+
24
+ @dataclass
25
+ class VehicleMonitoringDroneViewConfig(BaseConfig):
26
+ """Configuration for drone view vehicle monitoring use case."""
27
+ enable_smoothing: bool = True
28
+ smoothing_algorithm: str = "observability"
29
+ smoothing_window_size: int = 20
30
+ smoothing_cooldown_frames: int = 5
31
+ smoothing_confidence_range_factor: float = 0.5
32
+ confidence_threshold: float = 0.6
33
+
34
+ # Agnostic-NMS: Configuration parameters
35
+ enable_nms: bool = True
36
+ nms_iou_threshold: float = 0.45
37
+ nms_class_agnostic: bool = True
38
+ nms_min_box_size: float = 2.0
39
+ nms_use_vectorized: bool = True
40
+
41
+ # Class Aggregation: Configuration parameters
42
+ enable_class_aggregation: bool = True
43
+ class_aggregation_window_size: int = 30 # 30 frames ≈ 1 second at 30 FPS
44
+
45
+ #JBK_720_GATE POLYGON = [[86, 328], [844, 317], [1277, 520], [1273, 707], [125, 713]]
46
+ zone_config: Optional[Dict[str, List[List[float]]]] = None #field(
47
+ # default_factory=lambda: {
48
+ # "zones": {
49
+ # "Interest_Region": [[86, 328], [844, 317], [1277, 520], [1273, 707], [125, 713]],
50
+ # }
51
+ # }
52
+ # )
53
+ usecase_categories: List[str] = field(
54
+ default_factory=lambda: [
55
+ 'car', 'van', 'bus', 'truck'
56
+ ]
57
+ )
58
+ target_categories: List[str] = field(
59
+ default_factory=lambda: [
60
+ 'car', 'van', 'bus', 'truck'
61
+ ]
62
+ )
63
+ alert_config: Optional[AlertConfig] = None
64
+ index_to_category: Optional[Dict[int, str]] = field(
65
+ default_factory=lambda: {
66
+ 0: "car",
67
+ 1: "van",
68
+ 2: "bus",
69
+ 3: "truck"
70
+ }
71
+ )
72
+
73
+ class VehicleMonitoringDroneViewUseCase(BaseProcessor):
74
+ CATEGORY_DISPLAY = {
75
+ "car": "Car",
76
+ "van": "Van",
77
+ "bus": "Bus",
78
+ "truck": "Truck",
79
+ }
80
+
81
+ def __init__(self):
82
+ super().__init__("vehicle_monitoring_drone_view")
83
+ self.category = "traffic"
84
+ self.CASE_TYPE: Optional[str] = 'vehicle_monitoring_drone_view'
85
+ self.CASE_VERSION: Optional[str] = '1.0'
86
+ self.target_categories = ['car', 'van', 'bus', 'truck' ]
87
+ self.smoothing_tracker = None
88
+ self.tracker = None
89
+ self._total_frame_counter = 0
90
+ self._global_frame_offset = 0
91
+ self._tracking_start_time = None
92
+ self._track_aliases: Dict[Any, Any] = {}
93
+ self._canonical_tracks: Dict[Any, Dict[str, Any]] = {}
94
+ self._track_merge_iou_threshold: float = 0.05
95
+ self._track_merge_time_window: float = 7.0
96
+ self._ascending_alert_list: List[int] = []
97
+ self.current_incident_end_timestamp: str = "N/A"
98
+ self.start_timer = None
99
+
100
+ # Track ID storage for total count calculation
101
+ self._per_category_total_track_ids = {cat: set() for cat in self.target_categories}
102
+ self._current_frame_track_ids = {cat: set() for cat in self.target_categories}
103
+ self._tracked_in_zones = set() # New: Unique track IDs that have entered any zone
104
+ self._total_count = 0 # Cached total count
105
+ self._last_update_time = time.time() # Track when last updated
106
+ self._total_count_list = []
107
+
108
+ # Zone-based tracking storage
109
+ self._zone_current_track_ids = {} # zone_name -> set of current track IDs in zone
110
+ self._zone_total_track_ids = {} # zone_name -> set of all track IDs that have been in zone
111
+ self._zone_current_counts = {} # zone_name -> current count in zone
112
+ self._zone_total_counts = {} # zone_name -> total count that have been in zone
113
+
114
+ # Agnostic-NMS: Initialize reusable NMS module
115
+ self._nms_module = None
116
+
117
+ def process(self, data: Any, config: ConfigProtocol, context: Optional[ProcessingContext] = None,
118
+ stream_info: Optional[Dict[str, Any]] = None) -> ProcessingResult:
119
+ processing_start = time.time()
120
+ if not isinstance(config, VehicleMonitoringDroneViewConfig):
121
+ return self.create_error_result("Invalid config type", usecase=self.name, category=self.category, context=context)
122
+ if context is None:
123
+ context = ProcessingContext()
124
+
125
+ # Determine if zones are configured
126
+ has_zones = bool(config.zone_config and config.zone_config.get('zones'))
127
+
128
+ # ===== DEBUG POINT 1: RAW INPUT =====
129
+ self._log_detection_stats(data, "01_RAW_INPUT", show_samples=True)
130
+
131
+ # Normalize typical YOLO outputs (COCO pretrained) to internal schema
132
+ data = self._normalize_yolo_results(data, getattr(config, 'index_to_category', None))
133
+
134
+ # ===== DEBUG POINT 2: AFTER NORMALIZATION =====
135
+ self._log_detection_stats(data, "02_AFTER_NORMALIZATION", show_samples=True)
136
+
137
+ input_format = match_results_structure(data)
138
+ context.input_format = input_format
139
+ context.confidence_threshold = config.confidence_threshold
140
+ # NOTE : Confidence Threshold overwrite disabled for now
141
+ # config.confidence_threshold = 0.25
142
+
143
+ # param to be updated
144
+
145
+ if config.confidence_threshold is not None:
146
+ processed_data = filter_by_confidence(data, config.confidence_threshold)
147
+ self.logger.debug(f"Applied confidence filtering with threshold {config.confidence_threshold}")
148
+ # ===== DEBUG POINT 3: AFTER CONFIDENCE FILTER =====
149
+ self._log_detection_stats(processed_data, "03_AFTER_CONFIDENCE_FILTER")
150
+ else:
151
+ processed_data = data
152
+ self.logger.debug("Did not apply confidence filtering since no threshold provided")
153
+
154
+ if config.index_to_category:
155
+ processed_data = apply_category_mapping(processed_data, config.index_to_category)
156
+ self.logger.debug("Applied category mapping")
157
+ # ===== DEBUG POINT 4: AFTER CATEGORY MAPPING =====
158
+ self._log_detection_stats(processed_data, "04_AFTER_CATEGORY_MAPPING")
159
+
160
+ # Agnostic-NMS: Apply NMS using reusable module with safety
161
+ if getattr(config, 'enable_nms', False):
162
+ pre_nms_count = len(processed_data)
163
+
164
+ # ===== DEBUG POINT 5: BEFORE NMS =====
165
+ self._log_detection_stats(processed_data, "05_BEFORE_NMS", show_samples=True)
166
+
167
+
168
+ # Safety: Log pre-NMS state for debugging
169
+ if pre_nms_count > 0:
170
+ sample_det = processed_data[0]
171
+ self.logger.debug(
172
+ f"Pre-NMS sample detection keys: {list(sample_det.keys())}, "
173
+ f"category type: {type(sample_det.get('category')).__name__}, "
174
+ f"confidence type: {type(sample_det.get('confidence')).__name__}"
175
+ )
176
+
177
+ try:
178
+ # Initialize NMS module if needed
179
+ if self._nms_module is None:
180
+ self._nms_module = AgnosticNMS(
181
+ iou_threshold=getattr(config, 'nms_iou_threshold', 0.45),
182
+ min_box_size=getattr(config, 'nms_min_box_size', 2.0),
183
+ use_vectorized=getattr(config, 'nms_use_vectorized', True)
184
+ )
185
+ self.logger.info("AgnosticNMS module initialized")
186
+
187
+ # Apply NMS
188
+ processed_data = self._nms_module.apply(
189
+ processed_data,
190
+ class_agnostic=getattr(config, 'nms_class_agnostic', True),
191
+ target_categories=self.target_categories
192
+ )
193
+
194
+ post_nms_count = len(processed_data)
195
+ suppressed_count = pre_nms_count - post_nms_count
196
+
197
+ # ===== DEBUG POINT 6: AFTER NMS =====
198
+ self._log_detection_stats(processed_data, "06_AFTER_NMS")
199
+
200
+ self.logger.info(
201
+ f"NMS applied successfully: {pre_nms_count} -> {post_nms_count} detections "
202
+ f"({suppressed_count} suppressed, {100 * suppressed_count / max(pre_nms_count, 1):.1f}%)"
203
+ )
204
+
205
+ except ValueError as ve:
206
+ # Schema validation error - log detailed diagnostics
207
+ self.logger.error(f"NMS schema validation failed: {ve}")
208
+ self.logger.error("Continuing without NMS. Check logs above for detailed diagnostics.")
209
+
210
+ except Exception as e:
211
+ # Unexpected error - log full details
212
+ import traceback
213
+ self.logger.error(f"NMS failed with unexpected error: {e}")
214
+ self.logger.error(f"Traceback: {traceback.format_exc()}")
215
+ self.logger.error("Continuing without NMS.")
216
+
217
+
218
+ processed_data = [d for d in processed_data if d.get('category') in self.target_categories]
219
+ if config.target_categories:
220
+ processed_data = [d for d in processed_data if d.get('category') in self.target_categories]
221
+ self.logger.debug("Applied category filtering")
222
+
223
+ # ===== DEBUG POINT 7: AFTER TARGET CATEGORY FILTER =====
224
+ self._log_detection_stats(processed_data, "07_AFTER_TARGET_FILTER")
225
+
226
+
227
+ if config.enable_smoothing:
228
+ if self.smoothing_tracker is None:
229
+ smoothing_config = BBoxSmoothingConfig(
230
+ smoothing_algorithm=config.smoothing_algorithm,
231
+ window_size=config.smoothing_window_size,
232
+ cooldown_frames=config.smoothing_cooldown_frames,
233
+ confidence_threshold=config.confidence_threshold,
234
+ confidence_range_factor=config.smoothing_confidence_range_factor,
235
+ enable_smoothing=True
236
+ )
237
+ self.smoothing_tracker = BBoxSmoothingTracker(smoothing_config)
238
+ processed_data = bbox_smoothing(processed_data, self.smoothing_tracker.config, self.smoothing_tracker)
239
+
240
+ # ===== DEBUG POINT 8: AFTER SMOOTHING =====
241
+ self._log_detection_stats(processed_data, "08_AFTER_SMOOTHING")
242
+
243
+ try:
244
+ from ..advanced_tracker import AdvancedTracker
245
+ from ..advanced_tracker.config import TrackerConfig
246
+ if self.tracker is None:
247
+ tracker_config = TrackerConfig(
248
+ # CLASS AGGREGATION: Map from use case config
249
+ enable_class_aggregation=config.enable_class_aggregation,
250
+ class_aggregation_window_size=config.class_aggregation_window_size
251
+ )
252
+ self.tracker = AdvancedTracker(tracker_config)
253
+ self.logger.info("Initialized AdvancedTracker for Vehicle Monitoring Drone View Use Case")
254
+
255
+ if config.enable_class_aggregation:
256
+ self.logger.info(
257
+ f"AdvancedTracker initialized with class aggregation "
258
+ f"(window_size={config.class_aggregation_window_size})"
259
+ )
260
+ else:
261
+ self.logger.info("AdvancedTracker initialized without class aggregation")
262
+
263
+ processed_data = self.tracker.update(processed_data)
264
+
265
+ # ===== DEBUG POINT 9: AFTER TRACKING =====
266
+ self._log_detection_stats(processed_data, "09_AFTER_TRACKING")
267
+
268
+ except Exception as e:
269
+ self.logger.warning(f"AdvancedTracker failed: {e}")
270
+
271
+ self._update_tracking_state(processed_data, has_zones=has_zones)
272
+ self._total_frame_counter += 1
273
+
274
+ frame_number = None
275
+ if stream_info:
276
+ input_settings = stream_info.get("input_settings", {})
277
+ start_frame = input_settings.get("start_frame")
278
+ end_frame = input_settings.get("end_frame")
279
+ if start_frame is not None and end_frame is not None and start_frame == end_frame:
280
+ frame_number = start_frame
281
+
282
+ general_counting_summary = calculate_counting_summary(data)
283
+ counting_summary = self._count_categories(processed_data, config)
284
+ total_counts = self.get_total_counts()
285
+ counting_summary['total_counts'] = total_counts
286
+ counting_summary['categories'] = {}
287
+ for detection in processed_data:
288
+ category = detection.get("category", "unknown")
289
+ counting_summary["categories"][category] = counting_summary["categories"].get(category, 0) + 1
290
+
291
+ zone_analysis = {}
292
+ if has_zones:
293
+ # Convert single frame to format expected by count_objects_in_zones
294
+ frame_data = processed_data #[frame_detections]
295
+ zone_analysis = count_objects_in_zones(frame_data, config.zone_config['zones'], stream_info)
296
+
297
+ if zone_analysis:
298
+ enhanced_zone_analysis = self._update_zone_tracking(zone_analysis, processed_data, config)
299
+ # Merge enhanced zone analysis with original zone analysis
300
+ for zone_name, enhanced_data in enhanced_zone_analysis.items():
301
+ zone_analysis[zone_name] = enhanced_data
302
+
303
+ # Adjust counting_summary for zones (current counts based on union across zones)
304
+ per_category_count = {cat: len(self._current_frame_track_ids.get(cat, set())) for cat in self.target_categories}
305
+ counting_summary['per_category_count'] = {k: v for k, v in per_category_count.items() if v > 0}
306
+ counting_summary['total_count'] = sum(per_category_count.values())
307
+
308
+ alerts = self._check_alerts(counting_summary,zone_analysis, frame_number, config)
309
+ predictions = self._extract_predictions(processed_data)
310
+
311
+ incidents_list = self._generate_incidents(counting_summary,zone_analysis, alerts, config, frame_number, stream_info)
312
+ incidents_list = []
313
+ tracking_stats_list = self._generate_tracking_stats(counting_summary,zone_analysis, alerts, config, frame_number, stream_info)
314
+
315
+ business_analytics_list = self._generate_business_analytics(counting_summary,zone_analysis, alerts, config, stream_info, is_empty=True)
316
+ summary_list = self._generate_summary(counting_summary,zone_analysis, incidents_list, tracking_stats_list, business_analytics_list, alerts)
317
+
318
+ incidents = incidents_list[0] if incidents_list else {}
319
+ tracking_stats = tracking_stats_list[0] if tracking_stats_list else {}
320
+ business_analytics = business_analytics_list[0] if business_analytics_list else {}
321
+ summary = summary_list[0] if summary_list else {}
322
+ agg_summary = {str(frame_number): {
323
+ "incidents": incidents,
324
+ "tracking_stats": tracking_stats,
325
+ "business_analytics": business_analytics,
326
+ "alerts": alerts,
327
+ "zone_analysis": zone_analysis,
328
+ "human_text": summary}
329
+ }
330
+
331
+ context.mark_completed()
332
+ result = self.create_result(
333
+ data={"agg_summary": agg_summary},
334
+ usecase=self.name,
335
+ category=self.category,
336
+ context=context
337
+ )
338
+ proc_time = time.time() - processing_start
339
+ processing_latency_ms = proc_time * 1000.0
340
+ processing_fps = (1.0 / proc_time) if proc_time > 0 else None
341
+ # Log the performance metrics using the module-level logger
342
+ print("latency in ms:",processing_latency_ms,"| Throughput fps:",processing_fps,"| Frame_Number:",self._total_frame_counter)
343
+ return result
344
+
345
+ def _update_zone_tracking(self, zone_analysis: Dict[str, Dict[str, int]], detections: List[Dict], config: VehicleMonitoringDroneViewConfig) -> Dict[str, Dict[str, Any]]:
346
+ """
347
+ Update zone tracking with current frame data.
348
+
349
+ Args:
350
+ zone_analysis: Current zone analysis results
351
+ detections: List of detections with track IDs
352
+
353
+ Returns:
354
+ Enhanced zone analysis with tracking information
355
+ """
356
+ if not zone_analysis or not config.zone_config or not config.zone_config['zones']:
357
+ return {}
358
+
359
+ enhanced_zone_analysis = {}
360
+ zones = config.zone_config['zones']
361
+
362
+ # Get track to category mapping
363
+ track_to_cat = {det.get('track_id'): det.get('category') for det in detections if det.get('track_id') is not None}
364
+
365
+ # Get current frame track IDs in each zone
366
+ current_frame_zone_tracks = {}
367
+
368
+ # Initialize zone tracking for all zones
369
+ for zone_name in zones.keys():
370
+ current_frame_zone_tracks[zone_name] = set()
371
+ if zone_name not in self._zone_current_track_ids:
372
+ self._zone_current_track_ids[zone_name] = set()
373
+ if zone_name not in self._zone_total_track_ids:
374
+ self._zone_total_track_ids[zone_name] = set()
375
+
376
+ # Check each detection against each zone
377
+ for detection in detections:
378
+ track_id = detection.get("track_id")
379
+ if track_id is None:
380
+ continue
381
+
382
+ # Get detection bbox
383
+ bbox = detection.get("bounding_box", detection.get("bbox"))
384
+ if not bbox:
385
+ continue
386
+
387
+ # Get detection center point
388
+ center_point = get_bbox_bottom25_center(bbox) #get_bbox_center(bbox)
389
+
390
+ # Flag to check if this track is in any zone this frame
391
+ in_any_zone = False
392
+
393
+ # Check which zone this detection is in using actual zone polygons
394
+ for zone_name, zone_polygon in zones.items():
395
+ # Convert polygon points to tuples for point_in_polygon function
396
+ # zone_polygon format: [[x1, y1], [x2, y2], [x3, y3], ...]
397
+ polygon_points = [(point[0], point[1]) for point in zone_polygon]
398
+
399
+ # Check if detection center is inside the zone polygon using ray casting algorithm
400
+ if point_in_polygon(center_point, polygon_points):
401
+ current_frame_zone_tracks[zone_name].add(track_id)
402
+ in_any_zone = True
403
+ if track_id not in self._total_count_list:
404
+ self._total_count_list.append(track_id)
405
+
406
+ # If in any zone, update global current and total (cumulative only if new)
407
+ if in_any_zone:
408
+ cat = track_to_cat.get(track_id)
409
+ if cat:
410
+ # Update current frame global (union across zones)
411
+ self._current_frame_track_ids.setdefault(cat, set()).add(track_id)
412
+
413
+ # Update global cumulative if first time in any zone
414
+ if track_id not in self._tracked_in_zones:
415
+ self._tracked_in_zones.add(track_id)
416
+ self._per_category_total_track_ids.setdefault(cat, set()).add(track_id)
417
+
418
+ # Update zone tracking for each zone
419
+ for zone_name, zone_counts in zone_analysis.items():
420
+ # Get current frame tracks for this zone
421
+ current_tracks = current_frame_zone_tracks.get(zone_name, set())
422
+
423
+ # Update current zone tracks
424
+ self._zone_current_track_ids[zone_name] = current_tracks
425
+
426
+ # Update total zone tracks (accumulate all track IDs that have been in zone)
427
+ self._zone_total_track_ids[zone_name].update(current_tracks)
428
+
429
+ # Update counts
430
+ self._zone_current_counts[zone_name] = len(current_tracks)
431
+ self._zone_total_counts[zone_name] = len(self._zone_total_track_ids[zone_name])
432
+
433
+ # Create enhanced zone analysis
434
+ enhanced_zone_analysis[zone_name] = {
435
+ "current_count": self._zone_current_counts[zone_name],
436
+ "total_count": self._zone_total_counts[zone_name],
437
+ "current_track_ids": list(current_tracks),
438
+ "total_track_ids": list(self._zone_total_track_ids[zone_name]),
439
+ "original_counts": zone_counts # Preserve original zone counts
440
+ }
441
+
442
+ return enhanced_zone_analysis
443
+
444
+ def _normalize_yolo_results(self, data: Any, index_to_category: Optional[Dict[int, str]] = None) -> Any:
445
+ """
446
+ Normalize YOLO-style outputs to internal detection schema:
447
+ - category/category_id: prefer string label using COCO mapping if available
448
+ - confidence: map from 'conf'/'score' to 'confidence'
449
+ - bounding_box: ensure dict with keys (x1,y1,x2,y2) or (xmin,ymin,xmax,ymax)
450
+ - supports list of detections and frame_id -> detections dict
451
+ """
452
+ def to_bbox_dict(d: Dict[str, Any]) -> Dict[str, Any]:
453
+ if "bounding_box" in d and isinstance(d["bounding_box"], dict):
454
+ return d["bounding_box"]
455
+ if "bbox" in d:
456
+ bbox = d["bbox"]
457
+ if isinstance(bbox, dict):
458
+ return bbox
459
+ if isinstance(bbox, (list, tuple)) and len(bbox) >= 4:
460
+ x1, y1, x2, y2 = bbox[0], bbox[1], bbox[2], bbox[3]
461
+ return {"x1": x1, "y1": y1, "x2": x2, "y2": y2}
462
+ if "xyxy" in d and isinstance(d["xyxy"], (list, tuple)) and len(d["xyxy"]) >= 4:
463
+ x1, y1, x2, y2 = d["xyxy"][0], d["xyxy"][1], d["xyxy"][2], d["xyxy"][3]
464
+ return {"x1": x1, "y1": y1, "x2": x2, "y2": y2}
465
+ if "xywh" in d and isinstance(d["xywh"], (list, tuple)) and len(d["xywh"]) >= 4:
466
+ cx, cy, w, h = d["xywh"][0], d["xywh"][1], d["xywh"][2], d["xywh"][3]
467
+ x1, y1, x2, y2 = cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2
468
+ return {"x1": x1, "y1": y1, "x2": x2, "y2": y2}
469
+ return {}
470
+
471
+ def resolve_category(d: Dict[str, Any]) -> Tuple[str, Optional[int]]:
472
+ raw_cls = d.get("category", d.get("category_id", d.get("class", d.get("cls"))))
473
+ label_name = d.get("name")
474
+ if isinstance(raw_cls, int):
475
+ if index_to_category and raw_cls in index_to_category:
476
+ return index_to_category[raw_cls], raw_cls
477
+ return str(raw_cls), raw_cls
478
+ if isinstance(raw_cls, str):
479
+ # Some YOLO exports provide string labels directly
480
+ return raw_cls, None
481
+ if label_name:
482
+ return str(label_name), None
483
+ return "unknown", None
484
+
485
+ def normalize_det(det: Dict[str, Any]) -> Dict[str, Any]:
486
+ category_name, category_id = resolve_category(det)
487
+ confidence = det.get("confidence", det.get("conf", det.get("score", 0.0)))
488
+ bbox = to_bbox_dict(det)
489
+ normalized = {
490
+ "category": category_name,
491
+ "confidence": confidence,
492
+ "bounding_box": bbox,
493
+ }
494
+ if category_id is not None:
495
+ normalized["category_id"] = category_id
496
+ # Preserve optional fields
497
+ for key in ("track_id", "frame_id", "masks", "segmentation"):
498
+ if key in det:
499
+ normalized[key] = det[key]
500
+ return normalized
501
+
502
+ if isinstance(data, list):
503
+ return [normalize_det(d) if isinstance(d, dict) else d for d in data]
504
+ if isinstance(data, dict):
505
+ # Detect tracking style dict: frame_id -> list of detections
506
+ normalized_dict: Dict[str, Any] = {}
507
+ for k, v in data.items():
508
+ if isinstance(v, list):
509
+ normalized_dict[k] = [normalize_det(d) if isinstance(d, dict) else d for d in v]
510
+ elif isinstance(v, dict):
511
+ normalized_dict[k] = normalize_det(v)
512
+ else:
513
+ normalized_dict[k] = v
514
+ return normalized_dict
515
+ return data
516
+
517
+ def _check_alerts(self, summary: dict, zone_analysis: Dict, frame_number: Any, config: VehicleMonitoringDroneViewConfig) -> List[Dict]:
518
+ def get_trend(data, lookback=900, threshold=0.6):
519
+ window = data[-lookback:] if len(data) >= lookback else data
520
+ if len(window) < 2:
521
+ return True
522
+ increasing = 0
523
+ total = 0
524
+ for i in range(1, len(window)):
525
+ if window[i] >= window[i - 1]:
526
+ increasing += 1
527
+ total += 1
528
+ ratio = increasing / total
529
+ return ratio >= threshold
530
+
531
+ frame_key = str(frame_number) if frame_number is not None else "current_frame"
532
+ alerts = []
533
+ total_detections = summary.get("total_count", 0)
534
+ total_counts_dict = summary.get("total_counts", {})
535
+ per_category_count = summary.get("per_category_count", {})
536
+
537
+ if not config.alert_config:
538
+ return alerts
539
+
540
+ if hasattr(config.alert_config, 'count_thresholds') and config.alert_config.count_thresholds:
541
+ for category, threshold in config.alert_config.count_thresholds.items():
542
+ if category == "all" and total_detections > threshold:
543
+ alerts.append({
544
+ "alert_type": getattr(config.alert_config, 'alert_type', ['Default']),
545
+ "alert_id": f"alert_{category}_{frame_key}",
546
+ "incident_category": self.CASE_TYPE,
547
+ "threshold_level": threshold,
548
+ "ascending": get_trend(self._ascending_alert_list, lookback=900, threshold=0.8),
549
+ "settings": {t: v for t, v in zip(getattr(config.alert_config, 'alert_type', ['Default']),
550
+ getattr(config.alert_config, 'alert_value', ['JSON']))}
551
+ })
552
+ elif category in per_category_count and per_category_count[category] > threshold:
553
+ alerts.append({
554
+ "alert_type": getattr(config.alert_config, 'alert_type', ['Default']),
555
+ "alert_id": f"alert_{category}_{frame_key}",
556
+ "incident_category": self.CASE_TYPE,
557
+ "threshold_level": threshold,
558
+ "ascending": get_trend(self._ascending_alert_list, lookback=900, threshold=0.8),
559
+ "settings": {t: v for t, v in zip(getattr(config.alert_config, 'alert_type', ['Default']),
560
+ getattr(config.alert_config, 'alert_value', ['JSON']))}
561
+ })
562
+ return alerts
563
+
564
+ def _generate_incidents(self, counting_summary: Dict, zone_analysis: Dict, alerts: List, config: VehicleMonitoringDroneViewConfig,
565
+ frame_number: Optional[int] = None, stream_info: Optional[Dict[str, Any]] = None) -> List[Dict]:
566
+ incidents = []
567
+ total_detections = counting_summary.get("total_count", 0)
568
+ current_timestamp = self._get_current_timestamp_str(stream_info)
569
+ camera_info = self.get_camera_info_from_stream(stream_info)
570
+
571
+ self._ascending_alert_list = self._ascending_alert_list[-900:] if len(self._ascending_alert_list) > 900 else self._ascending_alert_list
572
+
573
+ if total_detections > 0:
574
+ level = "low"
575
+ intensity = 5.0
576
+ start_timestamp = self._get_start_timestamp_str(stream_info)
577
+ if start_timestamp and self.current_incident_end_timestamp == 'N/A':
578
+ self.current_incident_end_timestamp = 'Incident still active'
579
+ elif start_timestamp and self.current_incident_end_timestamp == 'Incident still active':
580
+ if len(self._ascending_alert_list) >= 15 and sum(self._ascending_alert_list[-15:]) / 15 < 1.5:
581
+ self.current_incident_end_timestamp = current_timestamp
582
+ elif self.current_incident_end_timestamp != 'Incident still active' and self.current_incident_end_timestamp != 'N/A':
583
+ self.current_incident_end_timestamp = 'N/A'
584
+
585
+ if config.alert_config and hasattr(config.alert_config, 'count_thresholds') and config.alert_config.count_thresholds:
586
+ threshold = config.alert_config.count_thresholds.get("all", 15)
587
+ intensity = min(10.0, (total_detections / threshold) * 10)
588
+ if intensity >= 9:
589
+ level = "critical"
590
+ self._ascending_alert_list.append(3)
591
+ elif intensity >= 7:
592
+ level = "significant"
593
+ self._ascending_alert_list.append(2)
594
+ elif intensity >= 5:
595
+ level = "medium"
596
+ self._ascending_alert_list.append(1)
597
+ else:
598
+ level = "low"
599
+ self._ascending_alert_list.append(0)
600
+ else:
601
+ if total_detections > 30:
602
+ level = "critical"
603
+ intensity = 10.0
604
+ self._ascending_alert_list.append(3)
605
+ elif total_detections > 25:
606
+ level = "significant"
607
+ intensity = 9.0
608
+ self._ascending_alert_list.append(2)
609
+ elif total_detections > 15:
610
+ level = "medium"
611
+ intensity = 7.0
612
+ self._ascending_alert_list.append(1)
613
+ else:
614
+ level = "low"
615
+ intensity = min(10.0, total_detections / 3.0)
616
+ self._ascending_alert_list.append(0)
617
+
618
+ human_text_lines = [f"VEHICLE INCIDENTS DETECTED @ {current_timestamp}:"]
619
+ human_text_lines.append(f"\tSeverity Level: {(self.CASE_TYPE, level)}")
620
+ human_text = "\n".join(human_text_lines)
621
+
622
+ alert_settings = []
623
+ if config.alert_config and hasattr(config.alert_config, 'alert_type'):
624
+ alert_settings.append({
625
+ "alert_type": getattr(config.alert_config, 'alert_type', ['Default']),
626
+ "incident_category": self.CASE_TYPE,
627
+ "threshold_level": config.alert_config.count_thresholds if hasattr(config.alert_config, 'count_thresholds') else {},
628
+ "ascending": True,
629
+ "settings": {t: v for t, v in zip(getattr(config.alert_config, 'alert_type', ['Default']),
630
+ getattr(config.alert_config, 'alert_value', ['JSON']))}
631
+ })
632
+
633
+ event = self.create_incident(
634
+ incident_id=f"{self.CASE_TYPE}_{frame_number}",
635
+ incident_type=self.CASE_TYPE,
636
+ severity_level=level,
637
+ human_text=human_text,
638
+ camera_info=camera_info,
639
+ alerts=alerts,
640
+ alert_settings=alert_settings,
641
+ start_time=start_timestamp,
642
+ end_time=self.current_incident_end_timestamp,
643
+ level_settings={"low": 1, "medium": 3, "significant": 4, "critical": 7}
644
+ )
645
+ incidents.append(event)
646
+ else:
647
+ self._ascending_alert_list.append(0)
648
+ incidents.append({})
649
+ return incidents
650
+
651
+ def _generate_tracking_stats(self, counting_summary: Dict, zone_analysis: Dict, alerts: List, config: VehicleMonitoringDroneViewConfig,
652
+ frame_number: Optional[int] = None, stream_info: Optional[Dict[str, Any]] = None) -> List[Dict]:
653
+ camera_info = self.get_camera_info_from_stream(stream_info)
654
+ tracking_stats = []
655
+ total_detections = counting_summary.get("total_count", 0)
656
+ total_counts_dict = counting_summary.get("total_counts", {})
657
+ per_category_count = counting_summary.get("per_category_count", {})
658
+ current_timestamp = self._get_current_timestamp_str(stream_info, precision=False)
659
+ start_timestamp = self._get_start_timestamp_str(stream_info, precision=False)
660
+ high_precision_start_timestamp = self._get_current_timestamp_str(stream_info, precision=True)
661
+ high_precision_reset_timestamp = self._get_start_timestamp_str(stream_info, precision=True)
662
+
663
+ total_counts = [{"category": cat, "count": count} for cat, count in total_counts_dict.items() if count > 0]
664
+ current_counts = [{"category": cat, "count": count} for cat, count in per_category_count.items() if count > 0 or total_detections > 0]
665
+
666
+ detections = []
667
+ for detection in counting_summary.get("detections", []):
668
+ bbox = detection.get("bounding_box", {})
669
+ category = detection.get("category", "vehicle")
670
+ if detection.get("masks"):
671
+ segmentation = detection.get("masks", [])
672
+ detection_obj = self.create_detection_object(category, bbox, segmentation=segmentation)
673
+ elif detection.get("segmentation"):
674
+ segmentation = detection.get("segmentation")
675
+ detection_obj = self.create_detection_object(category, bbox, segmentation=segmentation)
676
+ elif detection.get("mask"):
677
+ segmentation = detection.get("mask")
678
+ detection_obj = self.create_detection_object(category, bbox, segmentation=segmentation)
679
+ else:
680
+ detection_obj = self.create_detection_object(category, bbox)
681
+ detections.append(detection_obj)
682
+
683
+ alert_settings = []
684
+ if config.alert_config and hasattr(config.alert_config, 'alert_type'):
685
+ alert_settings.append({
686
+ "alert_type": getattr(config.alert_config, 'alert_type', ['Default']),
687
+ "incident_category": self.CASE_TYPE,
688
+ "threshold_level": config.alert_config.count_thresholds if hasattr(config.alert_config, 'count_thresholds') else {},
689
+ "ascending": True,
690
+ "settings": {t: v for t, v in zip(getattr(config.alert_config, 'alert_type', ['Default']),
691
+ getattr(config.alert_config, 'alert_value', ['JSON']))}
692
+ })
693
+
694
+ # Generate human text similar to people_counting format
695
+ human_text_lines = []
696
+ human_text_lines.append(f"CURRENT FRAME @ {current_timestamp}:")
697
+
698
+ # Display current counts - zone-wise or category-wise
699
+ if zone_analysis:
700
+ human_text_lines.append("\t- Vehicles Detected by Zone:")
701
+ for zone_name, zone_data in zone_analysis.items():
702
+ current_count = 0
703
+ if isinstance(zone_data, dict):
704
+ if "current_count" in zone_data:
705
+ current_count = zone_data.get("current_count", 0)
706
+ else:
707
+ counts_dict = zone_data.get("original_counts") if isinstance(zone_data.get("original_counts"), dict) else zone_data
708
+ current_count = counts_dict.get(
709
+ "total",
710
+ sum(v for v in counts_dict.values() if isinstance(v, (int, float)))
711
+ )
712
+ human_text_lines.append(f"\t\t- {zone_name}: {int(current_count)}")
713
+ else:
714
+ human_text_lines.append(f"\t- Vehicles Detected: {total_detections}")
715
+ if per_category_count:
716
+ for cat, count in per_category_count.items():
717
+ if count > 0:
718
+ human_text_lines.append(f"\t\t- {cat}: {count}")
719
+
720
+ human_text_lines.append("")
721
+ # human_text_lines.append(f"TOTAL SINCE @ {start_timestamp}:")
722
+
723
+ # # Display total counts - zone-wise or category-wise
724
+ # if zone_analysis:
725
+ # human_text_lines.append("\t- Total Vehicles by Zone:")
726
+ # for zone_name, zone_data in zone_analysis.items():
727
+ # total_count = 0
728
+ # if isinstance(zone_data, dict):
729
+ # # Prefer the numeric cumulative total if available
730
+ # if "total_count" in zone_data and isinstance(zone_data.get("total_count"), (int, float)):
731
+ # total_count = zone_data.get("total_count", 0)
732
+ # # Fallback: compute from list of total_track_ids if present
733
+ # elif "total_track_ids" in zone_data and isinstance(zone_data.get("total_track_ids"), list):
734
+ # total_count = len(zone_data.get("total_track_ids", []))
735
+ # else:
736
+ # # Last resort: try to sum numeric values present
737
+ # counts_dict = zone_data if isinstance(zone_data, dict) else {}
738
+ # total_count = sum(v for v in counts_dict.values() if isinstance(v, (int, float)))
739
+ # human_text_lines.append(f"\t\t- {zone_name}: {int(total_count)}")
740
+ # else:
741
+ # if total_counts_dict:
742
+ # human_text_lines.append("\t- Total Unique Vehicles:")
743
+ # for cat, count in total_counts_dict.items():
744
+ # if count > 0:
745
+ # human_text_lines.append(f"\t\t- {cat}: {count}")
746
+
747
+ # # Display alerts
748
+ # if alerts:
749
+ # human_text_lines.append("")
750
+ # for alert in alerts:
751
+ # human_text_lines.append(f"Alerts: {alert.get('settings', {})} sent @ {current_timestamp}")
752
+ # else:
753
+ # human_text_lines.append("")
754
+ # human_text_lines.append("Alerts: None")
755
+
756
+ human_text = "\n".join(human_text_lines)
757
+
758
+ reset_settings = [{"interval_type": "daily", "reset_time": {"value": 9, "time_unit": "hour"}}]
759
+ tracking_stat = self.create_tracking_stats(
760
+ total_counts=total_counts,
761
+ current_counts=current_counts,
762
+ detections=detections,
763
+ human_text=human_text,
764
+ camera_info=camera_info,
765
+ alerts=alerts,
766
+ alert_settings=alert_settings,
767
+ reset_settings=reset_settings,
768
+ start_time=high_precision_start_timestamp,
769
+ reset_time=high_precision_reset_timestamp
770
+ )
771
+ tracking_stat['target_categories'] = self.target_categories
772
+ tracking_stats.append(tracking_stat)
773
+ return tracking_stats
774
+
775
+ def _generate_business_analytics(self, counting_summary: Dict, zone_analysis: Dict, alerts: Any, config: VehicleMonitoringDroneViewConfig,
776
+ stream_info: Optional[Dict[str, Any]] = None, is_empty=False) -> List[Dict]:
777
+ if is_empty:
778
+ return []
779
+
780
+ def _generate_summary(self, summary: dict, zone_analysis: Dict, incidents: List, tracking_stats: List, business_analytics: List, alerts: List) -> List[str]:
781
+ """
782
+ Generate a human_text string for the tracking_stat, incident, business analytics and alerts.
783
+ """
784
+ lines = []
785
+ lines.append("Application Name: "+self.CASE_TYPE)
786
+ lines.append("Application Version: "+self.CASE_VERSION)
787
+ if len(incidents) > 0:
788
+ lines.append("Incidents: "+f"\n\t{incidents[0].get('human_text', 'No incidents detected')}")
789
+ if len(tracking_stats) > 0:
790
+ lines.append("Tracking Statistics: "+f"\t{tracking_stats[0].get('human_text', 'No tracking statistics detected')}")
791
+ if len(business_analytics) > 0:
792
+ lines.append("Business Analytics: "+f"\t{business_analytics[0].get('human_text', 'No business analytics detected')}")
793
+
794
+ if len(incidents) == 0 and len(tracking_stats) == 0 and len(business_analytics) == 0:
795
+ lines.append("Summary: "+"No Summary Data")
796
+
797
+ return ["\n".join(lines)]
798
+
799
+ def _get_track_ids_info(self, detections: list) -> Dict[str, Any]:
800
+ frame_track_ids = set()
801
+ for det in detections:
802
+ tid = det.get('track_id')
803
+ if tid is not None:
804
+ frame_track_ids.add(tid)
805
+ total_track_ids = set()
806
+ for s in getattr(self, '_per_category_total_track_ids', {}).values():
807
+ total_track_ids.update(s)
808
+ return {
809
+ "total_count": len(total_track_ids),
810
+ "current_frame_count": len(frame_track_ids),
811
+ "total_unique_track_ids": len(total_track_ids),
812
+ "current_frame_track_ids": list(frame_track_ids),
813
+ "last_update_time": time.time(),
814
+ "total_frames_processed": getattr(self, '_total_frame_counter', 0)
815
+ }
816
+
817
+ def _update_tracking_state(self, detections: list, has_zones: bool = False):
818
+ if not hasattr(self, "_per_category_total_track_ids"):
819
+ self._per_category_total_track_ids = {cat: set() for cat in self.target_categories}
820
+ self._current_frame_track_ids = {cat: set() for cat in self.target_categories}
821
+
822
+ for det in detections:
823
+ cat = det.get("category")
824
+ raw_track_id = det.get("track_id")
825
+ if cat not in self.target_categories or raw_track_id is None:
826
+ continue
827
+ bbox = det.get("bounding_box", det.get("bbox"))
828
+ canonical_id = self._merge_or_register_track(raw_track_id, bbox)
829
+ det["track_id"] = canonical_id
830
+ if not has_zones:
831
+ self._per_category_total_track_ids.setdefault(cat, set()).add(canonical_id)
832
+ # For current frame, add unconditionally here; will be overridden/adjusted if has_zones in _update_zone_tracking
833
+ self._current_frame_track_ids.setdefault(cat, set()).add(canonical_id)
834
+
835
+ def get_total_counts(self):
836
+ return {cat: len(ids) for cat, ids in getattr(self, '_per_category_total_track_ids', {}).items()}
837
+
838
+ def _format_timestamp_for_stream(self, timestamp: float) -> str:
839
+ dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
840
+ return dt.strftime('%Y:%m:%d %H:%M:%S')
841
+
842
+ def _format_timestamp_for_video(self, timestamp: float) -> str:
843
+ hours = int(timestamp // 3600)
844
+ minutes = int((timestamp % 3600) // 60)
845
+ seconds = round(float(timestamp % 60), 2)
846
+ return f"{hours:02d}:{minutes:02d}:{seconds:.1f}"
847
+
848
+ def _format_timestamp(self, timestamp: Any) -> str:
849
+ """Format a timestamp to match the current timestamp format: YYYY:MM:DD HH:MM:SS.
850
+
851
+ The input can be either:
852
+ 1. A numeric Unix timestamp (``float`` / ``int``) – it will be converted to datetime.
853
+ 2. A string in the format ``YYYY-MM-DD-HH:MM:SS.ffffff UTC``.
854
+
855
+ The returned value will be in the format: YYYY:MM:DD HH:MM:SS (no milliseconds, no UTC suffix).
856
+
857
+ Example
858
+ -------
859
+ >>> self._format_timestamp("2025-10-27-19:31:20.187574 UTC")
860
+ '2025:10:27 19:31:20'
861
+ """
862
+
863
+ # Convert numeric timestamps to datetime first
864
+ if isinstance(timestamp, (int, float)):
865
+ dt = datetime.fromtimestamp(timestamp, timezone.utc)
866
+ return dt.strftime('%Y:%m:%d %H:%M:%S')
867
+
868
+ # Ensure we are working with a string from here on
869
+ if not isinstance(timestamp, str):
870
+ return str(timestamp)
871
+
872
+ # Remove ' UTC' suffix if present
873
+ timestamp_clean = timestamp.replace(' UTC', '').strip()
874
+
875
+ # Remove milliseconds if present (everything after the last dot)
876
+ if '.' in timestamp_clean:
877
+ timestamp_clean = timestamp_clean.split('.')[0]
878
+
879
+ # Parse the timestamp string and convert to desired format
880
+ try:
881
+ # Handle format: YYYY-MM-DD-HH:MM:SS
882
+ if timestamp_clean.count('-') >= 2:
883
+ # Replace first two dashes with colons for date part, third with space
884
+ parts = timestamp_clean.split('-')
885
+ if len(parts) >= 4:
886
+ # parts = ['2025', '10', '27', '19:31:20']
887
+ formatted = f"{parts[0]}:{parts[1]}:{parts[2]} {'-'.join(parts[3:])}"
888
+ return formatted
889
+ except Exception:
890
+ pass
891
+
892
+ # If parsing fails, return the cleaned string as-is
893
+ return timestamp_clean
894
+
895
+ def _get_current_timestamp_str(self, stream_info: Optional[Dict[str, Any]], precision=False, frame_id: Optional[str]=None) -> str:
896
+ """Get formatted current timestamp based on stream type."""
897
+
898
+ if not stream_info:
899
+ return "00:00:00.00"
900
+ if precision:
901
+ if stream_info.get("input_settings", {}).get("start_frame", "na") != "na":
902
+ if frame_id:
903
+ start_time = int(frame_id)/stream_info.get("input_settings", {}).get("original_fps", 30)
904
+ else:
905
+ start_time = stream_info.get("input_settings", {}).get("start_frame", 30)/stream_info.get("input_settings", {}).get("original_fps", 30)
906
+ stream_time_str = self._format_timestamp_for_video(start_time)
907
+
908
+ return self._format_timestamp(stream_info.get("input_settings", {}).get("stream_time", "NA"))
909
+ else:
910
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
911
+
912
+ if stream_info.get("input_settings", {}).get("start_frame", "na") != "na":
913
+ if frame_id:
914
+ start_time = int(frame_id)/stream_info.get("input_settings", {}).get("original_fps", 30)
915
+ else:
916
+ start_time = stream_info.get("input_settings", {}).get("start_frame", 30)/stream_info.get("input_settings", {}).get("original_fps", 30)
917
+
918
+ stream_time_str = self._format_timestamp_for_video(start_time)
919
+
920
+
921
+ return self._format_timestamp(stream_info.get("input_settings", {}).get("stream_time", "NA"))
922
+ else:
923
+ stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
924
+ if stream_time_str:
925
+ try:
926
+ timestamp_str = stream_time_str.replace(" UTC", "")
927
+ dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
928
+ timestamp = dt.replace(tzinfo=timezone.utc).timestamp()
929
+ return self._format_timestamp_for_stream(timestamp)
930
+ except:
931
+ return self._format_timestamp_for_stream(time.time())
932
+ else:
933
+ return self._format_timestamp_for_stream(time.time())
934
+
935
+ def _get_start_timestamp_str(self, stream_info: Optional[Dict[str, Any]], precision=False) -> str:
936
+ """Get formatted start timestamp for 'TOTAL SINCE' based on stream type."""
937
+ if not stream_info:
938
+ return "00:00:00"
939
+
940
+ if precision:
941
+ if self.start_timer is None:
942
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
943
+ if not candidate or candidate == "NA":
944
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
945
+ self.start_timer = candidate
946
+ return self._format_timestamp(self.start_timer)
947
+ elif stream_info.get("input_settings", {}).get("start_frame", "na") == 1:
948
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
949
+ if not candidate or candidate == "NA":
950
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
951
+ self.start_timer = candidate
952
+ return self._format_timestamp(self.start_timer)
953
+ else:
954
+ return self._format_timestamp(self.start_timer)
955
+
956
+ if self.start_timer is None:
957
+ # Prefer direct input_settings.stream_time if available and not NA
958
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
959
+ if not candidate or candidate == "NA":
960
+ # Fallback to nested stream_info.stream_time used by current timestamp path
961
+ stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
962
+ if stream_time_str:
963
+ try:
964
+ timestamp_str = stream_time_str.replace(" UTC", "")
965
+ dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
966
+ self._tracking_start_time = dt.replace(tzinfo=timezone.utc).timestamp()
967
+ candidate = datetime.fromtimestamp(self._tracking_start_time, timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
968
+ except:
969
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
970
+ else:
971
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
972
+ self.start_timer = candidate
973
+ return self._format_timestamp(self.start_timer)
974
+ elif stream_info.get("input_settings", {}).get("start_frame", "na") == 1:
975
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
976
+ if not candidate or candidate == "NA":
977
+ stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
978
+ if stream_time_str:
979
+ try:
980
+ timestamp_str = stream_time_str.replace(" UTC", "")
981
+ dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
982
+ ts = dt.replace(tzinfo=timezone.utc).timestamp()
983
+ candidate = datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
984
+ except:
985
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
986
+ else:
987
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
988
+ self.start_timer = candidate
989
+ return self._format_timestamp(self.start_timer)
990
+
991
+ else:
992
+ if self.start_timer is not None and self.start_timer != "NA":
993
+ return self._format_timestamp(self.start_timer)
994
+
995
+ if self._tracking_start_time is None:
996
+ stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
997
+ if stream_time_str:
998
+ try:
999
+ timestamp_str = stream_time_str.replace(" UTC", "")
1000
+ dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
1001
+ self._tracking_start_time = dt.replace(tzinfo=timezone.utc).timestamp()
1002
+ except:
1003
+ self._tracking_start_time = time.time()
1004
+ else:
1005
+ self._tracking_start_time = time.time()
1006
+
1007
+ dt = datetime.fromtimestamp(self._tracking_start_time, tz=timezone.utc)
1008
+ dt = dt.replace(minute=0, second=0, microsecond=0)
1009
+ return dt.strftime('%Y:%m:%d %H:%M:%S')
1010
+
1011
+ def _count_categories(self, detections: list, config: VehicleMonitoringDroneViewConfig) -> dict:
1012
+ counts = {}
1013
+ for det in detections:
1014
+ cat = det.get('category', 'unknown')
1015
+ counts[cat] = counts.get(cat, 0) + 1
1016
+ return {
1017
+ "total_count": sum(counts.values()),
1018
+ "per_category_count": counts,
1019
+ "detections": [
1020
+ {
1021
+ "bounding_box": det.get("bounding_box"),
1022
+ "category": det.get("category"),
1023
+ "confidence": det.get("confidence"),
1024
+ "track_id": det.get("track_id"),
1025
+ "frame_id": det.get("frame_id")
1026
+ }
1027
+ for det in detections
1028
+ ]
1029
+ }
1030
+
1031
+ def _extract_predictions(self, detections: list) -> List[Dict[str, Any]]:
1032
+ return [
1033
+ {
1034
+ "category": det.get("category", "unknown"),
1035
+ "confidence": det.get("confidence", 0.0),
1036
+ "bounding_box": det.get("bounding_box", {})
1037
+ }
1038
+ for det in detections
1039
+ ]
1040
+
1041
+ def _compute_iou(self, box1: Any, box2: Any) -> float:
1042
+ def _bbox_to_list(bbox):
1043
+ if bbox is None:
1044
+ return []
1045
+ if isinstance(bbox, list):
1046
+ return bbox[:4] if len(bbox) >= 4 else []
1047
+ if isinstance(bbox, dict):
1048
+ if "xmin" in bbox:
1049
+ return [bbox["xmin"], bbox["ymin"], bbox["xmax"], bbox["ymax"]]
1050
+ if "x1" in bbox:
1051
+ return [bbox["x1"], bbox["y1"], bbox["x2"], bbox["y2"]]
1052
+ values = [v for v in bbox.values() if isinstance(v, (int, float))]
1053
+ return values[:4] if len(values) >= 4 else []
1054
+ return []
1055
+
1056
+ l1 = _bbox_to_list(box1)
1057
+ l2 = _bbox_to_list(box2)
1058
+ if len(l1) < 4 or len(l2) < 4:
1059
+ return 0.0
1060
+ x1_min, y1_min, x1_max, y1_max = l1
1061
+ x2_min, y2_min, x2_max, y2_max = l2
1062
+ x1_min, x1_max = min(x1_min, x1_max), max(x1_min, x1_max)
1063
+ y1_min, y1_max = min(y1_min, y1_max), max(y1_min, y1_max)
1064
+ x2_min, x2_max = min(x2_min, x2_max), max(x2_min, x2_max)
1065
+ y2_min, y2_max = min(y2_min, y2_max), max(y2_min, y2_max)
1066
+ inter_x_min = max(x1_min, x2_min)
1067
+ inter_y_min = max(y1_min, y2_min)
1068
+ inter_x_max = min(x1_max, x2_max)
1069
+ inter_y_max = min(y1_max, y2_max)
1070
+ inter_w = max(0.0, inter_x_max - inter_x_min)
1071
+ inter_h = max(0.0, inter_y_max - inter_y_min)
1072
+ inter_area = inter_w * inter_h
1073
+ area1 = (x1_max - x1_min) * (y1_max - y1_min)
1074
+ area2 = (x2_max - x2_min) * (y2_max - y2_min)
1075
+ union_area = area1 + area2 - inter_area
1076
+ return (inter_area / union_area) if union_area > 0 else 0.0
1077
+
1078
+ def _merge_or_register_track(self, raw_id: Any, bbox: Any) -> Any:
1079
+ if raw_id is None or bbox is None:
1080
+ return raw_id
1081
+ now = time.time()
1082
+ if raw_id in self._track_aliases:
1083
+ canonical_id = self._track_aliases[raw_id]
1084
+ track_info = self._canonical_tracks.get(canonical_id)
1085
+ if track_info is not None:
1086
+ track_info["last_bbox"] = bbox
1087
+ track_info["last_update"] = now
1088
+ track_info["raw_ids"].add(raw_id)
1089
+ return canonical_id
1090
+ for canonical_id, info in self._canonical_tracks.items():
1091
+ if now - info["last_update"] > self._track_merge_time_window:
1092
+ continue
1093
+ iou = self._compute_iou(bbox, info["last_bbox"])
1094
+ if iou >= self._track_merge_iou_threshold:
1095
+ self._track_aliases[raw_id] = canonical_id
1096
+ info["last_bbox"] = bbox
1097
+ info["last_update"] = now
1098
+ info["raw_ids"].add(raw_id)
1099
+ return canonical_id
1100
+ canonical_id = raw_id
1101
+ self._track_aliases[raw_id] = canonical_id
1102
+ self._canonical_tracks[canonical_id] = {
1103
+ "last_bbox": bbox,
1104
+ "last_update": now,
1105
+ "raw_ids": {raw_id},
1106
+ }
1107
+ return canonical_id
1108
+
1109
+ def _get_tracking_start_time(self) -> str:
1110
+ if self._tracking_start_time is None:
1111
+ return "N/A"
1112
+ return self._format_timestamp(self._tracking_start_time)
1113
+
1114
+ def _set_tracking_start_time(self) -> None:
1115
+ self._tracking_start_time = time.time()
1116
+
1117
+
1118
+ def _log_detection_stats(self, data: Any, stage_name: str, show_samples: bool = False) -> None:
1119
+ """
1120
+ Log detailed detection statistics at any pipeline stage.
1121
+
1122
+ Args:
1123
+ data: Detection data (list or dict format)
1124
+ stage_name: Name of the pipeline stage for identification
1125
+ show_samples: If True, show sample detection structure
1126
+ """
1127
+ separator = "=" * 80
1128
+ print(f"\n{separator}")
1129
+ print(f"[DETECTION_STATS] Stage: {stage_name}")
1130
+ print(separator)
1131
+
1132
+ # Handle different data formats
1133
+ detections = []
1134
+ if isinstance(data, list):
1135
+ detections = data
1136
+ elif isinstance(data, dict):
1137
+ # Frame-based format
1138
+ for frame_id, frame_dets in data.items():
1139
+ if isinstance(frame_dets, list):
1140
+ detections.extend(frame_dets)
1141
+
1142
+ if not detections:
1143
+ print(f" Total Detections: 0")
1144
+ print(separator)
1145
+ return
1146
+
1147
+ # Calculate statistics
1148
+ total_count = len(detections)
1149
+
1150
+ # Count by category
1151
+ category_counts = {}
1152
+ confidence_sum = {}
1153
+ confidence_min = {}
1154
+ confidence_max = {}
1155
+ bbox_format_count = {"x1/y1/x2/y2": 0, "xmin/ymin/xmax/ymax": 0, "other": 0}
1156
+
1157
+ for det in detections:
1158
+ if not isinstance(det, dict):
1159
+ continue
1160
+
1161
+ # Category counting
1162
+ cat = det.get('category', 'UNKNOWN')
1163
+ category_counts[cat] = category_counts.get(cat, 0) + 1
1164
+
1165
+ # Confidence stats
1166
+ conf = det.get('confidence', 0.0)
1167
+ if cat not in confidence_sum:
1168
+ confidence_sum[cat] = 0.0
1169
+ confidence_min[cat] = conf
1170
+ confidence_max[cat] = conf
1171
+ confidence_sum[cat] += conf
1172
+ confidence_min[cat] = min(confidence_min[cat], conf)
1173
+ confidence_max[cat] = max(confidence_max[cat], conf)
1174
+
1175
+ # BBox format detection
1176
+ bbox = det.get('bounding_box', det.get('bbox', {}))
1177
+ if isinstance(bbox, dict):
1178
+ if 'x1' in bbox and 'y1' in bbox:
1179
+ bbox_format_count["x1/y1/x2/y2"] += 1
1180
+ elif 'xmin' in bbox and 'ymin' in bbox:
1181
+ bbox_format_count["xmin/ymin/xmax/ymax"] += 1
1182
+ else:
1183
+ bbox_format_count["other"] += 1
1184
+
1185
+ # Print summary
1186
+ print(f" Total Detections: {total_count}")
1187
+ print(f"\n Category Distribution:")
1188
+
1189
+ # Sort categories by count (descending)
1190
+ sorted_cats = sorted(category_counts.items(), key=lambda x: x[1], reverse=True)
1191
+
1192
+ for cat, count in sorted_cats:
1193
+ percentage = (count / total_count) * 100
1194
+ avg_conf = confidence_sum[cat] / count
1195
+ min_conf = confidence_min[cat]
1196
+ max_conf = confidence_max[cat]
1197
+
1198
+ print(f" [{cat:20s}] Count: {count:4d} ({percentage:5.1f}%) | "
1199
+ f"Conf: avg={avg_conf:.3f}, min={min_conf:.3f}, max={max_conf:.3f}")
1200
+
1201
+ # Print bbox format distribution
1202
+ print(f"\n BBox Format Distribution:")
1203
+ for fmt, count in bbox_format_count.items():
1204
+ if count > 0:
1205
+ percentage = (count / total_count) * 100
1206
+ print(f" {fmt:25s}: {count:4d} ({percentage:5.1f}%)")
1207
+
1208
+ # Show sample detection structure if requested
1209
+ if show_samples and detections:
1210
+ print(f"\n Sample Detection Structure:")
1211
+ sample = detections[0]
1212
+ print(f" Keys: {list(sample.keys())}")
1213
+ print(f" Category: {sample.get('category')} (type: {type(sample.get('category')).__name__})")
1214
+ print(f" Confidence: {sample.get('confidence')} (type: {type(sample.get('confidence')).__name__})")
1215
+
1216
+ bbox = sample.get('bounding_box', sample.get('bbox', {}))
1217
+ if isinstance(bbox, dict):
1218
+ print(f" BBox Keys: {list(bbox.keys())}")
1219
+ if bbox:
1220
+ first_key = list(bbox.keys())[0]
1221
+ print(f" BBox Coord Type: {type(bbox[first_key]).__name__}")
1222
+
1223
+ print(separator)