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