nedo-vision-worker-core 0.2.0__py3-none-any.whl → 0.3.0__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 +47 -5
  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 +51 -8
  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 +208 -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.0.dist-info/METADATA +444 -0
  29. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.0.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.0.dist-info}/WHEEL +0 -0
  32. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.0.dist-info}/entry_points.txt +0 -0
  33. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.0.dist-info}/top_level.txt +0 -0
@@ -3,322 +3,284 @@ import cv2
3
3
  import threading
4
4
  import time
5
5
  import logging
6
- from typing import Optional
6
+ from typing import Optional, Union
7
+ from enum import Enum
8
+
9
+
10
+ class StreamState(Enum):
11
+ DISCONNECTED = "disconnected"
12
+ CONNECTING = "connecting"
13
+ CONNECTED = "connected"
14
+ RECONNECTING = "reconnecting"
15
+ STOPPED = "stopped"
7
16
 
8
17
 
9
18
  class VideoStream(threading.Thread):
10
- """Threaded class for capturing video from a source, with automatic reconnection.
19
+ """Thread-safe video capture with automatic reconnection and error handling."""
11
20
 
12
- This class provides a thread-safe way to capture video frames from various sources
13
- (cameras, files, network streams) with automatic reconnection on failure.
14
-
15
- Attributes:
16
- source: The video source (int for webcams, string for files/URLs)
17
- reconnect_interval: Time in seconds to wait before reconnecting on failure
18
- retry_limit: Maximum number of consecutive retries before giving up
19
- """
20
21
  def __init__(
21
22
  self,
22
- source: str,
23
- reconnect_interval: int = 5,
24
- retry_limit: int = 5,
23
+ source: Union[str, int],
24
+ reconnect_interval: float = 5.0,
25
+ max_failures: int = 5,
25
26
  max_reconnect_attempts: int = 10,
26
- reconnect_backoff_factor: float = 1.5
27
+ backoff_factor: float = 1.5,
28
+ target_fps: Optional[float] = None
27
29
  ):
28
- super().__init__()
30
+ super().__init__(daemon=True)
29
31
 
30
- # Stream configuration
31
32
  self.source = source
32
33
  self.reconnect_interval = reconnect_interval
33
- self.retry_limit = retry_limit
34
+ self.max_failures = max_failures
35
+ self.max_reconnect_attempts = max_reconnect_attempts
36
+ self.backoff_factor = backoff_factor
37
+ self.target_fps = target_fps
34
38
 
35
- # Stream state
36
39
  self.capture = None
37
- self.running = True
38
- self.connected = False
39
- self.start_time = time.time()
40
- self.is_file = isinstance(source, (int, str, bytes, os.PathLike)) and os.path.isfile(source)
41
- self.fps = 30 # Default FPS until determined
40
+ self.state = StreamState.DISCONNECTED
41
+ self.fps = 30.0
42
42
  self.frame_count = 0
43
+ self.start_time = time.time()
43
44
 
44
- # Reconnection control
45
- self.max_reconnect_attempts = max_reconnect_attempts
46
- self.reconnect_attempts = 0
47
- self.reconnect_backoff_factor = reconnect_backoff_factor
48
- self.current_reconnect_interval = reconnect_interval
49
-
50
- # Thread synchronization
51
- self.lock = threading.Lock()
45
+ self._running = True
46
+ self._lock = threading.Lock()
52
47
  self._latest_frame = None
48
+ self._reconnect_attempts = 0
49
+ self._current_interval = reconnect_interval
53
50
 
54
- # Start the capture thread
55
- self.start()
56
-
57
- def _initialize_capture(self) -> bool:
58
- """Initialize or reinitialize the capture device.
51
+ self.is_file = self._is_file_source()
59
52
 
60
- Returns:
61
- True if successful, False otherwise
62
- """
53
+ def _is_file_source(self) -> bool:
54
+ """Check if source is a file path."""
55
+ if isinstance(self.source, int):
56
+ return False
57
+ return isinstance(self.source, (str, bytes, os.PathLike)) and os.path.isfile(str(self.source))
58
+
59
+ def _get_source_for_cv2(self) -> Union[str, int]:
60
+ """Convert source to format suitable for cv2.VideoCapture."""
61
+ if isinstance(self.source, str) and self.source.isdigit():
62
+ return int(self.source)
63
+ return self.source
64
+
65
+ def _initialize_capture(self) -> bool:
66
+ """Initialize video capture device."""
63
67
  try:
