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.

Files changed (160) hide show
  1. matrice_analytics/__init__.py +28 -0
  2. matrice_analytics/boundary_drawing_internal/README.md +305 -0
  3. matrice_analytics/boundary_drawing_internal/__init__.py +45 -0
  4. matrice_analytics/boundary_drawing_internal/boundary_drawing_internal.py +1207 -0
  5. matrice_analytics/boundary_drawing_internal/boundary_drawing_tool.py +429 -0
  6. matrice_analytics/boundary_drawing_internal/boundary_tool_template.html +1036 -0
  7. matrice_analytics/boundary_drawing_internal/data/.gitignore +12 -0
  8. matrice_analytics/boundary_drawing_internal/example_usage.py +206 -0
  9. matrice_analytics/boundary_drawing_internal/usage/README.md +110 -0
  10. matrice_analytics/boundary_drawing_internal/usage/boundary_drawer_launcher.py +102 -0
  11. matrice_analytics/boundary_drawing_internal/usage/simple_boundary_launcher.py +107 -0
  12. matrice_analytics/post_processing/README.md +455 -0
  13. matrice_analytics/post_processing/__init__.py +732 -0
  14. matrice_analytics/post_processing/advanced_tracker/README.md +650 -0
  15. matrice_analytics/post_processing/advanced_tracker/__init__.py +17 -0
  16. matrice_analytics/post_processing/advanced_tracker/base.py +99 -0
  17. matrice_analytics/post_processing/advanced_tracker/config.py +77 -0
  18. matrice_analytics/post_processing/advanced_tracker/kalman_filter.py +370 -0
  19. matrice_analytics/post_processing/advanced_tracker/matching.py +195 -0
  20. matrice_analytics/post_processing/advanced_tracker/strack.py +230 -0
  21. matrice_analytics/post_processing/advanced_tracker/tracker.py +367 -0
  22. matrice_analytics/post_processing/config.py +142 -0
  23. matrice_analytics/post_processing/core/__init__.py +63 -0
  24. matrice_analytics/post_processing/core/base.py +704 -0
  25. matrice_analytics/post_processing/core/config.py +3188 -0
  26. matrice_analytics/post_processing/core/config_utils.py +925 -0
  27. matrice_analytics/post_processing/face_reg/__init__.py +43 -0
  28. matrice_analytics/post_processing/face_reg/compare_similarity.py +556 -0
  29. matrice_analytics/post_processing/face_reg/embedding_manager.py +681 -0
  30. matrice_analytics/post_processing/face_reg/face_recognition.py +1870 -0
  31. matrice_analytics/post_processing/face_reg/face_recognition_client.py +339 -0
  32. matrice_analytics/post_processing/face_reg/people_activity_logging.py +283 -0
  33. matrice_analytics/post_processing/ocr/__init__.py +0 -0
  34. matrice_analytics/post_processing/ocr/easyocr_extractor.py +248 -0
  35. matrice_analytics/post_processing/ocr/postprocessing.py +271 -0
  36. matrice_analytics/post_processing/ocr/preprocessing.py +52 -0
  37. matrice_analytics/post_processing/post_processor.py +1153 -0
  38. matrice_analytics/post_processing/test_cases/__init__.py +1 -0
  39. matrice_analytics/post_processing/test_cases/run_tests.py +143 -0
  40. matrice_analytics/post_processing/test_cases/test_advanced_customer_service.py +841 -0
  41. matrice_analytics/post_processing/test_cases/test_basic_counting_tracking.py +523 -0
  42. matrice_analytics/post_processing/test_cases/test_comprehensive.py +531 -0
  43. matrice_analytics/post_processing/test_cases/test_config.py +852 -0
  44. matrice_analytics/post_processing/test_cases/test_customer_service.py +585 -0
  45. matrice_analytics/post_processing/test_cases/test_data_generators.py +583 -0
  46. matrice_analytics/post_processing/test_cases/test_people_counting.py +510 -0
  47. matrice_analytics/post_processing/test_cases/test_processor.py +524 -0
  48. matrice_analytics/post_processing/test_cases/test_utilities.py +356 -0
  49. matrice_analytics/post_processing/test_cases/test_utils.py +743 -0
  50. matrice_analytics/post_processing/usecases/Histopathological_Cancer_Detection_img.py +604 -0
  51. matrice_analytics/post_processing/usecases/__init__.py +267 -0
  52. matrice_analytics/post_processing/usecases/abandoned_object_detection.py +797 -0
  53. matrice_analytics/post_processing/usecases/advanced_customer_service.py +1601 -0
  54. matrice_analytics/post_processing/usecases/age_detection.py +842 -0
  55. matrice_analytics/post_processing/usecases/age_gender_detection.py +1043 -0
  56. matrice_analytics/post_processing/usecases/anti_spoofing_detection.py +656 -0
  57. matrice_analytics/post_processing/usecases/assembly_line_detection.py +841 -0
  58. matrice_analytics/post_processing/usecases/banana_defect_detection.py +624 -0
  59. matrice_analytics/post_processing/usecases/basic_counting_tracking.py +667 -0
  60. matrice_analytics/post_processing/usecases/blood_cancer_detection_img.py +881 -0
  61. matrice_analytics/post_processing/usecases/car_damage_detection.py +834 -0
  62. matrice_analytics/post_processing/usecases/car_part_segmentation.py +946 -0
  63. matrice_analytics/post_processing/usecases/car_service.py +1601 -0
  64. matrice_analytics/post_processing/usecases/cardiomegaly_classification.py +864 -0
  65. matrice_analytics/post_processing/usecases/cell_microscopy_segmentation.py +897 -0
  66. matrice_analytics/post_processing/usecases/chicken_pose_detection.py +648 -0
  67. matrice_analytics/post_processing/usecases/child_monitoring.py +814 -0
  68. matrice_analytics/post_processing/usecases/color/clip.py +232 -0
  69. matrice_analytics/post_processing/usecases/color/clip_processor/merges.txt +48895 -0
  70. matrice_analytics/post_processing/usecases/color/clip_processor/preprocessor_config.json +28 -0
  71. matrice_analytics/post_processing/usecases/color/clip_processor/special_tokens_map.json +30 -0
  72. matrice_analytics/post_processing/usecases/color/clip_processor/tokenizer.json +245079 -0
  73. matrice_analytics/post_processing/usecases/color/clip_processor/tokenizer_config.json +32 -0
  74. matrice_analytics/post_processing/usecases/color/clip_processor/vocab.json +1 -0
  75. matrice_analytics/post_processing/usecases/color/color_map_utils.py +70 -0
  76. matrice_analytics/post_processing/usecases/color/color_mapper.py +468 -0
  77. matrice_analytics/post_processing/usecases/color_detection.py +1835 -0
  78. matrice_analytics/post_processing/usecases/color_map_utils.py +70 -0
  79. matrice_analytics/post_processing/usecases/concrete_crack_detection.py +827 -0
  80. matrice_analytics/post_processing/usecases/crop_weed_detection.py +781 -0
  81. matrice_analytics/post_processing/usecases/customer_service.py +1008 -0
  82. matrice_analytics/post_processing/usecases/defect_detection_products.py +936 -0
  83. matrice_analytics/post_processing/usecases/distracted_driver_detection.py +822 -0
  84. matrice_analytics/post_processing/usecases/drone_traffic_monitoring.py +930 -0
  85. matrice_analytics/post_processing/usecases/drowsy_driver_detection.py +829 -0
  86. matrice_analytics/post_processing/usecases/dwell_detection.py +829 -0
  87. matrice_analytics/post_processing/usecases/emergency_vehicle_detection.py +827 -0
  88. matrice_analytics/post_processing/usecases/face_emotion.py +813 -0
  89. matrice_analytics/post_processing/usecases/face_recognition.py +827 -0
  90. matrice_analytics/post_processing/usecases/fashion_detection.py +835 -0
  91. matrice_analytics/post_processing/usecases/field_mapping.py +902 -0
  92. matrice_analytics/post_processing/usecases/fire_detection.py +1112 -0
  93. matrice_analytics/post_processing/usecases/flare_analysis.py +891 -0
  94. matrice_analytics/post_processing/usecases/flower_segmentation.py +1006 -0
  95. matrice_analytics/post_processing/usecases/gas_leak_detection.py +837 -0
  96. matrice_analytics/post_processing/usecases/gender_detection.py +832 -0
  97. matrice_analytics/post_processing/usecases/human_activity_recognition.py +871 -0
  98. matrice_analytics/post_processing/usecases/intrusion_detection.py +1672 -0
  99. matrice_analytics/post_processing/usecases/leaf.py +821 -0
  100. matrice_analytics/post_processing/usecases/leaf_disease.py +840 -0
  101. matrice_analytics/post_processing/usecases/leak_detection.py +837 -0
  102. matrice_analytics/post_processing/usecases/license_plate_detection.py +914 -0
  103. matrice_analytics/post_processing/usecases/license_plate_monitoring.py +1194 -0
  104. matrice_analytics/post_processing/usecases/litter_monitoring.py +717 -0
  105. matrice_analytics/post_processing/usecases/mask_detection.py +869 -0
  106. matrice_analytics/post_processing/usecases/natural_disaster.py +907 -0
  107. matrice_analytics/post_processing/usecases/parking.py +787 -0
  108. matrice_analytics/post_processing/usecases/parking_space_detection.py +822 -0
  109. matrice_analytics/post_processing/usecases/pcb_defect_detection.py +888 -0
  110. matrice_analytics/post_processing/usecases/pedestrian_detection.py +808 -0
  111. matrice_analytics/post_processing/usecases/people_counting.py +1728 -0
  112. matrice_analytics/post_processing/usecases/people_tracking.py +1842 -0
  113. matrice_analytics/post_processing/usecases/pipeline_detection.py +605 -0
  114. matrice_analytics/post_processing/usecases/plaque_segmentation_img.py +874 -0
  115. matrice_analytics/post_processing/usecases/pothole_segmentation.py +915 -0
  116. matrice_analytics/post_processing/usecases/ppe_compliance.py +645 -0
  117. matrice_analytics/post_processing/usecases/price_tag_detection.py +822 -0
  118. matrice_analytics/post_processing/usecases/proximity_detection.py +1901 -0
  119. matrice_analytics/post_processing/usecases/road_lane_detection.py +623 -0
  120. matrice_analytics/post_processing/usecases/road_traffic_density.py +832 -0
  121. matrice_analytics/post_processing/usecases/road_view_segmentation.py +915 -0
  122. matrice_analytics/post_processing/usecases/shelf_inventory_detection.py +583 -0
  123. matrice_analytics/post_processing/usecases/shoplifting_detection.py +822 -0
  124. matrice_analytics/post_processing/usecases/shopping_cart_analysis.py +899 -0
  125. matrice_analytics/post_processing/usecases/skin_cancer_classification_img.py +864 -0
  126. matrice_analytics/post_processing/usecases/smoker_detection.py +833 -0
  127. matrice_analytics/post_processing/usecases/solar_panel.py +810 -0
  128. matrice_analytics/post_processing/usecases/suspicious_activity_detection.py +1030 -0
  129. matrice_analytics/post_processing/usecases/template_usecase.py +380 -0
  130. matrice_analytics/post_processing/usecases/theft_detection.py +648 -0
  131. matrice_analytics/post_processing/usecases/traffic_sign_monitoring.py +724 -0
  132. matrice_analytics/post_processing/usecases/underground_pipeline_defect_detection.py +775 -0
  133. matrice_analytics/post_processing/usecases/underwater_pollution_detection.py +842 -0
  134. matrice_analytics/post_processing/usecases/vehicle_monitoring.py +950 -0
  135. matrice_analytics/post_processing/usecases/warehouse_object_segmentation.py +899 -0
  136. matrice_analytics/post_processing/usecases/waterbody_segmentation.py +923 -0
  137. matrice_analytics/post_processing/usecases/weapon_detection.py +771 -0
  138. matrice_analytics/post_processing/usecases/weld_defect_detection.py +615 -0
  139. matrice_analytics/post_processing/usecases/wildlife_monitoring.py +898 -0
  140. matrice_analytics/post_processing/usecases/windmill_maintenance.py +834 -0
  141. matrice_analytics/post_processing/usecases/wound_segmentation.py +856 -0
  142. matrice_analytics/post_processing/utils/__init__.py +150 -0
  143. matrice_analytics/post_processing/utils/advanced_counting_utils.py +400 -0
  144. matrice_analytics/post_processing/utils/advanced_helper_utils.py +317 -0
  145. matrice_analytics/post_processing/utils/advanced_tracking_utils.py +461 -0
  146. matrice_analytics/post_processing/utils/alerting_utils.py +213 -0
  147. matrice_analytics/post_processing/utils/category_mapping_utils.py +94 -0
  148. matrice_analytics/post_processing/utils/color_utils.py +592 -0
  149. matrice_analytics/post_processing/utils/counting_utils.py +182 -0
  150. matrice_analytics/post_processing/utils/filter_utils.py +261 -0
  151. matrice_analytics/post_processing/utils/format_utils.py +293 -0
  152. matrice_analytics/post_processing/utils/geometry_utils.py +300 -0
  153. matrice_analytics/post_processing/utils/smoothing_utils.py +358 -0
  154. matrice_analytics/post_processing/utils/tracking_utils.py +234 -0
  155. matrice_analytics/py.typed +0 -0
  156. matrice_analytics-0.1.2.dist-info/METADATA +481 -0
  157. matrice_analytics-0.1.2.dist-info/RECORD +160 -0
  158. matrice_analytics-0.1.2.dist-info/WHEEL +5 -0
  159. matrice_analytics-0.1.2.dist-info/licenses/LICENSE.txt +21 -0
  160. 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