nedo-vision-worker-core 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of nedo-vision-worker-core might be problematic. Click here for more details.

Files changed (33) hide show
  1. nedo_vision_worker_core/__init__.py +47 -12
  2. nedo_vision_worker_core/callbacks/DetectionCallbackManager.py +306 -0
  3. nedo_vision_worker_core/callbacks/DetectionCallbackTypes.py +150 -0
  4. nedo_vision_worker_core/callbacks/__init__.py +27 -0
  5. nedo_vision_worker_core/cli.py +24 -34
  6. nedo_vision_worker_core/core_service.py +121 -55
  7. nedo_vision_worker_core/database/DatabaseManager.py +2 -2
  8. nedo_vision_worker_core/detection/BaseDetector.py +2 -1
  9. nedo_vision_worker_core/detection/DetectionManager.py +2 -2
  10. nedo_vision_worker_core/detection/RFDETRDetector.py +23 -5
  11. nedo_vision_worker_core/detection/YOLODetector.py +18 -5
  12. nedo_vision_worker_core/detection/detection_processing/DetectionProcessor.py +1 -1
  13. nedo_vision_worker_core/detection/detection_processing/HumanDetectionProcessor.py +57 -3
  14. nedo_vision_worker_core/detection/detection_processing/PPEDetectionProcessor.py +173 -10
  15. nedo_vision_worker_core/models/ai_model.py +23 -2
  16. nedo_vision_worker_core/pipeline/PipelineProcessor.py +299 -14
  17. nedo_vision_worker_core/pipeline/PipelineSyncThread.py +32 -0
  18. nedo_vision_worker_core/repositories/PPEDetectionRepository.py +18 -15
  19. nedo_vision_worker_core/repositories/RestrictedAreaRepository.py +17 -13
  20. nedo_vision_worker_core/services/SharedVideoStreamServer.py +276 -0
  21. nedo_vision_worker_core/services/VideoSharingDaemon.py +808 -0
  22. nedo_vision_worker_core/services/VideoSharingDaemonManager.py +257 -0
  23. nedo_vision_worker_core/streams/SharedVideoDeviceManager.py +383 -0
  24. nedo_vision_worker_core/streams/StreamSyncThread.py +16 -2
  25. nedo_vision_worker_core/streams/VideoStream.py +267 -246
  26. nedo_vision_worker_core/streams/VideoStreamManager.py +158 -6
  27. nedo_vision_worker_core/tracker/TrackerManager.py +25 -31
  28. nedo_vision_worker_core-0.3.1.dist-info/METADATA +444 -0
  29. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/RECORD +32 -25
  30. nedo_vision_worker_core-0.2.0.dist-info/METADATA +0 -347
  31. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/WHEEL +0 -0
  32. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/entry_points.txt +0 -0
  33. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/top_level.txt +0 -0
@@ -6,6 +6,7 @@ from .DetectionProcessor import DetectionProcessor
6
6
  from ...pipeline.PipelineConfigManager import PipelineConfigManager
7
7
  from ...repositories.PPEDetectionRepository import PPEDetectionRepository
8
8
  from ...util.PersonAttributeMatcher import PersonAttributeMatcher
9
+ from ...callbacks import DetectionType, DetectionAttribute, BoundingBox, DetectionData
9
10
 
10
11
  class PPEDetectionProcessor(DetectionProcessor):
11
12
  code = "ppe"
@@ -15,22 +16,118 @@ class PPEDetectionProcessor(DetectionProcessor):
15
16
  "vest": "icons/vest-green.png",
16
17
  "no_vest": "icons/vest-red.png"
17
18
  }
18
- labels = ["helmet", "no_helmet", "vest", "no_vest", "gloves", "no_gloves", "goggles", "no_goggles", "boots", "no_boots"]
19
- violation_labels = ["no_helmet", "no_vest", "no_gloves", "no_goggles", "no_boots"]
20
- compliance_labels = ["helmet", "vest", "gloves", "goggles", "boots"]
21
- exclusive_labels = [("helmet", "no_helmet"), ("vest", "no_vest"), ("gloves", "no_gloves"), ("goggles", "no_goggles"), ("boots", "no_boots")]
22
-
19
+
23
20
  def __init__(self):
24
21
  self.ppe_storage = PPEDetectionRepository()
25
22
  self.types = []