64
- logging.info(f"🔄 Attempting to connect to stream: {self.source} (attempt {self.reconnect_attempts + 1}/{self.max_reconnect_attempts})")
68
+ self.state = StreamState.CONNECTING
69
+ logging.info(f"Connecting to {self.source} (attempt {self._reconnect_attempts + 1})")
65
70
 
66
- # Clean up existing capture if needed
67
71
  if self.capture:
68
72
  self.capture.release()
69
-
70
- # Create new capture object
71
- self.capture = cv2.VideoCapture(self.source)
73
+
74
+ self.capture = cv2.VideoCapture(self._get_source_for_cv2())
72
75
 
73
76
  if not self.capture.isOpened():
74
- logging.error(f"Failed to open video source: {self.source}")
77
+ logging.error(f"Failed to open video source: {self.source}")
75
78
  return False
76
-
77
- # Get FPS if available or estimate it
78
- self.fps = self.capture.get(cv2.CAP_PROP_FPS)
79
-
80
- # Check FPS validity and estimate if needed
81
- if not self.fps or self.fps <= 0 or self.fps > 240:
82
- if self.reconnect_attempts:
83
- return False
84
-
85
- logging.warning(f"⚠️ Invalid FPS reported ({self.fps}). Using default 30 FPS.")
86
- self.fps = 30
87
79
 
88
- if self.is_file:
89
- total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
90
- duration = (total_frames / self.fps) if self.fps > 0 and total_frames > 0 else 0
91
- logging.info(f"✅ Connected to video file {self.source} at {self.fps:.2f} FPS, {total_frames} frames, {duration:.2f}s duration")
92
- else:
93
- logging.info(f"✅ Connected to stream {self.source} at {self.fps:.2f} FPS")
94
-
95
- self.connected = True
80
+ self._configure_capture()
81
+ self.state = StreamState.CONNECTED
96
82
  return True
97
83
 
98
84
  except Exception as e:
99
- logging.error(f"Error initializing capture for {self.source}: {e}")
100
- self.connected = False
101
- if self.capture:
102
- try:
103
- self.capture.release()
104
- except:
105
- pass
85
+ logging.error(f"Error initializing capture: {e}")
86
+ self._cleanup_capture()
87
+ return False
88
+
89
+ def _configure_capture(self):
90
+ """Configure capture properties and determine FPS."""
91
+ detected_fps = self.capture.get(cv2.CAP_PROP_FPS)
92
+
93
+ if self.target_fps:
94
+ self.fps = self.target_fps
95
+ if hasattr(cv2, 'CAP_PROP_FPS'):
96
+ self.capture.set(cv2.CAP_PROP_FPS, self.target_fps)
97
+ elif detected_fps and 0 < detected_fps <= 240:
98
+ self.fps = detected_fps
99
+ else:
100
+ self.fps = 30.0
101
+ logging.warning(f"Invalid FPS detected ({detected_fps}), using {self.fps}")
102
+
103
+ if self.is_file:
104
+ total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
105
+ duration = total_frames / self.fps if self.fps > 0 else 0
106
+ logging.info(f"Video file: {self.fps:.1f} FPS, {int(total_frames)} frames, {duration:.1f}s")
107
+ else:
108
+ logging.info(f"Stream connected: {self.fps:.1f} FPS")
109
+
110
+ def _cleanup_capture(self):
111
+ """Clean up capture resources."""
112
+ if self.capture:
113
+ try:
114
+ self.capture.release()
115
+ except Exception as e:
116
+ logging.error(f"Error releasing capture: {e}")
117
+ finally:
106
118
  self.capture = None
119
+ self.state = StreamState.DISCONNECTED
120
+
121
+ def _handle_reconnection(self) -> bool:
122
+ """Handle reconnection logic with backoff."""
123
+ if self._reconnect_attempts >= self.max_reconnect_attempts:
124
+ logging.error(f"Max reconnection attempts reached for {self.source}")
107
125
  return False
