matrice-analytics 0.1.60__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) 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 +146 -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 +3291 -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 +950 -0
  30. matrice_analytics/post_processing/face_reg/face_recognition.py +2234 -0
  31. matrice_analytics/post_processing/face_reg/face_recognition_client.py +606 -0
  32. matrice_analytics/post_processing/face_reg/people_activity_logging.py +321 -0
  33. matrice_analytics/post_processing/ocr/__init__.py +0 -0
  34. matrice_analytics/post_processing/ocr/easyocr_extractor.py +250 -0
  35. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/__init__.py +9 -0
  36. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/cli/__init__.py +4 -0
  37. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/cli/cli.py +33 -0
  38. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/cli/dataset_stats.py +139 -0
  39. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/cli/export.py +398 -0
  40. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/cli/train.py +447 -0
  41. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/cli/utils.py +129 -0
  42. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/cli/valid.py +93 -0
  43. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/cli/validate_dataset.py +240 -0
  44. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/cli/visualize_augmentation.py +176 -0
  45. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/cli/visualize_predictions.py +96 -0
  46. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/core/__init__.py +3 -0
  47. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/core/process.py +246 -0
  48. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/core/types.py +60 -0
  49. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/core/utils.py +87 -0
  50. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/inference/__init__.py +3 -0
  51. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/inference/config.py +82 -0
  52. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/inference/hub.py +141 -0
  53. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/inference/plate_recognizer.py +323 -0
  54. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/py.typed +0 -0
  55. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/train/__init__.py +0 -0
  56. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/train/data/__init__.py +0 -0
  57. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/train/data/augmentation.py +101 -0
  58. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/train/data/dataset.py +97 -0
  59. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/train/model/__init__.py +0 -0
  60. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/train/model/config.py +114 -0
  61. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/train/model/layers.py +553 -0
  62. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/train/model/loss.py +55 -0
  63. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/train/model/metric.py +86 -0
  64. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/train/model/model_builders.py +95 -0
  65. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/train/model/model_schema.py +395 -0
  66. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/train/utilities/__init__.py +0 -0
  67. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/train/utilities/backend_utils.py +38 -0
  68. matrice_analytics/post_processing/ocr/fast_plate_ocr_py38/train/utilities/utils.py +214 -0
  69. matrice_analytics/post_processing/ocr/postprocessing.py +270 -0
  70. matrice_analytics/post_processing/ocr/preprocessing.py +52 -0
  71. matrice_analytics/post_processing/post_processor.py +1175 -0
  72. matrice_analytics/post_processing/test_cases/__init__.py +1 -0
  73. matrice_analytics/post_processing/test_cases/run_tests.py +143 -0
  74. matrice_analytics/post_processing/test_cases/test_advanced_customer_service.py +841 -0
  75. matrice_analytics/post_processing/test_cases/test_basic_counting_tracking.py +523 -0
  76. matrice_analytics/post_processing/test_cases/test_comprehensive.py +531 -0
  77. matrice_analytics/post_processing/test_cases/test_config.py +852 -0
  78. matrice_analytics/post_processing/test_cases/test_customer_service.py +585 -0
  79. matrice_analytics/post_processing/test_cases/test_data_generators.py +583 -0
  80. matrice_analytics/post_processing/test_cases/test_people_counting.py +510 -0
  81. matrice_analytics/post_processing/test_cases/test_processor.py +524 -0
  82. matrice_analytics/post_processing/test_cases/test_usecases.py +165 -0
  83. matrice_analytics/post_processing/test_cases/test_utilities.py +356 -0
  84. matrice_analytics/post_processing/test_cases/test_utils.py +743 -0
  85. matrice_analytics/post_processing/usecases/Histopathological_Cancer_Detection_img.py +604 -0
  86. matrice_analytics/post_processing/usecases/__init__.py +267 -0
  87. matrice_analytics/post_processing/usecases/abandoned_object_detection.py +797 -0
  88. matrice_analytics/post_processing/usecases/advanced_customer_service.py +1601 -0
  89. matrice_analytics/post_processing/usecases/age_detection.py +842 -0
  90. matrice_analytics/post_processing/usecases/age_gender_detection.py +1085 -0
  91. matrice_analytics/post_processing/usecases/anti_spoofing_detection.py +656 -0
  92. matrice_analytics/post_processing/usecases/assembly_line_detection.py +841 -0
  93. matrice_analytics/post_processing/usecases/banana_defect_detection.py +624 -0
  94. matrice_analytics/post_processing/usecases/basic_counting_tracking.py +667 -0
  95. matrice_analytics/post_processing/usecases/blood_cancer_detection_img.py +881 -0
  96. matrice_analytics/post_processing/usecases/car_damage_detection.py +834 -0
  97. matrice_analytics/post_processing/usecases/car_part_segmentation.py +946 -0
  98. matrice_analytics/post_processing/usecases/car_service.py +1601 -0
  99. matrice_analytics/post_processing/usecases/cardiomegaly_classification.py +864 -0
  100. matrice_analytics/post_processing/usecases/cell_microscopy_segmentation.py +897 -0
  101. matrice_analytics/post_processing/usecases/chicken_pose_detection.py +648 -0
  102. matrice_analytics/post_processing/usecases/child_monitoring.py +814 -0
  103. matrice_analytics/post_processing/usecases/color/clip.py +660 -0
  104. matrice_analytics/post_processing/usecases/color/clip_processor/merges.txt +48895 -0
  105. matrice_analytics/post_processing/usecases/color/clip_processor/preprocessor_config.json +28 -0
  106. matrice_analytics/post_processing/usecases/color/clip_processor/special_tokens_map.json +30 -0
  107. matrice_analytics/post_processing/usecases/color/clip_processor/tokenizer.json +245079 -0
  108. matrice_analytics/post_processing/usecases/color/clip_processor/tokenizer_config.json +32 -0
  109. matrice_analytics/post_processing/usecases/color/clip_processor/vocab.json +1 -0
  110. matrice_analytics/post_processing/usecases/color/color_map_utils.py +70 -0
  111. matrice_analytics/post_processing/usecases/color/color_mapper.py +468 -0
  112. matrice_analytics/post_processing/usecases/color_detection.py +1936 -0
  113. matrice_analytics/post_processing/usecases/color_map_utils.py +70 -0
  114. matrice_analytics/post_processing/usecases/concrete_crack_detection.py +827 -0
  115. matrice_analytics/post_processing/usecases/crop_weed_detection.py +781 -0
  116. matrice_analytics/post_processing/usecases/customer_service.py +1008 -0
  117. matrice_analytics/post_processing/usecases/defect_detection_products.py +936 -0
  118. matrice_analytics/post_processing/usecases/distracted_driver_detection.py +822 -0
  119. matrice_analytics/post_processing/usecases/drone_traffic_monitoring.py +585 -0
  120. matrice_analytics/post_processing/usecases/drowsy_driver_detection.py +829 -0
  121. matrice_analytics/post_processing/usecases/dwell_detection.py +829 -0
  122. matrice_analytics/post_processing/usecases/emergency_vehicle_detection.py +827 -0
  123. matrice_analytics/post_processing/usecases/face_emotion.py +813 -0
  124. matrice_analytics/post_processing/usecases/face_recognition.py +827 -0
  125. matrice_analytics/post_processing/usecases/fashion_detection.py +835 -0
  126. matrice_analytics/post_processing/usecases/field_mapping.py +902 -0
  127. matrice_analytics/post_processing/usecases/fire_detection.py +1146 -0
  128. matrice_analytics/post_processing/usecases/flare_analysis.py +836 -0
  129. matrice_analytics/post_processing/usecases/flower_segmentation.py +1006 -0
  130. matrice_analytics/post_processing/usecases/gas_leak_detection.py +837 -0
  131. matrice_analytics/post_processing/usecases/gender_detection.py +832 -0
  132. matrice_analytics/post_processing/usecases/human_activity_recognition.py +871 -0
  133. matrice_analytics/post_processing/usecases/intrusion_detection.py +1672 -0
  134. matrice_analytics/post_processing/usecases/leaf.py +821 -0
  135. matrice_analytics/post_processing/usecases/leaf_disease.py +840 -0
  136. matrice_analytics/post_processing/usecases/leak_detection.py +837 -0
  137. matrice_analytics/post_processing/usecases/license_plate_detection.py +1188 -0
  138. matrice_analytics/post_processing/usecases/license_plate_monitoring.py +1781 -0
  139. matrice_analytics/post_processing/usecases/litter_monitoring.py +717 -0
  140. matrice_analytics/post_processing/usecases/mask_detection.py +869 -0
  141. matrice_analytics/post_processing/usecases/natural_disaster.py +907 -0
  142. matrice_analytics/post_processing/usecases/parking.py +787 -0
  143. matrice_analytics/post_processing/usecases/parking_space_detection.py +822 -0
  144. matrice_analytics/post_processing/usecases/pcb_defect_detection.py +888 -0
  145. matrice_analytics/post_processing/usecases/pedestrian_detection.py +808 -0
  146. matrice_analytics/post_processing/usecases/people_counting.py +706 -0
  147. matrice_analytics/post_processing/usecases/people_counting_bckp.py +1683 -0
  148. matrice_analytics/post_processing/usecases/people_tracking.py +1842 -0
  149. matrice_analytics/post_processing/usecases/pipeline_detection.py +605 -0
  150. matrice_analytics/post_processing/usecases/plaque_segmentation_img.py +874 -0
  151. matrice_analytics/post_processing/usecases/pothole_segmentation.py +915 -0
  152. matrice_analytics/post_processing/usecases/ppe_compliance.py +645 -0
  153. matrice_analytics/post_processing/usecases/price_tag_detection.py +822 -0
  154. matrice_analytics/post_processing/usecases/proximity_detection.py +1901 -0
  155. matrice_analytics/post_processing/usecases/road_lane_detection.py +623 -0
  156. matrice_analytics/post_processing/usecases/road_traffic_density.py +832 -0
  157. matrice_analytics/post_processing/usecases/road_view_segmentation.py +915 -0
  158. matrice_analytics/post_processing/usecases/shelf_inventory_detection.py +583 -0
  159. matrice_analytics/post_processing/usecases/shoplifting_detection.py +822 -0
  160. matrice_analytics/post_processing/usecases/shopping_cart_analysis.py +899 -0
  161. matrice_analytics/post_processing/usecases/skin_cancer_classification_img.py +864 -0
  162. matrice_analytics/post_processing/usecases/smoker_detection.py +833 -0
  163. matrice_analytics/post_processing/usecases/solar_panel.py +810 -0
  164. matrice_analytics/post_processing/usecases/suspicious_activity_detection.py +1030 -0
  165. matrice_analytics/post_processing/usecases/template_usecase.py +380 -0
  166. matrice_analytics/post_processing/usecases/theft_detection.py +648 -0
  167. matrice_analytics/post_processing/usecases/traffic_sign_monitoring.py +724 -0
  168. matrice_analytics/post_processing/usecases/underground_pipeline_defect_detection.py +775 -0
  169. matrice_analytics/post_processing/usecases/underwater_pollution_detection.py +842 -0
  170. matrice_analytics/post_processing/usecases/vehicle_monitoring.py +1029 -0
  171. matrice_analytics/post_processing/usecases/warehouse_object_segmentation.py +899 -0
  172. matrice_analytics/post_processing/usecases/waterbody_segmentation.py +923 -0
  173. matrice_analytics/post_processing/usecases/weapon_detection.py +771 -0
  174. matrice_analytics/post_processing/usecases/weld_defect_detection.py +615 -0
  175. matrice_analytics/post_processing/usecases/wildlife_monitoring.py +898 -0
  176. matrice_analytics/post_processing/usecases/windmill_maintenance.py +834 -0
  177. matrice_analytics/post_processing/usecases/wound_segmentation.py +856 -0
  178. matrice_analytics/post_processing/utils/__init__.py +150 -0
  179. matrice_analytics/post_processing/utils/advanced_counting_utils.py +400 -0
  180. matrice_analytics/post_processing/utils/advanced_helper_utils.py +317 -0
  181. matrice_analytics/post_processing/utils/advanced_tracking_utils.py +461 -0
  182. matrice_analytics/post_processing/utils/alerting_utils.py +213 -0
  183. matrice_analytics/post_processing/utils/category_mapping_utils.py +94 -0
  184. matrice_analytics/post_processing/utils/color_utils.py +592 -0
  185. matrice_analytics/post_processing/utils/counting_utils.py +182 -0
  186. matrice_analytics/post_processing/utils/filter_utils.py +261 -0
  187. matrice_analytics/post_processing/utils/format_utils.py +293 -0
  188. matrice_analytics/post_processing/utils/geometry_utils.py +300 -0
  189. matrice_analytics/post_processing/utils/smoothing_utils.py +358 -0
  190. matrice_analytics/post_processing/utils/tracking_utils.py +234 -0
  191. matrice_analytics/py.typed +0 -0
  192. matrice_analytics-0.1.60.dist-info/METADATA +481 -0
  193. matrice_analytics-0.1.60.dist-info/RECORD +196 -0
  194. matrice_analytics-0.1.60.dist-info/WHEEL +5 -0
  195. matrice_analytics-0.1.60.dist-info/licenses/LICENSE.txt +21 -0
  196. matrice_analytics-0.1.60.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1781 @@