23
+ self.ppe_groups = {}
24
+ self.group_thresholds = {}
25
+ self.main_class_threshold = 0.7
26
+ self.main_class = "person"
27
+
28
+ self.labels = ["helmet", "no_helmet", "vest", "no_vest", "gloves", "no_gloves", "goggles", "no_goggles", "boots", "no_boots"]
29
+ self.violation_labels = ["no_helmet", "no_vest", "no_gloves", "no_goggles", "no_boots"]
30
+ self.compliance_labels = ["helmet", "vest", "gloves", "goggles", "boots"]
31
+ self.exclusive_labels = [("helmet", "no_helmet"), ("vest", "no_vest"), ("gloves", "no_gloves"), ("goggles", "no_goggles"), ("boots", "no_boots")]
32
+
33
+ def update(self, config_manager: PipelineConfigManager, ai_model=None):
34
+ config = config_manager.get_feature_config(self.code, {})
35
+
36
+ # Update from AI model
37
+ if ai_model:
38
+ self._update_from_ai_model(ai_model)
39
+
40
+ # Update PPE type configuration
41
+ ppe_type_configs = config.get("ppeType", [])
42
+ self.types = []
43
+ self.group_thresholds = {}
44
+
45
+ for ppe_config in ppe_type_configs:
46
+ if isinstance(ppe_config, dict):
47
+ group = ppe_config.get("group")
48
+ threshold = ppe_config.get("confidenceThreshold", 0.7)
49
+ if group:
50
+ self.types.append(group)
51
+ self.group_thresholds[group] = threshold
52
+ elif isinstance(ppe_config, str):
53
+ # Backward compatibility
54
+ self.types.append(ppe_config)
55
+ self.group_thresholds[ppe_config] = 0.7
56
+
57
+ # Update main class threshold
58
+ self.main_class_threshold = config.get("mainClassConfidenceThreshold", 0.7)
59
+
60
+ def _update_from_ai_model(self, ai_model):
61
+ """Update processor settings from AI model configuration"""
62
+ if ai_model and hasattr(ai_model, 'ppe_groups') and ai_model.ppe_groups:
63
+ self.ppe_groups = {group.group_name: group for group in ai_model.ppe_groups}
64
+ self._build_labels_from_groups()
65
+
66
+ if ai_model and hasattr(ai_model, 'main_class') and ai_model.main_class:
67
+ self.main_class = ai_model.main_class
26
68
 
27
- def update(self, config_manager: PipelineConfigManager):
28
- config = config_manager.get_feature_config(self.code, [])
29
- self.types = config.get("ppeType", [])
69
+ def _build_labels_from_groups(self):
70
+ """Build standard PPE labels from AI model PPE groups"""
71
+ if not self.ppe_groups:
72
+ return
73
+
74
+ labels = []
75
+ violation_labels = []
76
+ compliance_labels = []
77
+ exclusive_labels = []
78
+
79
+ for group_name in self.ppe_groups.items():
80
+ compliance_class = group_name
81
+ violation_class = f"no_{group_name}"
82
+
83
+ # Build label lists using standard naming
84
+ labels.extend([compliance_class, violation_class])
85
+ compliance_labels.append(compliance_class)
86
+ violation_labels.append(violation_class)
87
+ exclusive_labels.append((compliance_class, violation_class))
88
+
89
+ # Update instance variables with dynamic labels
90
+ self.labels = labels
91
+ self.violation_labels = violation_labels
92
+ self.compliance_labels = compliance_labels
93
+ self.exclusive_labels = exclusive_labels
94
+
95
+ def get_multi_instance_classes(self):
96
+ """Get PPE classes that can have multiple instances per person"""
97
+ multi_instance_base = ["boots", "gloves", "goggles"]
98
+ multi_instance = []
99
+
100
+ for label in self.labels:
101
+ base_label = label.replace("no_", "") if label.startswith("no_") else label
102
+ if base_label in multi_instance_base:
103
+ multi_instance.append(label)
104
+ return multi_instance
30
105
 
31
106
  def process(self, detections: List[Dict[str, Any]], dimension: Tuple[int, int]) -> List[Dict[str, Any]]:
32
- persons = [d for d in detections if d["label"] == "person"]
33
- ppe_attributes = [d for d in detections if any(x in d["label"] for x in self.types)]
107
+ persons = [d for d in detections if d["label"] == self.main_class]
108
+
109
+ ppe_attributes = []
110
+ for detection in detections:
111
+ label = detection["label"]
112
+
113
+ for group_name in self.types:
114
+ if group_name in self.ppe_groups:
115
+ group_config = self.ppe_groups[group_name]
116
+
117
+ original_compliance = group_config.get("compliance")
118
+ original_violation = group_config.get("violation")
119
+
120
+ if label in [original_compliance, original_violation]:
121
+ if label == original_compliance:
122
+ detection["label"] = group_name
123
+ elif label == original_violation:
124
+ detection["label"] = f"no_{group_name}"
125
+
126
+ ppe_attributes.append(detection)
127
+ break
128
+ elif label == group_name or label == f"no_{group_name}":
129
+ ppe_attributes.append(detection)
130
+ break
34
131
 