108
-
126
+
127
+ self._reconnect_attempts += 1
128
+ self.state = StreamState.RECONNECTING
129
+ self._current_interval = min(self._current_interval * self.backoff_factor, 60.0)
130
+
131
+ logging.warning(f"Reconnecting in {self._current_interval:.1f}s...")
132
+ return self._sleep_interruptible(self._current_interval)
133
+
134
+ def _sleep_interruptible(self, duration: float) -> bool:
135
+ """Sleep with ability to interrupt on stop."""
136
+ end_time = time.time() + duration
137
+ while time.time() < end_time and self._running:
138
+ time.sleep(0.1)
139
+ return self._running
140
+
141
+ def _handle_file_end(self) -> bool:
142
+ """Handle video file reaching end."""
143
+ if not self.is_file:
144
+ return False
145
+
146
+ try:
147
+ current_pos = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
148
+ total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
149
+
150
+ if current_pos >= total_frames - 1:
151
+ logging.info(f"Video file ended, restarting: {self.source}")
152
+ self.capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
153
+ return True
154
+ except Exception as e:
155
+ logging.error(f"Error handling file end: {e}")
156
+
157
+ return False
158
+
109
159
  def run(self):
110
- """Main thread loop that continuously captures frames with automatic reconnection."""
111
- retry_count = 0
112
- frame_interval = 0 # Will be calculated once we know the FPS
160
+ """Main capture loop."""
161
+ failure_count = 0
162
+ frame_interval = 1.0 / self.fps
113
163
 
114
- while self.running:
164
+ while self._running:
115
165
  try:
116
- # Check if we should exit
117
- if not self.running:
118
- break
119
-
120
- # (Re)connect if needed
121
- if self.capture is None or not self.capture.isOpened():
122
- # Check if we've exceeded the maximum reconnection attempts
123
- if self.reconnect_attempts >= self.max_reconnect_attempts:
124
- logging.error(f"❌ Exceeded maximum reconnection attempts ({self.max_reconnect_attempts}) for {self.source}. Giving up.")
125
- self.running = False
126
- break
127
-
128
- if retry_count:
129
- self.reconnect_attempts += 1
130
-
166
+ if not self.capture or not self.capture.isOpened():
131
167
  if not self._initialize_capture():
132
- # Apply exponential backoff to reconnection interval
133
- self.current_reconnect_interval *= self.reconnect_backoff_factor
134
-
135
- logging.warning(f"⚠️ Reconnection attempt {self.reconnect_attempts}/{self.max_reconnect_attempts} "
136
- f"failed. Next attempt in {self.current_reconnect_interval:.1f}s...")
137
-
138
- # Check for thread stop before sleeping
139
- if not self.running:
168
+ if not self._handle_reconnection():
140
169
  break
141
-
142
- time.sleep(self.current_reconnect_interval)
143
170
  continue
144
171
 
145
- # Reset counters on successful connection
146
- retry_count = 0
147
- # Only reset reconnect_attempts if we've been connected for a meaningful duration
148
- # This prevents rapid success/fail cycles from resetting the counter
149
- if self.connected:
150
- self.reconnect_attempts = 0
151
- self.current_reconnect_interval = self.reconnect_interval
152
-
153
- frame_interval = 1.0 / self.fps if self.fps > 0 else 0.033
154
-
155
- # Check if we should exit before reading frame
156
- if not self.running:
157
- break
172
+ failure_count = 0
173
+ self._reconnect_attempts = 0
174
+ self._current_interval = self.reconnect_interval
175
+ frame_interval = 1.0 / self.fps
158
176
 
159
- # Read the next frame
160
- read_start = time.time()
177
+ start_time = time.time()
161
178
  ret, frame = self.capture.read()
162
179
 
163
- # Handle frame read failure
164
180
  if not ret or frame is None or frame.size == 0:
