matrice-analytics 0.1.2__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.
Potentially problematic release.
This version of matrice-analytics might be problematic. Click here for more details.
- matrice_analytics/__init__.py +28 -0
- matrice_analytics/boundary_drawing_internal/README.md +305 -0
- matrice_analytics/boundary_drawing_internal/__init__.py +45 -0
- matrice_analytics/boundary_drawing_internal/boundary_drawing_internal.py +1207 -0
- matrice_analytics/boundary_drawing_internal/boundary_drawing_tool.py +429 -0
- matrice_analytics/boundary_drawing_internal/boundary_tool_template.html +1036 -0
- matrice_analytics/boundary_drawing_internal/data/.gitignore +12 -0
- matrice_analytics/boundary_drawing_internal/example_usage.py +206 -0
- matrice_analytics/boundary_drawing_internal/usage/README.md +110 -0
- matrice_analytics/boundary_drawing_internal/usage/boundary_drawer_launcher.py +102 -0
- matrice_analytics/boundary_drawing_internal/usage/simple_boundary_launcher.py +107 -0
- matrice_analytics/post_processing/README.md +455 -0
- matrice_analytics/post_processing/__init__.py +732 -0
- matrice_analytics/post_processing/advanced_tracker/README.md +650 -0
- matrice_analytics/post_processing/advanced_tracker/__init__.py +17 -0
- matrice_analytics/post_processing/advanced_tracker/base.py +99 -0
- matrice_analytics/post_processing/advanced_tracker/config.py +77 -0
- matrice_analytics/post_processing/advanced_tracker/kalman_filter.py +370 -0
- matrice_analytics/post_processing/advanced_tracker/matching.py +195 -0
- matrice_analytics/post_processing/advanced_tracker/strack.py +230 -0
- matrice_analytics/post_processing/advanced_tracker/tracker.py +367 -0
- matrice_analytics/post_processing/config.py +142 -0
- matrice_analytics/post_processing/core/__init__.py +63 -0
- matrice_analytics/post_processing/core/base.py +704 -0
- matrice_analytics/post_processing/core/config.py +3188 -0
- matrice_analytics/post_processing/core/config_utils.py +925 -0
- matrice_analytics/post_processing/face_reg/__init__.py +43 -0
- matrice_analytics/post_processing/face_reg/compare_similarity.py +556 -0
- matrice_analytics/post_processing/face_reg/embedding_manager.py +681 -0
- matrice_analytics/post_processing/face_reg/face_recognition.py +1870 -0
- matrice_analytics/post_processing/face_reg/face_recognition_client.py +339 -0
- matrice_analytics/post_processing/face_reg/people_activity_logging.py +283 -0
- matrice_analytics/post_processing/ocr/__init__.py +0 -0
- matrice_analytics/post_processing/ocr/easyocr_extractor.py +248 -0
- matrice_analytics/post_processing/ocr/postprocessing.py +271 -0
- matrice_analytics/post_processing/ocr/preprocessing.py +52 -0
- matrice_analytics/post_processing/post_processor.py +1153 -0
- matrice_analytics/post_processing/test_cases/__init__.py +1 -0
- matrice_analytics/post_processing/test_cases/run_tests.py +143 -0
- matrice_analytics/post_processing/test_cases/test_advanced_customer_service.py +841 -0
- matrice_analytics/post_processing/test_cases/test_basic_counting_tracking.py +523 -0
- matrice_analytics/post_processing/test_cases/test_comprehensive.py +531 -0
- matrice_analytics/post_processing/test_cases/test_config.py +852 -0
- matrice_analytics/post_processing/test_cases/test_customer_service.py +585 -0
- matrice_analytics/post_processing/test_cases/test_data_generators.py +583 -0
- matrice_analytics/post_processing/test_cases/test_people_counting.py +510 -0
- matrice_analytics/post_processing/test_cases/test_processor.py +524 -0
- matrice_analytics/post_processing/test_cases/test_utilities.py +356 -0
- matrice_analytics/post_processing/test_cases/test_utils.py +743 -0
- matrice_analytics/post_processing/usecases/Histopathological_Cancer_Detection_img.py +604 -0
- matrice_analytics/post_processing/usecases/__init__.py +267 -0
- matrice_analytics/post_processing/usecases/abandoned_object_detection.py +797 -0
- matrice_analytics/post_processing/usecases/advanced_customer_service.py +1601 -0
- matrice_analytics/post_processing/usecases/age_detection.py +842 -0
- matrice_analytics/post_processing/usecases/age_gender_detection.py +1043 -0
- matrice_analytics/post_processing/usecases/anti_spoofing_detection.py +656 -0
- matrice_analytics/post_processing/usecases/assembly_line_detection.py +841 -0
- matrice_analytics/post_processing/usecases/banana_defect_detection.py +624 -0
- matrice_analytics/post_processing/usecases/basic_counting_tracking.py +667 -0
- matrice_analytics/post_processing/usecases/blood_cancer_detection_img.py +881 -0
- matrice_analytics/post_processing/usecases/car_damage_detection.py +834 -0
- matrice_analytics/post_processing/usecases/car_part_segmentation.py +946 -0
- matrice_analytics/post_processing/usecases/car_service.py +1601 -0
- matrice_analytics/post_processing/usecases/cardiomegaly_classification.py +864 -0
- matrice_analytics/post_processing/usecases/cell_microscopy_segmentation.py +897 -0
- matrice_analytics/post_processing/usecases/chicken_pose_detection.py +648 -0
- matrice_analytics/post_processing/usecases/child_monitoring.py +814 -0
- matrice_analytics/post_processing/usecases/color/clip.py +232 -0
- matrice_analytics/post_processing/usecases/color/clip_processor/merges.txt +48895 -0
- matrice_analytics/post_processing/usecases/color/clip_processor/preprocessor_config.json +28 -0
- matrice_analytics/post_processing/usecases/color/clip_processor/special_tokens_map.json +30 -0
- matrice_analytics/post_processing/usecases/color/clip_processor/tokenizer.json +245079 -0
- matrice_analytics/post_processing/usecases/color/clip_processor/tokenizer_config.json +32 -0
- matrice_analytics/post_processing/usecases/color/clip_processor/vocab.json +1 -0
- matrice_analytics/post_processing/usecases/color/color_map_utils.py +70 -0
- matrice_analytics/post_processing/usecases/color/color_mapper.py +468 -0
- matrice_analytics/post_processing/usecases/color_detection.py +1835 -0
- matrice_analytics/post_processing/usecases/color_map_utils.py +70 -0
- matrice_analytics/post_processing/usecases/concrete_crack_detection.py +827 -0
- matrice_analytics/post_processing/usecases/crop_weed_detection.py +781 -0
- matrice_analytics/post_processing/usecases/customer_service.py +1008 -0
- matrice_analytics/post_processing/usecases/defect_detection_products.py +936 -0
- matrice_analytics/post_processing/usecases/distracted_driver_detection.py +822 -0
- matrice_analytics/post_processing/usecases/drone_traffic_monitoring.py +930 -0
- matrice_analytics/post_processing/usecases/drowsy_driver_detection.py +829 -0
- matrice_analytics/post_processing/usecases/dwell_detection.py +829 -0
- matrice_analytics/post_processing/usecases/emergency_vehicle_detection.py +827 -0
- matrice_analytics/post_processing/usecases/face_emotion.py +813 -0
- matrice_analytics/post_processing/usecases/face_recognition.py +827 -0
- matrice_analytics/post_processing/usecases/fashion_detection.py +835 -0
- matrice_analytics/post_processing/usecases/field_mapping.py +902 -0
- matrice_analytics/post_processing/usecases/fire_detection.py +1112 -0
- matrice_analytics/post_processing/usecases/flare_analysis.py +891 -0
- matrice_analytics/post_processing/usecases/flower_segmentation.py +1006 -0
- matrice_analytics/post_processing/usecases/gas_leak_detection.py +837 -0
- matrice_analytics/post_processing/usecases/gender_detection.py +832 -0
- matrice_analytics/post_processing/usecases/human_activity_recognition.py +871 -0
- matrice_analytics/post_processing/usecases/intrusion_detection.py +1672 -0
- matrice_analytics/post_processing/usecases/leaf.py +821 -0
- matrice_analytics/post_processing/usecases/leaf_disease.py +840 -0
- matrice_analytics/post_processing/usecases/leak_detection.py +837 -0
- matrice_analytics/post_processing/usecases/license_plate_detection.py +914 -0
- matrice_analytics/post_processing/usecases/license_plate_monitoring.py +1194 -0
- matrice_analytics/post_processing/usecases/litter_monitoring.py +717 -0
- matrice_analytics/post_processing/usecases/mask_detection.py +869 -0
- matrice_analytics/post_processing/usecases/natural_disaster.py +907 -0
- matrice_analytics/post_processing/usecases/parking.py +787 -0
- matrice_analytics/post_processing/usecases/parking_space_detection.py +822 -0
- matrice_analytics/post_processing/usecases/pcb_defect_detection.py +888 -0
- matrice_analytics/post_processing/usecases/pedestrian_detection.py +808 -0
- matrice_analytics/post_processing/usecases/people_counting.py +1728 -0
- matrice_analytics/post_processing/usecases/people_tracking.py +1842 -0
- matrice_analytics/post_processing/usecases/pipeline_detection.py +605 -0
- matrice_analytics/post_processing/usecases/plaque_segmentation_img.py +874 -0
- matrice_analytics/post_processing/usecases/pothole_segmentation.py +915 -0
- matrice_analytics/post_processing/usecases/ppe_compliance.py +645 -0
- matrice_analytics/post_processing/usecases/price_tag_detection.py +822 -0
- matrice_analytics/post_processing/usecases/proximity_detection.py +1901 -0
- matrice_analytics/post_processing/usecases/road_lane_detection.py +623 -0
- matrice_analytics/post_processing/usecases/road_traffic_density.py +832 -0
- matrice_analytics/post_processing/usecases/road_view_segmentation.py +915 -0
- matrice_analytics/post_processing/usecases/shelf_inventory_detection.py +583 -0
- matrice_analytics/post_processing/usecases/shoplifting_detection.py +822 -0
- matrice_analytics/post_processing/usecases/shopping_cart_analysis.py +899 -0
- matrice_analytics/post_processing/usecases/skin_cancer_classification_img.py +864 -0
- matrice_analytics/post_processing/usecases/smoker_detection.py +833 -0
- matrice_analytics/post_processing/usecases/solar_panel.py +810 -0
- matrice_analytics/post_processing/usecases/suspicious_activity_detection.py +1030 -0
- matrice_analytics/post_processing/usecases/template_usecase.py +380 -0
- matrice_analytics/post_processing/usecases/theft_detection.py +648 -0
- matrice_analytics/post_processing/usecases/traffic_sign_monitoring.py +724 -0
- matrice_analytics/post_processing/usecases/underground_pipeline_defect_detection.py +775 -0
- matrice_analytics/post_processing/usecases/underwater_pollution_detection.py +842 -0
- matrice_analytics/post_processing/usecases/vehicle_monitoring.py +950 -0
- matrice_analytics/post_processing/usecases/warehouse_object_segmentation.py +899 -0
- matrice_analytics/post_processing/usecases/waterbody_segmentation.py +923 -0
- matrice_analytics/post_processing/usecases/weapon_detection.py +771 -0
- matrice_analytics/post_processing/usecases/weld_defect_detection.py +615 -0
- matrice_analytics/post_processing/usecases/wildlife_monitoring.py +898 -0
- matrice_analytics/post_processing/usecases/windmill_maintenance.py +834 -0
- matrice_analytics/post_processing/usecases/wound_segmentation.py +856 -0
- matrice_analytics/post_processing/utils/__init__.py +150 -0
- matrice_analytics/post_processing/utils/advanced_counting_utils.py +400 -0
- matrice_analytics/post_processing/utils/advanced_helper_utils.py +317 -0
- matrice_analytics/post_processing/utils/advanced_tracking_utils.py +461 -0
- matrice_analytics/post_processing/utils/alerting_utils.py +213 -0
- matrice_analytics/post_processing/utils/category_mapping_utils.py +94 -0
- matrice_analytics/post_processing/utils/color_utils.py +592 -0
- matrice_analytics/post_processing/utils/counting_utils.py +182 -0
- matrice_analytics/post_processing/utils/filter_utils.py +261 -0
- matrice_analytics/post_processing/utils/format_utils.py +293 -0
- matrice_analytics/post_processing/utils/geometry_utils.py +300 -0
- matrice_analytics/post_processing/utils/smoothing_utils.py +358 -0
- matrice_analytics/post_processing/utils/tracking_utils.py +234 -0
- matrice_analytics/py.typed +0 -0
- matrice_analytics-0.1.2.dist-info/METADATA +481 -0
- matrice_analytics-0.1.2.dist-info/RECORD +160 -0
- matrice_analytics-0.1.2.dist-info/WHEEL +5 -0
- matrice_analytics-0.1.2.dist-info/licenses/LICENSE.txt +21 -0
- matrice_analytics-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1870 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Face Recognition with Track ID Cache Optimization
|
|
3
|
+
|
|
4
|
+
This module includes an optimization that caches face recognition results by track ID
|
|
5
|
+
to reduce redundant API calls. When a face detection is processed:
|
|
6
|
+
|
|
7
|
+
1. It checks the cache for existing results using track_id
|
|
8
|
+
2. If track_id found in cache, uses cached result instead of API call
|
|
9
|
+
3. If track_id not found, makes API call and caches the result
|
|
10
|
+
4. Cache includes automatic cleanup with TTL and size limits
|
|
11
|
+
|
|
12
|
+
Configuration options:
|
|
13
|
+
- enable_track_id_cache: Enable/disable the optimization
|
|
14
|
+
- cache_max_size: Maximum number of cached track IDs (default: 1000)
|
|
15
|
+
- cache_ttl: Cache time-to-live in seconds (default: 3600)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
19
|
+
import time
|
|
20
|
+
import base64
|
|
21
|
+
import cv2
|
|
22
|
+
import numpy as np
|
|
23
|
+
import threading
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from collections import deque
|
|
26
|
+
|
|
27
|
+
from ..core.base import (
|
|
28
|
+
BaseProcessor,
|
|
29
|
+
ProcessingContext,
|
|
30
|
+
ProcessingResult,
|
|
31
|
+
ConfigProtocol,
|
|
32
|
+
)
|
|
33
|
+
from ..utils import (
|
|
34
|
+
filter_by_confidence,
|
|
35
|
+
filter_by_categories,
|
|
36
|
+
apply_category_mapping,
|
|
37
|
+
calculate_counting_summary,
|
|
38
|
+
match_results_structure,
|
|
39
|
+
)
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from ..core.config import BaseConfig, AlertConfig
|
|
42
|
+
from .face_recognition_client import FacialRecognitionClient
|
|
43
|
+
from .people_activity_logging import PeopleActivityLogging
|
|
44
|
+
from .embedding_manager import EmbeddingManager, EmbeddingConfig
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---- Lightweight identity tracking and temporal smoothing (adapted from compare_similarity.py) ---- #
|
|
48
|
+
from collections import deque, defaultdict
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _normalize_embedding(vec: List[float]) -> List[float]:
|
|
52
|
+
"""Normalize an embedding vector to unit length (L2). Returns float32 list."""
|
|
53
|
+
arr = np.asarray(vec, dtype=np.float32)
|
|
54
|
+
if arr.size == 0:
|
|
55
|
+
return []
|
|
56
|
+
n = np.linalg.norm(arr)
|
|
57
|
+
if n > 0:
|
|
58
|
+
arr = arr / n
|
|
59
|
+
return arr.tolist()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
## Removed FaceTracker fallback (using AdvancedTracker only)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TemporalIdentityManager:
|
|
66
|
+
"""
|
|
67
|
+
Maintains stable identity labels per tracker ID using temporal smoothing and embedding history.
|
|
68
|
+
|
|
69
|
+
Adaptation for production: _compute_best_identity queries the face recognition API
|
|
70
|
+
via search_similar_faces(embedding, threshold=0.01, limit=1) to obtain top-1 match and score.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
face_client: FacialRecognitionClient,
|
|
76
|
+
recognition_threshold: float = 0.35,
|
|
77
|
+
history_size: int = 20,
|
|
78
|
+
unknown_patience: int = 7,
|
|
79
|
+
switch_patience: int = 5,
|
|
80
|
+
fallback_margin: float = 0.05,
|
|
81
|
+
) -> None:
|
|
82
|
+
self.face_client = face_client
|
|
83
|
+
self.threshold = float(recognition_threshold)
|
|
84
|
+
self.history_size = int(history_size)
|
|
85
|
+
self.unknown_patience = int(unknown_patience)
|
|
86
|
+
self.switch_patience = int(switch_patience)
|
|
87
|
+
self.fallback_margin = float(fallback_margin)
|
|
88
|
+
self.tracks: Dict[Any, Dict[str, object]] = {}
|
|
89
|
+
|
|
90
|
+
def _ensure_track(self, track_id: Any) -> None:
|
|
91
|
+
if track_id not in self.tracks:
|
|
92
|
+
self.tracks[track_id] = {
|
|
93
|
+
"stable_staff_id": None,
|
|
94
|
+
"stable_person_name": None,
|
|
95
|
+
"stable_employee_id": None,
|
|
96
|
+
"stable_score": 0.0,
|
|
97
|
+
"stable_staff_details": {},
|
|
98
|
+
"label_votes": defaultdict(int), # staff_id -> votes
|
|
99
|
+
"embedding_history": deque(maxlen=self.history_size),
|
|
100
|
+
"unknown_streak": 0,
|
|
101
|
+
"streaks": defaultdict(int), # staff_id -> consecutive frames
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async def _compute_best_identity(self, emb: List[float], location: str = "", timestamp: str = "") -> Tuple[Optional[str], str, float, Optional[str], Dict[str, Any], str]:
|
|
105
|
+
"""
|
|
106
|
+
Query backend for top-1 match for the given embedding.
|
|
107
|
+
Returns (staff_id, person_name, score, employee_id, staff_details, detection_type).
|
|
108
|
+
Robust to varying response shapes.
|
|
109
|
+
"""
|
|
110
|
+
if not emb or not isinstance(emb, list):
|
|
111
|
+
return None, "Unknown", 0.0, None, {}, "unknown"
|
|
112
|
+
try:
|
|
113
|
+
resp = await self.face_client.search_similar_faces(
|
|
114
|
+
face_embedding=emb,
|
|
115
|
+
threshold=0.01, # low threshold to always get top-1
|
|
116
|
+
limit=1,
|
|
117
|
+
collection="staff_enrollment",
|
|
118
|
+
location=location,
|
|
119
|
+
timestamp=timestamp,
|
|
120
|
+
)
|
|
121
|
+
except Exception:
|
|
122
|
+
return None, "Unknown", 0.0, None, {}, "unknown"
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
results: List[Any] = []
|
|
126
|
+
print('----------------TOP@1 FROM API----------------------------')
|
|
127
|
+
print(resp)
|
|
128
|
+
print('----------------------------------------------------------')
|
|
129
|
+
if isinstance(resp, dict):
|
|
130
|
+
if isinstance(resp.get("data"), list):
|
|
131
|
+
results = resp.get("data", [])
|
|
132
|
+
elif isinstance(resp.get("results"), list):
|
|
133
|
+
results = resp.get("results", [])
|
|
134
|
+
elif isinstance(resp.get("items"), list):
|
|
135
|
+
results = resp.get("items", [])
|
|
136
|
+
elif isinstance(resp, list):
|
|
137
|
+
results = resp
|
|
138
|
+
|
|
139
|
+
if not results:
|
|
140
|
+
return None, "Unknown", 0.0, None, {}, "unknown"
|
|
141
|
+
|
|
142
|
+
item = results[0] if isinstance(results, list) else results
|
|
143
|
+
print('----------------TOP@1 LISTT FROM API----------------------------')
|
|
144
|
+
print(item)
|
|
145
|
+
print('----------------------------------------------------------')
|
|
146
|
+
# Be defensive with keys and types
|
|
147
|
+
staff_id = item.get("staffId") if isinstance(item, dict) else None
|
|
148
|
+
employee_id = str(item.get("_id")) if isinstance(item, dict) and item.get("_id") is not None else None
|
|
149
|
+
score = float(item.get("score", 0.0)) if isinstance(item, dict) else 0.0
|
|
150
|
+
detection_type = str(item.get("detectionType", "unknown")) if isinstance(item, dict) else "unknown"
|
|
151
|
+
staff_details = item.get("staffDetails", {}) if isinstance(item, dict) else {}
|
|
152
|
+
# Extract a person name from staff_details
|
|
153
|
+
person_name = "Unknown"
|
|
154
|
+
if isinstance(staff_details, dict) and staff_details:
|
|
155
|
+
first_name = staff_details.get("firstName")
|
|
156
|
+
last_name = staff_details.get("lastName")
|
|
157
|
+
name = staff_details.get("name")
|
|
158
|
+
if name:
|
|
159
|
+
person_name = str(name)
|
|
160
|
+
else:
|
|
161
|
+
if first_name or last_name:
|
|
162
|
+
person_name = f"{first_name or ''} {last_name or ''}".strip() or "UnknowNN" #TODO:ebugging change to normal once done
|
|
163
|
+
# If API says unknown or missing staff_id, treat as unknown
|
|
164
|
+
if not staff_id: #or detection_type == "unknown"
|
|
165
|
+
return None, "Unknown", float(score), employee_id, staff_details if isinstance(staff_details, dict) else {}, "unknown"
|
|
166
|
+
return str(staff_id), person_name, float(score), employee_id, staff_details if isinstance(staff_details, dict) else {}, "known"
|
|
167
|
+
except Exception:
|
|
168
|
+
return None, "Unknown", 0.0, None, {}, "unknown"
|
|
169
|
+
|
|
170
|
+
async def _compute_best_identity_from_history(self, track_state: Dict[str, object], location: str = "", timestamp: str = "") -> Tuple[Optional[str], str, float, Optional[str], Dict[str, Any], str]:
|
|
171
|
+
hist: deque = track_state.get("embedding_history", deque()) # type: ignore
|
|
172
|
+
if not hist:
|
|
173
|
+
return None, "Unknown", 0.0, None, {}, "unknown"
|
|
174
|
+
try:
|
|
175
|
+
proto = np.mean(np.asarray(list(hist), dtype=np.float32), axis=0)
|
|
176
|
+
proto_list = proto.tolist() if isinstance(proto, np.ndarray) else list(proto)
|
|
177
|
+
except Exception:
|
|
178
|
+
proto_list = []
|
|
179
|
+
return await self._compute_best_identity(proto_list, location=location, timestamp=timestamp)
|
|
180
|
+
|
|
181
|
+
async def update(
|
|
182
|
+
self,
|
|
183
|
+
track_id: Any,
|
|
184
|
+
emb: List[float],
|
|
185
|
+
eligible_for_recognition: bool,
|
|
186
|
+
location: str = "",
|
|
187
|
+
timestamp: str = "",
|
|
188
|
+
) -> Tuple[Optional[str], str, float, Optional[str], Dict[str, Any], str]:
|
|
189
|
+
"""
|
|
190
|
+
Update temporal identity state for a track and return a stabilized identity.
|
|
191
|
+
Returns (staff_id, person_name, score, employee_id, staff_details, detection_type).
|
|
192
|
+
"""
|
|
193
|
+
self._ensure_track(track_id)
|
|
194
|
+
s = self.tracks[track_id]
|
|
195
|
+
|
|
196
|
+
# Update embedding history
|
|
197
|
+
if emb:
|
|
198
|
+
try:
|
|
199
|
+
history: deque = s["embedding_history"] # type: ignore
|
|
200
|
+
history.append(_normalize_embedding(emb))
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
# Defaults for return values
|
|
205
|
+
stable_staff_id = s.get("stable_staff_id")
|
|
206
|
+
stable_person_name = s.get("stable_person_name")
|
|
207
|
+
stable_employee_id = s.get("stable_employee_id")
|
|
208
|
+
stable_score = float(s.get("stable_score", 0.0))
|
|
209
|
+
stable_staff_details = s.get("stable_staff_details", {}) if isinstance(s.get("stable_staff_details"), dict) else {}
|
|
210
|
+
|
|
211
|
+
if eligible_for_recognition and emb:
|
|
212
|
+
staff_id, person_name, inst_score, employee_id, staff_details, det_type = await self._compute_best_identity(
|
|
213
|
+
emb, location=location, timestamp=timestamp
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
is_inst_known = staff_id is not None and inst_score >= self.threshold
|
|
217
|
+
if is_inst_known:
|
|
218
|
+
s["label_votes"][staff_id] += 1 # type: ignore
|
|
219
|
+
s["streaks"][staff_id] += 1 # type: ignore
|
|
220
|
+
s["unknown_streak"] = 0
|
|
221
|
+
|
|
222
|
+
# Initialize stable if not set
|
|
223
|
+
if stable_staff_id is None:
|
|
224
|
+
s["stable_staff_id"] = staff_id
|
|
225
|
+
s["stable_person_name"] = person_name
|
|
226
|
+
s["stable_employee_id"] = employee_id
|
|
227
|
+
s["stable_score"] = float(inst_score)
|
|
228
|
+
s["stable_staff_details"] = staff_details
|
|
229
|
+
return staff_id, person_name, float(inst_score), employee_id, staff_details, "known"
|
|
230
|
+
|
|
231
|
+
# If same as stable, keep it and update score
|
|
232
|
+
if staff_id == stable_staff_id:
|
|
233
|
+
s["stable_score"] = float(inst_score)
|
|
234
|
+
# prefer latest name/details if present
|
|
235
|
+
if person_name and person_name != stable_person_name:
|
|
236
|
+
s["stable_person_name"] = person_name
|
|
237
|
+
if isinstance(staff_details, dict) and staff_details:
|
|
238
|
+
s["stable_staff_details"] = staff_details
|
|
239
|
+
if employee_id:
|
|
240
|
+
s["stable_employee_id"] = employee_id
|
|
241
|
+
return staff_id, s.get("stable_person_name") or person_name, float(inst_score), s.get("stable_employee_id") or employee_id, s.get("stable_staff_details", {}), "known"
|
|
242
|
+
|
|
243
|
+
# Competing identity: switch only if sustained and with margin & votes ratio (local parity)
|
|
244
|
+
if s["streaks"][staff_id] >= self.switch_patience: # type: ignore
|
|
245
|
+
try:
|
|
246
|
+
prev_votes = s["label_votes"].get(stable_staff_id, 0) if stable_staff_id is not None else 0 # type: ignore
|
|
247
|
+
cand_votes = s["label_votes"].get(staff_id, 0) # type: ignore
|
|
248
|
+
except Exception:
|
|
249
|
+
prev_votes, cand_votes = 0, 0
|
|
250
|
+
if cand_votes >= max(2, 0.75 * prev_votes) and float(inst_score) >= (self.threshold + 0.02):
|
|
251
|
+
s["stable_staff_id"] = staff_id
|
|
252
|
+
s["stable_person_name"] = person_name
|
|
253
|
+
s["stable_employee_id"] = employee_id
|
|
254
|
+
s["stable_score"] = float(inst_score)
|
|
255
|
+
s["stable_staff_details"] = staff_details
|
|
256
|
+
# reset other streaks
|
|
257
|
+
try:
|
|
258
|
+
for k in list(s["streaks"].keys()): # type: ignore
|
|
259
|
+
if k != staff_id:
|
|
260
|
+
s["streaks"][k] = 0 # type: ignore
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
return staff_id, person_name, float(inst_score), employee_id, staff_details, "known"
|
|
264
|
+
|
|
265
|
+
# Do not switch yet; keep stable but return instant score/name
|
|
266
|
+
return stable_staff_id, stable_person_name or person_name, float(inst_score), stable_employee_id or employee_id, stable_staff_details, "known" if stable_staff_id else "unknown"
|
|
267
|
+
|
|
268
|
+
# Instantaneous is unknown or low score
|
|
269
|
+
s["unknown_streak"] = int(s.get("unknown_streak", 0)) + 1
|
|
270
|
+
if stable_staff_id is not None and s["unknown_streak"] <= self.unknown_patience: # type: ignore
|
|
271
|
+
return stable_staff_id, stable_person_name or "Unknown", float(inst_score), stable_employee_id, stable_staff_details, "known"
|
|
272
|
+
|
|
273
|
+
# Fallback: use prototype from history
|
|
274
|
+
fb_staff_id, fb_name, fb_score, fb_employee_id, fb_details, fb_type = await self._compute_best_identity_from_history(s, location=location, timestamp=timestamp)
|
|
275
|
+
if fb_staff_id is not None and fb_score >= max(0.0, self.threshold - self.fallback_margin):
|
|
276
|
+
s["label_votes"][fb_staff_id] += 1 # type: ignore
|
|
277
|
+
s["stable_staff_id"] = fb_staff_id
|
|
278
|
+
s["stable_person_name"] = fb_name
|
|
279
|
+
s["stable_employee_id"] = fb_employee_id
|
|
280
|
+
s["stable_score"] = float(fb_score)
|
|
281
|
+
s["stable_staff_details"] = fb_details
|
|
282
|
+
s["unknown_streak"] = 0
|
|
283
|
+
return fb_staff_id, fb_name, float(fb_score), fb_employee_id, fb_details, "known"
|
|
284
|
+
|
|
285
|
+
# No confident identity
|
|
286
|
+
s["stable_staff_id"] = stable_staff_id
|
|
287
|
+
s["stable_person_name"] = stable_person_name
|
|
288
|
+
s["stable_employee_id"] = stable_employee_id
|
|
289
|
+
s["stable_score"] = float(stable_score)
|
|
290
|
+
s["stable_staff_details"] = stable_staff_details
|
|
291
|
+
return None, "Unknown", float(inst_score), None, {}, "unknown"
|
|
292
|
+
|
|
293
|
+
# Not eligible or no embedding; keep stable if present
|
|
294
|
+
if stable_staff_id is not None:
|
|
295
|
+
return stable_staff_id, stable_person_name or "Unknown", float(stable_score), stable_employee_id, stable_staff_details, "known"
|
|
296
|
+
return None, "Unknown", 0.0, None, {}, "unknown"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@dataclass
|
|
300
|
+
class FaceRecognitionEmbeddingConfig(BaseConfig):
|
|
301
|
+
"""Configuration for face recognition with embeddings use case."""
|
|
302
|
+
|
|
303
|
+
# Smoothing configuration
|
|
304
|
+
enable_smoothing: bool = False
|
|
305
|
+
smoothing_algorithm: str = "observability" # "window" or "observability"
|
|
306
|
+
smoothing_window_size: int = 20
|
|
307
|
+
smoothing_cooldown_frames: int = 5
|
|
308
|
+
smoothing_confidence_range_factor: float = 0.5
|
|
309
|
+
|
|
310
|
+
# Base confidence threshold (separate from embedding similarity threshold)
|
|
311
|
+
similarity_threshold: float = 0.45 #-- KEEP IT AT 0.45 ALWAYS
|
|
312
|
+
# Base confidence threshold (separate from embedding similarity threshold)
|
|
313
|
+
confidence_threshold: float = 0.1 #-- KEEP IT AT 0.1 ALWAYS
|
|
314
|
+
|
|
315
|
+
# Face recognition optional features
|
|
316
|
+
enable_face_tracking: bool = True # Enable BYTE TRACKER advanced face tracking -- KEEP IT TRUE ALWAYS
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
enable_auto_enrollment: bool = False # Enable auto-enrollment of unknown faces
|
|
320
|
+
enable_face_recognition: bool = (
|
|
321
|
+
True # Enable face recognition (requires credentials)
|
|
322
|
+
)
|
|
323
|
+
enable_unknown_face_processing: bool = (
|
|
324
|
+
False # TODO: Unable when we will be saving unkown faces # Enable unknown face cropping/uploading (requires frame data)
|
|
325
|
+
)
|
|
326
|
+
enable_people_activity_logging: bool = True # Enable logging of known face activities
|
|
327
|
+
|
|
328
|
+
usecase_categories: List[str] = field(default_factory=lambda: ["face"])
|
|
329
|
+
|
|
330
|
+
target_categories: List[str] = field(default_factory=lambda: ["face"])
|
|
331
|
+
|
|
332
|
+
alert_config: Optional[AlertConfig] = None
|
|
333
|
+
|
|
334
|
+
index_to_category: Optional[Dict[int, str]] = field(
|
|
335
|
+
default_factory=lambda: {0: "face"}
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
facial_recognition_server_id: str = ""
|
|
339
|
+
session: Any = None # Matrice session for face recognition client
|
|
340
|
+
|
|
341
|
+
# Embedding configuration
|
|
342
|
+
embedding_config: Optional[Any] = None # Will be set to EmbeddingConfig instance
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Track ID cache optimization settings
|
|
346
|
+
enable_track_id_cache: bool = True
|
|
347
|
+
cache_max_size: int = 3000
|
|
348
|
+
cache_ttl: int = 3600 # Cache time-to-live in seconds (1 hour)
|
|
349
|
+
|
|
350
|
+
# Search settings
|
|
351
|
+
search_limit: int = 5
|
|
352
|
+
search_collection: str = "staff_enrollment"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class FaceRecognitionEmbeddingUseCase(BaseProcessor):
|
|
356
|
+
# Human-friendly display names for categories
|
|
357
|
+
CATEGORY_DISPLAY = {"face": "face"}
|
|
358
|
+
|
|
359
|
+
def __init__(self):
|
|
360
|
+
super().__init__("face_recognition")
|
|
361
|
+
self.category = "security"
|
|
362
|
+
|
|
363
|
+
self.CASE_TYPE: Optional[str] = "face_recognition"
|
|
364
|
+
self.CASE_VERSION: Optional[str] = "1.0"
|
|
365
|
+
# List of categories to track
|
|
366
|
+
self.target_categories = ["face"]
|
|
367
|
+
|
|
368
|
+
# Initialize smoothing tracker
|
|
369
|
+
self.smoothing_tracker = None
|
|
370
|
+
|
|
371
|
+
# Initialize advanced tracker (will be created on first use)
|
|
372
|
+
self.tracker = None
|
|
373
|
+
# Initialize tracking state variables
|
|
374
|
+
self._total_frame_counter = 0
|
|
375
|
+
self._global_frame_offset = 0
|
|
376
|
+
|
|
377
|
+
# Track start time for "TOTAL SINCE" calculation
|
|
378
|
+
self._tracking_start_time = datetime.now(
|
|
379
|
+
timezone.utc
|
|
380
|
+
) # Store as datetime object for UTC
|
|
381
|
+
|
|
382
|
+
self._track_aliases: Dict[Any, Any] = {}
|
|
383
|
+
self._canonical_tracks: Dict[Any, Dict[str, Any]] = {}
|
|
384
|
+
# Tunable parameters – adjust if necessary for specific scenarios
|
|
385
|
+
self._track_merge_iou_threshold: float = 0.05 # IoU ≥ 0.05 →
|
|
386
|
+
self._track_merge_time_window: float = 7.0 # seconds within which to merge
|
|
387
|
+
|
|
388
|
+
self._ascending_alert_list: List[int] = []
|
|
389
|
+
self.current_incident_end_timestamp: str = "N/A"
|
|
390
|
+
|
|
391
|
+
# Session totals tracked per unique internal track id (thread-safe)
|
|
392
|
+
self._recognized_track_ids = set()
|
|
393
|
+
self._unknown_track_ids = set()
|
|
394
|
+
self._tracking_lock = threading.Lock()
|
|
395
|
+
|
|
396
|
+
# Person tracking: {person_id: [{"camera_id": str, "timestamp": str}, ...]}
|
|
397
|
+
self.person_tracking: Dict[str, List[Dict[str, str]]] = {}
|
|
398
|
+
|
|
399
|
+
self.face_client = None
|
|
400
|
+
|
|
401
|
+
# Initialize PeopleActivityLogging without face client initially
|
|
402
|
+
self.people_activity_logging = None
|
|
403
|
+
|
|
404
|
+
# Initialize EmbeddingManager - will be configured in process method
|
|
405
|
+
self.embedding_manager = None
|
|
406
|
+
# Temporal identity manager for API-based top-1 identity smoothing
|
|
407
|
+
self.temporal_identity_manager = None
|
|
408
|
+
# Removed lightweight face tracker fallback; we always use AdvancedTracker
|
|
409
|
+
# Optional gating similar to compare_similarity
|
|
410
|
+
self._track_first_seen: Dict[int, int] = {}
|
|
411
|
+
self._probation_frames: int = 260 # default gate ~4 seconds at 60 fps; tune per stream
|
|
412
|
+
self._min_face_w: int = 30
|
|
413
|
+
self._min_face_h: int = 30
|
|
414
|
+
|
|
415
|
+
self.start_timer = None
|
|
416
|
+
|
|
417
|
+
def _get_facial_recognition_client(
|
|
418
|
+
self, config: FaceRecognitionEmbeddingConfig
|
|
419
|
+
) -> FacialRecognitionClient:
|
|
420
|
+
"""Get facial recognition client"""
|
|
421
|
+
# Initialize face recognition client if not already done
|
|
422
|
+
if self.face_client is None:
|
|
423
|
+
self.logger.info(
|
|
424
|
+
f"Initializing face recognition client with server ID: {config.facial_recognition_server_id}"
|
|
425
|
+
)
|
|
426
|
+
self.face_client = FacialRecognitionClient(
|
|
427
|
+
server_id=config.facial_recognition_server_id, session=config.session
|
|
428
|
+
)
|
|
429
|
+
self.logger.info("Face recognition client initialized")
|
|
430
|
+
|
|
431
|
+
return self.face_client
|
|
432
|
+
|
|
433
|
+
async def process(
|
|
434
|
+
self,
|
|
435
|
+
data: Any,
|
|
436
|
+
config: ConfigProtocol,
|
|
437
|
+
input_bytes: Optional[bytes] = None,
|
|
438
|
+
context: Optional[ProcessingContext] = None,
|
|
439
|
+
stream_info: Optional[Dict[str, Any]] = None,
|
|
440
|
+
) -> ProcessingResult:
|
|
441
|
+
"""
|
|
442
|
+
Main entry point for face recognition with embeddings post-processing.
|
|
443
|
+
Applies all standard processing plus face recognition and auto-enrollment.
|
|
444
|
+
|
|
445
|
+
Thread-safe: Uses local variables for per-request state and locks for global totals.
|
|
446
|
+
Order-preserving: Processes detections sequentially to maintain input order.
|
|
447
|
+
"""
|
|
448
|
+
processing_start = time.time()
|
|
449
|
+
# Ensure config is correct type
|
|
450
|
+
if not isinstance(config, FaceRecognitionEmbeddingConfig):
|
|
451
|
+
return self.create_error_result(
|
|
452
|
+
"Invalid config type",
|
|
453
|
+
usecase=self.name,
|
|
454
|
+
category=self.category,
|
|
455
|
+
context=context,
|
|
456
|
+
)
|
|
457
|
+
#if context is None:
|
|
458
|
+
context = ProcessingContext()
|
|
459
|
+
|
|
460
|
+
if not self.face_client:
|
|
461
|
+
self.face_client = self._get_facial_recognition_client(config)
|
|
462
|
+
|
|
463
|
+
if not config.confidence_threshold:
|
|
464
|
+
config.confidence_threshold = 0.35
|
|
465
|
+
|
|
466
|
+
# Initialize People activity logging if enabled
|
|
467
|
+
if config.enable_people_activity_logging and not self.people_activity_logging:
|
|
468
|
+
self.people_activity_logging = PeopleActivityLogging(self.face_client)
|
|
469
|
+
self.people_activity_logging.start_background_processing()
|
|
470
|
+
self.logger.info("People activity logging enabled and started")
|
|
471
|
+
|
|
472
|
+
# Initialize EmbeddingManager if not already done
|
|
473
|
+
if not self.embedding_manager:
|
|
474
|
+
# Create default embedding config if not provided
|
|
475
|
+
if not config.embedding_config:
|
|
476
|
+
config.embedding_config = EmbeddingConfig(
|
|
477
|
+
similarity_threshold=config.similarity_threshold,
|
|
478
|
+
confidence_threshold=config.confidence_threshold,
|
|
479
|
+
enable_track_id_cache=config.enable_track_id_cache,
|
|
480
|
+
cache_max_size=config.cache_max_size,
|
|
481
|
+
cache_ttl=3600
|
|
482
|
+
)
|
|
483
|
+
self.embedding_manager = EmbeddingManager(config.embedding_config, self.face_client)
|
|
484
|
+
|
|
485
|
+
# Initialize TemporalIdentityManager (top-1 via API with smoothing)
|
|
486
|
+
if not self.temporal_identity_manager:
|
|
487
|
+
self.temporal_identity_manager = TemporalIdentityManager(
|
|
488
|
+
face_client=self.face_client,
|
|
489
|
+
recognition_threshold=float(config.similarity_threshold),
|
|
490
|
+
history_size=20,
|
|
491
|
+
unknown_patience=7,
|
|
492
|
+
switch_patience=5,
|
|
493
|
+
fallback_margin=0.05,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# Detect input format and store in context
|
|
498
|
+
input_format = match_results_structure(data)
|
|
499
|
+
context.input_format = input_format
|
|
500
|
+
|
|
501
|
+
context.confidence_threshold = config.confidence_threshold
|
|
502
|
+
|
|
503
|
+
# Parse face recognition model output format (with embeddings)
|
|
504
|
+
processed_data = self._parse_face_model_output(data)
|
|
505
|
+
# Normalize embeddings early for consistency (local parity)
|
|
506
|
+
for _det in processed_data:
|
|
507
|
+
try:
|
|
508
|
+
emb = _det.get("embedding", []) or []
|
|
509
|
+
if emb:
|
|
510
|
+
_det["embedding"] = _normalize_embedding(emb)
|
|
511
|
+
except Exception:
|
|
512
|
+
pass
|
|
513
|
+
# Ignore any pre-existing track_id on detections (we rely on our own tracker)
|
|
514
|
+
for _det in processed_data:
|
|
515
|
+
if isinstance(_det, dict) and "track_id" in _det:
|
|
516
|
+
try:
|
|
517
|
+
del _det["track_id"]
|
|
518
|
+
except Exception:
|
|
519
|
+
_det["track_id"] = None
|
|
520
|
+
|
|
521
|
+
# Apply standard confidence filtering
|
|
522
|
+
if config.confidence_threshold is not None:
|
|
523
|
+
processed_data = filter_by_confidence(
|
|
524
|
+
processed_data, config.confidence_threshold
|
|
525
|
+
)
|
|
526
|
+
self.logger.debug(
|
|
527
|
+
f"Applied confidence filtering with threshold {config.confidence_threshold}"
|
|
528
|
+
)
|
|
529
|
+
else:
|
|
530
|
+
self.logger.debug(
|
|
531
|
+
"Did not apply confidence filtering since threshold not provided"
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
# Apply category mapping if provided
|
|
535
|
+
if config.index_to_category:
|
|
536
|
+
processed_data = apply_category_mapping(
|
|
537
|
+
processed_data, config.index_to_category
|
|
538
|
+
)
|
|
539
|
+
self.logger.debug("Applied category mapping")
|
|
540
|
+
|
|
541
|
+
# Apply category filtering
|
|
542
|
+
if config.target_categories:
|
|
543
|
+
processed_data = filter_by_categories(
|
|
544
|
+
processed_data, config.target_categories
|
|
545
|
+
)
|
|
546
|
+
self.logger.debug("Applied category filtering")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# Advanced tracking (BYTETracker-like) - only if enabled
|
|
550
|
+
if config.enable_face_tracking:
|
|
551
|
+
from ..advanced_tracker import AdvancedTracker
|
|
552
|
+
from ..advanced_tracker.config import TrackerConfig
|
|
553
|
+
|
|
554
|
+
# Create tracker instance if it doesn't exist (preserves state across frames)
|
|
555
|
+
if self.tracker is None:
|
|
556
|
+
tracker_config = TrackerConfig(
|
|
557
|
+
track_high_thresh=0.5,
|
|
558
|
+
track_low_thresh=0.05,
|
|
559
|
+
new_track_thresh=0.5,
|
|
560
|
+
match_thresh=0.8,
|
|
561
|
+
track_buffer=int(300), # allow short occlusions
|
|
562
|
+
max_time_lost=int(150),
|
|
563
|
+
fuse_score=True,
|
|
564
|
+
enable_gmc=False,
|
|
565
|
+
frame_rate=int(20)
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
self.tracker = AdvancedTracker(tracker_config)
|
|
569
|
+
self.logger.info(
|
|
570
|
+
"Initialized AdvancedTracker for Face Recognition with thresholds: "
|
|
571
|
+
f"high={tracker_config.track_high_thresh}, "
|
|
572
|
+
f"low={tracker_config.track_low_thresh}, "
|
|
573
|
+
f"new={tracker_config.new_track_thresh}"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
# The tracker expects the data in the same format as input
|
|
577
|
+
# It will add track_id and frame_id to each detection (we rely ONLY on these)
|
|
578
|
+
processed_data = self.tracker.update(processed_data)
|
|
579
|
+
else:
|
|
580
|
+
self.logger.debug("Advanced face tracking disabled; continuing without external track IDs")
|
|
581
|
+
|
|
582
|
+
# Initialize local recognition summary variables
|
|
583
|
+
current_recognized_count = 0
|
|
584
|
+
current_unknown_count = 0
|
|
585
|
+
recognized_persons = {}
|
|
586
|
+
current_frame_staff_details = {}
|
|
587
|
+
|
|
588
|
+
# Process face recognition for each detection (if enabled)
|
|
589
|
+
if config.enable_face_recognition:
|
|
590
|
+
face_recognition_result = await self._process_face_recognition(
|
|
591
|
+
processed_data, config, stream_info, input_bytes
|
|
592
|
+
)
|
|
593
|
+
processed_data, current_recognized_count, current_unknown_count, recognized_persons, current_frame_staff_details = face_recognition_result
|
|
594
|
+
else:
|
|
595
|
+
# Just add default face recognition fields without actual recognition
|
|
596
|
+
for detection in processed_data:
|
|
597
|
+
detection["person_id"] = None
|
|
598
|
+
detection["person_name"] = "Unknown"
|
|
599
|
+
detection["recognition_status"] = "disabled"
|
|
600
|
+
detection["enrolled"] = False
|
|
601
|
+
|
|
602
|
+
# Update tracking state for total count per label
|
|
603
|
+
self._update_tracking_state(processed_data)
|
|
604
|
+
|
|
605
|
+
# Update frame counter
|
|
606
|
+
self._total_frame_counter += 1
|
|
607
|
+
|
|
608
|
+
# Extract frame information from stream_info
|
|
609
|
+
frame_number = None
|
|
610
|
+
if stream_info:
|
|
611
|
+
input_settings = stream_info.get("input_settings", {})
|
|
612
|
+
start_frame = input_settings.get("start_frame")
|
|
613
|
+
end_frame = input_settings.get("end_frame")
|
|
614
|
+
# If start and end frame are the same, it's a single frame
|
|
615
|
+
if (
|
|
616
|
+
start_frame is not None
|
|
617
|
+
and end_frame is not None
|
|
618
|
+
and start_frame == end_frame
|
|
619
|
+
):
|
|
620
|
+
frame_number = start_frame
|
|
621
|
+
|
|
622
|
+
# Compute summaries and alerts
|
|
623
|
+
general_counting_summary = calculate_counting_summary(data)
|
|
624
|
+
counting_summary = self._count_categories(processed_data, config)
|
|
625
|
+
# Add total unique counts after tracking using only local state
|
|
626
|
+
total_counts = self.get_total_counts()
|
|
627
|
+
counting_summary["total_counts"] = total_counts
|
|
628
|
+
|
|
629
|
+
# NEW: Add face recognition summary
|
|
630
|
+
counting_summary.update(self._get_face_recognition_summary(
|
|
631
|
+
current_recognized_count, current_unknown_count, recognized_persons
|
|
632
|
+
))
|
|
633
|
+
|
|
634
|
+
# Add detections to the counting summary (standard pattern for detection use cases)
|
|
635
|
+
# Ensure display label is present for UI (does not affect logic/counters)
|
|
636
|
+
for _d in processed_data:
|
|
637
|
+
if "display_name" not in _d:
|
|
638
|
+
name = _d.get("person_name")
|
|
639
|
+
# Use person_name only if recognized; otherwise leave empty to honor probation logic
|
|
640
|
+
_d["display_name"] = name if _d.get("recognition_status") == "known" else (_d.get("display_name", "") or "")
|
|
641
|
+
counting_summary["detections"] = processed_data
|
|
642
|
+
|
|
643
|
+
alerts = self._check_alerts(counting_summary, frame_number, config)
|
|
644
|
+
|
|
645
|
+
# Step: Generate structured incidents, tracking stats and business analytics with frame-based keys
|
|
646
|
+
incidents_list = self._generate_incidents(
|
|
647
|
+
counting_summary, alerts, config, frame_number, stream_info
|
|
648
|
+
)
|
|
649
|
+
tracking_stats_list = self._generate_tracking_stats(
|
|
650
|
+
counting_summary, alerts, config, frame_number, stream_info, current_frame_staff_details
|
|
651
|
+
)
|
|
652
|
+
business_analytics_list = self._generate_business_analytics(
|
|
653
|
+
counting_summary, alerts, config, stream_info, is_empty=True
|
|
654
|
+
)
|
|
655
|
+
summary_list = self._generate_summary(incidents_list, tracking_stats_list, business_analytics_list)
|
|
656
|
+
|
|
657
|
+
# Extract frame-based dictionaries from the lists
|
|
658
|
+
incidents = incidents_list[0] if incidents_list else {}
|
|
659
|
+
tracking_stats = tracking_stats_list[0] if tracking_stats_list else {}
|
|
660
|
+
business_analytics = (
|
|
661
|
+
business_analytics_list[0] if business_analytics_list else {}
|
|
662
|
+
)
|
|
663
|
+
summary = summary_list[0] if summary_list else {}
|
|
664
|
+
agg_summary = {
|
|
665
|
+
str(frame_number): {
|
|
666
|
+
"incidents": incidents,
|
|
667
|
+
"tracking_stats": tracking_stats,
|
|
668
|
+
"business_analytics": business_analytics,
|
|
669
|
+
"alerts": alerts,
|
|
670
|
+
"human_text": summary,
|
|
671
|
+
"person_tracking": self.get_person_tracking_summary(),
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
context.mark_completed()
|
|
676
|
+
|
|
677
|
+
# Build result object following the standard pattern - same structure as people counting
|
|
678
|
+
result = self.create_result(
|
|
679
|
+
data={"agg_summary": agg_summary},
|
|
680
|
+
usecase=self.name,
|
|
681
|
+
category=self.category,
|
|
682
|
+
context=context,
|
|
683
|
+
)
|
|
684
|
+
proc_time = time.time() - processing_start
|
|
685
|
+
processing_latency_ms = proc_time * 1000.0
|
|
686
|
+
processing_fps = (1.0 / proc_time) if proc_time > 0 else None
|
|
687
|
+
# Log the performance metrics using the module-level logger
|
|
688
|
+
print("latency in ms:",processing_latency_ms,"| Throughput fps:",processing_fps,"| Frame_Number:",self._total_frame_counter)
|
|
689
|
+
|
|
690
|
+
return result
|
|
691
|
+
|
|
692
|
+
def _parse_face_model_output(self, data: Any) -> List[Dict]:
|
|
693
|
+
"""Parse face recognition model output to standard detection format, preserving embeddings"""
|
|
694
|
+
processed_data = []
|
|
695
|
+
|
|
696
|
+
if isinstance(data, dict):
|
|
697
|
+
# Handle frame-based format: {"0": [...], "1": [...]}
|
|
698
|
+
for frame_id, frame_detections in data.items():
|
|
699
|
+
if isinstance(frame_detections, list):
|
|
700
|
+
for detection in frame_detections:
|
|
701
|
+
if isinstance(detection, dict):
|
|
702
|
+
# Convert to standard format but preserve face-specific fields
|
|
703
|
+
standard_detection = {
|
|
704
|
+
"category": detection.get("category", "face"),
|
|
705
|
+
"confidence": detection.get("confidence", 0.0),
|
|
706
|
+
"bounding_box": detection.get("bounding_box", {}),
|
|
707
|
+
"track_id": detection.get("track_id", ""),
|
|
708
|
+
"frame_id": detection.get("frame_id", frame_id),
|
|
709
|
+
# Preserve face-specific fields
|
|
710
|
+
"embedding": detection.get("embedding", []),
|
|
711
|
+
"landmarks": detection.get("landmarks", None),
|
|
712
|
+
"fps": detection.get("fps", 30),
|
|
713
|
+
}
|
|
714
|
+
processed_data.append(standard_detection)
|
|
715
|
+
elif isinstance(data, list):
|
|
716
|
+
# Handle list format
|
|
717
|
+
for detection in data:
|
|
718
|
+
if isinstance(detection, dict):
|
|
719
|
+
# Convert to standard format and ensure all required fields exist
|
|
720
|
+
standard_detection = {
|
|
721
|
+
"category": detection.get("category", "face"),
|
|
722
|
+
"confidence": detection.get("confidence", 0.0),
|
|
723
|
+
"bounding_box": detection.get("bounding_box", {}),
|
|
724
|
+
"track_id": detection.get("track_id", ""),
|
|
725
|
+
"frame_id": detection.get("frame_id", 0),
|
|
726
|
+
# Preserve face-specific fields
|
|
727
|
+
"embedding": detection.get("embedding", []),
|
|
728
|
+
"landmarks": detection.get("landmarks", None),
|
|
729
|
+
"fps": detection.get("fps", 30),
|
|
730
|
+
"metadata": detection.get("metadata", {}),
|
|
731
|
+
}
|
|
732
|
+
processed_data.append(standard_detection)
|
|
733
|
+
|
|
734
|
+
return processed_data
|
|
735
|
+
|
|
736
|
+
def _extract_frame_from_data(self, input_bytes: bytes) -> Optional[np.ndarray]:
|
|
737
|
+
"""
|
|
738
|
+
Extract frame from original model data
|
|
739
|
+
|
|
740
|
+
Args:
|
|
741
|
+
original_data: Original data from model (same format as model receives)
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
np.ndarray: Frame data or None if not found
|
|
745
|
+
"""
|
|
746
|
+
try:
|
|
747
|
+
try:
|
|
748
|
+
if isinstance(input_bytes, str):
|
|
749
|
+
frame_bytes = base64.b64decode(input_bytes)
|
|
750
|
+
else:
|
|
751
|
+
frame_bytes = input_bytes
|
|
752
|
+
nparr = np.frombuffer(frame_bytes, np.uint8)
|
|
753
|
+
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
|
754
|
+
return frame
|
|
755
|
+
except Exception as e:
|
|
756
|
+
self.logger.debug(f"Could not decode direct frame data: {e}")
|
|
757
|
+
|
|
758
|
+
return None
|
|
759
|
+
|
|
760
|
+
except Exception as e:
|
|
761
|
+
self.logger.debug(f"Error extracting frame from data: {e}")
|
|
762
|
+
return None
|
|
763
|
+
|
|
764
|
+
# Removed unused _calculate_bbox_area_percentage (not referenced)
|
|
765
|
+
|
|
766
|
+
async def _process_face_recognition(
|
|
767
|
+
self,
|
|
768
|
+
detections: List[Dict],
|
|
769
|
+
config: FaceRecognitionEmbeddingConfig,
|
|
770
|
+
stream_info: Optional[Dict[str, Any]] = None,
|
|
771
|
+
input_bytes: Optional[bytes] = None,
|
|
772
|
+
) -> List[Dict]:
|
|
773
|
+
"""Process face recognition for each detection with embeddings"""
|
|
774
|
+
|
|
775
|
+
# Initialize face client only when needed and if credentials are available
|
|
776
|
+
if not self.face_client:
|
|
777
|
+
try:
|
|
778
|
+
self.face_client = self._get_facial_recognition_client(config)
|
|
779
|
+
except Exception as e:
|
|
780
|
+
self.logger.warning(
|
|
781
|
+
f"Could not initialize face recognition client: {e}"
|
|
782
|
+
)
|
|
783
|
+
# No client available, return empty list (no results)
|
|
784
|
+
return []
|
|
785
|
+
|
|
786
|
+
# Initialize unknown faces storage if not exists
|
|
787
|
+
if not hasattr(self, "unknown_faces_storage"):
|
|
788
|
+
self.unknown_faces_storage = {}
|
|
789
|
+
|
|
790
|
+
# Initialize frame availability warning flag to avoid spam
|
|
791
|
+
if not hasattr(self, "_frame_warning_logged"):
|
|
792
|
+
self._frame_warning_logged = False
|
|
793
|
+
|
|
794
|
+
# Initialize per-request tracking (thread-safe)
|
|
795
|
+
current_recognized_count = 0
|
|
796
|
+
current_unknown_count = 0
|
|
797
|
+
recognized_persons = {}
|
|
798
|
+
current_frame_staff_details = {} # Store staff details for current frame
|
|
799
|
+
|
|
800
|
+
# Extract frame from original data for cropping unknown faces
|
|
801
|
+
current_frame = (
|
|
802
|
+
self._extract_frame_from_data(input_bytes) if input_bytes else None
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
# Log frame availability once per session
|
|
806
|
+
if current_frame is None and not self._frame_warning_logged:
|
|
807
|
+
if config.enable_unknown_face_processing:
|
|
808
|
+
self.logger.info(
|
|
809
|
+
"Frame data not available in model output - unknown face cropping/uploading will be skipped. "
|
|
810
|
+
"To disable this feature entirely, set enable_unknown_face_processing=False"
|
|
811
|
+
)
|
|
812
|
+
self._frame_warning_logged = True
|
|
813
|
+
|
|
814
|
+
# Get location from stream_info
|
|
815
|
+
location = (
|
|
816
|
+
stream_info.get("camera_location", "unknown") if stream_info else "unknown"
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
# Generate current timestamp
|
|
820
|
+
current_timestamp = datetime.now(timezone.utc).isoformat()
|
|
821
|
+
|
|
822
|
+
final_detections = []
|
|
823
|
+
# Process detections sequentially to preserve order
|
|
824
|
+
for detection in detections:
|
|
825
|
+
|
|
826
|
+
# Process each detection sequentially with await to preserve order
|
|
827
|
+
processed_detection = await self._process_face(
|
|
828
|
+
detection, current_frame, location, current_timestamp, config,
|
|
829
|
+
current_recognized_count, current_unknown_count,
|
|
830
|
+
recognized_persons, current_frame_staff_details
|
|
831
|
+
)
|
|
832
|
+
# Include both known and unknown faces in final detections (maintains original order)
|
|
833
|
+
if processed_detection:
|
|
834
|
+
final_detections.append(processed_detection)
|
|
835
|
+
# Update local counters based on processed detection
|
|
836
|
+
if processed_detection.get("recognition_status") == "known":
|
|
837
|
+
staff_id = processed_detection.get("person_id")
|
|
838
|
+
if staff_id:
|
|
839
|
+
current_frame_staff_details[staff_id] = processed_detection.get("person_name", "Unknown")
|
|
840
|
+
current_recognized_count += 1
|
|
841
|
+
recognized_persons[staff_id] = recognized_persons.get(staff_id, 0) + 1
|
|
842
|
+
elif processed_detection.get("recognition_status") == "unknown":
|
|
843
|
+
current_unknown_count += 1
|
|
844
|
+
|
|
845
|
+
return final_detections, current_recognized_count, current_unknown_count, recognized_persons, current_frame_staff_details
|
|
846
|
+
|
|
847
|
+
async def _process_face(
|
|
848
|
+
self,
|
|
849
|
+
detection: Dict,
|
|
850
|
+
current_frame: np.ndarray,
|
|
851
|
+
location: str = "",
|
|
852
|
+
current_timestamp: str = "",
|
|
853
|
+
config: FaceRecognitionEmbeddingConfig = None,
|
|
854
|
+
current_recognized_count: int = 0,
|
|
855
|
+
current_unknown_count: int = 0,
|
|
856
|
+
recognized_persons: Dict = None,
|
|
857
|
+
current_frame_staff_details: Dict = None,
|
|
858
|
+
) -> Dict:
|
|
859
|
+
|
|
860
|
+
# Extract and validate embedding using EmbeddingManager
|
|
861
|
+
detection, embedding = self.embedding_manager.extract_embedding_from_detection(detection)
|
|
862
|
+
if not embedding:
|
|
863
|
+
return None
|
|
864
|
+
|
|
865
|
+
# Internal tracker-provided ID (from AdvancedTracker; ignore upstream IDs entirely)
|
|
866
|
+
track_id = detection.get("track_id")
|
|
867
|
+
|
|
868
|
+
# Determine if detection is eligible for recognition (similar to compare_similarity gating)
|
|
869
|
+
bbox = detection.get("bounding_box", {}) or {}
|
|
870
|
+
x1 = int(bbox.get("xmin", bbox.get("x1", 0)))
|
|
871
|
+
y1 = int(bbox.get("ymin", bbox.get("y1", 0)))
|
|
872
|
+
x2 = int(bbox.get("xmax", bbox.get("x2", 0)))
|
|
873
|
+
y2 = int(bbox.get("ymax", bbox.get("y2", 0)))
|
|
874
|
+
w_box = max(1, x2 - x1)
|
|
875
|
+
h_box = max(1, y2 - y1)
|
|
876
|
+
frame_id = detection.get("frame_id", None) #TODO: Maybe replace this with stream_info frame_id
|
|
877
|
+
|
|
878
|
+
# Track probation age strictly by internal tracker id
|
|
879
|
+
if track_id is not None:
|
|
880
|
+
if track_id not in self._track_first_seen:
|
|
881
|
+
try:
|
|
882
|
+
self._track_first_seen[track_id] = int(frame_id) if frame_id is not None else self._total_frame_counter
|
|
883
|
+
except Exception:
|
|
884
|
+
self._track_first_seen[track_id] = self._total_frame_counter
|
|
885
|
+
age_frames = (int(frame_id) if frame_id is not None else self._total_frame_counter) - int(self._track_first_seen.get(track_id, 0)) + 1
|
|
886
|
+
else:
|
|
887
|
+
age_frames = 1
|
|
888
|
+
|
|
889
|
+
eligible_for_recognition = (w_box >= self._min_face_w and h_box >= self._min_face_h)
|
|
890
|
+
|
|
891
|
+
# Primary: API-based identity smoothing via TemporalIdentityManager
|
|
892
|
+
staff_id = None
|
|
893
|
+
person_name = "Unknown"
|
|
894
|
+
similarity_score = 0.0
|
|
895
|
+
employee_id = None
|
|
896
|
+
staff_details: Dict[str, Any] = {}
|
|
897
|
+
detection_type = "unknown"
|
|
898
|
+
try:
|
|
899
|
+
if self.temporal_identity_manager:
|
|
900
|
+
track_key = track_id if track_id is not None else f"no_track_{id(detection)}"
|
|
901
|
+
if not eligible_for_recognition:
|
|
902
|
+
# Mirror compare_similarity: when not eligible, keep stable label if present
|
|
903
|
+
s = self.temporal_identity_manager.tracks.get(track_key, {})
|
|
904
|
+
if isinstance(s, dict):
|
|
905
|
+
stable_staff_id = s.get("stable_staff_id")
|
|
906
|
+
stable_person_name = s.get("stable_person_name") or "Unknown"
|
|
907
|
+
stable_employee_id = s.get("stable_employee_id")
|
|
908
|
+
stable_score = float(s.get("stable_score", 0.0))
|
|
909
|
+
stable_staff_details = s.get("stable_staff_details") if isinstance(s.get("stable_staff_details"), dict) else {}
|
|
910
|
+
if stable_staff_id is not None:
|
|
911
|
+
staff_id = stable_staff_id
|
|
912
|
+
person_name = stable_person_name
|
|
913
|
+
employee_id = stable_employee_id
|
|
914
|
+
similarity_score = stable_score
|
|
915
|
+
staff_details = stable_staff_details
|
|
916
|
+
detection_type = "known"
|
|
917
|
+
else:
|
|
918
|
+
detection_type = "unknown"
|
|
919
|
+
# Also append embedding to history for temporal smoothing
|
|
920
|
+
if embedding:
|
|
921
|
+
try:
|
|
922
|
+
|
|
923
|
+
self.temporal_identity_manager._ensure_track(track_key)
|
|
924
|
+
hist = self.temporal_identity_manager.tracks[track_key]["embedding_history"] # type: ignore
|
|
925
|
+
hist.append(_normalize_embedding(embedding)) # type: ignore
|
|
926
|
+
except Exception:
|
|
927
|
+
pass
|
|
928
|
+
else: #if eligible for recognition
|
|
929
|
+
staff_id, person_name, similarity_score, employee_id, staff_details, detection_type = await self.temporal_identity_manager.update(
|
|
930
|
+
track_id=track_key,
|
|
931
|
+
emb=embedding,
|
|
932
|
+
eligible_for_recognition=True,
|
|
933
|
+
location=location,
|
|
934
|
+
timestamp=current_timestamp,
|
|
935
|
+
)
|
|
936
|
+
except Exception as e:
|
|
937
|
+
self.logger.warning(f"TemporalIdentityManager update failed: {e}")
|
|
938
|
+
|
|
939
|
+
# # Fallback: if still unknown and we have an EmbeddingManager, use local search
|
|
940
|
+
# if (staff_id is None or detection_type == "unknown") and self.embedding_manager is not None:
|
|
941
|
+
# try:
|
|
942
|
+
# search_result = await self.embedding_manager.search_face_embedding(
|
|
943
|
+
# embedding=embedding,
|
|
944
|
+
# track_id=track_id,
|
|
945
|
+
# location=location,
|
|
946
|
+
# timestamp=current_timestamp,
|
|
947
|
+
# )
|
|
948
|
+
# if search_result:
|
|
949
|
+
# employee_id = search_result.employee_id
|
|
950
|
+
# staff_id = search_result.staff_id
|
|
951
|
+
# detection_type = search_result.detection_type
|
|
952
|
+
# staff_details = search_result.staff_details
|
|
953
|
+
# person_name = search_result.person_name
|
|
954
|
+
# similarity_score = search_result.similarity_score
|
|
955
|
+
# except Exception as e:
|
|
956
|
+
# self.logger.warning(f"Local embedding search fallback failed: {e}")
|
|
957
|
+
|
|
958
|
+
# Update detection object directly (avoid relying on SearchResult type)
|
|
959
|
+
detection = detection.copy()
|
|
960
|
+
detection["person_id"] = staff_id
|
|
961
|
+
detection["person_name"] = person_name or "Unknown"
|
|
962
|
+
detection["recognition_status"] = "known" if staff_id else "unknown"
|
|
963
|
+
detection["employee_id"] = employee_id
|
|
964
|
+
detection["staff_details"] = staff_details if isinstance(staff_details, dict) else {}
|
|
965
|
+
detection["similarity_score"] = float(similarity_score)
|
|
966
|
+
detection["enrolled"] = bool(staff_id)
|
|
967
|
+
# Display label policy: show only if identified OR probation exceeded, else empty label
|
|
968
|
+
is_identified = (staff_id is not None and detection_type == "known")
|
|
969
|
+
show_label = is_identified or (age_frames >= self._probation_frames and not is_identified)
|
|
970
|
+
detection["display_name"] = (person_name if is_identified else ("Unknown" if show_label else ""))
|
|
971
|
+
# Preserve original category (e.g., 'face') for tracking/counting
|
|
972
|
+
|
|
973
|
+
# Update global tracking per unique internal track id to avoid double-counting within a frame
|
|
974
|
+
# Determine unknown strictly by recognition_status (display label never affects counters)
|
|
975
|
+
is_truly_unknown = (detection.get("recognition_status") == "unknown")
|
|
976
|
+
|
|
977
|
+
try:
|
|
978
|
+
internal_tid = detection.get("track_id")
|
|
979
|
+
except Exception:
|
|
980
|
+
internal_tid = None
|
|
981
|
+
|
|
982
|
+
if not is_truly_unknown and detection_type == "known":
|
|
983
|
+
# Mark recognized and ensure it is not counted as unknown anymore
|
|
984
|
+
self._track_person(staff_id)
|
|
985
|
+
with self._tracking_lock:
|
|
986
|
+
if internal_tid is not None:
|
|
987
|
+
self._unknown_track_ids.discard(internal_tid)
|
|
988
|
+
self._recognized_track_ids.add(internal_tid)
|
|
989
|
+
else:
|
|
990
|
+
# Only count as unknown in session totals if probation has been exceeded and still unknown
|
|
991
|
+
matured_unknown = (age_frames >= self._probation_frames)
|
|
992
|
+
if matured_unknown:
|
|
993
|
+
with self._tracking_lock:
|
|
994
|
+
if internal_tid is not None:
|
|
995
|
+
# If it later becomes recognized, we'll remove it from unknown set above
|
|
996
|
+
self._unknown_track_ids.add(internal_tid)
|
|
997
|
+
|
|
998
|
+
# Enqueue detection for background logging with all required parameters
|
|
999
|
+
try:
|
|
1000
|
+
# Log known faces for activity tracking (skip any employee_id starting with "unknown_")
|
|
1001
|
+
if (
|
|
1002
|
+
detection["recognition_status"] == "known"
|
|
1003
|
+
and self.people_activity_logging
|
|
1004
|
+
and config
|
|
1005
|
+
and getattr(config, 'enable_people_activity_logging', True)
|
|
1006
|
+
and employee_id
|
|
1007
|
+
and not str(employee_id).startswith("unknown_")
|
|
1008
|
+
):
|
|
1009
|
+
await self.people_activity_logging.enqueue_detection(
|
|
1010
|
+
detection=detection,
|
|
1011
|
+
current_frame=current_frame,
|
|
1012
|
+
location=location,
|
|
1013
|
+
)
|
|
1014
|
+
self.logger.debug(f"Enqueued known face detection for activity logging: {detection.get('person_name', 'Unknown')}")
|
|
1015
|
+
except Exception as e:
|
|
1016
|
+
self.logger.error(f"Error enqueueing detection for activity logging: {e}")
|
|
1017
|
+
|
|
1018
|
+
return detection
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _return_error_detection(
|
|
1023
|
+
self,
|
|
1024
|
+
detection: Dict,
|
|
1025
|
+
person_id: str,
|
|
1026
|
+
person_name: str,
|
|
1027
|
+
recognition_status: str,
|
|
1028
|
+
enrolled: bool,
|
|
1029
|
+
category: str,
|
|
1030
|
+
error: str,
|
|
1031
|
+
) -> Dict:
|
|
1032
|
+
"""Return error detection"""
|
|
1033
|
+
detection["person_id"] = person_id
|
|
1034
|
+
detection["person_name"] = person_name
|
|
1035
|
+
detection["recognition_status"] = recognition_status
|
|
1036
|
+
detection["enrolled"] = enrolled
|
|
1037
|
+
detection["category"] = category
|
|
1038
|
+
detection["error"] = error
|
|
1039
|
+
return detection
|
|
1040
|
+
|
|
1041
|
+
def _track_person(self, person_id: str) -> None:
|
|
1042
|
+
"""Track person with camera ID and UTC timestamp"""
|
|
1043
|
+
if person_id not in self.person_tracking:
|
|
1044
|
+
self.person_tracking[person_id] = []
|
|
1045
|
+
|
|
1046
|
+
# Add current detection
|
|
1047
|
+
detection_record = {
|
|
1048
|
+
"camera_id": "test_camera_001", # TODO: Get from stream_info in production
|
|
1049
|
+
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
1050
|
+
}
|
|
1051
|
+
self.person_tracking[person_id].append(detection_record)
|
|
1052
|
+
|
|
1053
|
+
def get_person_tracking_summary(self) -> Dict:
|
|
1054
|
+
"""Get summary of tracked persons with camera IDs and timestamps"""
|
|
1055
|
+
return dict(self.person_tracking)
|
|
1056
|
+
|
|
1057
|
+
def get_unknown_faces_storage(self) -> Dict[str, bytes]:
|
|
1058
|
+
"""Get stored unknown face images as bytes"""
|
|
1059
|
+
if self.people_activity_logging:
|
|
1060
|
+
return self.people_activity_logging.get_unknown_faces_storage()
|
|
1061
|
+
return {}
|
|
1062
|
+
|
|
1063
|
+
def clear_unknown_faces_storage(self) -> None:
|
|
1064
|
+
"""Clear stored unknown face images"""
|
|
1065
|
+
if self.people_activity_logging:
|
|
1066
|
+
self.people_activity_logging.clear_unknown_faces_storage()
|
|
1067
|
+
|
|
1068
|
+
def _get_face_recognition_summary(self, current_recognized_count: int, current_unknown_count: int, recognized_persons: Dict) -> Dict:
|
|
1069
|
+
"""Get face recognition summary for current frame"""
|
|
1070
|
+
recognition_rate = 0.0
|
|
1071
|
+
total_current = current_recognized_count + current_unknown_count
|
|
1072
|
+
if total_current > 0:
|
|
1073
|
+
recognition_rate = (current_recognized_count / total_current) * 100
|
|
1074
|
+
|
|
1075
|
+
# Get thread-safe global totals
|
|
1076
|
+
with self._tracking_lock:
|
|
1077
|
+
total_recognized = len(self._recognized_track_ids)
|
|
1078
|
+
total_unknown = len(self._unknown_track_ids)
|
|
1079
|
+
|
|
1080
|
+
return {
|
|
1081
|
+
"face_recognition_summary": {
|
|
1082
|
+
"current_frame": {
|
|
1083
|
+
"recognized": current_recognized_count,
|
|
1084
|
+
"unknown": current_unknown_count,
|
|
1085
|
+
"total": total_current,
|
|
1086
|
+
"recognized_persons": dict(recognized_persons),
|
|
1087
|
+
"recognition_rate": round(recognition_rate, 1),
|
|
1088
|
+
},
|
|
1089
|
+
"session_totals": {
|
|
1090
|
+
"total_recognized": total_recognized,
|
|
1091
|
+
"total_unknown": total_unknown,
|
|
1092
|
+
"total_processed": total_recognized + total_unknown,
|
|
1093
|
+
},
|
|
1094
|
+
"person_tracking": self.get_person_tracking_summary(),
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
def _check_alerts(
|
|
1099
|
+
self, summary: dict, frame_number: Any, config: FaceRecognitionEmbeddingConfig
|
|
1100
|
+
) -> List[Dict]:
|
|
1101
|
+
"""
|
|
1102
|
+
Check if any alert thresholds are exceeded and return alert dicts.
|
|
1103
|
+
"""
|
|
1104
|
+
|
|
1105
|
+
def get_trend(data, lookback=900, threshold=0.6):
|
|
1106
|
+
window = data[-lookback:] if len(data) >= lookback else data
|
|
1107
|
+
if len(window) < 2:
|
|
1108
|
+
return True # not enough data to determine trend
|
|
1109
|
+
increasing = 0
|
|
1110
|
+
total = 0
|
|
1111
|
+
for i in range(1, len(window)):
|
|
1112
|
+
if window[i] >= window[i - 1]:
|
|
1113
|
+
increasing += 1
|
|
1114
|
+
total += 1
|
|
1115
|
+
ratio = increasing / total
|
|
1116
|
+
if ratio >= threshold:
|
|
1117
|
+
return True
|
|
1118
|
+
elif ratio <= (1 - threshold):
|
|
1119
|
+
return False
|
|
1120
|
+
|
|
1121
|
+
frame_key = str(frame_number) if frame_number is not None else "current_frame"
|
|
1122
|
+
alerts = []
|
|
1123
|
+
total_detections = summary.get("total_count", 0)
|
|
1124
|
+
face_summary = summary.get("face_recognition_summary", {})
|
|
1125
|
+
current_unknown = face_summary.get("current_frame", {}).get("unknown", 0)
|
|
1126
|
+
|
|
1127
|
+
if not config.alert_config:
|
|
1128
|
+
return alerts
|
|
1129
|
+
|
|
1130
|
+
if (
|
|
1131
|
+
hasattr(config.alert_config, "count_thresholds")
|
|
1132
|
+
and config.alert_config.count_thresholds
|
|
1133
|
+
):
|
|
1134
|
+
for category, threshold in config.alert_config.count_thresholds.items():
|
|
1135
|
+
if category == "unknown_faces" and current_unknown > threshold:
|
|
1136
|
+
alerts.append(
|
|
1137
|
+
{
|
|
1138
|
+
"alert_type": (
|
|
1139
|
+
getattr(config.alert_config, "alert_type", ["Default"])
|
|
1140
|
+
if hasattr(config.alert_config, "alert_type")
|
|
1141
|
+
else ["Default"]
|
|
1142
|
+
),
|
|
1143
|
+
"alert_id": f"alert_unknown_faces_{frame_key}",
|
|
1144
|
+
"incident_category": "unknown_face_detection",
|
|
1145
|
+
"threshold_level": threshold,
|
|
1146
|
+
"current_count": current_unknown,
|
|
1147
|
+
"ascending": get_trend(
|
|
1148
|
+
self._ascending_alert_list, lookback=900, threshold=0.8
|
|
1149
|
+
),
|
|
1150
|
+
"settings": {
|
|
1151
|
+
t: v
|
|
1152
|
+
for t, v in zip(
|
|
1153
|
+
(
|
|
1154
|
+
getattr(
|
|
1155
|
+
config.alert_config,
|
|
1156
|
+
"alert_type",
|
|
1157
|
+
["Default"],
|
|
1158
|
+
)
|
|
1159
|
+
if hasattr(config.alert_config, "alert_type")
|
|
1160
|
+
else ["Default"]
|
|
1161
|
+
),
|
|
1162
|
+
(
|
|
1163
|
+
getattr(
|
|
1164
|
+
config.alert_config, "alert_value", ["JSON"]
|
|
1165
|
+
)
|
|
1166
|
+
if hasattr(config.alert_config, "alert_value")
|
|
1167
|
+
else ["JSON"]
|
|
1168
|
+
),
|
|
1169
|
+
)
|
|
1170
|
+
},
|
|
1171
|
+
}
|
|
1172
|
+
)
|
|
1173
|
+
elif category == "all" and total_detections > threshold:
|
|
1174
|
+
alerts.append(
|
|
1175
|
+
{
|
|
1176
|
+
"alert_type": (
|
|
1177
|
+
getattr(config.alert_config, "alert_type", ["Default"])
|
|
1178
|
+
if hasattr(config.alert_config, "alert_type")
|
|
1179
|
+
else ["Default"]
|
|
1180
|
+
),
|
|
1181
|
+
"alert_id": "alert_" + category + "_" + frame_key,
|
|
1182
|
+
"incident_category": self.CASE_TYPE,
|
|
1183
|
+
"threshold_level": threshold,
|
|
1184
|
+
"ascending": get_trend(
|
|
1185
|
+
self._ascending_alert_list, lookback=900, threshold=0.8
|
|
1186
|
+
),
|
|
1187
|
+
"settings": {
|
|
1188
|
+
t: v
|
|
1189
|
+
for t, v in zip(
|
|
1190
|
+
(
|
|
1191
|
+
getattr(
|
|
1192
|
+
config.alert_config,
|
|
1193
|
+
"alert_type",
|
|
1194
|
+
["Default"],
|
|
1195
|
+
)
|
|
1196
|
+
if hasattr(config.alert_config, "alert_type")
|
|
1197
|
+
else ["Default"]
|
|
1198
|
+
),
|
|
1199
|
+
(
|
|
1200
|
+
getattr(
|
|
1201
|
+
config.alert_config, "alert_value", ["JSON"]
|
|
1202
|
+
)
|
|
1203
|
+
if hasattr(config.alert_config, "alert_value")
|
|
1204
|
+
else ["JSON"]
|
|
1205
|
+
),
|
|
1206
|
+
)
|
|
1207
|
+
},
|
|
1208
|
+
}
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
return alerts
|
|
1212
|
+
|
|
1213
|
+
def _generate_tracking_stats(
|
|
1214
|
+
self,
|
|
1215
|
+
counting_summary: Dict,
|
|
1216
|
+
alerts: List,
|
|
1217
|
+
config: FaceRecognitionEmbeddingConfig,
|
|
1218
|
+
frame_number: Optional[int] = None,
|
|
1219
|
+
stream_info: Optional[Dict[str, Any]] = None,
|
|
1220
|
+
current_frame_staff_details: Dict = None,
|
|
1221
|
+
) -> List[Dict]:
|
|
1222
|
+
"""Generate structured tracking stats matching eg.json format with face recognition data."""
|
|
1223
|
+
camera_info = self.get_camera_info_from_stream(stream_info)
|
|
1224
|
+
tracking_stats = []
|
|
1225
|
+
|
|
1226
|
+
total_detections = counting_summary.get("total_count", 0)
|
|
1227
|
+
total_counts_dict = counting_summary.get("total_counts", {})
|
|
1228
|
+
cumulative_total = sum(total_counts_dict.values()) if total_counts_dict else 0
|
|
1229
|
+
per_category_count = counting_summary.get("per_category_count", {})
|
|
1230
|
+
face_summary = counting_summary.get("face_recognition_summary", {})
|
|
1231
|
+
|
|
1232
|
+
current_timestamp = self._get_current_timestamp_str(
|
|
1233
|
+
stream_info, precision=False
|
|
1234
|
+
)
|
|
1235
|
+
start_timestamp = self._get_start_timestamp_str(stream_info, precision=False)
|
|
1236
|
+
|
|
1237
|
+
# Create high precision timestamps for input_timestamp and reset_timestamp
|
|
1238
|
+
high_precision_start_timestamp = self._get_current_timestamp_str(
|
|
1239
|
+
stream_info, precision=True
|
|
1240
|
+
)
|
|
1241
|
+
high_precision_reset_timestamp = self._get_start_timestamp_str(
|
|
1242
|
+
stream_info, precision=True
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
# Build total_counts array in expected format
|
|
1246
|
+
total_counts = []
|
|
1247
|
+
for cat, count in total_counts_dict.items():
|
|
1248
|
+
if count > 0:
|
|
1249
|
+
total_counts.append({"category": cat, "count": count})
|
|
1250
|
+
|
|
1251
|
+
# Add face recognition specific total counts
|
|
1252
|
+
session_totals = face_summary.get("session_totals", {})
|
|
1253
|
+
total_counts.extend(
|
|
1254
|
+
[
|
|
1255
|
+
{
|
|
1256
|
+
"category": "recognized_faces",
|
|
1257
|
+
"count": session_totals.get("total_recognized", 0),
|
|
1258
|
+
},
|
|
1259
|
+
{
|
|
1260
|
+
"category": "unknown_faces",
|
|
1261
|
+
"count": session_totals.get("total_unknown", 0),
|
|
1262
|
+
},
|
|
1263
|
+
]
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
# Build current_counts array in expected format
|
|
1267
|
+
current_counts = []
|
|
1268
|
+
for cat, count in per_category_count.items():
|
|
1269
|
+
if count > 0 or total_detections > 0:
|
|
1270
|
+
current_counts.append({"category": cat, "count": count})
|
|
1271
|
+
|
|
1272
|
+
# Add face recognition specific current counts
|
|
1273
|
+
current_frame = face_summary.get("current_frame", {})
|
|
1274
|
+
current_counts.extend(
|
|
1275
|
+
[
|
|
1276
|
+
{
|
|
1277
|
+
"category": "recognized_faces",
|
|
1278
|
+
"count": current_frame.get("recognized", 0),
|
|
1279
|
+
},
|
|
1280
|
+
{"category": "unknown_faces", "count": current_frame.get("unknown", 0)},
|
|
1281
|
+
]
|
|
1282
|
+
)
|
|
1283
|
+
|
|
1284
|
+
# Prepare detections with face recognition info
|
|
1285
|
+
detections = []
|
|
1286
|
+
for detection in counting_summary.get("detections", []):
|
|
1287
|
+
bbox = detection.get("bounding_box", {})
|
|
1288
|
+
category = detection.get("display_name", "") #detection.get("category", "face")
|
|
1289
|
+
print(f"CATegory: {category}")
|
|
1290
|
+
detection_obj = self.create_detection_object(category, bbox)
|
|
1291
|
+
# Add face recognition specific fields
|
|
1292
|
+
# detection_obj.update(
|
|
1293
|
+
# {
|
|
1294
|
+
# "person_id": detection.get("person_id"),
|
|
1295
|
+
# # Use display_name for front-end label suppression policy
|
|
1296
|
+
# "person_name": detection.get("display_name", ""),
|
|
1297
|
+
# # Explicit label field for UI overlays
|
|
1298
|
+
# "label": detection.get("display_name", ""),
|
|
1299
|
+
# "recognition_status": detection.get(
|
|
1300
|
+
# "recognition_status", "unknown"
|
|
1301
|
+
# ),
|
|
1302
|
+
# "enrolled": detection.get("enrolled", False),
|
|
1303
|
+
# }
|
|
1304
|
+
# )
|
|
1305
|
+
detections.append(detection_obj)
|
|
1306
|
+
|
|
1307
|
+
# Build alert_settings array in expected format
|
|
1308
|
+
alert_settings = []
|
|
1309
|
+
if config.alert_config and hasattr(config.alert_config, "alert_type"):
|
|
1310
|
+
alert_settings.append(
|
|
1311
|
+
{
|
|
1312
|
+
"alert_type": (
|
|
1313
|
+
getattr(config.alert_config, "alert_type", ["Default"])
|
|
1314
|
+
if hasattr(config.alert_config, "alert_type")
|
|
1315
|
+
else ["Default"]
|
|
1316
|
+
),
|
|
1317
|
+
"incident_category": self.CASE_TYPE,
|
|
1318
|
+
"threshold_level": (
|
|
1319
|
+
config.alert_config.count_thresholds
|
|
1320
|
+
if hasattr(config.alert_config, "count_thresholds")
|
|
1321
|
+
else {}
|
|
1322
|
+
),
|
|
1323
|
+
"ascending": True,
|
|
1324
|
+
"settings": {
|
|
1325
|
+
t: v
|
|
1326
|
+
for t, v in zip(
|
|
1327
|
+
(
|
|
1328
|
+
getattr(config.alert_config, "alert_type", ["Default"])
|
|
1329
|
+
if hasattr(config.alert_config, "alert_type")
|
|
1330
|
+
else ["Default"]
|
|
1331
|
+
),
|
|
1332
|
+
(
|
|
1333
|
+
getattr(config.alert_config, "alert_value", ["JSON"])
|
|
1334
|
+
if hasattr(config.alert_config, "alert_value")
|
|
1335
|
+
else ["JSON"]
|
|
1336
|
+
),
|
|
1337
|
+
)
|
|
1338
|
+
},
|
|
1339
|
+
}
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
human_text_lines = [f"CURRENT FRAME @ {current_timestamp}"]
|
|
1344
|
+
|
|
1345
|
+
current_recognized = current_frame.get("recognized", 0)
|
|
1346
|
+
current_unknown = current_frame.get("unknown", 0)
|
|
1347
|
+
recognized_persons = current_frame.get("recognized_persons", {})
|
|
1348
|
+
total_current = current_recognized + current_unknown
|
|
1349
|
+
|
|
1350
|
+
# Show staff names and IDs being recognized in current frame (with tabs)
|
|
1351
|
+
human_text_lines.append(f"\tCurrent Total Faces: {total_current}")
|
|
1352
|
+
human_text_lines.append(f"\tCurrent Recognized: {current_recognized}")
|
|
1353
|
+
|
|
1354
|
+
if recognized_persons:
|
|
1355
|
+
for person_id in recognized_persons.keys():
|
|
1356
|
+
# Get actual staff name from current frame processing
|
|
1357
|
+
staff_name = (current_frame_staff_details or {}).get(
|
|
1358
|
+
person_id, f"Staff {person_id}"
|
|
1359
|
+
)
|
|
1360
|
+
human_text_lines.append(f"\tName: {staff_name} (ID: {person_id})")
|
|
1361
|
+
human_text_lines.append(f"\tCurrent Unknown: {current_unknown}")
|
|
1362
|
+
|
|
1363
|
+
# Show current frame counts only (with tabs)
|
|
1364
|
+
human_text_lines.append("")
|
|
1365
|
+
human_text_lines.append(f"TOTAL SINCE @ {start_timestamp}")
|
|
1366
|
+
human_text_lines.append(f"\tTotal Faces: {cumulative_total}")
|
|
1367
|
+
human_text_lines.append(f"\tRecognized: {face_summary.get('session_totals',{}).get('total_recognized', 0)}")
|
|
1368
|
+
human_text_lines.append(f"\tUnknown: {face_summary.get('session_totals',{}).get('total_unknown', 0)}")
|
|
1369
|
+
# Additional counts similar to compare_similarity HUD
|
|
1370
|
+
try:
|
|
1371
|
+
human_text_lines.append(f"\tCurrent Faces (detections): {total_detections}")
|
|
1372
|
+
human_text_lines.append(f"\tTotal Unique Tracks: {cumulative_total}")
|
|
1373
|
+
except Exception:
|
|
1374
|
+
pass
|
|
1375
|
+
|
|
1376
|
+
human_text = "\n".join(human_text_lines)
|
|
1377
|
+
|
|
1378
|
+
if alerts:
|
|
1379
|
+
for alert in alerts:
|
|
1380
|
+
human_text_lines.append(
|
|
1381
|
+
f"Alerts: {alert.get('settings', {})} sent @ {current_timestamp}"
|
|
1382
|
+
)
|
|
1383
|
+
else:
|
|
1384
|
+
human_text_lines.append("Alerts: None")
|
|
1385
|
+
|
|
1386
|
+
human_text = "\n".join(human_text_lines)
|
|
1387
|
+
reset_settings = [
|
|
1388
|
+
{"interval_type": "daily", "reset_time": {"value": 9, "time_unit": "hour"}}
|
|
1389
|
+
]
|
|
1390
|
+
|
|
1391
|
+
tracking_stat = self.create_tracking_stats(
|
|
1392
|
+
total_counts=total_counts,
|
|
1393
|
+
current_counts=current_counts,
|
|
1394
|
+
detections=detections,
|
|
1395
|
+
human_text=human_text,
|
|
1396
|
+
camera_info=camera_info,
|
|
1397
|
+
alerts=alerts,
|
|
1398
|
+
alert_settings=alert_settings,
|
|
1399
|
+
reset_settings=reset_settings,
|
|
1400
|
+
start_time=high_precision_start_timestamp,
|
|
1401
|
+
reset_time=high_precision_reset_timestamp,
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
tracking_stats.append(tracking_stat)
|
|
1405
|
+
return tracking_stats
|
|
1406
|
+
|
|
1407
|
+
# Copy all other methods from face_recognition.py but add face recognition info to human text
|
|
1408
|
+
def _generate_incidents(
|
|
1409
|
+
self,
|
|
1410
|
+
counting_summary: Dict,
|
|
1411
|
+
alerts: List,
|
|
1412
|
+
config: FaceRecognitionEmbeddingConfig,
|
|
1413
|
+
frame_number: Optional[int] = None,
|
|
1414
|
+
stream_info: Optional[Dict[str, Any]] = None,
|
|
1415
|
+
) -> List[Dict]:
|
|
1416
|
+
"""Generate structured incidents for the output format with frame-based keys."""
|
|
1417
|
+
|
|
1418
|
+
incidents = []
|
|
1419
|
+
total_detections = counting_summary.get("total_count", 0)
|
|
1420
|
+
face_summary = counting_summary.get("face_recognition_summary", {})
|
|
1421
|
+
current_frame = face_summary.get("current_frame", {})
|
|
1422
|
+
|
|
1423
|
+
current_timestamp = self._get_current_timestamp_str(stream_info)
|
|
1424
|
+
camera_info = self.get_camera_info_from_stream(stream_info)
|
|
1425
|
+
|
|
1426
|
+
self._ascending_alert_list = (
|
|
1427
|
+
self._ascending_alert_list[-900:]
|
|
1428
|
+
if len(self._ascending_alert_list) > 900
|
|
1429
|
+
else self._ascending_alert_list
|
|
1430
|
+
)
|
|
1431
|
+
|
|
1432
|
+
if total_detections > 0:
|
|
1433
|
+
# Determine event level based on unknown faces ratio
|
|
1434
|
+
level = "low"
|
|
1435
|
+
intensity = 5.0
|
|
1436
|
+
start_timestamp = self._get_start_timestamp_str(stream_info)
|
|
1437
|
+
if start_timestamp and self.current_incident_end_timestamp == "N/A":
|
|
1438
|
+
self.current_incident_end_timestamp = "Incident still active"
|
|
1439
|
+
elif (
|
|
1440
|
+
start_timestamp
|
|
1441
|
+
and self.current_incident_end_timestamp == "Incident still active"
|
|
1442
|
+
):
|
|
1443
|
+
if (
|
|
1444
|
+
len(self._ascending_alert_list) >= 15
|
|
1445
|
+
and sum(self._ascending_alert_list[-15:]) / 15 < 1.5
|
|
1446
|
+
):
|
|
1447
|
+
self.current_incident_end_timestamp = current_timestamp
|
|
1448
|
+
elif (
|
|
1449
|
+
self.current_incident_end_timestamp != "Incident still active"
|
|
1450
|
+
and self.current_incident_end_timestamp != "N/A"
|
|
1451
|
+
):
|
|
1452
|
+
self.current_incident_end_timestamp = "N/A"
|
|
1453
|
+
|
|
1454
|
+
# Base intensity on unknown faces
|
|
1455
|
+
current_unknown = current_frame.get("unknown", 0)
|
|
1456
|
+
unknown_ratio = (
|
|
1457
|
+
current_unknown / total_detections if total_detections > 0 else 0
|
|
1458
|
+
)
|
|
1459
|
+
intensity = min(10.0, unknown_ratio * 10 + (current_unknown / 3))
|
|
1460
|
+
|
|
1461
|
+
if intensity >= 9:
|
|
1462
|
+
level = "critical"
|
|
1463
|
+
self._ascending_alert_list.append(3)
|
|
1464
|
+
elif intensity >= 7:
|
|
1465
|
+
level = "significant"
|
|
1466
|
+
self._ascending_alert_list.append(2)
|
|
1467
|
+
elif intensity >= 5:
|
|
1468
|
+
level = "medium"
|
|
1469
|
+
self._ascending_alert_list.append(1)
|
|
1470
|
+
else:
|
|
1471
|
+
level = "low"
|
|
1472
|
+
self._ascending_alert_list.append(0)
|
|
1473
|
+
|
|
1474
|
+
# Generate human text in new format with face recognition info
|
|
1475
|
+
current_recognized = current_frame.get("recognized", 0)
|
|
1476
|
+
human_text_lines = [f"FACE RECOGNITION INCIDENTS @ {current_timestamp}:"]
|
|
1477
|
+
human_text_lines.append(f"\tSeverity Level: {(self.CASE_TYPE,level)}")
|
|
1478
|
+
human_text_lines.append(f"\tRecognized Faces: {current_recognized}")
|
|
1479
|
+
human_text_lines.append(f"\tUnknown Faces: {current_unknown}")
|
|
1480
|
+
human_text_lines.append(f"\tTotal Faces: {total_detections}")
|
|
1481
|
+
human_text = "\n".join(human_text_lines)
|
|
1482
|
+
|
|
1483
|
+
alert_settings = []
|
|
1484
|
+
if config.alert_config and hasattr(config.alert_config, "alert_type"):
|
|
1485
|
+
alert_settings.append(
|
|
1486
|
+
{
|
|
1487
|
+
"alert_type": (
|
|
1488
|
+
getattr(config.alert_config, "alert_type", ["Default"])
|
|
1489
|
+
if hasattr(config.alert_config, "alert_type")
|
|
1490
|
+
else ["Default"]
|
|
1491
|
+
),
|
|
1492
|
+
"incident_category": self.CASE_TYPE,
|
|
1493
|
+
"threshold_level": (
|
|
1494
|
+
config.alert_config.count_thresholds
|
|
1495
|
+
if hasattr(config.alert_config, "count_thresholds")
|
|
1496
|
+
else {}
|
|
1497
|
+
),
|
|
1498
|
+
"ascending": True,
|
|
1499
|
+
"settings": {
|
|
1500
|
+
t: v
|
|
1501
|
+
for t, v in zip(
|
|
1502
|
+
(
|
|
1503
|
+
getattr(
|
|
1504
|
+
config.alert_config, "alert_type", ["Default"]
|
|
1505
|
+
)
|
|
1506
|
+
if hasattr(config.alert_config, "alert_type")
|
|
1507
|
+
else ["Default"]
|
|
1508
|
+
),
|
|
1509
|
+
(
|
|
1510
|
+
getattr(
|
|
1511
|
+
config.alert_config, "alert_value", ["JSON"]
|
|
1512
|
+
)
|
|
1513
|
+
if hasattr(config.alert_config, "alert_value")
|
|
1514
|
+
else ["JSON"]
|
|
1515
|
+
),
|
|
1516
|
+
)
|
|
1517
|
+
},
|
|
1518
|
+
}
|
|
1519
|
+
)
|
|
1520
|
+
|
|
1521
|
+
event = self.create_incident(
|
|
1522
|
+
incident_id=self.CASE_TYPE + "_" + str(frame_number),
|
|
1523
|
+
incident_type=self.CASE_TYPE,
|
|
1524
|
+
severity_level=level,
|
|
1525
|
+
human_text=human_text,
|
|
1526
|
+
camera_info=camera_info,
|
|
1527
|
+
alerts=alerts,
|
|
1528
|
+
alert_settings=alert_settings,
|
|
1529
|
+
start_time=start_timestamp,
|
|
1530
|
+
end_time=self.current_incident_end_timestamp,
|
|
1531
|
+
level_settings={"low": 1, "medium": 3, "significant": 4, "critical": 7},
|
|
1532
|
+
)
|
|
1533
|
+
incidents.append(event)
|
|
1534
|
+
|
|
1535
|
+
else:
|
|
1536
|
+
self._ascending_alert_list.append(0)
|
|
1537
|
+
incidents.append({})
|
|
1538
|
+
|
|
1539
|
+
return incidents
|
|
1540
|
+
|
|
1541
|
+
def _generate_business_analytics(
|
|
1542
|
+
self,
|
|
1543
|
+
counting_summary: Dict,
|
|
1544
|
+
alerts: Any,
|
|
1545
|
+
config: FaceRecognitionEmbeddingConfig,
|
|
1546
|
+
stream_info: Optional[Dict[str, Any]] = None,
|
|
1547
|
+
is_empty=False,
|
|
1548
|
+
) -> List[Dict]:
|
|
1549
|
+
"""Generate standardized business analytics for the agg_summary structure."""
|
|
1550
|
+
if is_empty:
|
|
1551
|
+
return []
|
|
1552
|
+
return []
|
|
1553
|
+
|
|
1554
|
+
def _generate_summary(self, incidents: List, tracking_stats: List, business_analytics: List) -> List[str]:
|
|
1555
|
+
"""
|
|
1556
|
+
Generate a human_text string for the tracking_stat, incident, business analytics and alerts.
|
|
1557
|
+
"""
|
|
1558
|
+
lines = []
|
|
1559
|
+
lines.append("Application Name: "+self.CASE_TYPE)
|
|
1560
|
+
lines.append("Application Version: "+self.CASE_VERSION)
|
|
1561
|
+
if len(incidents) > 0:
|
|
1562
|
+
lines.append("Incidents: "+f"\n\t{incidents[0].get('human_text', 'No incidents detected')}")
|
|
1563
|
+
if len(tracking_stats) > 0:
|
|
1564
|
+
lines.append("Tracking Statistics: "+f"\t{tracking_stats[0].get('human_text', 'No tracking statistics detected')}")
|
|
1565
|
+
if len(business_analytics) > 0:
|
|
1566
|
+
lines.append("Business Analytics: "+f"\t{business_analytics[0].get('human_text', 'No business analytics detected')}")
|
|
1567
|
+
|
|
1568
|
+
if len(incidents) == 0 and len(tracking_stats) == 0 and len(business_analytics) == 0:
|
|
1569
|
+
lines.append("Summary: "+"No Summary Data")
|
|
1570
|
+
|
|
1571
|
+
return ["\n".join(lines)]
|
|
1572
|
+
|
|
1573
|
+
# Include all the standard helper methods from face_recognition.py...
|
|
1574
|
+
def _count_categories(
|
|
1575
|
+
self, detections: list, config: FaceRecognitionEmbeddingConfig
|
|
1576
|
+
) -> dict:
|
|
1577
|
+
"""
|
|
1578
|
+
Count the number of detections per category and return a summary dict.
|
|
1579
|
+
The detections list is expected to have 'track_id' (from tracker), 'category', 'bounding_box', etc.
|
|
1580
|
+
Output structure will include 'track_id' for each detection as per AdvancedTracker output.
|
|
1581
|
+
"""
|
|
1582
|
+
counts = {}
|
|
1583
|
+
for det in detections:
|
|
1584
|
+
cat = det.get("category", "unknown")
|
|
1585
|
+
counts[cat] = counts.get(cat, 0) + 1
|
|
1586
|
+
# Each detection dict will now include 'track_id' and face recognition fields
|
|
1587
|
+
return {
|
|
1588
|
+
"total_count": sum(counts.values()),
|
|
1589
|
+
"per_category_count": counts,
|
|
1590
|
+
"detections": [
|
|
1591
|
+
{
|
|
1592
|
+
"bounding_box": det.get("bounding_box"),
|
|
1593
|
+
"category": det.get("category"),
|
|
1594
|
+
"confidence": det.get("confidence"),
|
|
1595
|
+
"track_id": det.get("track_id"),
|
|
1596
|
+
"frame_id": det.get("frame_id"),
|
|
1597
|
+
# Face recognition fields
|
|
1598
|
+
"person_id": det.get("person_id"),
|
|
1599
|
+
"person_name": det.get("person_name"),
|
|
1600
|
+
"label": det.get("display_name", ""),
|
|
1601
|
+
"recognition_status": det.get("recognition_status"),
|
|
1602
|
+
"enrolled": det.get("enrolled"),
|
|
1603
|
+
"embedding": det.get("embedding", []),
|
|
1604
|
+
"landmarks": det.get("landmarks"),
|
|
1605
|
+
"staff_details": det.get(
|
|
1606
|
+
"staff_details"
|
|
1607
|
+
), # Full staff information from API
|
|
1608
|
+
}
|
|
1609
|
+
for det in detections
|
|
1610
|
+
],
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
# Removed unused _extract_predictions (counts and outputs are built elsewhere)
|
|
1614
|
+
|
|
1615
|
+
# Copy all standard tracking, IoU, timestamp methods from face_recognition.py
|
|
1616
|
+
def _update_tracking_state(self, detections: list):
|
|
1617
|
+
"""Track unique categories track_ids per category for total count after tracking."""
|
|
1618
|
+
if not hasattr(self, "_per_category_total_track_ids"):
|
|
1619
|
+
self._per_category_total_track_ids = {
|
|
1620
|
+
cat: set() for cat in self.target_categories
|
|
1621
|
+
}
|
|
1622
|
+
self._current_frame_track_ids = {cat: set() for cat in self.target_categories}
|
|
1623
|
+
|
|
1624
|
+
for det in detections:
|
|
1625
|
+
cat = det.get("category")
|
|
1626
|
+
raw_track_id = det.get("track_id")
|
|
1627
|
+
if cat not in self.target_categories or raw_track_id is None:
|
|
1628
|
+
continue
|
|
1629
|
+
bbox = det.get("bounding_box", det.get("bbox"))
|
|
1630
|
+
canonical_id = self._merge_or_register_track(raw_track_id, bbox)
|
|
1631
|
+
det["track_id"] = canonical_id
|
|
1632
|
+
|
|
1633
|
+
self._per_category_total_track_ids.setdefault(cat, set()).add(canonical_id)
|
|
1634
|
+
self._current_frame_track_ids[cat].add(canonical_id)
|
|
1635
|
+
|
|
1636
|
+
def get_total_counts(self):
|
|
1637
|
+
"""Return total unique track_id count for each category."""
|
|
1638
|
+
return {
|
|
1639
|
+
cat: len(ids)
|
|
1640
|
+
for cat, ids in getattr(self, "_per_category_total_track_ids", {}).items()
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
def _format_timestamp_for_stream(self, timestamp: float) -> str:
|
|
1644
|
+
"""Format timestamp for streams (YYYY:MM:DD HH:MM:SS format)."""
|
|
1645
|
+
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
|
1646
|
+
return dt.strftime("%Y:%m:%d %H:%M:%S")
|
|
1647
|
+
|
|
1648
|
+
def _format_timestamp_for_video(self, timestamp: float) -> str:
|
|
1649
|
+
"""Format timestamp for video chunks (HH:MM:SS.ms format)."""
|
|
1650
|
+
hours = int(timestamp // 3600)
|
|
1651
|
+
minutes = int((timestamp % 3600) // 60)
|
|
1652
|
+
seconds = round(float(timestamp % 60), 2)
|
|
1653
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:.1f}"
|
|
1654
|
+
|
|
1655
|
+
def _format_timestamp(self, timestamp: Any) -> str:
|
|
1656
|
+
"""Format a timestamp so that exactly two digits follow the decimal point (milliseconds).
|
|
1657
|
+
|
|
1658
|
+
The input can be either:
|
|
1659
|
+
1. A numeric Unix timestamp (``float`` / ``int``) – it will first be converted to a
|
|
1660
|
+
string in the format ``YYYY-MM-DD-HH:MM:SS.ffffff UTC``.
|
|
1661
|
+
2. A string already following the same layout.
|
|
1662
|
+
|
|
1663
|
+
The returned value preserves the overall format of the input but truncates or pads
|
|
1664
|
+
the fractional seconds portion to **exactly two digits**.
|
|
1665
|
+
|
|
1666
|
+
Example
|
|
1667
|
+
-------
|
|
1668
|
+
>>> self._format_timestamp("2025-08-19-04:22:47.187574 UTC")
|
|
1669
|
+
'2025-08-19-04:22:47.18 UTC'
|
|
1670
|
+
"""
|
|
1671
|
+
|
|
1672
|
+
# Convert numeric timestamps to the expected string representation first
|
|
1673
|
+
if isinstance(timestamp, (int, float)):
|
|
1674
|
+
timestamp = datetime.fromtimestamp(timestamp, timezone.utc).strftime(
|
|
1675
|
+
'%Y-%m-%d-%H:%M:%S.%f UTC'
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
# Ensure we are working with a string from here on
|
|
1679
|
+
if not isinstance(timestamp, str):
|
|
1680
|
+
return str(timestamp)
|
|
1681
|
+
|
|
1682
|
+
# If there is no fractional component, simply return the original string
|
|
1683
|
+
if '.' not in timestamp:
|
|
1684
|
+
return timestamp
|
|
1685
|
+
|
|
1686
|
+
# Split out the main portion (up to the decimal point)
|
|
1687
|
+
main_part, fractional_and_suffix = timestamp.split('.', 1)
|
|
1688
|
+
|
|
1689
|
+
# Separate fractional digits from the suffix (typically ' UTC')
|
|
1690
|
+
if ' ' in fractional_and_suffix:
|
|
1691
|
+
fractional_part, suffix = fractional_and_suffix.split(' ', 1)
|
|
1692
|
+
suffix = ' ' + suffix # Re-attach the space removed by split
|
|
1693
|
+
else:
|
|
1694
|
+
fractional_part, suffix = fractional_and_suffix, ''
|
|
1695
|
+
|
|
1696
|
+
# Guarantee exactly two digits for the fractional part
|
|
1697
|
+
fractional_part = (fractional_part + '00')[:2]
|
|
1698
|
+
|
|
1699
|
+
return f"{main_part}.{fractional_part}{suffix}"
|
|
1700
|
+
|
|
1701
|
+
def _get_current_timestamp_str(self, stream_info: Optional[Dict[str, Any]], precision=False, frame_id: Optional[str]=None) -> str:
|
|
1702
|
+
"""Get formatted current timestamp based on stream type."""
|
|
1703
|
+
if not stream_info:
|
|
1704
|
+
return "00:00:00.00"
|
|
1705
|
+
|
|
1706
|
+
if precision:
|
|
1707
|
+
if stream_info.get("input_settings", {}).get("start_frame", "na") != "na":
|
|
1708
|
+
if frame_id:
|
|
1709
|
+
start_time = int(frame_id)/stream_info.get("input_settings", {}).get("original_fps", 30)
|
|
1710
|
+
else:
|
|
1711
|
+
start_time = stream_info.get("input_settings", {}).get("start_frame", 30)/stream_info.get("input_settings", {}).get("original_fps", 30)
|
|
1712
|
+
stream_time_str = self._format_timestamp_for_video(start_time)
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
return self._format_timestamp(stream_info.get("input_settings", {}).get("stream_time", "NA"))
|
|
1716
|
+
else:
|
|
1717
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
|
|
1718
|
+
|
|
1719
|
+
if stream_info.get("input_settings", {}).get("start_frame", "na") != "na":
|
|
1720
|
+
if frame_id:
|
|
1721
|
+
start_time = int(frame_id)/stream_info.get("input_settings", {}).get("original_fps", 30)
|
|
1722
|
+
else:
|
|
1723
|
+
start_time = stream_info.get("input_settings", {}).get("start_frame", 30)/stream_info.get("input_settings", {}).get("original_fps", 30)
|
|
1724
|
+
|
|
1725
|
+
stream_time_str = self._format_timestamp_for_video(start_time)
|
|
1726
|
+
|
|
1727
|
+
return self._format_timestamp(stream_info.get("input_settings", {}).get("stream_time", "NA"))
|
|
1728
|
+
else:
|
|
1729
|
+
stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
|
|
1730
|
+
if stream_time_str:
|
|
1731
|
+
try:
|
|
1732
|
+
timestamp_str = stream_time_str.replace(" UTC", "")
|
|
1733
|
+
dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
|
|
1734
|
+
timestamp = dt.replace(tzinfo=timezone.utc).timestamp()
|
|
1735
|
+
return self._format_timestamp_for_stream(timestamp)
|
|
1736
|
+
except:
|
|
1737
|
+
return self._format_timestamp_for_stream(time.time())
|
|
1738
|
+
else:
|
|
1739
|
+
return self._format_timestamp_for_stream(time.time())
|
|
1740
|
+
|
|
1741
|
+
def _get_start_timestamp_str(self, stream_info: Optional[Dict[str, Any]], precision=False) -> str:
|
|
1742
|
+
"""Get formatted start timestamp for 'TOTAL SINCE' based on stream type."""
|
|
1743
|
+
if not stream_info:
|
|
1744
|
+
return "00:00:00"
|
|
1745
|
+
|
|
1746
|
+
if precision:
|
|
1747
|
+
if self.start_timer is None:
|
|
1748
|
+
self.start_timer = stream_info.get("input_settings", {}).get("stream_time", "NA")
|
|
1749
|
+
return self._format_timestamp(self.start_timer)
|
|
1750
|
+
elif stream_info.get("input_settings", {}).get("start_frame", "na") == 1:
|
|
1751
|
+
self.start_timer = stream_info.get("input_settings", {}).get("stream_time", "NA")
|
|
1752
|
+
return self._format_timestamp(self.start_timer)
|
|
1753
|
+
else:
|
|
1754
|
+
return self._format_timestamp(self.start_timer)
|
|
1755
|
+
|
|
1756
|
+
if self.start_timer is None:
|
|
1757
|
+
self.start_timer = stream_info.get("input_settings", {}).get("stream_time", "NA")
|
|
1758
|
+
return self._format_timestamp(self.start_timer)
|
|
1759
|
+
elif stream_info.get("input_settings", {}).get("start_frame", "na") == 1:
|
|
1760
|
+
self.start_timer = stream_info.get("input_settings", {}).get("stream_time", "NA")
|
|
1761
|
+
return self._format_timestamp(self.start_timer)
|
|
1762
|
+
|
|
1763
|
+
else:
|
|
1764
|
+
if self.start_timer is not None:
|
|
1765
|
+
return self._format_timestamp(self.start_timer)
|
|
1766
|
+
|
|
1767
|
+
if self._tracking_start_time is None:
|
|
1768
|
+
stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
|
|
1769
|
+
if stream_time_str:
|
|
1770
|
+
try:
|
|
1771
|
+
timestamp_str = stream_time_str.replace(" UTC", "")
|
|
1772
|
+
dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
|
|
1773
|
+
self._tracking_start_time = dt.replace(tzinfo=timezone.utc).timestamp()
|
|
1774
|
+
except:
|
|
1775
|
+
self._tracking_start_time = time.time()
|
|
1776
|
+
else:
|
|
1777
|
+
self._tracking_start_time = time.time()
|
|
1778
|
+
|
|
1779
|
+
dt = datetime.fromtimestamp(self._tracking_start_time, tz=timezone.utc)
|
|
1780
|
+
dt = dt.replace(minute=0, second=0, microsecond=0)
|
|
1781
|
+
return dt.strftime('%Y:%m:%d %H:%M:%S')
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def _compute_iou(self, box1: Any, box2: Any) -> float:
|
|
1785
|
+
"""Compute IoU between two bounding boxes which may be dicts or lists."""
|
|
1786
|
+
|
|
1787
|
+
def _bbox_to_list(bbox):
|
|
1788
|
+
if bbox is None:
|
|
1789
|
+
return []
|
|
1790
|
+
if isinstance(bbox, list):
|
|
1791
|
+
return bbox[:4] if len(bbox) >= 4 else []
|
|
1792
|
+
if isinstance(bbox, dict):
|
|
1793
|
+
if "xmin" in bbox:
|
|
1794
|
+
return [bbox["xmin"], bbox["ymin"], bbox["xmax"], bbox["ymax"]]
|
|
1795
|
+
if "x1" in bbox:
|
|
1796
|
+
return [bbox["x1"], bbox["y1"], bbox["x2"], bbox["y2"]]
|
|
1797
|
+
values = [v for v in bbox.values() if isinstance(v, (int, float))]
|
|
1798
|
+
return values[:4] if len(values) >= 4 else []
|
|
1799
|
+
return []
|
|
1800
|
+
|
|
1801
|
+
l1 = _bbox_to_list(box1)
|
|
1802
|
+
l2 = _bbox_to_list(box2)
|
|
1803
|
+
if len(l1) < 4 or len(l2) < 4:
|
|
1804
|
+
return 0.0
|
|
1805
|
+
x1_min, y1_min, x1_max, y1_max = l1
|
|
1806
|
+
x2_min, y2_min, x2_max, y2_max = l2
|
|
1807
|
+
|
|
1808
|
+
x1_min, x1_max = min(x1_min, x1_max), max(x1_min, x1_max)
|
|
1809
|
+
y1_min, y1_max = min(y1_min, y1_max), max(y1_min, y1_max)
|
|
1810
|
+
x2_min, x2_max = min(x2_min, x2_max), max(x2_min, x2_max)
|
|
1811
|
+
y2_min, y2_max = min(y2_min, y2_max), max(y2_min, y2_max)
|
|
1812
|
+
|
|
1813
|
+
inter_x_min = max(x1_min, x2_min)
|
|
1814
|
+
inter_y_min = max(y1_min, y2_min)
|
|
1815
|
+
inter_x_max = min(x1_max, x2_max)
|
|
1816
|
+
inter_y_max = min(y1_max, y2_max)
|
|
1817
|
+
|
|
1818
|
+
inter_w = max(0.0, inter_x_max - inter_x_min)
|
|
1819
|
+
inter_h = max(0.0, inter_y_max - inter_y_min)
|
|
1820
|
+
inter_area = inter_w * inter_h
|
|
1821
|
+
|
|
1822
|
+
area1 = (x1_max - x1_min) * (y1_max - y1_min)
|
|
1823
|
+
area2 = (x2_max - x2_min) * (y2_max - y2_min)
|
|
1824
|
+
union_area = area1 + area2 - inter_area
|
|
1825
|
+
|
|
1826
|
+
return (inter_area / union_area) if union_area > 0 else 0.0
|
|
1827
|
+
|
|
1828
|
+
def _merge_or_register_track(self, raw_id: Any, bbox: Any) -> Any:
|
|
1829
|
+
"""Return a stable canonical ID for a raw tracker ID."""
|
|
1830
|
+
if raw_id is None or bbox is None:
|
|
1831
|
+
return raw_id
|
|
1832
|
+
|
|
1833
|
+
now = time.time()
|
|
1834
|
+
|
|
1835
|
+
if raw_id in self._track_aliases:
|
|
1836
|
+
canonical_id = self._track_aliases[raw_id]
|
|
1837
|
+
track_info = self._canonical_tracks.get(canonical_id)
|
|
1838
|
+
if track_info is not None:
|
|
1839
|
+
track_info["last_bbox"] = bbox
|
|
1840
|
+
track_info["last_update"] = now
|
|
1841
|
+
track_info["raw_ids"].add(raw_id)
|
|
1842
|
+
return canonical_id
|
|
1843
|
+
|
|
1844
|
+
for canonical_id, info in self._canonical_tracks.items():
|
|
1845
|
+
if now - info["last_update"] > self._track_merge_time_window:
|
|
1846
|
+
continue
|
|
1847
|
+
iou = self._compute_iou(bbox, info["last_bbox"])
|
|
1848
|
+
if iou >= self._track_merge_iou_threshold:
|
|
1849
|
+
self._track_aliases[raw_id] = canonical_id
|
|
1850
|
+
info["last_bbox"] = bbox
|
|
1851
|
+
info["last_update"] = now
|
|
1852
|
+
info["raw_ids"].add(raw_id)
|
|
1853
|
+
return canonical_id
|
|
1854
|
+
|
|
1855
|
+
canonical_id = raw_id
|
|
1856
|
+
self._track_aliases[raw_id] = canonical_id
|
|
1857
|
+
self._canonical_tracks[canonical_id] = {
|
|
1858
|
+
"last_bbox": bbox,
|
|
1859
|
+
"last_update": now,
|
|
1860
|
+
"raw_ids": {raw_id},
|
|
1861
|
+
}
|
|
1862
|
+
return canonical_id
|
|
1863
|
+
|
|
1864
|
+
def __del__(self):
|
|
1865
|
+
"""Cleanup when object is destroyed"""
|
|
1866
|
+
try:
|
|
1867
|
+
if hasattr(self, "people_activity_logging") and self.people_activity_logging:
|
|
1868
|
+
self.people_activity_logging.stop_background_processing()
|
|
1869
|
+
except:
|
|
1870
|
+
pass
|