1
+ from typing import Any, Dict, List, Optional
2
+ from dataclasses import asdict, dataclass, field
3
+ import time
4
+ from datetime import datetime, timezone
5
+ import copy
6
+ import tempfile
7
+ import os
8
+ from ..core.base import BaseProcessor, ProcessingContext, ProcessingResult, ConfigProtocol
9
+ from ..utils import (
10
+ filter_by_confidence,
11
+ filter_by_categories,
12
+ apply_category_mapping,
13
+ count_objects_by_category,
14
+ count_objects_in_zones,
15
+ calculate_counting_summary,
16
+ match_results_structure,
17
+ bbox_smoothing,
18
+ BBoxSmoothingConfig,
19
+ BBoxSmoothingTracker
20
+ )
21
+ # External dependencies
22
+ import cv2
23
+ import numpy as np
24
+ #import torch
25
+ import re
26
+ from collections import Counter, defaultdict
27
+ import sys
28
+ import subprocess
29
+ import logging
30
+ import asyncio
31
+ import urllib
32
+ import urllib.request
33
+ import base64
34
+ # Get the major and minor version numbers
35
+ major_version = sys.version_info.major
36
+ minor_version = sys.version_info.minor
37
+ print(f"Python version: {major_version}.{minor_version}")
38
+ os.environ["ORT_LOG_SEVERITY_LEVEL"] = "3"
39
+
40
+
41
+ # Lazy import mechanism for LicensePlateRecognizer
42
+ _OCR_IMPORT_SOURCE = None
43
+ _LicensePlateRecognizerClass = None
44
+
45
+ def _get_license_plate_recognizer_class():
46
+ """Lazy load LicensePlateRecognizer with automatic installation fallback."""
47
+ global _OCR_IMPORT_SOURCE, _LicensePlateRecognizerClass
48
+
49
+ if _LicensePlateRecognizerClass is not None:
50
+ return _LicensePlateRecognizerClass
51
+
52
+ # Try to import from local repo first
53
+ try:
54
+ from ..ocr.fast_plate_ocr_py38 import LicensePlateRecognizer
55
+ _OCR_IMPORT_SOURCE = "local_repo"
56
+ _LicensePlateRecognizerClass = LicensePlateRecognizer
57
+ logging.info("Successfully imported LicensePlateRecognizer from local repo")
58
+ return _LicensePlateRecognizerClass
59
+ except ImportError as e:
60
+ logging.debug(f"Could not import from local repo: {e}")
61
+
62
+ # Try to import from installed package
63
+ try:
64
+ from fast_plate_ocr import LicensePlateRecognizer # type: ignore
65
+ _OCR_IMPORT_SOURCE = "installed_package"
66
+ _LicensePlateRecognizerClass = LicensePlateRecognizer
67
+ logging.info("Successfully imported LicensePlateRecognizer from installed package")
68
+ return _LicensePlateRecognizerClass
69
+ except ImportError as e:
70
+ logging.warning(f"Could not import from installed package: {e}")
71
+
72
+ # Try to install with GPU support first
73
+ logging.info("Attempting to install fast-plate-ocr with GPU support...")
74
+ try:
75
+ import subprocess
76
+ result = subprocess.run(
77
+ [sys.executable, "-m", "pip", "install", "fast-plate-ocr[onnx-gpu]", "--no-cache-dir"],
78
+ capture_output=True,
79
+ text=True,
80
+ timeout=300
81
+ )
82
+ if result.returncode == 0:
83
+ logging.info("Successfully installed fast-plate-ocr[onnx-gpu]")
84
+ try:
85
+ from fast_plate_ocr import LicensePlateRecognizer # type: ignore
86
+ _OCR_IMPORT_SOURCE = "installed_package_gpu"
87
+ _LicensePlateRecognizerClass = LicensePlateRecognizer
88
+ logging.info("Successfully imported LicensePlateRecognizer after GPU installation")
89
+ return _LicensePlateRecognizerClass
90
+ except ImportError as e:
91
+ logging.warning(f"Installation succeeded but import failed: {e}")
92
+ else:
93
+ logging.warning(f"GPU installation failed: {result.stderr}")
94
+ except Exception as e:
95
+ logging.warning(f"Error during GPU installation: {e}")
96
+
97
+ # Try to install with CPU support as fallback
98
+ logging.info("Attempting to install fast-plate-ocr with CPU support...")
99
+ try:
100
+ import subprocess
101
+ result = subprocess.run(
102
+ [sys.executable, "-m", "pip", "install", "fast-plate-ocr[onnx]", "--no-cache-dir"],
103
+ capture_output=True,
104
+ text=True,
105
+ timeout=300
106
+ )
107
+ if result.returncode == 0:
108
+ logging.info("Successfully installed fast-plate-ocr[onnx]")
109
+ try:
110
+ from fast_plate_ocr import LicensePlateRecognizer # type: ignore
111
+ _OCR_IMPORT_SOURCE = "installed_package_cpu"
112
+ _LicensePlateRecognizerClass = LicensePlateRecognizer
113
+ logging.info("Successfully imported LicensePlateRecognizer after CPU installation")
114
+ return _LicensePlateRecognizerClass
115
+ except ImportError as e:
116
+ logging.error(f"Installation succeeded but import failed: {e}")
117
+ else:
118
+ logging.error(f"CPU installation failed: {result.stderr}")
119
+ except Exception as e:
120
+ logging.error(f"Error during CPU installation: {e}")
121
+
122
+ # Return None if all attempts failed
123
+ logging.error("All attempts to load or install LicensePlateRecognizer failed")
124
+ _OCR_IMPORT_SOURCE = "unavailable"
125
+ return None
126
+
127
+ # Internal utilities that are still required
128
+ from ..ocr.preprocessing import ImagePreprocessor
129
+ from ..core.config import BaseConfig, AlertConfig, ZoneConfig
130
+
131
+ try:
132
+ from matrice_common.session import Session
133
+ HAS_MATRICE_SESSION = True
134
+ except ImportError:
135
+ HAS_MATRICE_SESSION = False
136
+ logging.warning("Matrice session not available")
137
+
138
+ @dataclass
139
+ class LicensePlateMonitorConfig(BaseConfig):
140
+ """Configuration for License plate detection use case in License plate monitoring."""
141
+ enable_smoothing: bool = False
142
+ smoothing_algorithm: str = "observability" # "window" or "observability"
143
+ smoothing_window_size: int = 20
144
+ smoothing_cooldown_frames: int = 5
145
+ smoothing_confidence_range_factor: float = 0.5
146
+ confidence_threshold: float = 0.5
147
+ frame_skip: int = 1
148
+ fps: Optional[float] = None
149
+ bbox_format: str = "auto"
150
+ usecase_categories: List[str] = field(default_factory=lambda: ['license_plate'])
151
+ target_categories: List[str] = field(default_factory=lambda: ['license_plate'])
152
+ alert_config: Optional[AlertConfig] = None
153
+ index_to_category: Optional[Dict[int, str]] = field(default_factory=lambda: {0: "license_plate"})
154
+ language: List[str] = field(default_factory=lambda: ['en'])
155
+ country: str = field(default_factory=lambda: 'us')
156
+ ocr_mode:str = field(default_factory=lambda: "numeric") # "alphanumeric" or "numeric" or "alphabetic"
157
+ session: Optional[Session] = None
158
+ lpr_server_id: Optional[str] = None # Optional LPR server ID for remote logging
159
+ plate_log_cooldown: float = 30.0 # Cooldown period in seconds for logging same plate
160
+
161
+ def validate(self) -> List[str]:
162
+ """Validate configuration parameters."""
163
+ errors = super().validate()
164
+ if self.confidence_threshold < 0 or self.confidence_threshold > 1:
165
+ errors.append("confidence_threshold must be between 0 and 1")
166
+ if self.frame_skip <= 0:
167
+ errors.append("frame_skip must be positive")
168
+ if self.bbox_format not in ["auto", "xmin_ymin_xmax_ymax", "x_y_width_height"]:
169
+ errors.append("bbox_format must be one of: auto, xmin_ymin_xmax_ymax, x_y_width_height")
170
+ if self.smoothing_window_size <= 0:
171
+ errors.append("smoothing_window_size must be positive")
172
+ if self.smoothing_cooldown_frames < 0:
173
+ errors.append("smoothing_cooldown_frames cannot be negative")
174
+ if self.smoothing_confidence_range_factor <= 0:
175
+ errors.append("smoothing_confidence_range_factor must be positive")
176
+ return errors
177
+
178
+ class LicensePlateMonitorLogger:
179
+ def __init__(self):
180
+ self.session = None
181
+ self.logger = logging.getLogger(__name__)
182
+ self.lpr_server_id = None
183
+ self.server_info = None
184
+ self.plate_log_timestamps: Dict[str, float] = {} # Track last log time per plate
185
+ self.server_base_url = None
186
+ self.public_ip = self._get_public_ip()
187
+
188
+ def initialize_session(self, config: LicensePlateMonitorConfig) -> None:
189
+ """Initialize session and fetch server connection info if lpr_server_id is provided."""
190
+ print("[LP_LOGGING] ===== INITIALIZING LP LOGGER SESSION =====")
191
+ print(f"[LP_LOGGING] Config lpr_server_id: {config.lpr_server_id}")
192
+ self.logger.info("[LP_LOGGING] ===== INITIALIZING LP LOGGER SESSION =====")
193
+ self.logger.info(f"[LP_LOGGING] Config lpr_server_id: {config.lpr_server_id}")
194
+
195
+ # Use existing session if provided, otherwise create new one
196
+ if self.session and self.server_info and self.server_base_url:
197
+ self.logger.info("[LP_LOGGING] Session already initialized with server info, skipping re-initialization")
198
+ self.logger.info(f"[LP_LOGGING] Using existing server: {self.server_base_url}")
199
+ return
200
+ elif self.session:
201
+ self.logger.info("[LP_LOGGING] Session exists but server info missing, continuing initialization...")
202
+ else:
203
+ self.logger.info("[LP_LOGGING] No existing session, initializing from scratch...")
204
+
205
+ if config.session:
206
+ self.session = config.session
207
+ self.logger.info("[LP_LOGGING] Using provided session from config")
208
+
209
+ if not self.session:
210
+ # Initialize Matrice session
211
+ if not HAS_MATRICE_SESSION:
212
+ self.logger.error("[LP_LOGGING] Matrice session module not available")
213
+ raise ImportError("Matrice session is required for License Plate Monitoring")
214
+ try:
215
+ self.logger.info("[LP_LOGGING] Creating new Matrice session from environment variables...")
216
+ account_number = os.getenv("MATRICE_ACCOUNT_NUMBER", "")
217
+ access_key_id = os.getenv("MATRICE_ACCESS_KEY_ID", "")
218
+ secret_key = os.getenv("MATRICE_SECRET_ACCESS_KEY", "")
219
+ project_id = os.getenv("MATRICE_PROJECT_ID", "")
220
+
221
+ self.logger.info(f"[LP_LOGGING] Account Number: {'SET' if account_number else 'NOT SET'}")
222
+ self.logger.info(f"[LP_LOGGING] Access Key ID: {'SET' if access_key_id else 'NOT SET'}")
223
+ self.logger.info(f"[LP_LOGGING] Secret Key: {'SET' if secret_key else 'NOT SET'}")
224
+ self.logger.info(f"[LP_LOGGING] Project ID: {'SET' if project_id else 'NOT SET'}")
225
+
226
+ self.session = Session(
227
+ account_number=account_number,
228
+ access_key=access_key_id,
229
+ secret_key=secret_key,
230
+ project_id=project_id,
231
+ )
232
+ self.logger.info("[LP_LOGGING] Successfully initialized new Matrice session")
233
+ except Exception as e:
234
+ self.logger.error(f"[LP_LOGGING] Failed to initialize Matrice session: {e}", exc_info=True)
235
+ raise
236
+
237
+ # Fetch server connection info if lpr_server_id is provided
238
+ if config.lpr_server_id:
239
+ self.lpr_server_id = config.lpr_server_id
240
+ self.logger.info(f"[LP_LOGGING] Fetching LPR server connection info for server ID: {self.lpr_server_id}")
241
+ try:
242
+ self.server_info = self.get_server_connection_info()
243
+ if self.server_info:
244
+ self.logger.info(f"[LP_LOGGING] Successfully fetched LPR server info")
245
+ self.logger.info(f"[LP_LOGGING] - Name: {self.server_info.get('name', 'Unknown')}")
246
+ self.logger.info(f"[LP_LOGGING] - Host: {self.server_info.get('host', 'Unknown')}")
247
+ self.logger.info(f"[LP_LOGGING] - Port: {self.server_info.get('port', 'Unknown')}")
248
+ self.logger.info(f"[LP_LOGGING] - Status: {self.server_info.get('status', 'Unknown')}")
249
+ self.logger.info(f"[LP_LOGGING] - Project ID: {self.server_info.get('projectID', 'Unknown')}")
250
+
251
+ # Compare server host with public IP to determine if it's localhost
252
+ server_host = self.server_info.get('host', 'localhost')
253
+ server_port = self.server_info.get('port', 8200)
254
+
255
+ if server_host == self.public_ip:
256
+ self.server_base_url = f"http://localhost:{server_port}"
257
+ self.logger.info(f"[LP_LOGGING] Server host matches public IP ({self.public_ip}), using localhost: {self.server_base_url}")
258
+ else:
259
+ self.server_base_url = f"http://{server_host}:{server_port}"
260
+ self.logger.info(f"[LP_LOGGING] LPR server base URL configured: {self.server_base_url}")
261
+
262
+ self.session.update(self.server_info.get('projectID', ''))
263
+ self.logger.info(f"[LP_LOGGING] Updated Matrice session with project ID: {self.server_info.get('projectID', '')}")
264
+ else:
265
+ self.logger.error("[LP_LOGGING] Failed to fetch LPR server connection info - server_info is None")
266
+ self.logger.error("[LP_LOGGING] This will prevent plate logging from working!")
267
+ except Exception as e:
268
+ self.logger.error(f"[LP_LOGGING] Error fetching LPR server connection info: {e}", exc_info=True)
269
+ self.logger.error("[LP_LOGGING] This will prevent plate logging from working!")
270
+ else:
271
+ self.logger.warning("[LP_LOGGING] No lpr_server_id provided in config, skipping server connection info fetch")
272
+
273
+ print("[LP_LOGGING] ===== LP LOGGER SESSION INITIALIZATION COMPLETE =====")
274
+ self.logger.info("[LP_LOGGING] ===== LP LOGGER SESSION INITIALIZATION COMPLETE =====")
275
+
276
+ def _get_public_ip(self) -> str:
277
+ """Get the public IP address of this machine."""
278
+ self.logger.info("Fetching public IP address...")
279
+ try:
280
+ public_ip = urllib.request.urlopen("https://v4.ident.me", timeout=120).read().decode("utf8").strip()
281
+ self.logger.info(f"Successfully fetched external IP: {public_ip}")
282
+ return public_ip
283
+ except Exception as e:
284
+ self.logger.error(f"Error fetching external IP: {e}", exc_info=True)
285
+ return "localhost"
286
+
287
+ def get_server_connection_info(self) -> Optional[Dict[str, Any]]:
288
+ """Fetch server connection info from RPC."""
289
+ if not self.lpr_server_id:
290
+ self.logger.warning("No lpr_server_id set, cannot fetch server connection info")
291
+ return None
292
+
293
+ try:
294
+ endpoint = f"/v1/actions/lpr_servers/{self.lpr_server_id}"
295
+ self.logger.info(f"Sending GET request to: {endpoint}")
296
+ response = self.session.rpc.get(endpoint)
297
+ self.logger.info(f"Received response: success={response.get('success')}, code={response.get('code')}, message={response.get('message')}")
298
+
299
+ if response.get("success", False) and response.get("code") == 200:
300
+ # Response format:
301
+ # {'success': True,
302
+ # 'code': 200,
303
+ # 'message': 'Success',
304
+ # 'serverTime': '2025-10-19T04:58:04Z',
305
+ # 'data': {'id': '68f07e515cd5c6134a075384',
306
+ # 'name': 'lpr-server-1',
307
+ # 'host': '106.219.122.19',
308
+ # 'port': 8200,
309
+ # 'status': 'created',
310
+ # 'accountNumber': '3823255831182978487149732',
311
+ # 'projectID': '68ca6372ab79ba13ef699ba6',
312
+ # 'region': 'United States',
313
+ # 'isShared': False}}
314
+ data = response.get("data", {})
315
+ self.logger.info(f"Server connection info retrieved: name={data.get('name')}, host={data.get('host')}, port={data.get('port')}, status={data.get('status')}")
316
+ return data
317
+ else:
318
+ self.logger.warning(f"Failed to fetch server info: {response.get('message', 'Unknown error')}")
319
+ return None
320
+ except Exception as e:
321
+ self.logger.error(f"Exception while fetching server connection info: {e}", exc_info=True)
322
+ return None
323
+
324
+ def should_log_plate(self, plate_text: str, cooldown: float) -> bool:
325
+ """Check if enough time has passed since last log for this plate."""
326
+ current_time = time.time()
327
+ last_log_time = self.plate_log_timestamps.get(plate_text, 0)
328
+ time_since_last_log = current_time - last_log_time
329
+
330
+ if time_since_last_log >= cooldown:
331
+ print(f"[LP_LOGGING] ✓ Plate '{plate_text}' ready to log ({time_since_last_log:.1f}s since last)")
332
+ self.logger.info(f"[LP_LOGGING] OK - Plate '{plate_text}' ready to log (last logged {time_since_last_log:.1f}s ago, cooldown={cooldown}s)")
333
+ return True
334
+ else:
335
+ print(f"[LP_LOGGING] ⊗ Plate '{plate_text}' in cooldown ({cooldown - time_since_last_log:.1f}s remaining)")
336
+ self.logger.info(f"[LP_LOGGING] SKIP - Plate '{plate_text}' in cooldown period ({time_since_last_log:.1f}s elapsed, {cooldown - time_since_last_log:.1f}s remaining)")
337
+ return False
338
+
339
+ def update_log_timestamp(self, plate_text: str) -> None:
340
+ """Update the last log timestamp for a plate."""
341
+ self.plate_log_timestamps[plate_text] = time.time()
342
+ self.logger.debug(f"Updated log timestamp for plate: {plate_text}")
343
+
344
+ def _format_timestamp_rfc3339(self, timestamp: str) -> str:
345
+ """Convert timestamp to RFC3339 format (2006-01-02T15:04:05Z).
346
+
347
+ Handles various input formats:
348
+ - "YYYY-MM-DD-HH:MM:SS.ffffff UTC"
349
+ - "YYYY:MM:DD HH:MM:SS"
350
+ - Unix timestamp (float/int)
351
+ """
352
+ try:
353
+ # If already in RFC3339 format, return as is
354
+ if 'T' in timestamp and timestamp.endswith('Z'):
355
+ return timestamp
356
+
357
+ # Try to parse common formats
358
+ dt = None
359
+
360
+ # Format: "2025-08-19-04:22:47.187574 UTC"
361
+ if '-' in timestamp and 'UTC' in timestamp:
362
+ timestamp_clean = timestamp.replace(' UTC', '')
363
+ dt = datetime.strptime(timestamp_clean, '%Y-%m-%d-%H:%M:%S.%f')
364
+ # Format: "2025:10:23 14:30:45"
365
+ elif ':' in timestamp and ' ' in timestamp:
366
+ dt = datetime.strptime(timestamp, '%Y:%m:%d %H:%M:%S')
367
+ # Format: numeric timestamp
368
+ elif timestamp.replace('.', '').isdigit():
369
+ dt = datetime.fromtimestamp(float(timestamp), tz=timezone.utc)
370
+
371
+ if dt is None:
372
+ # Fallback to current time
373
+ dt = datetime.now(timezone.utc)
374
+ else:
375
+ # Ensure timezone is UTC
376
+ if dt.tzinfo is None:
377
+ dt = dt.replace(tzinfo=timezone.utc)
378
+
379
+ # Format to RFC3339: 2006-01-02T15:04:05Z
380
+ return dt.strftime('%Y-%m-%dT%H:%M:%SZ')
381
+
382
+ except Exception as e:
383
+ self.logger.warning(f"Failed to parse timestamp '{timestamp}': {e}. Using current time.")
384
+ return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
385
+
386
+ async def log_plate(self, plate_text: str, timestamp: str, stream_info: Dict[str, Any],
387
+ image_data: Optional[str] = None, cooldown: float = 30.0) -> bool:
388
+ """Log plate to RPC server with cooldown period.
389
+
390
+ Args:
391
+ plate_text: The license plate text
392
+ timestamp: Capture timestamp
393
+ stream_info: Stream information dict
394
+ image_data: Base64-encoded JPEG image of the license plate crop
395
+ cooldown: Cooldown period in seconds
396
+ """
397
+ print(f"[LP_LOGGING] ===== PLATE LOG REQUEST START =====")
398
+ print(f"[LP_LOGGING] Plate: '{plate_text}', Timestamp: {timestamp}")
399
+ self.logger.info(f"[LP_LOGGING] ===== PLATE LOG REQUEST START =====")
400
+ self.logger.info(f"[LP_LOGGING] Plate: '{plate_text}', Timestamp: {timestamp}")
401
+
402
+ # Check cooldown
403
+ if not self.should_log_plate(plate_text, cooldown):
404
+ print(f"[LP_LOGGING] Plate '{plate_text}' NOT SENT - cooldown")
405
+ self.logger.info(f"[LP_LOGGING] Plate '{plate_text}' NOT SENT - skipped due to cooldown period")
406
+ self.logger.info(f"[LP_LOGGING] ===== PLATE LOG REQUEST END (SKIPPED) =====")
407
+ return False
408
+
409
+ try:
410
+ camera_info = stream_info.get("camera_info", {})
411
+ camera_name = camera_info.get("camera_name", "")
412
+ location = camera_info.get("location", "")
413
+ frame_id = stream_info.get("frame_id", "")
414
+
415
+ print(f"[LP_LOGGING] Camera: '{camera_name}', Location: '{location}'")
416
+ self.logger.info(f"[LP_LOGGING] Stream Info - Camera: '{camera_name}', Location: '{location}', Frame ID: '{frame_id}'")
417
+
418
+ # Get project ID from server_info
419
+ project_id = self.server_info.get('projectID', '') if self.server_info else ''
420
+ self.logger.info(f"[LP_LOGGING] Project ID: '{project_id}'")
421
+
422
+ # Format timestamp to RFC3339 format (2006-01-02T15:04:05Z)
423
+ rfc3339_timestamp = self._format_timestamp_rfc3339(timestamp)
424
+ self.logger.info(f"[LP_LOGGING] Formatted timestamp: {timestamp} -> {rfc3339_timestamp}")
425
+
426
+ payload = {
427
+ 'licensePlate': plate_text,
428
+ 'frameId': frame_id,
429
+ 'location': location,
430
+ 'camera': camera_name,
431
+ 'captureTimestamp': rfc3339_timestamp,
432
+ 'projectId': project_id,
433
+ 'imageData': image_data if image_data else ""
434
+ }
435
+
436
+ # Add projectId as query parameter
437
+ endpoint = f'/v1/lpr-server/detections?projectId={project_id}'
438
+ full_url = f"{self.server_base_url}{endpoint}"
439
+ print(f"[LP_LOGGING] Sending POST to: {full_url}")
440
+ self.logger.info(f"[LP_LOGGING] Sending POST request to: {full_url}")
441
+ self.logger.info(f"[LP_LOGGING] Payload: licensePlate='{plate_text}', frameId='{frame_id}', location='{location}', camera='{camera_name}', imageData length={len(image_data) if image_data else 0}")
442
+
443
+ response = await self.session.rpc.post_async(endpoint, payload=payload, base_url=self.server_base_url)
444
+
445
+ print(f"[LP_LOGGING] Response: {response}")
446
+ self.logger.info(f"[LP_LOGGING] API Response received: {response}")
447
+
448
+ # Update timestamp after successful log
449
+ self.update_log_timestamp(plate_text)
450
+ print(f"[LP_LOGGING] ✓ Plate '{plate_text}' SUCCESSFULLY SENT")
451
+ self.logger.info(f"[LP_LOGGING] Plate '{plate_text}' SUCCESSFULLY SENT at {rfc3339_timestamp}")
452
+ self.logger.info(f"[LP_LOGGING] ===== PLATE LOG REQUEST END (SUCCESS) =====")
453
+ return True
454
+
455
+ except Exception as e:
456
+ print(f"[LP_LOGGING] ✗ Plate '{plate_text}' FAILED - {e}")
457
+ self.logger.error(f"[LP_LOGGING] Plate '{plate_text}' NOT SENT - Exception occurred: {e}", exc_info=True)
458
+ self.logger.info(f"[LP_LOGGING] ===== PLATE LOG REQUEST END (FAILED) =====")
459
+ return False
460
+
461
+ class LicensePlateMonitorUseCase(BaseProcessor):
462
+ CATEGORY_DISPLAY = {"license_plate": "license_plate"}
463
+
464
+ def __init__(self):
465
+ super().__init__("license_plate_monitor")
466
+ self.category = "license_plate_monitor"
467
+ self.target_categories = ['license_plate']
468
+ self.CASE_TYPE: Optional[str] = 'license_plate_monitor'
469
+ self.CASE_VERSION: Optional[str] = '1.3'
470
+ self.smoothing_tracker = None
471
+ self.tracker = None
472
+ self._total_frame_counter = 0
473
+ self._global_frame_offset = 0
474
+ self._tracking_start_time = None
475
+ self._track_aliases: Dict[Any, Any] = {}
476
+ self._canonical_tracks: Dict[Any, Dict[str, Any]] = {}
477
+ self._track_merge_iou_threshold: float = 0.05
478
+ self._track_merge_time_window: float = 7.0
479
+ self._ascending_alert_list: List[int] = []
480
+ self.current_incident_end_timestamp: str = "N/A"
481
+ self._seen_plate_texts = set()
482
+ # CHANGE: Added _tracked_plate_texts to store the longest plate_text per track_id
483
+ self._tracked_plate_texts: Dict[Any, str] = {}
484
+ # Containers for text stability & uniqueness
485
+ self._unique_plate_texts: Dict[str, str] = {} # cleaned_text -> original (longest)
486
+ # NEW: track-wise frequency of cleaned texts to pick the dominant variant per track
487
+ self._track_text_counts: Dict[Any, Counter] = defaultdict(Counter) # track_id -> Counter(cleaned_text -> count)
488
+ # Helper dictionary to keep history of plate texts per track
489
+ self.helper: Dict[Any, List[str]] = {}
490
+ # Map of track_id -> current dominant plate text
491
+ self.unique_plate_track: Dict[Any, str] = {}
492
+ self.image_preprocessor = ImagePreprocessor()
493
+ # OCR model will be lazily initialized when first used
494
+ self.ocr_model = None
495
+ self._ocr_initialization_attempted = False
496
+ # OCR text history for stability checks (text consecutive frame count)
497
+ self._text_history: Dict[str, int] = {}
498
+
499
+ self.start_timer = None
500
+ #self.reset_timer = "2025-08-19-04:22:47.187574 UTC"
501
+
502
+ # Minimum length for a valid plate (after cleaning)
503
+ self._min_plate_len = 5
504
+ # number of consecutive frames a plate must appear to be considered "stable"
505
+ self._stable_frames_required = 3
506
+ self._non_alnum_regex = re.compile(r"[^A-Za-z0-9]+")
507
+ self._ocr_mode = None
508
+ #self.jpeg = TurboJPEG()
509
+
510
+ # Initialize plate logger (optional, only used if lpr_server_id is provided)
511
+ self.plate_logger: Optional[LicensePlateMonitorLogger] = None
512
+ self._logging_enabled = True
513
+ self._plate_logger_initialized = False # Track if plate logger has been initialized
514
+
515
+
516
+ def reset_tracker(self) -> None:
517
+ """Reset the advanced tracker instance."""
518
+ if self.tracker is not None:
519
+ self.tracker.reset()
520
+ self.logger.info("AdvancedTracker reset for new tracking session")
521
+
522
+ def reset_plate_tracking(self) -> None:
523
+ """Reset plate tracking state."""
524
+ self._seen_plate_texts = set()
525
+ # CHANGE: Reset _tracked_plate_texts
526
+ self._tracked_plate_texts = {}
527
+ self._total_frame_counter = 0
528
+ self._global_frame_offset = 0
529
+ self._text_history = {}
530
+ self._unique_plate_texts = {}
531
+ self.helper = {}
532
+ self.unique_plate_track = {}
533
+ self.logger.info("Plate tracking state reset")
534
+
535
+ def reset_all_tracking(self) -> None:
536
+ """Reset both advanced tracker and plate tracking state."""
537
+ self.reset_tracker()
538
+ self.reset_plate_tracking()
539
+ self.logger.info("All plate tracking state reset")
540
+
541
+ def _initialize_plate_logger(self, config: LicensePlateMonitorConfig) -> bool:
542
+ """Initialize the plate logger if lpr_server_id is provided. Returns True if successful."""
543
+ self.logger.info(f"[LP_LOGGING] _initialize_plate_logger called with lpr_server_id: {config.lpr_server_id}")
544
+
545
+ if not config.lpr_server_id:
546
+ self._logging_enabled = False
547
+ self._plate_logger_initialized = False
548
+ self.logger.warning("[LP_LOGGING] Plate logging disabled: no lpr_server_id provided")
549
+ return False
550
+
551
+ try:
552
+ if self.plate_logger is None:
553
+ self.logger.info("[LP_LOGGING] Creating new LicensePlateMonitorLogger instance")
554
+ self.plate_logger = LicensePlateMonitorLogger()
555
+ else:
556
+ self.logger.info("[LP_LOGGING] Using existing LicensePlateMonitorLogger instance")
557
+
558
+ self.logger.info("[LP_LOGGING] Initializing session for plate logger")
559
+ self.plate_logger.initialize_session(config)
560
+ self._logging_enabled = True
561
+ self._plate_logger_initialized = True
562
+ self.logger.info(f"[LP_LOGGING] SUCCESS - Plate logging ENABLED with server ID: {config.lpr_server_id}")
563
+ return True
564
+ except Exception as e:
565
+ self.logger.error(f"[LP_LOGGING] ERROR - Failed to initialize plate logger: {e}", exc_info=True)
566
+ self._logging_enabled = False
567
+ self._plate_logger_initialized = False
568
+ self.logger.error(f"[LP_LOGGING] Plate logging has been DISABLED due to initialization failure")
569
+ return False
570
+
571
+ async def _log_detected_plates(self, detections: List[Dict[str, Any]], config: LicensePlateMonitorConfig,
572
+ stream_info: Optional[Dict[str, Any]], image_bytes: Optional[bytes] = None) -> None:
573
+ """Log all detected plates to RPC server with cooldown."""
574
+ # Enhanced logging for diagnostics
575
+ print(f"[LP_LOGGING] Starting plate logging check - detections count: {len(detections)}")
576
+ self.logger.info(f"[LP_LOGGING] Starting plate logging check - detections count: {len(detections)}")
577
+ self.logger.info(f"[LP_LOGGING] Logging enabled: {self._logging_enabled}, Plate logger exists: {self.plate_logger is not None}, Stream info exists: {stream_info is not None}")
578
+
579
+ if not self._logging_enabled:
580
+ print("[LP_LOGGING] Plate logging is DISABLED")
581
+ self.logger.warning("[LP_LOGGING] Plate logging is DISABLED - logging_enabled flag is False")
582
+ return
583
+
584
+ if not self.plate_logger:
585
+ print("[LP_LOGGING] Plate logging SKIPPED - plate_logger not initialized")
586
+ self.logger.warning("[LP_LOGGING] Plate logging SKIPPED - plate_logger is not initialized (lpr_server_id may not be configured)")
587
+ return
588
+
589
+ if not stream_info:
590
+ print("[LP_LOGGING] Plate logging SKIPPED - stream_info is None")
591
+ self.logger.warning("[LP_LOGGING] Plate logging SKIPPED - stream_info is None")
592
+ return
593
+
594
+ print("[LP_LOGGING] All pre-conditions met, proceeding with plate logging")
595
+ self.logger.info(f"[LP_LOGGING] All pre-conditions met, proceeding with plate logging")
596
+
597
+ # Get current timestamp
598
+ current_timestamp = self._get_current_timestamp_str(stream_info, precision=True)
599
+
600
+ # Encode the full frame image as base64 JPEG
601
+ image_data = ""
602
+ if image_bytes:
603
+ try:
604
+ # Decode image bytes
605
+ image_array = np.frombuffer(image_bytes, np.uint8)
606
+ image = cv2.imdecode(image_array, cv2.IMREAD_COLOR)
607
+
608
+ if image is not None:
609
+ # Encode as JPEG with 85% quality
610
+ success, jpeg_buffer = cv2.imencode('.jpg', image, [cv2.IMWRITE_JPEG_QUALITY, 99])
611
+ if success:
612
+ # Convert to base64
613
+ image_data = base64.b64encode(jpeg_buffer.tobytes()).decode('utf-8')
614
+ self.logger.info(f"[LP_LOGGING] Encoded frame image as base64, length: {len(image_data)}")
615
+ else:
616
+ self.logger.warning(f"[LP_LOGGING] Failed to encode JPEG image")
617
+ else:
618
+ self.logger.warning(f"[LP_LOGGING] Failed to decode image bytes")
619
+ except Exception as e:
620
+ self.logger.error(f"[LP_LOGGING] Exception while encoding frame image: {e}", exc_info=True)
621
+ else:
622
+ self.logger.info(f"[LP_LOGGING] No image_bytes provided, sending without image")
623
+
624
+ # Collect all unique plates from current detections
625
+ plates_to_log = set()
626
+ detections_without_text = 0
627
+ for det in detections:
628
+ plate_text = det.get('plate_text')
629
+ if not plate_text:
630
+ detections_without_text += 1
631
+ continue
632
+ plates_to_log.add(plate_text)
633
+
634
+ print(f"[LP_LOGGING] Collected {len(plates_to_log)} unique plates to log: {plates_to_log}")
635
+ self.logger.info(f"[LP_LOGGING] Collected {len(plates_to_log)} unique plates to log: {plates_to_log}")
636
+ if detections_without_text > 0:
637
+ self.logger.warning(f"[LP_LOGGING] {detections_without_text} detections have NO plate_text (OCR may have failed or not run yet)")
638
+
639
+ # Log each unique plate directly with await (respecting cooldown)
640
+ if plates_to_log:
641
+ print(f"[LP_LOGGING] Logging {len(plates_to_log)} plates with cooldown={config.plate_log_cooldown}s")
642
+ self.logger.info(f"[LP_LOGGING] Logging {len(plates_to_log)} plates with cooldown={config.plate_log_cooldown}s")
643
+ try:
644
+ # Call log_plate directly with await for each plate
645
+ for plate_text in plates_to_log:
646
+ print(f"[LP_LOGGING] Processing plate: {plate_text}")
647
+ self.logger.info(f"[LP_LOGGING] Processing plate: {plate_text}")
648
+ try:
649
+ result = await self.plate_logger.log_plate(
650
+ plate_text=plate_text,
651
+ timestamp=current_timestamp,
652
+ stream_info=stream_info,
653
+ image_data=image_data,
654
+ cooldown=config.plate_log_cooldown
655
+ )
656
+ status = "SENT" if result else "SKIPPED (cooldown)"
657
+ print(f"[LP_LOGGING] Plate {plate_text}: {status}")
658
+ self.logger.info(f"[LP_LOGGING] Plate {plate_text}: {status}")
659
+ except Exception as e:
660
+ print(f"[LP_LOGGING] ERROR - Plate {plate_text} failed: {e}")
661
+ self.logger.error(f"[LP_LOGGING] Plate {plate_text} raised exception: {e}", exc_info=True)
662
+
663
+ print("[LP_LOGGING] Plate logging complete")
664
+ self.logger.info(f"[LP_LOGGING] Plate logging complete")
665
+ except Exception as e:
666
+ print(f"[LP_LOGGING] CRITICAL ERROR during plate logging: {e}")
667
+ self.logger.error(f"[LP_LOGGING] CRITICAL ERROR during plate logging: {e}", exc_info=True)
668
+ else:
669
+ print("[LP_LOGGING] No plates to log")
670
+ self.logger.info(f"[LP_LOGGING] No plates to log (plates_to_log is empty)")
671
+
672
+ async def process(self, data: Any, config: ConfigProtocol, input_bytes: Optional[bytes] = None,
673
+ context: Optional[ProcessingContext] = None, stream_info: Optional[Dict[str, Any]] = None) -> ProcessingResult:
674
+ processing_start = time.time()
675
+ try:
676
+ if not isinstance(config, LicensePlateMonitorConfig):
677
+ return self.create_error_result("Invalid configuration type for license plate monitoring",
678
+ usecase=self.name, category=self.category, context=context)
679
+
680
+ if context is None:
681
+ context = ProcessingContext()
682
+
683
+ if not input_bytes:
684
+ return self.create_error_result("input_bytes (video/image) is required for license plate monitoring",
685
+ usecase=self.name, category=self.category, context=context)
686
+
687
+ # Initialize plate logger once if lpr_server_id is provided (optional flow)
688
+ if not self._plate_logger_initialized and config.lpr_server_id:
689
+ self.logger.info(f"[LP_LOGGING] First-time initialization - lpr_server_id: {config.lpr_server_id}")
690
+ success = self._initialize_plate_logger(config)
691
+ if success:
692
+ self.logger.info(f"[LP_LOGGING] Plate logger initialized successfully and ready to send plates")
693
+ else:
694
+ self.logger.error(f"[LP_LOGGING] Plate logger initialization FAILED - plates will NOT be sent")
695
+ elif self._plate_logger_initialized:
696
+ self.logger.debug(f"[LP_LOGGING] Plate logger already initialized, skipping re-initialization")
697
+ elif not config.lpr_server_id:
698
+ if self._total_frame_counter == 0: # Only log once at start
699
+ self.logger.warning(f"[LP_LOGGING] Plate logging will be DISABLED - no lpr_server_id provided in config")
700
+
701
+ # Normalize alert_config if provided as a plain dict (JS JSON)
702
+ if isinstance(getattr(config, 'alert_config', None), dict):
703
+ try:
704
+ config.alert_config = AlertConfig(**config.alert_config) # type: ignore[arg-type]
705
+ except Exception:
706
+ pass
707
+
708
+ # OCR model will be lazily initialized when _run_ocr is first called
709
+ # No need to initialize here
710
+
711
+ input_format = match_results_structure(data)
712
+ context.input_format = input_format
713
+ context.confidence_threshold = config.confidence_threshold
714
+ self._ocr_mode = config.ocr_mode
715
+ self.logger.info(f"Processing license plate monitoring with format: {input_format.value}")
716
+
717
+ # Step 1: Apply confidence filtering 1
718
+ # print("---------CONFIDENCE FILTERING",config.confidence_threshold)
719
+ # print("---------DATA1--------------",data)
720
+ processed_data = filter_by_confidence(data, config.confidence_threshold)
721
+ #self.logger.debug(f"Applied confidence filtering with threshold {config.confidence_threshold}")
722
+
723
+ # Step 2: Apply category mapping if provided
724
+ if config.index_to_category:
725
+ processed_data = apply_category_mapping(processed_data, config.index_to_category)
726
+ #self.logger.debug("Applied category mapping")
727
+ #print("---------DATA2--------------",processed_data)
728
+ # Step 3: Filter to target categories (handle dict or list)
729
+ if isinstance(processed_data, dict):
730
+ processed_data = processed_data.get("detections", [])
731
+ # Accept case-insensitive category values and allow overriding via config
732
+ effective_targets = getattr(config, 'target_categories', self.target_categories) or self.target_categories
733
+ targets_lower = {str(cat).lower() for cat in effective_targets}
734
+ processed_data = [d for d in processed_data if str(d.get('category', '')).lower() in targets_lower]
735
+ #self.logger.debug("Applied category filtering")
736
+
737
+ raw_processed_data = [copy.deepcopy(det) for det in processed_data]
738
+ #print("---------DATA2--------------",processed_data)
739
+ # Step 4: Apply bounding box smoothing if enabled
740
+ if config.enable_smoothing:
741
+ if self.smoothing_tracker is None:
742
+ smoothing_config = BBoxSmoothingConfig(
743
+ smoothing_algorithm=config.smoothing_algorithm,
744
+ window_size=config.smoothing_window_size,
745
+ cooldown_frames=config.smoothing_cooldown_frames,
746
+ confidence_threshold=config.confidence_threshold,
747
+ confidence_range_factor=config.smoothing_confidence_range_factor,
748
+ enable_smoothing=True
749
+ )
750
+ self.smoothing_tracker = BBoxSmoothingTracker(smoothing_config)
751
+ processed_data = bbox_smoothing(processed_data, self.smoothing_tracker.config, self.smoothing_tracker)
752
+
753
+ # Step 5: Apply advanced tracking
754
+ try:
755
+ from ..advanced_tracker import AdvancedTracker
756
+ from ..advanced_tracker.config import TrackerConfig
757
+ if self.tracker is None:
758
+ tracker_config = TrackerConfig(
759
+ track_high_thresh=float(config.confidence_threshold),
760
+ track_low_thresh=max(0.05, float(config.confidence_threshold) / 2),
761
+ new_track_thresh=float(config.confidence_threshold)
762
+ )
763
+ self.tracker = AdvancedTracker(tracker_config)
764
+ self.logger.info(f"Initialized AdvancedTracker with thresholds: high={tracker_config.track_high_thresh}, "
765
+ f"low={tracker_config.track_low_thresh}, new={tracker_config.new_track_thresh}")
766
+ processed_data = self.tracker.update(processed_data)
767
+ except Exception as e:
768
+ self.logger.warning(f"AdvancedTracker failed: {e}")
769
+ #print("---------DATA3--------------",processed_data)
770
+ # Step 6: Update tracking state
771
+ self._update_tracking_state(processed_data)
772
+ #print("---------DATA4--------------",processed_data)
773
+ # Step 7: Attach masks to detections
774
+ processed_data = self._attach_masks_to_detections(processed_data, raw_processed_data)
775
+ #print("---------DATA5--------------",processed_data)
776
+ # Step 8: Perform OCR on media
777
+ ocr_analysis = self._analyze_ocr_in_media(processed_data, input_bytes, config)
778
+ self.logger.info(f"[LP_LOGGING] OCR analysis completed, found {len(ocr_analysis)} results")
779
+ ocr_plates_found = [r.get('plate_text') for r in ocr_analysis if r.get('plate_text')]
780
+ if ocr_plates_found:
781
+ self.logger.info(f"[LP_LOGGING] OCR detected plates: {ocr_plates_found}")
782
+ else:
783
+ self.logger.warning(f"[LP_LOGGING] OCR did not detect any valid plate texts")
784
+
785
+ # Step 9: Update plate texts
786
+ processed_data = self._update_detections_with_ocr(processed_data, ocr_analysis)
787
+ self._update_plate_texts(processed_data)
788
+
789
+ # Log final detection state before sending
790
+ final_plates = [d.get('plate_text') for d in processed_data if d.get('plate_text')]
791
+ self.logger.info(f"[LP_LOGGING] After OCR update, {len(final_plates)} detections have plate_text: {final_plates}")
792
+
793
+ # Step 9.5: Log detected plates to RPC (optional, only if lpr_server_id is provided)
794
+ # Direct await since process is now async
795
+ await self._log_detected_plates(processed_data, config, stream_info, input_bytes)
796
+
797
+ # Step 10: Update frame counter
798
+ self._total_frame_counter += 1
799
+
800
+ # Step 11: Extract frame information
801
+ frame_number = None
802
+ if stream_info:
803
+ input_settings = stream_info.get("input_settings", {})
804
+ start_frame = input_settings.get("start_frame")
805
+ end_frame = input_settings.get("end_frame")
806
+ if start_frame is not None and end_frame is not None and start_frame == end_frame:
807
+ frame_number = start_frame
808
+
809
+ # Step 12: Calculate summaries
810
+ counting_summary = self._count_categories(processed_data, config)
811
+ counting_summary['total_counts'] = self.get_total_counts()
812
+
813
+ # Step 13: Generate alerts and summaries
814
+ alerts = self._check_alerts(counting_summary, frame_number, config)
815
+ incidents_list = self._generate_incidents(counting_summary, alerts, config, frame_number, stream_info)
816
+ tracking_stats_list = self._generate_tracking_stats(counting_summary, alerts, config, frame_number, stream_info)
817
+ business_analytics_list = []
818
+ summary_list = self._generate_summary(counting_summary, incidents_list, tracking_stats_list, business_analytics_list, alerts)
819
+
820
+ # Step 14: Build result
821
+ incidents = incidents_list[0] if incidents_list else {}
822
+ tracking_stats = tracking_stats_list[0] if tracking_stats_list else {}
823
+ business_analytics = business_analytics_list[0] if business_analytics_list else {}
824
+ summary = summary_list[0] if summary_list else {}
825
+ # Build LPR_dict (per-track history) and counter (dominant in last 50%)
826
+ LPR_dict = {}
827
+ counter = {}
828
+ for tid, history in self.helper.items():
829
+ if not history:
830
+ continue
831
+ LPR_dict[str(tid)] = list(history)
832
+ # dominant from last 50%
833
+ half = max(1, len(history) // 2)
834
+ window = history[-half:]
835
+ from collections import Counter as _Ctr
836
+ dom, cnt = _Ctr(window).most_common(1)[0]
837
+ counter[str(tid)] = {"plate": dom, "count": cnt}
838
+
839
+ agg_summary = {str(frame_number): {
840
+ "incidents": incidents,
841
+ "tracking_stats": tracking_stats,
842
+ "business_analytics": business_analytics,
843
+ "alerts": alerts,
844
+ "human_text": summary
845
+ }}
846
+
847
+ context.mark_completed()
848
+ result = self.create_result(
849
+ data={"agg_summary": agg_summary},
850
+ usecase=self.name,
851
+ category=self.category,
852
+ context=context
853
+ )
854
+ proc_time = time.time() - processing_start
855
+ processing_latency_ms = proc_time * 1000.0
856
+ processing_fps = (1.0 / proc_time) if proc_time > 0 else None
857
+ # Log the performance metrics using the module-level logger
858
+ print("latency in ms:",processing_latency_ms,"| Throughput fps:",processing_fps,"| Frame_Number:",self._total_frame_counter)
859
+
860
+ return result
861
+
862
+ except Exception as e:
863
+ self.logger.error(f"License plate monitoring failed: {str(e)}", exc_info=True)
864
+ if context:
865
+ context.mark_completed()
866
+ return self.create_error_result(str(e), type(e).__name__, usecase=self.name, category=self.category, context=context)
867
+
868
+ def _is_video_bytes(self, media_bytes: bytes) -> bool:
869
+ """Determine if bytes represent a video file."""
870
+ video_signatures = [
871
+ b'\x00\x00\x00\x20ftypmp4', # MP4
872
+ b'\x00\x00\x00\x18ftypmp4', # MP4 variant
873
+ b'RIFF', # AVI
874
+ b'\x1aE\xdf\xa3', # MKV/WebM
875
+ b'ftyp', # General MP4 family
876
+ ]
877
+ for signature in video_signatures:
878
+ if media_bytes.startswith(signature) or signature in media_bytes[:50]:
879
+ return True
880
+ return False
881
+
882
+ def _analyze_ocr_in_media(self, data: Any, media_bytes: bytes, config: LicensePlateMonitorConfig) -> List[Dict[str, Any]]:
883
+ """Analyze OCR of license plates in video frames or images."""
884
+ return self._analyze_ocr_in_image(data, media_bytes, config)
885
+
886
+
887
+ def _analyze_ocr_in_image(self, data: Any, image_bytes: bytes, config: LicensePlateMonitorConfig) -> List[Dict[str, Any]]:
888
+ """Analyze OCR in a single image."""
889
+ image_array = np.frombuffer(image_bytes, np.uint8)
890
+ image = cv2.imdecode(image_array, cv2.IMREAD_COLOR)
891
+ #image = self.jpeg.decode(image_bytes, pixel_format=TJPF_RGB) #cv2.imdecode(image_array, cv2.IMREAD_UNCHANGED)
892
+
893
+ if image is None:
894
+ raise RuntimeError("Failed to decode image from bytes")
895
+
896
+ rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
897
+ ocr_analysis = []
898
+ detections = self._get_frame_detections(data, "0")
899
+
900
+ #print("OCR-detections", detections)
901
+
902
+ for detection in detections:
903
+ #print("---------OCR DETECTION",detection)
904
+ if detection.get("confidence", 1.0) < config.confidence_threshold:
905
+ continue
906
+
907
+ bbox = detection.get("bounding_box", detection.get("bbox"))
908
+ #print("---------OCR BBOX",bbox)
909
+ if not bbox:
910
+ continue
911
+
912
+ crop = self._crop_bbox(rgb_image, bbox, config.bbox_format)
913
+ #print("---------OCR CROP SIZEE",crop.size)
914
+ if crop.size == 0:
915
+ continue
916
+
917
+ plate_text_raw = self._run_ocr(crop)
918
+ #print("---------OCR PLATE TEXT",plate_text_raw)
919
+ plate_text = plate_text_raw if plate_text_raw else None
920
+
921
+ ocr_record = {
922
+ "frame_id": "0",
923
+ "timestamp": 0.0,
924
+ "category": detection.get("category", ""),
925
+ "confidence": round(detection.get("confidence", 0.0), 3),
926
+ "plate_text": plate_text,
927
+ "bbox": bbox,
928
+ "detection_id": detection.get("id", f"det_{len(ocr_analysis)}"),
929
+ "track_id": detection.get("track_id")
930
+ }
931
+ ocr_analysis.append(ocr_record)
932
+
933
+ return ocr_analysis
934
+
935
+ def _crop_bbox(self, image: np.ndarray, bbox: Dict[str, Any], bbox_format: str) -> np.ndarray:
936
+ """Crop bounding box region from image."""
937
+ h, w = image.shape[:2]
938
+
939
+ if bbox_format == "auto":
940
+ if "xmin" in bbox:
941
+ bbox_format = "xmin_ymin_xmax_ymax"
942
+ elif "x" in bbox:
943
+ bbox_format = "x_y_width_height"
944
+ else:
945
+ return np.zeros((0, 0, 3), dtype=np.uint8)
946
+
947
+ if bbox_format == "xmin_ymin_xmax_ymax":
948
+ xmin = max(0, int(bbox["xmin"]))
949
+ ymin = max(0, int(bbox["ymin"]))
950
+ xmax = min(w, int(bbox["xmax"]))
951
+ ymax = min(h, int(bbox["ymax"]))
952
+ elif bbox_format == "x_y_width_height":
953
+ xmin = max(0, int(bbox["x"]))
954
+ ymin = max(0, int(bbox["y"]))
955
+ xmax = min(w, int(bbox["x"] + bbox["width"]))
956
+ ymax = min(h, int(bbox["y"] + bbox["height"]))
957
+ else:
958
+ return np.zeros((0, 0, 3), dtype=np.uint8)
959
+
960
+ return image[ymin:ymax, xmin:xmax]
961
+
962
+ # ------------------------------------------------------------------
963
+ # Fast OCR helpers
964
+ # ------------------------------------------------------------------
965
+ def _ensure_ocr_model_loaded(self) -> bool:
966
+ """Lazy initialization of OCR model. Returns True if model is available."""
967
+ if self.ocr_model is not None:
968
+ return True
969
+
970
+ if self._ocr_initialization_attempted:
971
+ return False
972
+
973
+ self._ocr_initialization_attempted = True
974
+
975
+ # Try to get the LicensePlateRecognizer class
976
+ LicensePlateRecognizerClass = _get_license_plate_recognizer_class()
977
+
978
+ if LicensePlateRecognizerClass is None:
979
+ self.logger.error("OCR module not available. LicensePlateRecognizer will not function.")
980
+ return False
981
+
982
+ # Try to initialize the OCR model
983
+ try:
984
+ self.ocr_model = LicensePlateRecognizerClass('cct-s-v1-global-model')
985
+ source_msg = {
986
+ "local_repo": "from local repo",
987
+ "installed_package": "from installed package",
988
+ "installed_package_gpu": "from installed package (GPU)",
989
+ "installed_package_cpu": "from installed package (CPU)"
990
+ }.get(_OCR_IMPORT_SOURCE, "from unknown source")
991
+ self.logger.info(f"LicensePlateRecognizer loaded successfully {source_msg}")
992
+ return True
993
+ except Exception as e:
994
+ self.logger.error(f"Failed to initialize LicensePlateRecognizer: {e}", exc_info=True)
995
+ self.ocr_model = None
996
+ return False
997
+
998
+ def _clean_text(self, text: str) -> str:
999
+ """Sanitise OCR output to keep only alphanumerics and uppercase."""
1000
+ if not text:
1001
+ return ""
1002
+ return self._non_alnum_regex.sub('', text).upper()
1003
+
1004
+ def _run_ocr(self, crop: np.ndarray) -> str:
1005
+ """Run OCR on a cropped plate image and return cleaned text or empty string."""
1006
+ if crop is None or crop.size == 0:
1007
+ return ""
1008
+
1009
+ # Lazy load OCR model on first use
1010
+ if not self._ensure_ocr_model_loaded():
1011
+ return ""
1012
+
1013
+ # Double-check model is available
1014
+ if self.ocr_model is None:
1015
+ return ""
1016
+
1017
+ # Check if we have a valid OCR model with run method
1018
+ if not hasattr(self.ocr_model, 'run'):
1019
+ return ""
1020
+
1021
+ try:
1022
+ # fast_plate_ocr LicensePlateRecognizer has a run() method
1023
+ res = self.ocr_model.run(crop)
1024
+
1025
+ if isinstance(res, list):
1026
+ res = res[0] if res else ""
1027
+ cleaned_text = self._clean_text(str(res))
1028
+ if cleaned_text and len(cleaned_text) >= self._min_plate_len:
1029
+ if self._ocr_mode == "numeric":
1030
+ response = all(ch.isdigit() for ch in cleaned_text)
1031
+ elif self._ocr_mode == "alphabetic":
1032
+ response = all(ch.isalpha() for ch in cleaned_text)
1033
+ elif self._ocr_mode == "alphanumeric":
1034
+ response = True
1035
+ else:
1036
+ response = False
1037
+
1038
+ if response:
1039
+ return cleaned_text
1040
+ return ""
1041
+ except Exception as exc:
1042
+ # Only log at debug level to avoid spam
1043
+ self.logger.warning(f"OCR failed: {exc}")
1044
+ return ""
1045
+
1046
+ def _get_frame_detections(self, data: Any, frame_key: str) -> List[Dict[str, Any]]:
1047
+ """Extract detections for a specific frame from data."""
1048
+ if isinstance(data, dict):
1049
+ return data.get(frame_key, [])
1050
+ elif isinstance(data, list):
1051
+ return data
1052
+ else:
1053
+ return []
1054
+
1055
+ def _update_detections_with_ocr(self, detections: List[Dict[str, Any]], ocr_analysis: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1056
+ """Update detections with OCR results using track_id or bounding box for matching."""
1057
+ #print("---------UPDATE DETECTIONS WITH OCR",ocr_analysis)
1058
+ ocr_dict = {}
1059
+ for rec in ocr_analysis:
1060
+ if rec.get("plate_text"):
1061
+ # Primary key: track_id
1062
+ track_id = rec.get("track_id")
1063
+ if track_id is not None:
1064
+ ocr_dict[track_id] = rec["plate_text"]
1065
+ # Fallback key: bounding box as tuple
1066
+ else:
1067
+ bbox_key = tuple(sorted(rec["bbox"].items())) if rec.get("bbox") else None
1068
+ if bbox_key:
1069
+ ocr_dict[bbox_key] = rec["plate_text"]
1070
+ #self.logger.info(f"OCR record: track_id={track_id}, plate_text={rec.get('plate_text')}, bbox={rec.get('bbox')}")
1071
+
1072
+ #print("---------UPDATE DETECTIONS WITH OCR -II",ocr_dict)
1073
+ for det in detections:
1074
+ track_id = det.get("track_id")
1075
+ bbox_key = tuple(sorted(det.get("bounding_box", det.get("bbox", {})).items())) if det.get("bounding_box") or det.get("bbox") else None
1076
+ plate_text = None
1077
+ if track_id is not None and track_id in ocr_dict:
1078
+ plate_text = ocr_dict[track_id]
1079
+ elif bbox_key and bbox_key in ocr_dict:
1080
+ plate_text = ocr_dict[bbox_key]
1081
+ det["plate_text"] = plate_text
1082
+ #self.logger.info(f"Detection track_id={track_id}, bbox={det.get('bounding_box')}: Assigned plate_text={plate_text}")
1083
+ return detections
1084
+
1085
+ def _count_categories(self, detections: List[Dict], config: LicensePlateMonitorConfig) -> Dict[str, Any]:
1086
+ """Count unique licence-plate texts per frame and attach detections."""
1087
+ unique_texts: set = set()
1088
+ valid_detections: List[Dict[str, Any]] = []
1089
+
1090
+ # Group detections by track_id for per-track dominance
1091
+ tracks: Dict[Any, List[Dict[str, Any]]] = {}
1092
+ for det in detections:
1093
+ if not all(k in det for k in ['category', 'confidence', 'bounding_box']):
1094
+ continue
1095
+ tid = det.get('track_id')
1096
+ if tid is None:
1097
+ # If no track id, treat as its own pseudo-track keyed by bbox
1098
+ tid = (det.get("bounding_box") or det.get("bbox"))
1099
+ tracks.setdefault(tid, []).append(det)
1100
+
1101
+ for tid, dets in tracks.items():
1102
+ # Pick a representative bbox (first occurrence)
1103
+ rep = dets[0]
1104
+ cat = rep.get('category', '')
1105
+ bbox = rep.get('bounding_box')
1106
+ conf = rep.get('confidence')
1107
+ frame_id = rep.get('frame_id')
1108
+
1109
+ # Compute dominant text for this track from last 50% of history
1110
+ dominant_text = None
1111
+ history = self.helper.get(tid, [])
1112
+ if history:
1113
+ half = max(1, len(history) // 2)
1114
+ window = history[-half:]
1115
+ from collections import Counter as _Ctr
1116
+ dominant_text, _ = _Ctr(window).most_common(1)[0]
1117
+ elif rep.get('plate_text'):
1118
+ candidate = self._clean_text(rep.get('plate_text', ''))
1119
+ if self._min_plate_len <= len(candidate) <= 6:
1120
+ dominant_text = candidate
1121
+
1122
+ # Fallback to already computed per-track mapping
1123
+ if not dominant_text:
1124
+ dominant_text = self.unique_plate_track.get(tid)
1125
+
1126
+ # Enforce length 56 and uniqueness per frame
1127
+ if dominant_text and self._min_plate_len <= len(dominant_text) <= 6:
1128
+ unique_texts.add(dominant_text)
1129
+ valid_detections.append({
1130
+ "bounding_box": bbox,
1131
+ "category": cat,
1132
+ "confidence": conf,
1133
+ "track_id": rep.get('track_id'),
1134
+ "frame_id": frame_id,
1135
+ "masks": rep.get("masks", []),
1136
+ "plate_text": dominant_text
1137
+ })
1138
+
1139
+ counts = {"License_Plate": len(unique_texts)} if unique_texts else {}
1140
+
1141
+ return {
1142
+ "total_count": len(unique_texts),
1143
+ "per_category_count": counts,
1144
+ "detections": valid_detections
1145
+ }
1146
+
1147
+ def _generate_tracking_stats(self, counting_summary: Dict, alerts: Any, config: LicensePlateMonitorConfig,
1148
+ frame_number: Optional[int] = None, stream_info: Optional[Dict[str, Any]] = None) -> List[Dict]:
1149
+ """Generate structured tracking stats with frame-based keys."""
1150
+ tracking_stats = []
1151
+ total_detections = counting_summary.get("total_count", 0)
1152
+ total_counts = counting_summary.get("total_counts", {})
1153
+ cumulative_total = sum(set(total_counts.values())) if total_counts else 0
1154
+ per_category_count = counting_summary.get("per_category_count", {})
1155
+ track_ids_info = self._get_track_ids_info(counting_summary.get("detections", []))
1156
+ current_timestamp = self._get_current_timestamp_str(stream_info, precision=False)
1157
+ start_timestamp = self._get_start_timestamp_str(stream_info, precision=False)
1158
+ high_precision_start_timestamp = self._get_current_timestamp_str(stream_info, precision=True)
1159
+ high_precision_reset_timestamp = self._get_start_timestamp_str(stream_info, precision=True)
1160
+ camera_info = self.get_camera_info_from_stream(stream_info)
1161
+
1162
+ human_text_lines = []
1163
+ #print("counting_summary", counting_summary)
1164
+ human_text_lines.append(f"CURRENT FRAME @ {current_timestamp}:")
1165
+ if total_detections > 0:
1166
+ category_counts = [f"{count} {cat}" for cat, count in per_category_count.items()]
1167
+ detection_text = category_counts[0] + " detected" if len(category_counts) == 1 else f"{', '.join(category_counts[:-1])}, and {category_counts[-1]} detected"
1168
+ human_text_lines.append(f"\t- {detection_text}")
1169
+ # Show dominant per-track license plates for current frame
1170
+ seen = set()
1171
+ display_texts = []
1172
+ for det in counting_summary.get("detections", []):
1173
+ t = det.get("track_id")
1174
+ dom = det.get("plate_text")
1175
+ if not dom or not (self._min_plate_len <= len(dom) <= 6):
1176
+ continue
1177
+ if t in seen:
1178
+ continue
1179
+ seen.add(t)
1180
+ display_texts.append(dom)
1181
+ if display_texts:
1182
+ human_text_lines.append(f"\t- License Plates: {', '.join(display_texts)}")
1183
+ else:
1184
+ human_text_lines.append(f"\t- No detections")
1185
+
1186
+ human_text_lines.append("")
1187
+ human_text_lines.append(f"TOTAL SINCE {start_timestamp}:")
1188
+ human_text_lines.append(f"\t- Total Detected: {cumulative_total}")
1189
+
1190
+ if self._unique_plate_texts:
1191
+ human_text_lines.append("\t- Unique License Plates:")
1192
+ for text in sorted(self._unique_plate_texts.values()):
1193
+ human_text_lines.append(f"\t\t- {text}")
1194
+
1195
+ current_counts = [{"category": cat, "count": count} for cat, count in per_category_count.items() if count > 0 or total_detections > 0]
1196
+ total_counts_list = [{"category": cat, "count": count} for cat, count in total_counts.items() if count > 0 or cumulative_total > 0]
1197
+
1198
+ human_text = "\n".join(human_text_lines)
1199
+ detections = []
1200
+ for detection in counting_summary.get("detections", []):
1201
+ dom = detection.get("plate_text", "")
1202
+ if not dom:
1203
+ dom = "license_plate"
1204
+ bbox = detection.get("bounding_box", {})
1205
+ category = detection.get("category", "license_plate")
1206
+ segmentation = detection.get("masks", detection.get("segmentation", detection.get("mask", [])))
1207
+ detection_obj = self.create_detection_object(category, bbox, segmentation=None, plate_text=dom)
1208
+ detections.append(detection_obj)
1209
+
1210
+ alert_settings = []
1211
+ # Build alert settings tolerating dict or dataclass for alert_config
1212
+ if config.alert_config:
1213
+ alert_cfg = config.alert_config
1214
+ alert_type = getattr(alert_cfg, 'alert_type', None) if not isinstance(alert_cfg, dict) else alert_cfg.get('alert_type')
1215
+ alert_value = getattr(alert_cfg, 'alert_value', None) if not isinstance(alert_cfg, dict) else alert_cfg.get('alert_value')
1216
+ count_thresholds = getattr(alert_cfg, 'count_thresholds', None) if not isinstance(alert_cfg, dict) else alert_cfg.get('count_thresholds')
1217
+ alert_type = alert_type if isinstance(alert_type, list) else (list(alert_type) if alert_type is not None else ['Default'])
1218
+ alert_value = alert_value if isinstance(alert_value, list) else (list(alert_value) if alert_value is not None else ['JSON'])
1219
+ alert_settings.append({
1220
+ "alert_type": alert_type,
1221
+ "incident_category": self.CASE_TYPE,
1222
+ "threshold_level": count_thresholds or {},
1223
+ "ascending": True,
1224
+ "settings": {t: v for t, v in zip(alert_type, alert_value)}
1225
+ })
1226
+
1227
+ if alerts:
1228
+ human_text_lines.append(f"Alerts: {alerts[0].get('settings', {})}")
1229
+ else:
1230
+ human_text_lines.append("Alerts: None")
1231
+
1232
+ human_text = "\n".join(human_text_lines)
1233
+ reset_settings = [{"interval_type": "daily", "reset_time": {"value": 9, "time_unit": "hour"}}]
1234
+
1235
+ tracking_stat = self.create_tracking_stats(
1236
+ total_counts=total_counts_list,
1237
+ current_counts=current_counts,
1238
+ detections=detections,
1239
+ human_text=human_text,
1240
+ camera_info=camera_info,
1241
+ alerts=alerts,
1242
+ alert_settings=alert_settings,
1243
+ reset_settings=reset_settings,
1244
+ start_time=high_precision_start_timestamp,
1245
+ reset_time=high_precision_reset_timestamp
1246
+ )
1247
+ tracking_stats.append(tracking_stat)
1248
+ return tracking_stats
1249
+
1250
+ def _check_alerts(self, summary: Dict, frame_number: Any, config: LicensePlateMonitorConfig) -> List[Dict]:
1251
+ """Check if any alert thresholds are exceeded."""
1252
+ def get_trend(data, lookback=900, threshold=0.6):
1253
+ window = data[-lookback:] if len(data) >= lookback else data
1254
+ if len(window) < 2:
1255
+ return True
1256
+ increasing = sum(1 for i in range(1, len(window)) if window[i] >= window[i - 1])
1257
+ return increasing / (len(window) - 1) >= threshold
1258
+
1259
+ frame_key = str(frame_number) if frame_number is not None else "current_frame"
1260
+ alerts = []
1261
+ total_detections = summary.get("total_count", 0)
1262
+ total_counts_dict = summary.get("total_counts", {})
1263
+ cumulative_total = sum(total_counts_dict.values()) if total_counts_dict else 0
1264
+ per_category_count = summary.get("per_category_count", {})
1265
+
1266
+ if not config.alert_config:
1267
+ return alerts
1268
+
1269
+ # Extract thresholds regardless of dict/dataclass
1270
+ _alert_cfg = config.alert_config
1271
+ _thresholds = getattr(_alert_cfg, 'count_thresholds', None) if not isinstance(_alert_cfg, dict) else _alert_cfg.get('count_thresholds')
1272
+ _types = getattr(_alert_cfg, 'alert_type', None) if not isinstance(_alert_cfg, dict) else _alert_cfg.get('alert_type')
1273
+ _values = getattr(_alert_cfg, 'alert_value', None) if not isinstance(_alert_cfg, dict) else _alert_cfg.get('alert_value')
1274
+ _types = _types if isinstance(_types, list) else (list(_types) if _types is not None else ['Default'])
1275
+ _values = _values if isinstance(_values, list) else (list(_values) if _values is not None else ['JSON'])
1276
+ if _thresholds:
1277
+ for category, threshold in _thresholds.items():
1278
+ if category == "all" and total_detections > threshold:
1279
+ alerts.append({
1280
+ "alert_type": _types,
1281
+ "alert_id": f"alert_{category}_{frame_key}",
1282
+ "incident_category": self.CASE_TYPE,
1283
+ "threshold_level": threshold,
1284
+ "ascending": get_trend(self._ascending_alert_list),
1285
+ "settings": {t: v for t, v in zip(_types, _values)}
1286
+ })
1287
+ elif category in per_category_count and per_category_count[category] > threshold:
1288
+ alerts.append({
1289
+ "alert_type": _types,
1290
+ "alert_id": f"alert_{category}_{frame_key}",
1291
+ "incident_category": self.CASE_TYPE,
1292
+ "threshold_level": threshold,
1293
+ "ascending": get_trend(self._ascending_alert_list),
1294
+ "settings": {t: v for t, v in zip(_types, _values)}
1295
+ })
1296
+ return alerts
1297
+
1298
+ def _generate_incidents(self, counting_summary: Dict, alerts: List, config: LicensePlateMonitorConfig,
1299
+ frame_number: Optional[int] = None, stream_info: Optional[Dict[str, Any]] = None) -> List[Dict]:
1300
+ """Generate structured incidents."""
1301
+ frame_key = str(frame_number) if frame_number is not None else "current_frame"
1302
+ incidents = []
1303
+ total_detections = counting_summary.get("total_count", 0)
1304
+ current_timestamp = self._get_current_timestamp_str(stream_info, precision=False)
1305
+ camera_info = self.get_camera_info_from_stream(stream_info)
1306
+
1307
+ self._ascending_alert_list = self._ascending_alert_list[-900:] if len(self._ascending_alert_list) > 900 else self._ascending_alert_list
1308
+
1309
+ if total_detections > 0:
1310
+ level = "low"
1311
+ intensity = 5.0
1312
+ start_timestamp = self._get_start_timestamp_str(stream_info, precision=False)
1313
+ if start_timestamp and self.current_incident_end_timestamp == 'N/A':
1314
+ self.current_incident_end_timestamp = 'Incident still active'
1315
+ elif start_timestamp and self.current_incident_end_timestamp == 'Incident still active':
1316
+ if len(self._ascending_alert_list) >= 15 and sum(self._ascending_alert_list[-15:]) / 15 < 1.5:
1317
+ self.current_incident_end_timestamp = current_timestamp
1318
+ elif self.current_incident_end_timestamp != 'Incident still active' and self.current_incident_end_timestamp != 'N/A':
1319
+ self.current_incident_end_timestamp = 'N/A'
1320
+
1321
+ if config.alert_config and config.alert_config.count_thresholds:
1322
+ threshold = config.alert_config.count_thresholds.get("all", 15)
1323
+ intensity = min(10.0, (total_detections / threshold) * 10)
1324
+ if intensity >= 9:
1325
+ level = "critical"
1326
+ self._ascending_alert_list.append(3)
1327
+ elif intensity >= 7:
1328
+ level = "significant"
1329
+ self._ascending_alert_list.append(2)
1330
+ elif intensity >= 5:
1331
+ level = "medium"
1332
+ self._ascending_alert_list.append(1)
1333
+ else:
1334
+ level = "low"
1335
+ self._ascending_alert_list.append(0)
1336
+ else:
1337
+ if total_detections > 30:
1338
+ level = "critical"
1339
+ intensity = 10.0
1340
+ self._ascending_alert_list.append(3)
1341
+ elif total_detections > 25:
1342
+ level = "significant"
1343
+ intensity = 9.0
1344
+ self._ascending_alert_list.append(2)
1345
+ elif total_detections > 15:
1346
+ level = "medium"
1347
+ intensity = 7.0
1348
+ self._ascending_alert_list.append(1)
1349
+ else:
1350
+ level = "low"
1351
+ intensity = min(10.0, total_detections / 3.0)
1352
+ self._ascending_alert_list.append(0)
1353
+
1354
+ human_text_lines = [f"INCIDENTS DETECTED @ {current_timestamp}:"]
1355
+ human_text_lines.append(f"\tSeverity Level: {(self.CASE_TYPE, level)}")
1356
+ human_text = "\n".join(human_text_lines)
1357
+
1358
+ alert_settings = []
1359
+ if config.alert_config:
1360
+ _alert_cfg = config.alert_config
1361
+ _types = getattr(_alert_cfg, 'alert_type', None) if not isinstance(_alert_cfg, dict) else _alert_cfg.get('alert_type')
1362
+ _values = getattr(_alert_cfg, 'alert_value', None) if not isinstance(_alert_cfg, dict) else _alert_cfg.get('alert_value')
1363
+ _thresholds = getattr(_alert_cfg, 'count_thresholds', None) if not isinstance(_alert_cfg, dict) else _alert_cfg.get('count_thresholds')
1364
+ _types = _types if isinstance(_types, list) else (list(_types) if _types is not None else ['Default'])
1365
+ _values = _values if isinstance(_values, list) else (list(_values) if _values is not None else ['JSON'])
1366
+ alert_settings.append({
1367
+ "alert_type": _types,
1368
+ "incident_category": self.CASE_TYPE,
1369
+ "threshold_level": _thresholds or {},
1370
+ "ascending": True,
1371
+ "settings": {t: v for t, v in zip(_types, _values)}
1372
+ })
1373
+
1374
+ event = self.create_incident(
1375
+ incident_id=f"{self.CASE_TYPE}_{frame_key}",
1376
+ incident_type=self.CASE_TYPE,
1377
+ severity_level=level,
1378
+ human_text=human_text,
1379
+ camera_info=camera_info,
1380
+ alerts=alerts,
1381
+ alert_settings=alert_settings,
1382
+ start_time=start_timestamp,
1383
+ end_time=self.current_incident_end_timestamp,
1384
+ level_settings={"low": 1, "medium": 3, "significant": 4, "critical": 7}
1385
+ )
1386
+ incidents.append(event)
1387
+ else:
1388
+ self._ascending_alert_list.append(0)
1389
+ incidents.append({})
1390
+
1391
+ return incidents
1392
+
1393
+ def _generate_summary(self, summary: Dict, incidents: List, tracking_stats: List, business_analytics: List, alerts: List) -> List[str]:
1394
+ """Generate a human-readable summary."""
1395
+ """
1396
+ Generate a human_text string for the tracking_stat, incident, business analytics and alerts.
1397
+ """
1398
+ lines = []
1399
+ lines.append("Application Name: "+self.CASE_TYPE)
1400
+ lines.append("Application Version: "+self.CASE_VERSION)
1401
+ if len(incidents) > 0:
1402
+ lines.append("Incidents: "+f"\n\t{incidents[0].get('human_text', 'No incidents detected')}")
1403
+ if len(tracking_stats) > 0:
1404
+ lines.append("Tracking Statistics: "+f"\t{tracking_stats[0].get('human_text', 'No tracking statistics detected')}")
1405
+ if len(business_analytics) > 0:
1406
+ lines.append("Business Analytics: "+f"\t{business_analytics[0].get('human_text', 'No business analytics detected')}")
1407
+
1408
+ if len(incidents) == 0 and len(tracking_stats) == 0 and len(business_analytics) == 0:
1409
+ lines.append("Summary: "+"No Summary Data")
1410
+
1411
+ return ["\n".join(lines)]
1412
+
1413
+ def _update_tracking_state(self, detections: List[Dict]):
1414
+ """Track unique track_ids per category."""
1415
+ if not hasattr(self, "_per_category_total_track_ids"):
1416
+ self._per_category_total_track_ids = {cat: set() for cat in self.target_categories}
1417
+ self._current_frame_track_ids = {cat: set() for cat in self.target_categories}
1418
+
1419
+ for det in detections:
1420
+ cat = det.get("category")
1421
+ raw_track_id = det.get("track_id")
1422
+ if cat not in self.target_categories or raw_track_id is None:
1423
+ continue
1424
+ bbox = det.get("bounding_box", det.get("bbox"))
1425
+ canonical_id = self._merge_or_register_track(raw_track_id, bbox)
1426
+ det["track_id"] = canonical_id
1427
+ self._per_category_total_track_ids.setdefault(cat, set()).add(canonical_id)
1428
+ self._current_frame_track_ids[cat].add(canonical_id)
1429
+
1430
+ def _update_plate_texts(self, detections: List[Dict]):
1431
+ """Update set of seen plate texts and track the longest plate_text per track_id."""
1432
+ for det in detections:
1433
+ raw_text = det.get('plate_text')
1434
+ track_id = det.get('track_id')
1435
+ if not raw_text or track_id is None:
1436
+ continue
1437
+
1438
+ cleaned = self._clean_text(raw_text)
1439
+
1440
+ # Enforce plate length 5 or 6 characters ("greater than 4 and less than 7")
1441
+ if not (self._min_plate_len <= len(cleaned) <= 6):
1442
+ continue
1443
+
1444
+ # Append to per-track rolling history (keep reasonable size)
1445
+ history = self.helper.get(track_id)
1446
+ if history is None:
1447
+ history = []
1448
+ self.helper[track_id] = history
1449
+ history.append(cleaned)
1450
+ if len(history) > 200:
1451
+ del history[: len(history) - 200]
1452
+
1453
+ # Update per-track frequency counter (all-time)
1454
+ self._track_text_counts[track_id][cleaned] += 1
1455
+
1456
+ # Update consecutive frame counter for stability across whole video
1457
+ self._text_history[cleaned] = self._text_history.get(cleaned, 0) + 1
1458
+
1459
+ # Once stable, decide dominant text from LAST 50% of history
1460
+ if self._text_history[cleaned] >= self._stable_frames_required:
1461
+ half = max(1, len(history) // 2)
1462
+ window = history[-half:]
1463
+ from collections import Counter as _Ctr
1464
+ dominant, _ = _Ctr(window).most_common(1)[0]
1465
+
1466
+ # Update per-track mapping to dominant
1467
+ self._tracked_plate_texts[track_id] = dominant
1468
+ self.unique_plate_track[track_id] = dominant
1469
+
1470
+ # Maintain global unique mapping with dominant only
1471
+ if dominant not in self._unique_plate_texts:
1472
+ self._unique_plate_texts[dominant] = dominant
1473
+
1474
+ # Reset counters for texts NOT seen in this frame (to preserve stability requirement)
1475
+ current_frame_texts = {self._clean_text(det.get('plate_text', '')) for det in detections if det.get('plate_text')}
1476
+ for t in list(self._text_history.keys()):
1477
+ if t not in current_frame_texts:
1478
+ self._text_history[t] = 0
1479
+
1480
+ def get_total_counts(self):
1481
+ """Return total unique license plate texts encountered so far."""
1482
+ return {'License_Plate': len(self._unique_plate_texts)}
1483
+
1484
+ def _get_track_ids_info(self, detections: List[Dict]) -> Dict[str, Any]:
1485
+ """Get detailed information about track IDs."""
1486
+ frame_track_ids = {det.get('track_id') for det in detections if det.get('track_id') is not None}
1487
+ total_track_ids = set()
1488
+ for s in getattr(self, '_per_category_total_track_ids', {}).values():
1489
+ total_track_ids.update(s)
1490
+ return {
1491
+ "total_count": len(total_track_ids),
1492
+ "current_frame_count": len(frame_track_ids),
1493
+ "total_unique_track_ids": len(total_track_ids),
1494
+ "current_frame_track_ids": list(frame_track_ids),
1495
+ "last_update_time": time.time(),
1496
+ "total_frames_processed": getattr(self, '_total_frame_counter', 0)
1497
+ }
1498
+
1499
+ def _compute_iou(self, box1: Any, box2: Any) -> float:
1500
+ """Compute IoU between two bounding boxes."""
1501
+ def _bbox_to_list(bbox):
1502
+ if bbox is None:
1503
+ return []
1504
+ if isinstance(bbox, list):
1505
+ return bbox[:4] if len(bbox) >= 4 else []
1506
+ if isinstance(bbox, dict):
1507
+ if "xmin" in bbox:
1508
+ return [bbox["xmin"], bbox["ymin"], bbox["xmax"], bbox["ymax"]]
1509
+ if "x1" in bbox:
1510
+ return [bbox["x1"], bbox["y1"], bbox["x2"], bbox["y2"]]
1511
+ values = [v for v in bbox.values() if isinstance(v, (int, float))]
1512
+ return values[:4] if len(values) >= 4 else []
1513
+ return []
1514
+
1515
+ l1 = _bbox_to_list(box1)
1516
+ l2 = _bbox_to_list(box2)
1517
+ if len(l1) < 4 or len(l2) < 4:
1518
+ return 0.0
1519
+ x1_min, y1_min, x1_max, y1_max = l1
1520
+ x2_min, y2_min, x2_max, y2_max = l2
1521
+ x1_min, x1_max = min(x1_min, x1_max), max(x1_min, x1_max)
1522
+ y1_min, y1_max = min(y1_min, y1_max), max(y1_min, y1_max)
1523
+ x2_min, x2_max = min(x2_min, x2_max), max(x2_min, x2_max)
1524
+ y2_min, y2_max = min(y2_min, y2_max), max(y2_min, y2_max)
1525
+ inter_x_min = max(x1_min, x2_min)
1526
+ inter_y_min = max(y1_min, y2_min)
1527
+ inter_x_max = min(x1_max, x2_max)
1528
+ inter_y_max = min(y1_max, y2_max)
1529
+ inter_w = max(0.0, inter_x_max - inter_x_min)
1530
+ inter_h = max(0.0, inter_y_max - inter_y_min)
1531
+ inter_area = inter_w * inter_h
1532
+ area1 = (x1_max - x1_min) * (y1_max - y1_min)
1533
+ area2 = (x2_max - x2_min) * (y2_max - y2_min)
1534
+ union_area = area1 + area2 - inter_area
1535
+ return (inter_area / union_area) if union_area > 0 else 0.0
1536
+
1537
+ def _merge_or_register_track(self, raw_id: Any, bbox: Any) -> Any:
1538
+ """Return a stable canonical ID for a raw tracker ID."""
1539
+ if raw_id is None or bbox is None:
1540
+ return raw_id
1541
+ now = time.time()
1542
+ if raw_id in self._track_aliases:
1543
+ canonical_id = self._track_aliases[raw_id]
1544
+ track_info = self._canonical_tracks.get(canonical_id)
1545
+ if track_info is not None:
1546
+ track_info["last_bbox"] = bbox
1547
+ track_info["last_update"] = now
1548
+ track_info["raw_ids"].add(raw_id)
1549
+ return canonical_id
1550
+ for canonical_id, info in self._canonical_tracks.items():
1551
+ if now - info["last_update"] > self._track_merge_time_window:
1552
+ continue
1553
+ iou = self._compute_iou(bbox, info["last_bbox"])
1554
+ if iou >= self._track_merge_iou_threshold:
1555
+ self._track_aliases[raw_id] = canonical_id
1556
+ info["last_bbox"] = bbox
1557
+ info["last_update"] = now
1558
+ info["raw_ids"].add(raw_id)
1559
+ return canonical_id
1560
+ canonical_id = raw_id
1561
+ self._track_aliases[raw_id] = canonical_id
1562
+ self._canonical_tracks[canonical_id] = {
1563
+ "last_bbox": bbox,
1564
+ "last_update": now,
1565
+ "raw_ids": {raw_id},
1566
+ }
1567
+ return canonical_id
1568
+
1569
+ def _format_timestamp_for_stream(self, timestamp: float) -> str:
1570
+ """Format timestamp for streams (YYYY:MM:DD HH:MM:SS format)."""
1571
+ dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
1572
+ return dt.strftime('%Y:%m:%d %H:%M:%S')
1573
+
1574
+ def _format_timestamp_for_video(self, timestamp: float) -> str:
1575
+ """Format timestamp for video chunks (HH:MM:SS.ms format)."""
1576
+ hours = int(timestamp // 3600)
1577
+ minutes = int((timestamp % 3600) // 60)
1578
+ seconds = round(float(timestamp % 60), 2)
1579
+ return f"{hours:02d}:{minutes:02d}:{seconds:.1f}"
1580
+
1581
+ def _format_timestamp(self, timestamp: Any) -> str:
1582
+ """Format a timestamp to match the current timestamp format: YYYY:MM:DD HH:MM:SS.
1583
+
1584
+ The input can be either:
1585
+ 1. A numeric Unix timestamp (``float`` / ``int``) – it will be converted to datetime.
1586
+ 2. A string in the format ``YYYY-MM-DD-HH:MM:SS.ffffff UTC``.
1587
+
1588
+ The returned value will be in the format: YYYY:MM:DD HH:MM:SS (no milliseconds, no UTC suffix).
1589
+
1590
+ Example
1591
+ -------
1592
+ >>> self._format_timestamp("2025-10-27-19:31:20.187574 UTC")
1593
+ '2025:10:27 19:31:20'
1594
+ """
1595
+
1596
+ # Convert numeric timestamps to datetime first
1597
+ if isinstance(timestamp, (int, float)):
1598
+ dt = datetime.fromtimestamp(timestamp, timezone.utc)
1599
+ return dt.strftime('%Y:%m:%d %H:%M:%S')
1600
+
1601
+ # Ensure we are working with a string from here on
1602
+ if not isinstance(timestamp, str):
1603
+ return str(timestamp)
1604
+
1605
+ # Remove ' UTC' suffix if present
1606
+ timestamp_clean = timestamp.replace(' UTC', '').strip()
1607
+
1608
+ # Remove milliseconds if present (everything after the last dot)
1609
+ if '.' in timestamp_clean:
1610
+ timestamp_clean = timestamp_clean.split('.')[0]
1611
+
1612
+ # Parse the timestamp string and convert to desired format
1613
+ try:
1614
+ # Handle format: YYYY-MM-DD-HH:MM:SS
1615
+ if timestamp_clean.count('-') >= 2:
1616
+ # Replace first two dashes with colons for date part, third with space
1617
+ parts = timestamp_clean.split('-')
1618
+ if len(parts) >= 4:
1619
+ # parts = ['2025', '10', '27', '19:31:20']
1620
+ formatted = f"{parts[0]}:{parts[1]}:{parts[2]} {'-'.join(parts[3:])}"
1621
+ return formatted
1622
+ except Exception:
1623
+ pass
1624
+
1625
+ # If parsing fails, return the cleaned string as-is
1626
+ return timestamp_clean
1627
+
1628
+ def _get_current_timestamp_str(self, stream_info: Optional[Dict[str, Any]], precision=False, frame_id: Optional[str]=None) -> str:
1629
+ """Get formatted current timestamp based on stream type."""
1630
+
1631
+ if not stream_info:
1632
+ return "00:00:00.00"
1633
+ if precision:
1634
+ if stream_info.get("input_settings", {}).get("start_frame", "na") != "na":
1635
+ if frame_id:
1636
+ start_time = int(frame_id)/stream_info.get("input_settings", {}).get("original_fps", 30)
1637
+ else:
1638
+ start_time = stream_info.get("input_settings", {}).get("start_frame", 30)/stream_info.get("input_settings", {}).get("original_fps", 30)
1639
+ stream_time_str = self._format_timestamp_for_video(start_time)
1640
+
1641
+ return self._format_timestamp(stream_info.get("input_settings", {}).get("stream_time", "NA"))
1642
+ else:
1643
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1644
+
1645
+ if stream_info.get("input_settings", {}).get("start_frame", "na") != "na":
1646
+ if frame_id:
1647
+ start_time = int(frame_id)/stream_info.get("input_settings", {}).get("original_fps", 30)
1648
+ else:
1649
+ start_time = stream_info.get("input_settings", {}).get("start_frame", 30)/stream_info.get("input_settings", {}).get("original_fps", 30)
1650
+
1651
+ stream_time_str = self._format_timestamp_for_video(start_time)
1652
+
1653
+
1654
+ return self._format_timestamp(stream_info.get("input_settings", {}).get("stream_time", "NA"))
1655
+ else:
1656
+ stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
1657
+ if stream_time_str:
1658
+ try:
1659
+ timestamp_str = stream_time_str.replace(" UTC", "")
1660
+ dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
1661
+ timestamp = dt.replace(tzinfo=timezone.utc).timestamp()
1662
+ return self._format_timestamp_for_stream(timestamp)
1663
+ except:
1664
+ return self._format_timestamp_for_stream(time.time())
1665
+ else:
1666
+ return self._format_timestamp_for_stream(time.time())
1667
+
1668
+ def _get_start_timestamp_str(self, stream_info: Optional[Dict[str, Any]], precision=False) -> str:
1669
+ """Get formatted start timestamp for 'TOTAL SINCE' based on stream type."""
1670
+ if not stream_info:
1671
+ return "00:00:00"
1672
+
1673
+ if precision:
1674
+ if self.start_timer is None:
1675
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
1676
+ if not candidate or candidate == "NA":
1677
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1678
+ self.start_timer = candidate
1679
+ return self._format_timestamp(self.start_timer)
1680
+ elif stream_info.get("input_settings", {}).get("start_frame", "na") == 1:
1681
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
1682
+ if not candidate or candidate == "NA":
1683
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1684
+ self.start_timer = candidate
1685
+ return self._format_timestamp(self.start_timer)
1686
+ else:
1687
+ return self._format_timestamp(self.start_timer)
1688
+
1689
+ if self.start_timer is None:
1690
+ # Prefer direct input_settings.stream_time if available and not NA
1691
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
1692
+ if not candidate or candidate == "NA":
1693
+ # Fallback to nested stream_info.stream_time used by current timestamp path
1694
+ stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
1695
+ if stream_time_str:
1696
+ try:
1697
+ timestamp_str = stream_time_str.replace(" UTC", "")
1698
+ dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
1699
+ self._tracking_start_time = dt.replace(tzinfo=timezone.utc).timestamp()
1700
+ candidate = datetime.fromtimestamp(self._tracking_start_time, timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1701
+ except:
1702
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1703
+ else:
1704
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1705
+ self.start_timer = candidate
1706
+ return self._format_timestamp(self.start_timer)
1707
+ elif stream_info.get("input_settings", {}).get("start_frame", "na") == 1:
1708
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
1709
+ if not candidate or candidate == "NA":
1710
+ stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
1711
+ if stream_time_str:
1712
+ try:
1713
+ timestamp_str = stream_time_str.replace(" UTC", "")
1714
+ dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
1715
+ ts = dt.replace(tzinfo=timezone.utc).timestamp()
1716
+ candidate = datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1717
+ except:
1718
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1719
+ else:
1720
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1721
+ self.start_timer = candidate
1722
+ return self._format_timestamp(self.start_timer)
1723
+
1724
+ else:
1725
+ if self.start_timer is not None and self.start_timer != "NA":
1726
+ return self._format_timestamp(self.start_timer)
1727
+
1728
+ if self._tracking_start_time is None:
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
+ self._tracking_start_time = dt.replace(tzinfo=timezone.utc).timestamp()
1735
+ except:
1736
+ self._tracking_start_time = time.time()
1737
+ else:
1738
+ self._tracking_start_time = time.time()
1739
+
1740
+ dt = datetime.fromtimestamp(self._tracking_start_time, tz=timezone.utc)
1741
+ dt = dt.replace(minute=0, second=0, microsecond=0)
1742
+ return dt.strftime('%Y:%m:%d %H:%M:%S')
1743
+
1744
+ def _get_tracking_start_time(self) -> str:
1745
+ """Get the tracking start time, formatted as a string."""
1746
+ if self._tracking_start_time is None:
1747
+ return "N/A"
1748
+ return self._format_timestamp(self._tracking_start_time)
1749
+
1750
+ def _set_tracking_start_time(self) -> None:
1751
+ """Set the tracking start time to the current time."""
1752
+ self._tracking_start_time = time.time()
1753
+
1754
+ def _attach_masks_to_detections(self, processed_detections: List[Dict[str, Any]], raw_detections: List[Dict[str, Any]],
1755
+ iou_threshold: float = 0.5) -> List[Dict[str, Any]]:
1756
+ """Attach segmentation masks from raw detections to processed detections."""
1757
+ if not processed_detections or not raw_detections:
1758
+ for det in processed_detections:
1759
+ det.setdefault("masks", [])
1760
+ return processed_detections
1761
+
1762
+ used_raw_indices = set()
1763
+ for det in processed_detections:
1764
+ best_iou = 0.0
1765
+ best_idx = None
1766
+ for idx, raw_det in enumerate(raw_detections):
1767
+ if idx in used_raw_indices:
1768
+ continue
1769
+ iou = self._compute_iou(det.get("bounding_box"), raw_det.get("bounding_box"))
1770
+ if iou > best_iou:
1771
+ best_iou = iou
1772
+ best_idx = idx
1773
+ if best_idx is not None and best_iou >= iou_threshold:
1774
+ raw_det = raw_detections[best_idx]
1775
+ masks = raw_det.get("masks", raw_det.get("mask"))
1776
+ if masks is not None:
1777
+ det["masks"] = masks
1778
+ used_raw_indices.add(best_idx)
1779
+ else:
1780
+ det.setdefault("masks", ["EMPTY"])
1781
+ return processed_detections