165
- if self.is_file:
166
- # For video files, check if we've reached the end
167
- current_pos = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
168
- total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
169
-
170
- # If we're at or beyond the end of the video, restart from beginning
171
- if current_pos >= total_frames - 1:
172
- logging.info(f"🔄 Video file {self.source} reached end. Restarting from beginning...")
173
- self.capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
174
- # Reset retry count for video files when restarting
175
- retry_count = 0
176
- continue
177
- else:
178
- # We're not at the end, this might be a real error
179
- retry_count += 1
180
- logging.warning(f"⚠️ Failed to read frame from {self.source} at position {current_pos}/{total_frames} (attempt {retry_count}/{self.retry_limit})")
181
- else:
182
- # For non-file sources (cameras, streams), increment retry count
183
- retry_count += 1
184
- logging.warning(f"⚠️ Failed to read frame from {self.source} (attempt {retry_count}/{self.retry_limit})")
181
+ if self._handle_file_end():
182
+ continue
185
183
 
186
- if retry_count > self.retry_limit:
187
- logging.error(f"❌ Too many consecutive frame failures. Reconnecting...")
188
- self.connected = False
189
- if self.capture and self.running: # Only release if we're still running
190
- try:
191
- self.capture.release()
192
- self.capture = None
193
- except Exception as e:
194
- logging.error(f"Error releasing capture during failure: {e}")
184
+ failure_count += 1
185
+ if failure_count > self.max_failures:
186
+ logging.error("Too many consecutive failures, reconnecting...")
187
+ self._cleanup_capture()
188
+ failure_count = 0
195
189
  continue
196
190
 
197
- if not self.running:
191
+ if not self._sleep_interruptible(0.1):
198
192
  break
199
-
200
- time.sleep(0.1)
201
193
  continue
202
194
 
203
- # Reset retry count on successful frame
204
- retry_count = 0
195
+ failure_count = 0
205
196
  self.frame_count += 1
206
197
 
207
- # Store the frame - but make sure we're still running first
208
- if not self.running:
209
- break
210
-
211
- with self.lock:
212
- self._latest_frame = frame.copy()
198
+ with self._lock:
199
+ if self._running:
200
+ self._latest_frame = frame.copy()
213
201
 
214
- # Regulate frame rate to match source FPS for efficiency
215
- # This helps prevent CPU overuse when reading from fast sources
216
- elapsed = time.time() - read_start
202
+ elapsed = time.time() - start_time
217
203
  sleep_time = max(0, frame_interval - elapsed)
218
- if sleep_time > 0 and self.running: # Check running before sleep
219
- time.sleep(sleep_time)
204
+ if sleep_time > 0 and not self._sleep_interruptible(sleep_time):
205
+ break
220
206
 
221
- except cv2.error as cv_err:
222
- logging.error(f"OpenCV error in frame processing: {cv_err}")
223
- self.connected = False
224
- if not self.running:
207
+ except cv2.error as e:
208
+ logging.error(f"OpenCV error: {e}")
209
+ self._cleanup_capture()
210
+ if not self._sleep_interruptible(1.0):
225
211
  break
226
- time.sleep(1) # Brief pause before retry
227
-
212
+
228
213
  except Exception as e:
229
- logging.error(f" Error in VideoStream {self.source}: {e}", exc_info=True)
230
- self.connected = False
231
- if not self.running:
214
+ logging.error(f"Unexpected error: {e}", exc_info=True)
215
+ if not self._sleep_interruptible(self.reconnect_interval):
232
216
  break
233
- time.sleep(self.reconnect_interval)
234
217
 
235
- # Final cleanup
236
- self._cleanup()
237
-
238
- def _cleanup(self):
239
- """Release resources and perform cleanup."""
240
- try:
241
- if self.capture:
242
- self.capture.release()
243
- self.capture = None
244
-
245
- with self.lock:
246
- self._latest_frame = None
247
-
248
- self.connected = False
249
- logging.info(f"🔴 Stream {self.source} stopped and cleaned up.")
250
-
251
- except Exception as e:
252
- logging.error(f"Error during final VideoStream cleanup: {e}")
253
-
254
- def get_frame(self) -> Optional[cv2.Mat]:
255
- """Returns the latest available frame (thread-safe).
218
+ self._final_cleanup()
219
+
220
+ def _final_cleanup(self):
221
+ """Final resource cleanup."""
222
+ self.state = StreamState.STOPPED
223
+ self._cleanup_capture()
256
224
 
257
- Returns:
258
- A copy of the latest frame or None if no frames are available
259
- """
260
- if not self.running:
225
+ with self._lock:
226
+ self._latest_frame = None
227
+
228
+ logging.info(f"VideoStream stopped: {self.source}")
229
+
230
+ def get_frame(self) -> Optional[cv2.Mat]:
231
+ """Get the latest frame (thread-safe)."""
232
+ if not self._running or self.state not in (StreamState.CONNECTED, StreamState.RECONNECTING):
261
233
  return None
