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.
- nedo_vision_worker_core/__init__.py +47 -12
- nedo_vision_worker_core/callbacks/DetectionCallbackManager.py +306 -0
- nedo_vision_worker_core/callbacks/DetectionCallbackTypes.py +150 -0
- nedo_vision_worker_core/callbacks/__init__.py +27 -0
- nedo_vision_worker_core/cli.py +24 -34
- nedo_vision_worker_core/core_service.py +121 -55
- nedo_vision_worker_core/database/DatabaseManager.py +2 -2
- nedo_vision_worker_core/detection/BaseDetector.py +2 -1
- nedo_vision_worker_core/detection/DetectionManager.py +2 -2
- nedo_vision_worker_core/detection/RFDETRDetector.py +23 -5
- nedo_vision_worker_core/detection/YOLODetector.py +18 -5
- nedo_vision_worker_core/detection/detection_processing/DetectionProcessor.py +1 -1
- nedo_vision_worker_core/detection/detection_processing/HumanDetectionProcessor.py +57 -3
- nedo_vision_worker_core/detection/detection_processing/PPEDetectionProcessor.py +173 -10
- nedo_vision_worker_core/models/ai_model.py +23 -2
- nedo_vision_worker_core/pipeline/PipelineProcessor.py +299 -14
- nedo_vision_worker_core/pipeline/PipelineSyncThread.py +32 -0
- nedo_vision_worker_core/repositories/PPEDetectionRepository.py +18 -15
- nedo_vision_worker_core/repositories/RestrictedAreaRepository.py +17 -13
- nedo_vision_worker_core/services/SharedVideoStreamServer.py +276 -0
- nedo_vision_worker_core/services/VideoSharingDaemon.py +808 -0
- nedo_vision_worker_core/services/VideoSharingDaemonManager.py +257 -0
- nedo_vision_worker_core/streams/SharedVideoDeviceManager.py +383 -0
- nedo_vision_worker_core/streams/StreamSyncThread.py +16 -2
- nedo_vision_worker_core/streams/VideoStream.py +267 -246
- nedo_vision_worker_core/streams/VideoStreamManager.py +158 -6
- nedo_vision_worker_core/tracker/TrackerManager.py +25 -31
- nedo_vision_worker_core-0.3.1.dist-info/METADATA +444 -0
- {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/RECORD +32 -25
- nedo_vision_worker_core-0.2.0.dist-info/METADATA +0 -347
- {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/WHEEL +0 -0
- {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/entry_points.txt +0 -0
- {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/top_level.txt +0 -0
|
@@ -3,322 +3,343 @@ 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
|
-
"""
|
|
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:
|
|
24
|
-
|
|
23
|
+
source: Union[str, int],
|
|
24
|
+
reconnect_interval: float = 5.0,
|
|
25
|
+
max_failures: int = 5,
|
|
25
26
|
max_reconnect_attempts: int = 10,
|
|
26
|
-
|
|
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.
|
|
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.
|
|
38
|
-
self.
|
|
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
|
-
|
|
45
|
-
self.
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
self.is_file = self._is_file_source()
|
|
52
|
+
|
|
53
|
+
# Error tracking for diagnostics
|
|
54
|
+
self._recent_errors = []
|
|
55
|
+
self._max_error_history = 50
|
|
56
|
+
self._codec_info = None
|
|
59
57
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
def _is_file_source(self) -> bool:
|
|
59
|
+
"""Check if source is a file path."""
|
|
60
|
+
if isinstance(self.source, int):
|
|
61
|
+
return False
|
|
62
|
+
return isinstance(self.source, (str, bytes, os.PathLike)) and os.path.isfile(str(self.source))
|
|
63
|
+
|
|
64
|
+
def _get_source_for_cv2(self) -> Union[str, int]:
|
|
65
|
+
"""Convert source to format suitable for cv2.VideoCapture."""
|
|
66
|
+
if isinstance(self.source, str) and self.source.isdigit():
|
|
67
|
+
return int(self.source)
|
|
68
|
+
return self.source
|
|
69
|
+
|
|
70
|
+
def _initialize_capture(self) -> bool:
|
|
71
|
+
"""Initialize video capture device."""
|
|
63
72
|
try:
|
|
64
|
-
|
|
73
|
+
self.state = StreamState.CONNECTING
|
|
74
|
+
logging.info(f"Connecting to {self.source} (attempt {self._reconnect_attempts + 1})")
|
|
65
75
|
|
|
66
|
-
# Clean up existing capture if needed
|
|
67
76
|
if self.capture:
|
|
68
77
|
self.capture.release()
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
self.capture = cv2.VideoCapture(self.source)
|
|
78
|
+
|
|
79
|
+
self.capture = cv2.VideoCapture(self._get_source_for_cv2())
|
|
72
80
|
|
|
73
81
|
if not self.capture.isOpened():
|
|
74
|
-
logging.error(f"
|
|
82
|
+
logging.error(f"Failed to open video source: {self.source}")
|
|
75
83
|
return False
|
|
76
|
-
|
|
77
|
-
# Get FPS if available or estimate it
|
|
78
|
-
self.fps = self.capture.get(cv2.CAP_PROP_FPS)
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
85
|
+
self._configure_capture()
|
|
86
|
+
self.state = StreamState.CONNECTED
|
|
96
87
|
return True
|
|
97
88
|
|
|
98
89
|
except Exception as e:
|
|
99
|
-
logging.error(f"
|
|
100
|
-
self.
|
|
101
|
-
if self.capture:
|
|
102
|
-
try:
|
|
103
|
-
self.capture.release()
|
|
104
|
-
except:
|
|
105
|
-
pass
|
|
106
|
-
self.capture = None
|
|
90
|
+
logging.error(f"Error initializing capture: {e}")
|
|
91
|
+
self._cleanup_capture()
|
|
107
92
|
return False
|
|
108
|
-
|
|
109
|
-
def
|
|
110
|
-
"""
|
|
111
|
-
|
|
112
|
-
frame_interval = 0 # Will be calculated once we know the FPS
|
|
93
|
+
|
|
94
|
+
def _configure_capture(self):
|
|
95
|
+
"""Configure capture properties and determine FPS."""
|
|
96
|
+
detected_fps = self.capture.get(cv2.CAP_PROP_FPS)
|
|
113
97
|
|
|
114
|
-
|
|
98
|
+
# Configure error-resilient settings for video streams
|
|
99
|
+
if not self.is_file:
|
|
115
100
|
try:
|
|
116
|
-
#
|
|
117
|
-
|
|
118
|
-
|
|
101
|
+
# Set buffer size to reduce latency and handle corrupted frames better
|
|
102
|
+
self.capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
|
103
|
+
|
|
104
|
+
# Set codec-specific properties for better error handling
|
|
105
|
+
# These help with HEVC streams that have QP delta and POC reference errors
|
|
106
|
+
if hasattr(cv2, 'CAP_PROP_FOURCC'):
|
|
107
|
+
# Try to get current codec
|
|
108
|
+
fourcc = self.capture.get(cv2.CAP_PROP_FOURCC)
|
|
109
|
+
codec_str = "".join([chr((int(fourcc) >> 8 * i) & 0xFF) for i in range(4)])
|
|
119
110
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
111
|
+
# Log codec information for debugging
|
|
112
|
+
logging.info(f"Stream codec detected: {codec_str} (fourcc: {int(fourcc)})")
|
|
113
|
+
self._codec_info = codec_str
|
|
127
114
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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...")
|
|
115
|
+
# For HEVC streams, we can't change the codec but we can optimize buffering
|
|
116
|
+
if 'hevc' in codec_str.lower() or 'h265' in codec_str.lower():
|
|
117
|
+
logging.info("HEVC stream detected - applying error-resilient settings")
|
|
118
|
+
# Reduce buffer to minimize impact of corrupted frames
|
|
119
|
+
self.capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
|
137
120
|
|
|
138
|
-
|
|
139
|
-
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logging.warning(f"Could not configure capture properties: {e}")
|
|
123
|
+
|
|
124
|
+
if self.target_fps:
|
|
125
|
+
self.fps = self.target_fps
|
|
126
|
+
if hasattr(cv2, 'CAP_PROP_FPS'):
|
|
127
|
+
self.capture.set(cv2.CAP_PROP_FPS, self.target_fps)
|
|
128
|
+
elif detected_fps and 0 < detected_fps <= 240:
|
|
129
|
+
self.fps = detected_fps
|
|
130
|
+
else:
|
|
131
|
+
self.fps = 30.0
|
|
132
|
+
logging.warning(f"Invalid FPS detected ({detected_fps}), using {self.fps}")
|
|
133
|
+
|
|
134
|
+
if self.is_file:
|
|
135
|
+
total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
|
|
136
|
+
duration = total_frames / self.fps if self.fps > 0 else 0
|
|
137
|
+
logging.info(f"Video file: {self.fps:.1f} FPS, {int(total_frames)} frames, {duration:.1f}s")
|
|
138
|
+
else:
|
|
139
|
+
logging.info(f"Stream connected: {self.fps:.1f} FPS")
|
|
140
|
+
|
|
141
|
+
def _cleanup_capture(self):
|
|
142
|
+
"""Clean up capture resources."""
|
|
143
|
+
if self.capture:
|
|
144
|
+
try:
|
|
145
|
+
self.capture.release()
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logging.error(f"Error releasing capture: {e}")
|
|
148
|
+
finally:
|
|
149
|
+
self.capture = None
|
|
150
|
+
self.state = StreamState.DISCONNECTED
|
|
151
|
+
|
|
152
|
+
def _handle_reconnection(self) -> bool:
|
|
153
|
+
"""Handle reconnection logic with backoff."""
|
|
154
|
+
if self._reconnect_attempts >= self.max_reconnect_attempts:
|
|
155
|
+
logging.error(f"Max reconnection attempts reached for {self.source}")
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
self._reconnect_attempts += 1
|
|
159
|
+
self.state = StreamState.RECONNECTING
|
|
160
|
+
self._current_interval = min(self._current_interval * self.backoff_factor, 60.0)
|
|
161
|
+
|
|
162
|
+
logging.warning(f"Reconnecting in {self._current_interval:.1f}s...")
|
|
163
|
+
return self._sleep_interruptible(self._current_interval)
|
|
164
|
+
|
|
165
|
+
def _sleep_interruptible(self, duration: float) -> bool:
|
|
166
|
+
"""Sleep with ability to interrupt on stop."""
|
|
167
|
+
end_time = time.time() + duration
|
|
168
|
+
while time.time() < end_time and self._running:
|
|
169
|
+
time.sleep(0.1)
|
|
170
|
+
return self._running
|
|
171
|
+
|
|
172
|
+
def _handle_file_end(self) -> bool:
|
|
173
|
+
"""Handle video file reaching end."""
|
|
174
|
+
if not self.is_file:
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
current_pos = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
|
|
179
|
+
total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
|
|
180
|
+
|
|
181
|
+
if current_pos >= total_frames - 1:
|
|
182
|
+
logging.info(f"Video file ended, restarting: {self.source}")
|
|
183
|
+
self.capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
184
|
+
return True
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logging.error(f"Error handling file end: {e}")
|
|
187
|
+
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
def run(self):
|
|
191
|
+
"""Main capture loop."""
|
|
192
|
+
failure_count = 0
|
|
193
|
+
frame_interval = 1.0 / self.fps
|
|
194
|
+
|
|
195
|
+
while self._running:
|
|
196
|
+
try:
|
|
197
|
+
if not self.capture or not self.capture.isOpened():
|
|
198
|
+
if not self._initialize_capture():
|
|
199
|
+
if not self._handle_reconnection():
|
|
140
200
|
break
|
|
141
|
-
|
|
142
|
-
time.sleep(self.current_reconnect_interval)
|
|
143
201
|
continue
|
|
144
202
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
203
|
+
failure_count = 0
|
|
204
|
+
self._reconnect_attempts = 0
|
|
205
|
+
self._current_interval = self.reconnect_interval
|
|
206
|
+
frame_interval = 1.0 / self.fps
|
|
158
207
|
|
|
159
|
-
|
|
160
|
-
read_start = time.time()
|
|
208
|
+
start_time = time.time()
|
|
161
209
|
ret, frame = self.capture.read()
|
|
162
210
|
|
|
163
|
-
# Handle frame read failure
|
|
164
211
|
if not ret or frame is None or frame.size == 0:
|
|
165
|
-
if self.
|
|
166
|
-
|
|
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})")
|
|
212
|
+
if self._handle_file_end():
|
|
213
|
+
continue
|
|
185
214
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
self.capture.release()
|
|
192
|
-
self.capture = None
|
|
193
|
-
except Exception as e:
|
|
194
|
-
logging.error(f"Error releasing capture during failure: {e}")
|
|
215
|
+
failure_count += 1
|
|
216
|
+
if failure_count > self.max_failures:
|
|
217
|
+
logging.error("Too many consecutive failures, reconnecting...")
|
|
218
|
+
self._cleanup_capture()
|
|
219
|
+
failure_count = 0
|
|
195
220
|
continue
|
|
196
221
|
|
|
197
|
-
if not self.
|
|
222
|
+
if not self._sleep_interruptible(0.1):
|
|
198
223
|
break
|
|
199
|
-
|
|
200
|
-
time.sleep(0.1)
|
|
201
224
|
continue
|
|
202
225
|
|
|
203
|
-
|
|
204
|
-
retry_count = 0
|
|
226
|
+
failure_count = 0
|
|
205
227
|
self.frame_count += 1
|
|
206
228
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
with self.lock:
|
|
212
|
-
self._latest_frame = frame.copy()
|
|
229
|
+
with self._lock:
|
|
230
|
+
if self._running:
|
|
231
|
+
self._latest_frame = frame.copy()
|
|
213
232
|
|
|
214
|
-
|
|
215
|
-
# This helps prevent CPU overuse when reading from fast sources
|
|
216
|
-
elapsed = time.time() - read_start
|
|
233
|
+
elapsed = time.time() - start_time
|
|
217
234
|
sleep_time = max(0, frame_interval - elapsed)
|
|
218
|
-
if sleep_time > 0 and self.
|
|
219
|
-
|
|
235
|
+
if sleep_time > 0 and not self._sleep_interruptible(sleep_time):
|
|
236
|
+
break
|
|
220
237
|
|
|
221
|
-
except cv2.error as
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
238
|
+
except cv2.error as e:
|
|
239
|
+
error_msg = f"OpenCV error: {e}"
|
|
240
|
+
logging.error(error_msg)
|
|
241
|
+
self._add_error_to_history(error_msg)
|
|
242
|
+
self._cleanup_capture()
|
|
243
|
+
if not self._sleep_interruptible(1.0):
|
|
225
244
|
break
|
|
226
|
-
|
|
227
|
-
|
|
245
|
+
|
|
228
246
|
except Exception as e:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
247
|
+
error_msg = f"Unexpected error: {e}"
|
|
248
|
+
logging.error(error_msg, exc_info=True)
|
|
249
|
+
self._add_error_to_history(error_msg)
|
|
250
|
+
if not self._sleep_interruptible(self.reconnect_interval):
|
|
232
251
|
break
|
|
233
|
-
time.sleep(self.reconnect_interval)
|
|
234
252
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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).
|
|
253
|
+
self._final_cleanup()
|
|
254
|
+
|
|
255
|
+
def _final_cleanup(self):
|
|
256
|
+
"""Final resource cleanup."""
|
|
257
|
+
self.state = StreamState.STOPPED
|
|
258
|
+
self._cleanup_capture()
|
|
259
|
+
|
|
260
|
+
with self._lock:
|
|
261
|
+
self._latest_frame = None
|
|
256
262
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
263
|
+
logging.info(f"VideoStream stopped: {self.source}")
|
|
264
|
+
|
|
265
|
+
def get_frame(self) -> Optional[cv2.Mat]:
|
|
266
|
+
"""Get the latest frame (thread-safe)."""
|
|
267
|
+
if not self._running or self.state not in (StreamState.CONNECTED, StreamState.RECONNECTING):
|
|
261
268
|
return None
|
|
262
269
|
|
|
263
|
-
with self.
|
|
264
|
-
if self._latest_frame is not None
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
270
|
+
with self._lock:
|
|
271
|
+
return self._latest_frame.copy() if self._latest_frame is not None else None
|
|
272
|
+
|
|
273
|
+
def is_connected(self) -> bool:
|
|
274
|
+
"""Check if stream is currently connected."""
|
|
275
|
+
return self.state == StreamState.CONNECTED
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def running(self) -> bool:
|
|
279
|
+
"""Check if stream is currently running."""
|
|
280
|
+
return self._running and self.state != StreamState.STOPPED
|
|
281
|
+
|
|
282
|
+
def get_state(self) -> StreamState:
|
|
283
|
+
"""Get current stream state."""
|
|
284
|
+
return self.state
|
|
285
|
+
|
|
268
286
|
def is_video_ended(self) -> bool:
|
|
269
|
-
"""Check if
|
|
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():
|
|
287
|
+
"""Check if video file has ended."""
|
|
288
|
+
if not self.is_file or not self.capture:
|
|
275
289
|
return False
|
|
276
|
-
|
|
290
|
+
|
|
277
291
|
try:
|
|
278
292
|
current_pos = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
|
|
279
293
|
total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
|
|
280
294
|
return current_pos >= total_frames - 1
|
|
281
295
|
except Exception:
|
|
282
296
|
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
297
|
|
|
289
|
-
|
|
290
|
-
|
|
298
|
+
def stop(self, timeout: float = 5.0):
|
|
299
|
+
"""Stop the video stream gracefully."""
|
|
300
|
+
if not self._running:
|
|
301
|
+
return
|
|
291
302
|
|
|
292
|
-
|
|
293
|
-
|
|
303
|
+
logging.info(f"Stopping VideoStream: {self.source}")
|
|
304
|
+
self._running = False
|
|
294
305
|
|
|
295
|
-
|
|
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:
|
|
306
|
+
with self._lock:
|
|
309
307
|
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
308
|
|
|
323
|
-
|
|
324
|
-
|
|
309
|
+
if self.is_alive():
|
|
310
|
+
self.join(timeout=timeout)
|
|
311
|
+
if self.is_alive():
|
|
312
|
+
logging.warning(f"Stream thread did not exit within {timeout}s")
|
|
313
|
+
|
|
314
|
+
def __enter__(self):
|
|
315
|
+
"""Context manager entry."""
|
|
316
|
+
self.start()
|
|
317
|
+
return self
|
|
318
|
+
|
|
319
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
320
|
+
"""Context manager exit."""
|
|
321
|
+
self.stop()
|
|
322
|
+
|
|
323
|
+
def _add_error_to_history(self, error_msg: str):
|
|
324
|
+
"""Add error to recent error history for diagnostics."""
|
|
325
|
+
with self._lock:
|
|
326
|
+
self._recent_errors.append({
|
|
327
|
+
'timestamp': time.time(),
|
|
328
|
+
'error': error_msg
|
|
329
|
+
})
|
|
330
|
+
# Keep only recent errors
|
|
331
|
+
if len(self._recent_errors) > self._max_error_history:
|
|
332
|
+
self._recent_errors = self._recent_errors[-self._max_error_history:]
|
|
333
|
+
|
|
334
|
+
def get_recent_errors(self, max_age_seconds: float = 300) -> list:
|
|
335
|
+
"""Get recent errors within the specified time window."""
|
|
336
|
+
current_time = time.time()
|
|
337
|
+
with self._lock:
|
|
338
|
+
return [
|
|
339
|
+
err for err in self._recent_errors
|
|
340
|
+
if current_time - err['timestamp'] <= max_age_seconds
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
def get_codec_info(self) -> Optional[str]:
|
|
344
|
+
"""Get codec information for the stream."""
|
|
345
|
+
return self._codec_info
|