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.
- 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 +47 -5
- 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 +51 -8
- 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 +208 -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.0.dist-info/METADATA +444 -0
- {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.0.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.0.dist-info}/WHEEL +0 -0
- {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
"""
|
|
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
|
-
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
89
|
-
|
|
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"
|
|
100
|
-
self.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
frame_interval = 0
|
|
160
|
+
"""Main capture loop."""
|
|
161
|
+
failure_count = 0
|
|
162
|
+
frame_interval = 1.0 / self.fps
|
|
113
163
|
|
|
114
|
-
while self.
|
|
164
|
+
while self._running:
|
|
115
165
|
try:
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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})")
|
|
181
|
+
if self._handle_file_end():
|
|
182
|
+
continue
|
|
185
183
|
|
|
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}")
|
|
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.
|
|
191
|
+
if not self._sleep_interruptible(0.1):
|
|
198
192
|
break
|
|
199
|
-
|
|
200
|
-
time.sleep(0.1)
|
|
201
193
|
continue
|
|
202
194
|
|
|
203
|
-
|
|
204
|
-
retry_count = 0
|
|
195
|
+
failure_count = 0
|
|
205
196
|
self.frame_count += 1
|
|
206
197
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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.
|
|
219
|
-
|
|
204
|
+
if sleep_time > 0 and not self._sleep_interruptible(sleep_time):
|
|
205
|
+
break
|
|
220
206
|
|
|
221
|
-
except cv2.error as
|
|
222
|
-
logging.error(f"
|
|
223
|
-
self.
|
|
224
|
-
if not self.
|
|
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
|
-
|
|
227
|
-
|
|
212
|
+
|
|
228
213
|
except Exception as e:
|
|
229
|
-
logging.error(f"
|
|
230
|
-
self.
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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.
|
|
264
|
-
if self._latest_frame is not None
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
-
|
|
290
|
-
|
|
263
|
+
def stop(self, timeout: float = 5.0):
|
|
264
|
+
"""Stop the video stream gracefully."""
|
|
265
|
+
if not self._running:
|
|
266
|
+
return
|
|
291
267
|
|
|
292
|
-
|
|
293
|
-
|
|
268
|
+
logging.info(f"Stopping VideoStream: {self.source}")
|
|
269
|
+
self._running = False
|
|
294
270
|
|
|
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:
|
|
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
|
-
|
|
324
|
-
|
|
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()
|