262
234
 
263
- with self.lock:
264
- if self._latest_frame is not None:
265
- return self._latest_frame.copy()
266
- return None
267
-
235
+ with self._lock:
236
+ return self._latest_frame.copy() if self._latest_frame is not None else None
237
+
238
+ def is_connected(self) -> bool:
239
+ """Check if stream is currently connected."""
240
+ return self.state == StreamState.CONNECTED
241
+
242
+ @property
243
+ def running(self) -> bool:
244
+ """Check if stream is currently running."""
245
+ return self._running and self.state != StreamState.STOPPED
246
+
247
+ def get_state(self) -> StreamState:
248
+ """Get current stream state."""
249
+ return self.state
250
+
268
251
  def is_video_ended(self) -> bool:
269
- """Check if a video file has reached its end (only for video files).
270
-
271
- Returns:
272
- True if video file has ended, False otherwise
273
- """
274
- if not self.is_file or not self.capture or not self.capture.isOpened():
252
+ """Check if video file has ended."""
253
+ if not self.is_file or not self.capture:
275
254
  return False
276
-
255
+
277
256
  try:
278
257
  current_pos = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
279
258
  total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
280
259
  return current_pos >= total_frames - 1
281
260
  except Exception:
282
261
  return False
283
-
284
- def stop(self):
285
- """Stops the video stream and releases resources safely."""
286
- if not self.running: # Prevent multiple stops
287
- return
288
262
 
289
- # Step 1: Signal the thread to stop
290
- self.running = False
263
+ def stop(self, timeout: float = 5.0):
264
+ """Stop the video stream gracefully."""
265
+ if not self._running:
266
+ return
291
267
 
292
- # Step 2: Wait for any ongoing OpenCV operations to complete
293
- time.sleep(0.2) # Short delay to let operations finish
268
+ logging.info(f"Stopping VideoStream: {self.source}")
269
+ self._running = False
294
270
 
295
- # Step 3: Explicitly release capture device before joining thread
296
- # This is critical to avoid segmentation faults in some OpenCV implementations
297
- if self.capture:
298
- try:
299
- logging.debug(f"Releasing capture device for {self.source}")
300
- with self.lock: # Use lock to ensure thread isn't accessing capture during release
301
- if self.capture:
302
- self.capture.release()
303
- self.capture = None
304
- except Exception as e:
305
- logging.error(f"Error releasing capture device: {e}")
306
-
307
- # Step 4: Clear any references to frames to help garbage collection
308
- with self.lock:
271
+ with self._lock:
309
272
  self._latest_frame = None
310
-
311
- # Step 5: Wait for thread to exit
312
- try:
313
- if self.is_alive():
314
- logging.debug(f"Waiting for thread to exit for stream {self.source}")
315
- self.join(timeout=30) # Reduced timeout since we already released resources
316
-
317
- # Check if thread is still alive
318
- if self.is_alive():
319
- logging.warning(f"⚠️ Stream {self.source} thread did not exit cleanly within timeout")
320
- except Exception as e:
321
- logging.error(f"Error joining thread: {e}")
322
273
 
323
- # Final status update
324
- self.connected = False
274
+ if self.is_alive():
275
+ self.join(timeout=timeout)
276
+ if self.is_alive():
277
+ logging.warning(f"Stream thread did not exit within {timeout}s")
278
+
279
+ def __enter__(self):
280
+ """Context manager entry."""
281
+ self.start()
282
+ return self
283
+
284
+ def __exit__(self, exc_type, exc_val, exc_tb):
285
+ """Context manager exit."""
286
+ self.stop()