35
132
  matched_results = PersonAttributeMatcher.match_persons_with_attributes(
36
133
  persons, ppe_attributes, coverage_threshold=0.5
@@ -38,7 +135,73 @@ class PPEDetectionProcessor(DetectionProcessor):
38
135
 
39
136
  return matched_results
40
137
 
138
+ def get_class_thresholds(self):
139
+ """Get confidence thresholds for each class using original AI model class names"""
140
+ thresholds = {}
141
+
142
+ for group_name, threshold in self.group_thresholds.items():
143
+ if group_name in self.ppe_groups:
144
+ group_config = self.ppe_groups[group_name]
145
+
146
+ original_compliance = group_config.get("compliance")
147
+ original_violation = group_config.get("violation")
148
+
149
+ if original_compliance:
150
+ thresholds[original_compliance] = threshold
151
+ if original_violation:
152
+ thresholds[original_violation] = threshold
153
+
154
+ thresholds[group_name] = threshold
155
+ thresholds[f"no_{group_name}"] = threshold
156
+ else:
157
+ thresholds[group_name] = threshold
158
+ if not group_name.startswith("no_"):
159
+ thresholds[f"no_{group_name}"] = threshold
160
+
161
+ return thresholds
162
+
41
163
  def save_to_db(self, pipeline_id: str, worker_source_id: str, frame_counter: int, tracked_objects: List[Dict[str, Any]], frame: np.ndarray, frame_drawer: FrameDrawer):
42
164
  self.ppe_storage.save_ppe_detection(
43
165
  pipeline_id, worker_source_id, frame_counter, tracked_objects, frame, frame_drawer
44
166
  )
167
+
168
+ @staticmethod
169
+ def create_detection_data(pipeline_id: str, worker_source_id: str, person_id: str,
170
+ detection_id: str, tracked_obj: Dict[str, Any],
171
+ image_path: str = "", image_tile_path: str = "",
172
+ frame_id: int = 0) -> DetectionData:
173
+ """Create DetectionData from PPE detection data."""
174
+ bbox = BoundingBox.from_list(tracked_obj["bbox"])
175
+
176
+ attributes = []
177
+ for attr in tracked_obj.get("attributes", []):
178
+ attr_bbox = None
179
+ if "bbox" in attr:
180
+ attr_bbox = BoundingBox.from_list(attr["bbox"])
181
+
182
+ # Determine if this is a violation based on label
183
+ is_violation = attr["label"].startswith("no_") or attr["label"] in [
184
+ "no_helmet", "no_vest", "no_gloves", "no_goggles", "no_boots"
185
+ ]
186
+
187
+ attributes.append(DetectionAttribute(
188
+ label=attr["label"],
189
+ confidence=attr.get("confidence", 1.0),
190
+ count=attr.get("count", 0),
191
+ bbox=attr_bbox,
192
+ is_violation=is_violation
193
+ ))
194
+
195
+ return DetectionData(
196
+ detection_type=DetectionType.PPE_DETECTION,
197
+ detection_id=detection_id,
198
+ person_id=person_id,
199
+ pipeline_id=pipeline_id,
200
+ worker_source_id=worker_source_id,
201
+ confidence_score=tracked_obj.get("confidence", 1.0),
202
+ bbox=bbox,
203
+ attributes=attributes,
204
+ image_path=image_path,
205
+ image_tile_path=image_tile_path,
206
+ frame_id=frame_id
207
+ )
@@ -1,6 +1,6 @@
1
1
  import uuid
2
+ import json
2
3
  from sqlalchemy import Column, String, DateTime
3
- from datetime import datetime
4
4
  from ..database.DatabaseManager import Base
5
5
 
6
6
  class AIModelEntity(Base):
@@ -15,6 +15,9 @@ class AIModelEntity(Base):
15
15
  download_status = Column(String, nullable=True, default="completed") # pending, downloading, completed, failed
16
16
  last_download_attempt = Column(DateTime, nullable=True)
17
17
  download_error = Column(String, nullable=True)
18
+ classes = Column(String, nullable=True)
19
+ ppe_class_groups = Column(String, nullable=True)
20
+ main_class = Column(String, nullable=True)
18
21
 
19
22
  def __repr__(self):
20
23
  return (
@@ -38,4 +41,22 @@ class AIModelEntity(Base):
38
41
 
39
42
  def has_download_failed(self) -> bool:
40
43
  """Check if the model download has failed."""
41
- return self.download_status == "failed"
44
+ return self.download_status == "failed"
45
+
46
+ def set_classes(self, classes_list):
47
+ self.classes = json.dumps(classes_list)
48
+
49
+ def get_classes(self):
50
+ return json.loads(self.classes) if self.classes else []
51
+
52
+ def set_ppe_class_groups(self, groups_list):
53
+ self.ppe_class_groups = json.dumps(groups_list)
54
+
55
+ def get_ppe_class_groups(self):
56
+ return json.loads(self.ppe_class_groups) if self.ppe_class_groups else []
57
+
58
+ def set_main_class(self, main_class):
59
+ self.main_class = main_class
60
+
61
+ def get_main_class(self):
62
+ return self.main_class or None
@@ -54,6 +54,32 @@ class PipelineProcessor:
54
54
  self.debug_flag = False
55
55
  self.debug_repo = WorkerSourcePipelineDebugRepository()
56
56
  self.detection_repo = WorkerSourcePipelineDetectionRepository()
57
+
58
+ # Frame recovery mechanism
59
+ self.consecutive_frame_failures = 0
60
+ self.max_consecutive_failures = 150 # 1.5 seconds at 0.01s intervals
61
+ self.last_successful_frame_time = time.time()
62
+ self.stream_recovery_timeout = 30.0 # 30 seconds timeout for stream recovery
63
+
64
+ # HEVC error tracking
65
+ self.hevc_error_count = 0
66
+ self.last_hevc_recovery = 0
67
+ self.hevc_recovery_cooldown = 30.0 # 30 seconds between HEVC recovery attempts
68
+
69
+ def load_model(self, model):
70
+ """
71
+ Load a new AI model into the detection manager.
72
+ This allows runtime model updates without restarting the pipeline.
73
+
74
+ :param model: The new AI model to load
75
+ """
76
+ logging.info(f"🔄 Loading new model for pipeline {self.pipeline_id}: {model.name if model else 'None'}")
77
+ self.detection_manager.load_model(model)
78
+
79
+ # Re-initialize detection processor to use the new model configuration
80
+ self._update_detection_processor()
81
+
82
+ logging.info(f"✅ Model updated for pipeline {self.pipeline_id}")
57
83
 
58
84
  def _get_detection_processor_code(self):
59
85
  for code in self.detection_processor_codes:
@@ -83,18 +109,31 @@ class PipelineProcessor:
83
109
  violation_labels=self.detection_processor.violation_labels,
84
110
  compliance_labels=self.detection_processor.compliance_labels,
85
111
  )
86
- self.tracker_manager.attribute_labels = self.detection_processor.labels
87
- self.tracker_manager.exclusive_attribute_groups = self.detection_processor.exclusive_labels
112
+ multi_instance_classes = []
113
+ if hasattr(self.detection_processor, 'get_multi_instance_classes'):
114
+ multi_instance_classes = self.detection_processor.get_multi_instance_classes()
115
+
116
+ self.tracker_manager.update_config(
117
+ attribute_labels=self.detection_processor.labels,
118
+ exclusive_attribute_groups=self.detection_processor.exclusive_labels,
119
+ multi_instance_classes=multi_instance_classes
120
+ )
88
121
 
89
122
  def _update_config(self):
90
123
  self.config_manager.update(self.pipeline_id)
91
124
  self.preprocessor.update(self.config_manager)
92
125
  self.detection_interval = self._get_detection_interval()
93
126
  self._update_detection_processor()
127
+
128
+ # Reset frame failure counters on config update
129
+ self.consecutive_frame_failures = 0
130
+ self.last_successful_frame_time = time.time()
131
+
132
+ ai_model = self.detection_manager.model_metadata
94
133
 
95
134
  if self.detection_processor:
96
135
  config = self.config_manager.get_feature_config(self.detection_processor.code)
97
- self.detection_processor.update(self.config_manager)
136
+ self.detection_processor.update(self.config_manager, ai_model)
98
137
  self.threshold = config.get("minimumDetectionConfidence", 0.7)
99
138
 
100
139
  if self.detection_processor.code == HumanDetectionProcessor.code:
@@ -102,8 +141,11 @@ class PipelineProcessor:
102
141
  else:
103
142
  self.threshold = 0.7
104
143
  self.frame_drawer.update_config()
105
- self.tracker_manager.attribute_labels = []
106
- self.tracker_manager.exclusive_attribute_groups = []
144
+ self.tracker_manager.update_config(
145
+ attribute_labels=[],
146
+ exclusive_attribute_groups=[],
147
+ multi_instance_classes=[]
148
+ )
107
149
 
108
150
  def process_pipeline(self, video_manager: VideoStreamManager):
109
151
  """
@@ -115,6 +157,10 @@ class PipelineProcessor:
115
157
  logging.info(f"🎯 Running pipeline processing for pipeline {pipeline_id} | Source: {worker_source_id}")
116
158
 
117
159
  self._update_config()
160
+
161
+ # Reset failure counters at start
162
+ self.consecutive_frame_failures = 0
163
+ self.last_successful_frame_time = time.time()
118
164
 
119
165
  initial_frame = self._wait_for_frame(video_manager)
120
166
  if initial_frame is None:
@@ -136,14 +182,14 @@ class PipelineProcessor:
136
182
  frame = video_manager.get_frame(worker_source_id)
137
183
 
138
184
  if frame is None:
139
- logging.warning(f"⚠️ No frame available for {worker_source_id}. Retrying...")
140
- # Check if stream was removed
141
- if not video_manager.has_stream(worker_source_id):
142
- logging.info(f"🛑 Stream {worker_source_id} was removed, stopping pipeline")
185
+ if not self._handle_frame_failure(video_manager, worker_source_id):
143
186
  break
144
- time.sleep(0.01)
145
187
  continue
146
188
 
189
+ # Reset failure counters on successful frame
190
+ self.consecutive_frame_failures = 0
191
+ self.last_successful_frame_time = time.time()
192
+
147
193
  self.frame_counter += 1
148
194
 
149
195
  self.frame_drawer.draw_polygons(frame)
@@ -195,10 +241,26 @@ class PipelineProcessor:
195
241
  dimension = frame.shape[:2]
196
242
 
197
243
  processed_frame = self.preprocessor.apply(frame)
198
- detections = self.detection_manager.detect_objects(processed_frame, self.threshold)
244
+
245
+ class_thresholds = {}
246
+ ai_model = self.detection_manager.model_metadata
247
+
248
+ if self.detection_processor:
249
+ if self.detection_processor.code == PPEDetectionProcessor.code:
250
+ class_thresholds.update(self.detection_processor.get_class_thresholds())
251
+ elif self.detection_processor.code == HumanDetectionProcessor.code:
252
+ main_threshold = self.detection_processor.get_main_class_threshold(ai_model)
253
+ if main_threshold and ai_model and ai_model.get_main_class():
254
+ class_thresholds[ai_model.get_main_class()] = main_threshold
255
+
256
+ detections = self.detection_manager.detect_objects(processed_frame, self.threshold, class_thresholds)
199
257
  detections = self.preprocessor.revert_detections_bboxes(detections, dimension)
200
- matched_results = self.detection_processor.process(detections, dimension)
201
- return self.tracker_manager.track_objects(matched_results)
258
+
259
+ if self.detection_processor:
260
+ matched_results = self.detection_processor.process(detections, dimension)
261
+ return self.tracker_manager.track_objects(matched_results)
262
+ else:
263
+ return self.tracker_manager.track_objects(detections)
202
264
 
203
265
 
204
266
  def _detection_worker(self):
@@ -262,15 +324,200 @@ class PipelineProcessor:
262
324
 
263
325
  def _wait_for_frame(self, video_manager, max_retries=10, sleep_time=3):
264
326
  """Waits until a frame is available from the video source."""
327
+ logging.info(f"⏳ Waiting for initial frame from {self.worker_source_id}...")
328
+
265
329
  for retry_count in range(max_retries):
266
330
  frame = video_manager.get_frame(self.worker_source_id)
267
331
  if frame is not None:
332
+ logging.info(f"✅ Initial frame received from {self.worker_source_id}")
268
333
  return frame
334
+
335
+ # Check if stream exists
336
+ if not video_manager.has_stream(self.worker_source_id):
337
+ logging.error(f"❌ Stream {self.worker_source_id} not found in video manager")
338
+ return None
339
+
269
340
  logging.warning(f"⚠️ Waiting for video stream {self.worker_source_id} (Attempt {retry_count + 1}/{max_retries})...")
341
+
342
+ # Log stream diagnostics on later attempts
343
+ if retry_count >= 3:
344
+ self._log_stream_diagnostics(video_manager, self.worker_source_id)
345
+
270
346
  time.sleep(sleep_time)
271
347
 
348
+ logging.error(f"❌ Failed to get initial frame from {self.worker_source_id} after {max_retries} attempts")
272
349
  return None
273
350
 
351
+ def _handle_frame_failure(self, video_manager, worker_source_id):
352
+ """
353
+ Handle frame retrieval failures with progressive backoff and recovery attempts.
354
+ Returns False if pipeline should stop, True to continue.
355
+ """
356
+ self.consecutive_frame_failures += 1
357
+
358
+ # Check if stream was removed
359
+ if not video_manager.has_stream(worker_source_id):
360
+ logging.info(f"🛑 Stream {worker_source_id} was removed, stopping pipeline")
361
+ return False
362
+
363
+ # Check for stream recovery timeout
364
+ time_since_last_frame = time.time() - self.last_successful_frame_time
365
+ if time_since_last_frame > self.stream_recovery_timeout:
366
+ logging.error(f"❌ Stream {worker_source_id} recovery timeout ({self.stream_recovery_timeout}s). Stopping pipeline.")
367
+ return False
368
+
369
+ # Progressive logging and backoff
370
+ if self.consecutive_frame_failures <= 10:
371
+ # First 10 failures: minimal logging, fast retry
372
+ if self.consecutive_frame_failures % 5 == 1: # Log every 5th failure
373
+ logging.debug(f"⚠️ No frame available for {worker_source_id} (attempt {self.consecutive_frame_failures})")
374
+ time.sleep(0.01)
375
+ elif self.consecutive_frame_failures <= 50:
376
+ # 11-50 failures: moderate logging, slightly longer wait
377
+ if self.consecutive_frame_failures % 10 == 1: # Log every 10th failure
378
+ logging.warning(f"⚠️ No frame available for {worker_source_id} (attempt {self.consecutive_frame_failures}). Stream may be reconnecting...")
379
+ time.sleep(0.05)
380
+ elif self.consecutive_frame_failures <= self.max_consecutive_failures:
381
+ # 51-150 failures: more frequent logging, longer wait
382
+ if self.consecutive_frame_failures % 20 == 1: # Log every 20th failure
383
+ logging.warning(f"⚠️ Persistent frame issues for {worker_source_id} (attempt {self.consecutive_frame_failures}). Checking stream health...")
384
+ self._log_stream_diagnostics(video_manager, worker_source_id)
385
+
386
+ # Attempt HEVC recovery on severe persistent failures (every 60 failures to avoid too frequent reconnections)
387
+ if self.consecutive_frame_failures % 60 == 1:
388
+ # Check if we should attempt HEVC recovery based on error patterns and cooldown
389
+ if self._should_attempt_hevc_recovery(video_manager, worker_source_id):
390
+ logging.info(f"🔧 Attempting HEVC-specific recovery for persistent frame failures...")
391
+ recovery_success = self._handle_hevc_recovery(video_manager, worker_source_id)
392
+ if recovery_success:
393
+ logging.info(f"✅ HEVC recovery successful, continuing pipeline...")
394
+ return True # Continue processing after successful recovery
395
+
396
+ time.sleep(0.1)
397
+ else:
398
+ # Over max failures: critical logging and stop
399
+ logging.error(f"❌ Too many consecutive frame failures for {worker_source_id} ({self.consecutive_frame_failures}). Stopping pipeline.")
400
+ self._log_stream_diagnostics(video_manager, worker_source_id)
401
+ return False
402
+
403
+ return True
404
+
405
+ def _log_stream_diagnostics(self, video_manager, worker_source_id):
406
+ """Log diagnostic information about the stream state."""
407
+ try:
408
+ stream_url = video_manager.get_stream_url(worker_source_id)
409
+ is_file = video_manager.is_video_file(worker_source_id)
410
+
411
+ # Get stream object for more detailed diagnostics
412
+ if hasattr(video_manager, 'streams') and worker_source_id in video_manager.streams:
413
+ stream = video_manager.streams[worker_source_id]
414
+ state = stream.get_state() if hasattr(stream, 'get_state') else "unknown"
415
+ is_connected = stream.is_connected() if hasattr(stream, 'is_connected') else "unknown"
416
+
417
+ logging.info(f"📊 Stream diagnostics for {worker_source_id}:")
418
+ logging.info(f" URL: {stream_url}")
419
+ logging.info(f" Type: {'Video file' if is_file else 'Live stream'}")
420
+ logging.info(f" State: {state}")
421
+ logging.info(f" Connected: {is_connected}")
422
+ logging.info(f" Time since last frame: {time.time() - self.last_successful_frame_time:.1f}s")
423
+
424
+ # Check for HEVC/codec specific issues
425
+ if hasattr(stream, 'get_codec_info'):
426
+ codec_info = stream.get_codec_info()
427
+ if codec_info:
428
+ logging.info(f" Codec: {codec_info}")
429
+ if 'hevc' in str(codec_info).lower() or 'h265' in str(codec_info).lower():
430
+ logging.warning(f" ⚠️ HEVC stream detected - may experience QP delta or POC reference errors")
431
+
432
+ # Log recent error patterns if available
433
+ if hasattr(stream, 'get_recent_errors'):
434
+ recent_errors = stream.get_recent_errors()
435
+ if recent_errors:
436
+ hevc_errors = [err for err in recent_errors if 'cu_qp_delta' in str(err.get('error', '')) or 'Could not find ref with POC' in str(err.get('error', ''))]
437
+ if hevc_errors:
438
+ logging.warning(f" 🔥 Recent HEVC errors detected: {len(hevc_errors)} codec-related errors")
439
+ self.hevc_error_count += len(hevc_errors)
440
+
441
+ # Log sample of recent HEVC errors for debugging
442
+ for i, err in enumerate(hevc_errors[-3:]): # Show last 3 errors
443
+ logging.warning(f" 🔥 HEVC Error {i+1}: {err.get('error', '')[:100]}...")
444
+ else:
445
+ logging.info(f"📊 Stream {worker_source_id} not found in regular streams, checking direct device streams...")
446
+
447
+ except Exception as e:
448
+ logging.error(f"Error getting stream diagnostics: {e}")
449
+
450
+ def _should_attempt_hevc_recovery(self, video_manager, worker_source_id) -> bool:
451
+ """
452
+ Determine if HEVC recovery should be attempted based on error patterns and cooldown.
453
+ """
454
+ current_time = time.time()
455
+
456
+ # Check cooldown period
457
+ if current_time - self.last_hevc_recovery < self.hevc_recovery_cooldown:
458
+ logging.debug(f"HEVC recovery on cooldown ({current_time - self.last_hevc_recovery:.1f}s elapsed)")
459
+ return False
460
+
461
+ # Check if stream has HEVC-related errors
462
+ if hasattr(video_manager, 'streams') and worker_source_id in video_manager.streams:
463
+ stream = video_manager.streams[worker_source_id]
464
+ if hasattr(stream, 'get_recent_errors'):
465
+ recent_errors = stream.get_recent_errors(max_age_seconds=60) # Last minute
466
+ hevc_errors = [err for err in recent_errors if
467
+ 'cu_qp_delta' in str(err.get('error', '')) or
468
+ 'Could not find ref with POC' in str(err.get('error', ''))]
469
+
470
+ if len(hevc_errors) >= 3: # Threshold for HEVC errors
471
+ logging.info(f"HEVC recovery warranted: {len(hevc_errors)} HEVC errors in last minute")
472
+ return True
473
+
474
+ # Check if we have accumulated enough general HEVC errors
475
+ if self.hevc_error_count >= 5:
476
+ logging.info(f"HEVC recovery warranted: {self.hevc_error_count} total HEVC errors detected")
477
+ return True
478
+
479
+ return False
480
+
481
+ def _handle_hevc_recovery(self, video_manager, worker_source_id):
482
+ """
483
+ Handle HEVC-specific recovery strategies for codec errors.
484
+ This method attempts to recover from common HEVC issues like QP delta and POC reference errors.
485
+ """
486
+ try:
487
+ self.last_hevc_recovery = time.time() # Update recovery timestamp
488
+ logging.info(f"🔧 Attempting HEVC stream recovery for {worker_source_id}")
489
+
490
+ # Get the stream URL for recreation
491
+ stream_url = video_manager.get_stream_url(worker_source_id)
492
+ if not stream_url:
493
+ logging.error(f" Cannot get stream URL for {worker_source_id}")
494
+ return False
495
+
496
+ # Strategy 1: Remove and re-add the stream to reset decoder state
497
+ logging.info(f" Recreating stream {worker_source_id} to reset decoder state...")
498
+ video_manager.remove_stream(worker_source_id)
499
+ time.sleep(1.0) # Give time for cleanup
500
+
501
+ # Re-add the stream
502
+ video_manager.add_stream(worker_source_id, stream_url)
503
+ time.sleep(2.0) # Give time for stream to initialize
504
+
505
+ # Strategy 2: Check if stream was successfully recreated
506
+ if not video_manager.has_stream(worker_source_id):
507
+ logging.error(f" Failed to recreate stream {worker_source_id}")
508
+ return False
509
+
510
+ # Strategy 3: Reset failure counters and error counts after recovery attempt
511
+ self.reset_frame_failure_counters()
512
+ self.hevc_error_count = 0 # Reset HEVC error counter
513
+
514
+ logging.info(f"✅ HEVC recovery attempt completed for {worker_source_id}")
515
+ return True
516
+
517
+ except Exception as e:
518
+ logging.error(f"❌ HEVC recovery failed for {worker_source_id}: {e}")
519
+ return False
520
+
274
521
  def stop(self):
275
522
  """Stops the Pipeline processor and cleans up resources."""
276
523
  if not self.running: # Prevent multiple stops
@@ -335,4 +582,42 @@ class PipelineProcessor:
335
582
 
336
583
  def enable_debug(self):
337
584
  """Enable debug mode for this pipeline."""
338
- self.debug_flag = True
585
+ self.debug_flag = True
586
+ # Reset failure counters when debug is enabled as it may help with recovery
587
+ self.consecutive_frame_failures = 0
588
+ self.last_successful_frame_time = time.time()
589
+
590
+ def reset_frame_failure_counters(self):
591
+ """Reset frame failure counters. Can be called externally to help with recovery."""
592
+ logging.info(f"🔄 Resetting frame failure counters for pipeline {self.pipeline_id}")
593
+ self.consecutive_frame_failures = 0
594
+ self.last_successful_frame_time = time.time()
595
+ self.hevc_error_count = 0 # Also reset HEVC error count
596
+
597
+ def get_hevc_diagnostics(self, video_manager) -> dict:
598
+ """Get HEVC-specific diagnostics for the pipeline."""
599
+ diagnostics = {
600
+ 'hevc_error_count': self.hevc_error_count,
601
+ 'last_hevc_recovery': self.last_hevc_recovery,
602
+ 'time_since_last_recovery': time.time() - self.last_hevc_recovery,
603
+ 'recovery_cooldown_remaining': max(0, self.hevc_recovery_cooldown - (time.time() - self.last_hevc_recovery)),
604
+ 'consecutive_failures': self.consecutive_frame_failures,
605
+ 'time_since_last_frame': time.time() - self.last_successful_frame_time,
606
+ }
607
+
608
+ # Add stream-specific HEVC information
609
+ if hasattr(video_manager, 'streams') and self.worker_source_id in video_manager.streams:
610
+ stream = video_manager.streams[self.worker_source_id]
611
+
612
+ if hasattr(stream, 'get_codec_info'):
613
+ diagnostics['codec'] = stream.get_codec_info()
614
+
615
+ if hasattr(stream, 'get_recent_errors'):
616
+ recent_errors = stream.get_recent_errors(max_age_seconds=300) # Last 5 minutes
617
+ hevc_errors = [err for err in recent_errors if
618
+ 'cu_qp_delta' in str(err.get('error', '')) or
619
+ 'Could not find ref with POC' in str(err.get('error', ''))]
620
+ diagnostics['recent_hevc_errors'] = len(hevc_errors)
621
+ diagnostics['total_recent_errors'] = len(recent_errors)
622
+
623
+ return diagnostics
@@ -171,6 +171,38 @@ class PipelineSyncThread(threading.Thread):
171
171
  else:
172
172
  logging.warning(f"⚠️ Pipeline {pid}: {readiness['reason']}")
173
173
 
174
+ # Case 7: Model metadata has changed (same ID and version, but different properties)
175
+ elif local_model and db_model and local_model.id == db_model.id and local_model.version == db_model.version:
176
+ # Check if model metadata (classes, PPE groups, main_class) has changed
177
+ if self._has_model_metadata_changed(local_model, db_model):
178
+ readiness = ModelReadinessChecker.check_model_readiness(db_model)
179
+ if readiness["ready"]:
180
+ local_proc.load_model(db_model)
181
+ logging.info(f"🔄 Model metadata updated for pipeline {pid}: {db_pipeline.name} "
182
+ f"(same version {db_model.version}, updated properties)")
183
+ else:
184
+ logging.warning(f"⚠️ Pipeline {pid}: {readiness['reason']}")
185
+
186
+ def _has_model_metadata_changed(self, local_model, db_model):
187
+ """Check if model metadata has changed without version change."""
188
+ # Compare classes
189
+ local_classes = set(local_model.get_classes() or [])
190
+ db_classes = set(db_model.get_classes() or [])
191
+ if local_classes != db_classes:
192
+ return True
193
+
194
+ # Compare PPE class groups
195
+ local_ppe_groups = local_model.get_ppe_class_groups() or {}
196
+ db_ppe_groups = db_model.get_ppe_class_groups() or {}
197
+ if local_ppe_groups != db_ppe_groups:
198
+ return True
199
+
200
+ # Compare main class
201
+ if local_model.get_main_class() != db_model.get_main_class():
202
+ return True
203
+
204
+ return False
205
+
174
206
  def _has_pipeline_changed(self, local_pipeline, db_pipeline):
175
207
  """Checks if the pipeline configuration has changed."""
176
208
  if db_pipeline.pipeline_status_code == "restart":