nedo-vision-worker-core 0.3.2__py3-none-any.whl → 0.3.4__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 +1 -1
- nedo_vision_worker_core/detection/RFDETRDetector.py +3 -0
- nedo_vision_worker_core/pipeline/ModelManager.py +139 -0
- nedo_vision_worker_core/pipeline/PipelineManager.py +3 -3
- nedo_vision_worker_core/pipeline/PipelineProcessor.py +36 -29
- nedo_vision_worker_core/pipeline/PipelineSyncThread.py +61 -109
- nedo_vision_worker_core/repositories/AIModelRepository.py +21 -1
- nedo_vision_worker_core/streams/RTMPStreamer.py +178 -233
- nedo_vision_worker_core/streams/SharedVideoDeviceManager.py +5 -1
- nedo_vision_worker_core/streams/VideoStream.py +202 -263
- nedo_vision_worker_core/streams/VideoStreamManager.py +14 -18
- nedo_vision_worker_core/util/PlatformDetector.py +100 -0
- {nedo_vision_worker_core-0.3.2.dist-info → nedo_vision_worker_core-0.3.4.dist-info}/METADATA +1 -1
- {nedo_vision_worker_core-0.3.2.dist-info → nedo_vision_worker_core-0.3.4.dist-info}/RECORD +17 -16
- nedo_vision_worker_core/detection/DetectionManager.py +0 -83
- {nedo_vision_worker_core-0.3.2.dist-info → nedo_vision_worker_core-0.3.4.dist-info}/WHEEL +0 -0
- {nedo_vision_worker_core-0.3.2.dist-info → nedo_vision_worker_core-0.3.4.dist-info}/entry_points.txt +0 -0
- {nedo_vision_worker_core-0.3.2.dist-info → nedo_vision_worker_core-0.3.4.dist-info}/top_level.txt +0 -0
|
@@ -5,9 +5,13 @@ import threading
|
|
|
5
5
|
import logging
|
|
6
6
|
from typing import Optional, Union, List, Dict
|
|
7
7
|
from enum import Enum
|
|
8
|
+
from ..util.PlatformDetector import PlatformDetector
|
|
8
9
|
|
|
10
|
+
import numpy as np
|
|
11
|
+
from numpy.typing import NDArray
|
|
12
|
+
MatLike = NDArray[np.uint8]
|
|
9
13
|
|
|
10
|
-
# ---------- States ----------
|
|
14
|
+
# ---------- States and Enums ----------
|
|
11
15
|
class StreamState(Enum):
|
|
12
16
|
DISCONNECTED = "disconnected"
|
|
13
17
|
CONNECTING = "connecting"
|
|
@@ -15,17 +19,23 @@ class StreamState(Enum):
|
|
|
15
19
|
RECONNECTING = "reconnecting"
|
|
16
20
|
STOPPED = "stopped"
|
|
17
21
|
|
|
22
|
+
class HWAccel(Enum):
|
|
23
|
+
"""Hardware acceleration preference."""
|
|
24
|
+
AUTO = "auto"
|
|
25
|
+
NVDEC = "nvdec"
|
|
26
|
+
NONE = "none"
|
|
18
27
|
|
|
19
|
-
# ---------- FFmpeg / RTSP tuning (
|
|
28
|
+
# ---------- FFmpeg / RTSP tuning (for fallback) ----------
|
|
20
29
|
def set_ffmpeg_rtsp_env(
|
|
21
30
|
*,
|
|
22
31
|
prefer_tcp: bool = True,
|
|
23
32
|
probesize: str = "256k",
|
|
24
|
-
analyzeduration_us: int = 1_000_000,
|
|
33
|
+
analyzeduration_us: int = 1_000_000,
|
|
25
34
|
buffer_size: str = "256k",
|
|
26
|
-
max_delay_us: int = 700_000,
|
|
27
|
-
stimeout_us: int = 5_000_000
|
|
35
|
+
max_delay_us: int = 700_000,
|
|
36
|
+
stimeout_us: int = 5_000_000
|
|
28
37
|
) -> None:
|
|
38
|
+
"""Sets environment variables for OpenCV's FFmpeg backend."""
|
|
29
39
|
opts = [
|
|
30
40
|
f"rtsp_transport;{'tcp' if prefer_tcp else 'udp'}",
|
|
31
41
|
f"probesize;{probesize}",
|
|
@@ -35,37 +45,33 @@ def set_ffmpeg_rtsp_env(
|
|
|
35
45
|
f"stimeout;{stimeout_us}",
|
|
36
46
|
"flags;low_delay",
|
|
37
47
|
"rtsp_flags;prefer_tcp" if prefer_tcp else "",
|
|
38
|
-
# NOTE: do NOT set reorder_queue_size=0 here; let ffmpeg reorder if needed.
|
|
39
48
|
]
|
|
40
|
-
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "
|
|
49
|
+
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "|".join([o for o in opts if o])
|
|
41
50
|
|
|
42
|
-
|
|
43
|
-
# ---------- VideoStream (low-latency, freeze-safe) ----------
|
|
51
|
+
# ---------- VideoStream with NVDEC support ----------
|
|
44
52
|
class VideoStream(threading.Thread):
|
|
45
53
|
"""
|
|
46
|
-
RTSP/file capture that
|
|
47
|
-
|
|
48
|
-
- Publishes only the freshest frame (double-buffer).
|
|
49
|
-
- Adds a no-progress watchdog to force reconnect if frames stall.
|
|
50
|
-
- Uses tolerant FFmpeg options to avoid 'first-frame then freeze'.
|
|
54
|
+
RTSP/file capture that prioritizes low-latency hardware decoding (NVDEC)
|
|
55
|
+
with a robust fallback to CPU-based decoding.
|
|
51
56
|
"""
|
|
52
57
|
|
|
53
58
|
def __init__(
|
|
54
59
|
self,
|
|
55
60
|
source: Union[str, int],
|
|
56
61
|
*,
|
|
62
|
+
hw_accel: HWAccel = HWAccel.AUTO,
|
|
63
|
+
video_codec: str = "h264",
|
|
57
64
|
reconnect_interval: float = 5.0,
|
|
58
65
|
max_failures: int = 5,
|
|
59
66
|
max_reconnect_attempts: int = 10,
|
|
60
67
|
backoff_factor: float = 1.5,
|
|
61
68
|
max_sleep_backoff: float = 60.0,
|
|
62
|
-
target_fps: Optional[float] = None,
|
|
63
|
-
enable_backlog_drain: bool = False,
|
|
69
|
+
target_fps: Optional[float] = None,
|
|
70
|
+
enable_backlog_drain: bool = False,
|
|
64
71
|
ffmpeg_prefer_tcp: bool = True
|
|
65
72
|
):
|
|
66
73
|
super().__init__(daemon=True)
|
|
67
74
|
|
|
68
|
-
# config
|
|
69
75
|
self.source = source
|
|
70
76
|
self.reconnect_interval = reconnect_interval
|
|
71
77
|
self.max_failures = max_failures
|
|
@@ -74,131 +80,207 @@ class VideoStream(threading.Thread):
|
|
|
74
80
|
self.max_sleep_backoff = max_sleep_backoff
|
|
75
81
|
self.target_fps = target_fps
|
|
76
82
|
self._drain_backlog = enable_backlog_drain
|
|
83
|
+
self.ffmpeg_prefer_tcp = ffmpeg_prefer_tcp
|
|
84
|
+
|
|
85
|
+
self.hw_accel = hw_accel
|
|
86
|
+
self.video_codec = video_codec.lower()
|
|
87
|
+
self._platform = PlatformDetector()
|
|
88
|
+
self._current_backend: Optional[str] = None
|
|
77
89
|
|
|
78
|
-
# runtime
|
|
79
90
|
self.capture: Optional[cv2.VideoCapture] = None
|
|
80
91
|
self.state: StreamState = StreamState.DISCONNECTED
|
|
81
92
|
self.fps: float = 30.0
|
|
82
93
|
self.frame_count: int = 0
|
|
83
94
|
self.start_time: float = time.time()
|
|
84
|
-
self.
|
|
95
|
+
self.running: bool = True
|
|
85
96
|
|
|
86
|
-
# first-frame signaling
|
|
87
97
|
self._first_frame_evt = threading.Event()
|
|
88
98
|
|
|
89
|
-
# latest-frame (short lock)
|
|
90
99
|
self._latest_frame_lock = threading.Lock()
|
|
91
|
-
self._latest_frame: Optional[
|
|
100
|
+
self._latest_frame: Optional[MatLike] = None
|
|
92
101
|
|
|
93
|
-
# double buffer (very short lock)
|
|
94
102
|
self._buffer_lock = threading.Lock()
|
|
95
|
-
self._buf_a: Optional[
|
|
96
|
-
self._buf_b: Optional[
|
|
103
|
+
self._buf_a: Optional[MatLike] = None
|
|
104
|
+
self._buf_b: Optional[MatLike] = None
|
|
97
105
|
self._active_buf: str = "a"
|
|
98
106
|
|
|
99
|
-
# reconnect backoff
|
|
100
107
|
self._reconnect_attempts = 0
|
|
101
108
|
self._current_interval = reconnect_interval
|
|
102
109
|
|
|
103
|
-
# diagnostics
|
|
104
|
-
self._recent_errors: List[Dict[str, Union[str, float]]] = []
|
|
105
|
-
self._max_error_history = 50
|
|
106
110
|
self._codec_info: Optional[str] = None
|
|
107
|
-
|
|
108
|
-
# progress watchdog
|
|
109
111
|
self._last_frame_ts: float = 0.0
|
|
110
|
-
|
|
111
|
-
# type
|
|
112
112
|
self.is_file = self._is_file_source()
|
|
113
113
|
|
|
114
|
-
# set FFmpeg/RTSP options (tolerant profile)
|
|
115
|
-
set_ffmpeg_rtsp_env(prefer_tcp=ffmpeg_prefer_tcp)
|
|
116
|
-
|
|
117
|
-
# ----- helpers -----
|
|
118
114
|
def _is_file_source(self) -> bool:
|
|
115
|
+
source_str = str(self.source)
|
|
116
|
+
if source_str.startswith(("rtsp://", "rtmp://", "http://", "https://")):
|
|
117
|
+
return False
|
|
119
118
|
if isinstance(self.source, int):
|
|
120
119
|
return False
|
|
121
|
-
return
|
|
120
|
+
return os.path.isfile(source_str)
|
|
122
121
|
|
|
123
122
|
def _get_source_for_cv2(self) -> Union[str, int]:
|
|
124
123
|
if isinstance(self.source, str) and self.source.isdigit():
|
|
125
124
|
return int(self.source)
|
|
126
125
|
return self.source
|
|
127
126
|
|
|
128
|
-
def
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
def _build_nvv4l2_pipeline(self) -> Optional[str]:
|
|
128
|
+
"""Hardware decode pipeline for NVIDIA Jetson devices."""
|
|
129
|
+
if self.video_codec == 'h264':
|
|
130
|
+
depay_parse = "rtpjitterbuffer ! rtph264depay ! h264parse"
|
|
131
|
+
elif self.video_codec == 'h265':
|
|
132
|
+
depay_parse = "rtpjitterbuffer ! rtph265depay ! h265parse"
|
|
133
|
+
else:
|
|
134
|
+
return None
|
|
132
135
|
|
|
133
|
-
|
|
134
|
-
|
|
136
|
+
pipeline = (
|
|
137
|
+
f"rtspsrc location=\"{self.source}\" latency=200 protocols=tcp ! "
|
|
138
|
+
f"{depay_parse} ! nvv4l2decoder ! "
|
|
139
|
+
"nvvidconv ! video/x-raw, format=BGRx ! "
|
|
140
|
+
"videoconvert ! video/x-raw, format=BGR ! "
|
|
141
|
+
"appsink drop=1 max-buffers=1"
|
|
142
|
+
)
|
|
143
|
+
return pipeline
|
|
144
|
+
|
|
145
|
+
def _build_nvcodec_pipeline(self) -> Optional[str]:
|
|
146
|
+
"""Hardware decode pipeline for Linux systems with NVIDIA dGPUs."""
|
|
147
|
+
if self.video_codec == 'h264':
|
|
148
|
+
decoder_element = "nvh264dec"
|
|
149
|
+
depay_parse = "rtpjitterbuffer ! rtph264depay ! h264parse config-interval=-1"
|
|
150
|
+
elif self.video_codec == 'h265':
|
|
151
|
+
decoder_element = "nvh265dec"
|
|
152
|
+
depay_parse = "rtpjitterbuffer ! rtph265depay ! h265parse config-interval=-1"
|
|
153
|
+
else:
|
|
154
|
+
return None
|
|
135
155
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
156
|
+
pipeline = (
|
|
157
|
+
f"rtspsrc location=\"{self.source}\" latency=200 protocols=tcp ! "
|
|
158
|
+
f"{depay_parse} ! {decoder_element} ! "
|
|
159
|
+
"videoconvert ! video/x-raw,format=BGR ! "
|
|
160
|
+
"appsink max-buffers=1 drop=true sync=false"
|
|
161
|
+
)
|
|
162
|
+
return pipeline
|
|
140
163
|
|
|
141
|
-
|
|
142
|
-
|
|
164
|
+
def _initialize_capture(self) -> bool:
|
|
165
|
+
self.state = StreamState.CONNECTING
|
|
166
|
+
logging.info(f"Connecting to {self.source} (attempt {self._reconnect_attempts + 1})")
|
|
167
|
+
if self.capture:
|
|
168
|
+
self.capture.release()
|
|
169
|
+
|
|
170
|
+
is_supported = self._platform.is_linux() and self._platform.has_gstreamer() and self._platform.has_nvidia_gpu()
|
|
171
|
+
use_nvdec = (self.hw_accel != HWAccel.NONE) and is_supported
|
|
172
|
+
|
|
173
|
+
# For RTSP streams, try GStreamer with hardware acceleration first
|
|
174
|
+
if not self.is_file and use_nvdec:
|
|
175
|
+
# Choose the best pipeline based on the platform
|
|
176
|
+
if self._platform.is_jetson():
|
|
177
|
+
logging.info("Jetson platform detected. Prioritizing nvv4l2decoder.")
|
|
178
|
+
pipeline = self._build_nvv4l2_pipeline()
|
|
179
|
+
backend_name = "gstreamer_nvv4l2"
|
|
180
|
+
else:
|
|
181
|
+
logging.info("dGPU platform detected. Prioritizing nvcodec.")
|
|
182
|
+
pipeline = self._build_nvcodec_pipeline()
|
|
183
|
+
backend_name = "gstreamer_nvcodec"
|
|
143
184
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
185
|
+
if pipeline:
|
|
186
|
+
self.capture = cv2.VideoCapture(pipeline, cv2.CAP_GSTREAMER)
|
|
187
|
+
if self.capture.isOpened():
|
|
188
|
+
self._current_backend = backend_name
|
|
147
189
|
|
|
148
|
-
|
|
190
|
+
# Fallback for local files, or if GStreamer failed
|
|
191
|
+
if not self.capture or not self.capture.isOpened():
|
|
192
|
+
if not self.is_file and use_nvdec:
|
|
193
|
+
logging.warning("Primary GStreamer pipeline failed. Falling back to FFmpeg/CPU.")
|
|
194
|
+
elif self.is_file:
|
|
195
|
+
logging.info("Source is a file. Using FFmpeg backend.")
|
|
196
|
+
|
|
197
|
+
if not self.is_file:
|
|
198
|
+
set_ffmpeg_rtsp_env(prefer_tcp=self.ffmpeg_prefer_tcp)
|
|
199
|
+
|
|
200
|
+
self.capture = cv2.VideoCapture(self._get_source_for_cv2(), cv2.CAP_FFMPEG)
|
|
201
|
+
if self.capture.isOpened():
|
|
202
|
+
self._current_backend = "ffmpeg_cpu" if not self.is_file else "ffmpeg_file"
|
|
149
203
|
|
|
150
|
-
|
|
151
|
-
logging.error(f"
|
|
152
|
-
self._cleanup_capture()
|
|
204
|
+
if not self.capture or not self.capture.isOpened():
|
|
205
|
+
logging.error(f"Failed to open video source: {self.source}")
|
|
153
206
|
return False
|
|
154
207
|
|
|
208
|
+
logging.info(f"Successfully opened stream using '{self._current_backend}' backend.")
|
|
209
|
+
self._configure_capture()
|
|
210
|
+
self.state = StreamState.CONNECTED
|
|
211
|
+
return True
|
|
212
|
+
|
|
155
213
|
def _configure_capture(self) -> None:
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
except Exception:
|
|
171
|
-
pass
|
|
172
|
-
|
|
173
|
-
# optional HW accel (ignored if unsupported)
|
|
174
|
-
try:
|
|
175
|
-
if hasattr(cv2, "CAP_PROP_HW_ACCELERATION"):
|
|
176
|
-
self.capture.set(cv2.CAP_PROP_HW_ACCELERATION, cv2.VIDEO_ACCELERATION_ANY)
|
|
177
|
-
except Exception:
|
|
178
|
-
pass
|
|
179
|
-
|
|
180
|
-
# fourcc/codec log
|
|
181
|
-
try:
|
|
182
|
-
fourcc = int(self.capture.get(cv2.CAP_PROP_FOURCC))
|
|
183
|
-
if fourcc:
|
|
184
|
-
self._codec_info = "".join([chr((fourcc >> (8 * i)) & 0xFF) for i in range(4)])
|
|
185
|
-
logging.info(f"Stream codec: {self._codec_info} (fourcc: {fourcc})")
|
|
186
|
-
except Exception:
|
|
187
|
-
pass
|
|
214
|
+
is_gstreamer = self._current_backend and "gstreamer" in self._current_backend
|
|
215
|
+
|
|
216
|
+
if not is_gstreamer:
|
|
217
|
+
try:
|
|
218
|
+
self.capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
fourcc = int(self.capture.get(cv2.CAP_PROP_FOURCC))
|
|
224
|
+
if fourcc:
|
|
225
|
+
self._codec_info = "".join([chr((fourcc >> (8 * i)) & 0xFF) for i in range(4)])
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
188
228
|
|
|
189
229
|
detected_fps = self.capture.get(cv2.CAP_PROP_FPS)
|
|
190
230
|
if detected_fps and 0 < detected_fps <= 240:
|
|
191
231
|
self.fps = detected_fps
|
|
192
232
|
else:
|
|
193
233
|
self.fps = 30.0
|
|
194
|
-
|
|
234
|
+
|
|
235
|
+
logging.info(f"Stream connected at ~{self.fps:.1f} FPS. Codec: {self._codec_info or 'N/A'}")
|
|
195
236
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
237
|
+
def run(self) -> None:
|
|
238
|
+
"""Main capture loop."""
|
|
239
|
+
failures = 0
|
|
240
|
+
while self.running:
|
|
241
|
+
try:
|
|
242
|
+
if not self.capture or not self.capture.isOpened():
|
|
243
|
+
if not self._initialize_capture():
|
|
244
|
+
if not self._handle_reconnection():
|
|
245
|
+
break
|
|
246
|
+
continue
|
|
247
|
+
failures = 0
|
|
248
|
+
self._reconnect_attempts = 0
|
|
249
|
+
self._current_interval = self.reconnect_interval
|
|
250
|
+
|
|
251
|
+
ret, frame = self.capture.read()
|
|
252
|
+
|
|
253
|
+
if not ret or frame is None:
|
|
254
|
+
if self._handle_file_end():
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
failures += 1
|
|
258
|
+
if failures > self.max_failures:
|
|
259
|
+
logging.error("Too many consecutive read failures; forcing reconnect.")
|
|
260
|
+
self._cleanup_capture()
|
|
261
|
+
failures = 0
|
|
262
|
+
time.sleep(0.02)
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
failures = 0
|
|
266
|
+
self.frame_count += 1
|
|
267
|
+
self._publish_latest(frame)
|
|
268
|
+
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logging.error(f"Unexpected error in capture loop: {e}", exc_info=True)
|
|
271
|
+
self._cleanup_capture()
|
|
272
|
+
if not self._sleep_interruptible(self.reconnect_interval):
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
self._final_cleanup()
|
|
276
|
+
|
|
277
|
+
def stop(self, timeout: float = 5.0) -> None:
|
|
278
|
+
if not self.running:
|
|
279
|
+
return
|
|
280
|
+
logging.info(f"Stopping VideoStream: {self.source}")
|
|
281
|
+
self.running = False
|
|
282
|
+
if self.is_alive():
|
|
283
|
+
self.join(timeout=timeout)
|
|
202
284
|
|
|
203
285
|
def _cleanup_capture(self) -> None:
|
|
204
286
|
if self.capture:
|
|
@@ -212,14 +294,16 @@ class VideoStream(threading.Thread):
|
|
|
212
294
|
|
|
213
295
|
def _sleep_interruptible(self, duration: float) -> bool:
|
|
214
296
|
end = time.perf_counter() + duration
|
|
215
|
-
while self.
|
|
297
|
+
while self.running and time.perf_counter() < end:
|
|
216
298
|
time.sleep(0.05)
|
|
217
|
-
return self.
|
|
299
|
+
return self.running
|
|
300
|
+
|
|
301
|
+
|
|
218
302
|
|
|
219
303
|
def _handle_reconnection(self) -> bool:
|
|
220
|
-
if self._reconnect_attempts >= self.max_reconnect_attempts:
|
|
221
|
-
logging.error(f"Max reconnection attempts reached for {self.source}")
|
|
304
|
+
if self.is_file or self._reconnect_attempts >= self.max_reconnect_attempts:
|
|
222
305
|
return False
|
|
306
|
+
|
|
223
307
|
self._reconnect_attempts += 1
|
|
224
308
|
self.state = StreamState.RECONNECTING
|
|
225
309
|
self._current_interval = min(self._current_interval * self.backoff_factor, self.max_sleep_backoff)
|
|
@@ -229,18 +313,17 @@ class VideoStream(threading.Thread):
|
|
|
229
313
|
def _handle_file_end(self) -> bool:
|
|
230
314
|
if not self.is_file:
|
|
231
315
|
return False
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
316
|
+
|
|
317
|
+
current_frame = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
|
|
318
|
+
total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
|
|
319
|
+
|
|
320
|
+
if total_frames > 0 and current_frame >= total_frames:
|
|
321
|
+
self.capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
322
|
+
return True
|
|
323
|
+
|
|
240
324
|
return False
|
|
241
325
|
|
|
242
|
-
|
|
243
|
-
def _publish_latest(self, frame: cv2.Mat) -> None:
|
|
326
|
+
def _publish_latest(self, frame: MatLike) -> None:
|
|
244
327
|
with self._buffer_lock:
|
|
245
328
|
if self._active_buf == "a":
|
|
246
329
|
self._buf_b = frame
|
|
@@ -248,175 +331,31 @@ class VideoStream(threading.Thread):
|
|
|
248
331
|
else:
|
|
249
332
|
self._buf_a = frame
|
|
250
333
|
self._active_buf = "a"
|
|
334
|
+
|
|
251
335
|
with self._latest_frame_lock:
|
|
252
336
|
src = self._buf_b if self._active_buf == "b" else self._buf_a
|
|
253
337
|
self._latest_frame = None if src is None else src.copy()
|
|
254
338
|
if not self._first_frame_evt.is_set() and self._latest_frame is not None:
|
|
255
339
|
self._first_frame_evt.set()
|
|
340
|
+
|
|
256
341
|
self._last_frame_ts = time.perf_counter()
|
|
257
342
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
deadline = time.perf_counter() + timeout
|
|
261
|
-
attempts = 0
|
|
262
|
-
got = False
|
|
263
|
-
while self._running and time.perf_counter() < deadline and attempts < max_attempts:
|
|
264
|
-
attempts += 1
|
|
265
|
-
if not self.capture.grab():
|
|
266
|
-
time.sleep(0.01)
|
|
267
|
-
continue
|
|
268
|
-
ok, frame = self.capture.retrieve()
|
|
269
|
-
if ok and frame is not None and frame.size > 0:
|
|
270
|
-
self._publish_latest(frame)
|
|
271
|
-
got = True
|
|
272
|
-
break
|
|
273
|
-
if got:
|
|
274
|
-
# immediately step into steady-state
|
|
275
|
-
ret, frame2 = self.capture.read()
|
|
276
|
-
if ret and frame2 is not None and frame2.size > 0:
|
|
277
|
-
self._publish_latest(frame2)
|
|
278
|
-
return got
|
|
279
|
-
|
|
280
|
-
# ----- main loop -----
|
|
281
|
-
def run(self) -> None:
|
|
282
|
-
failures = 0
|
|
283
|
-
while self._running:
|
|
284
|
-
try:
|
|
285
|
-
if not self.capture or not self.capture.isOpened():
|
|
286
|
-
if not self._initialize_capture():
|
|
287
|
-
if not self._handle_reconnection():
|
|
288
|
-
break
|
|
289
|
-
continue
|
|
290
|
-
# reset counters
|
|
291
|
-
failures = 0
|
|
292
|
-
self._reconnect_attempts = 0
|
|
293
|
-
self._current_interval = self.reconnect_interval
|
|
294
|
-
|
|
295
|
-
ret, frame = self.capture.read()
|
|
296
|
-
|
|
297
|
-
if not ret or frame is None or frame.size == 0:
|
|
298
|
-
if self._handle_file_end():
|
|
299
|
-
continue
|
|
300
|
-
failures += 1
|
|
301
|
-
if failures > self.max_failures:
|
|
302
|
-
logging.error("Too many consecutive read failures; reconnecting.")
|
|
303
|
-
self._cleanup_capture()
|
|
304
|
-
failures = 0
|
|
305
|
-
continue
|
|
306
|
-
if not self._sleep_interruptible(0.02):
|
|
307
|
-
break
|
|
308
|
-
# watchdog: if we’re “connected” but making no progress
|
|
309
|
-
if self.state in (StreamState.CONNECTED, StreamState.RECONNECTING):
|
|
310
|
-
if self._last_frame_ts and (time.perf_counter() - self._last_frame_ts) > 2.5:
|
|
311
|
-
logging.warning("No new frames for 2.5s while CONNECTED; forcing reconnect.")
|
|
312
|
-
self._cleanup_capture()
|
|
313
|
-
continue
|
|
314
|
-
|
|
315
|
-
# success
|
|
316
|
-
failures = 0
|
|
317
|
-
self.frame_count += 1
|
|
318
|
-
|
|
319
|
-
# (optional) backlog drain — disabled by default to avoid freeze
|
|
320
|
-
if self._drain_backlog and not self.is_file and self._first_frame_evt.is_set():
|
|
321
|
-
# bounded single-drain example (do not loop aggressively)
|
|
322
|
-
grabbed = self.capture.grab()
|
|
323
|
-
if grabbed:
|
|
324
|
-
ok, last = self.capture.retrieve()
|
|
325
|
-
if ok and last is not None and last.size > 0:
|
|
326
|
-
frame = last
|
|
327
|
-
|
|
328
|
-
self._publish_latest(frame)
|
|
329
|
-
|
|
330
|
-
# watchdog: reconnect if no progress despite being connected
|
|
331
|
-
if self.state in (StreamState.CONNECTED, StreamState.RECONNECTING):
|
|
332
|
-
if self._last_frame_ts and (time.perf_counter() - self._last_frame_ts) > 2.5:
|
|
333
|
-
logging.warning("No new frames for 2.5s while CONNECTED; forcing reconnect.")
|
|
334
|
-
self._cleanup_capture()
|
|
335
|
-
|
|
336
|
-
except cv2.error as e:
|
|
337
|
-
msg = f"OpenCV error: {e}"
|
|
338
|
-
logging.error(msg)
|
|
339
|
-
self._add_error_to_history(msg)
|
|
340
|
-
self._cleanup_capture()
|
|
341
|
-
if not self._sleep_interruptible(0.5):
|
|
342
|
-
break
|
|
343
|
-
|
|
344
|
-
except Exception as e:
|
|
345
|
-
msg = f"Unexpected error: {e}"
|
|
346
|
-
logging.error(msg, exc_info=True)
|
|
347
|
-
self._add_error_to_history(msg)
|
|
348
|
-
if not self._sleep_interruptible(self.reconnect_interval):
|
|
349
|
-
break
|
|
350
|
-
|
|
351
|
-
self._final_cleanup()
|
|
352
|
-
|
|
353
|
-
# ----- public API -----
|
|
354
|
-
def get_frame(self) -> Optional[cv2.Mat]:
|
|
355
|
-
if not self._running or self.state not in (StreamState.CONNECTED, StreamState.RECONNECTING):
|
|
343
|
+
def get_frame(self) -> Optional[MatLike]:
|
|
344
|
+
if not self.running or self.state != StreamState.CONNECTED:
|
|
356
345
|
return None
|
|
357
346
|
with self._latest_frame_lock:
|
|
358
|
-
return
|
|
347
|
+
return self._latest_frame.copy() if self._latest_frame is not None else None
|
|
359
348
|
|
|
360
|
-
def wait_first_frame(self, timeout: float =
|
|
349
|
+
def wait_first_frame(self, timeout: float = 10.0) -> bool:
|
|
361
350
|
return self._first_frame_evt.wait(timeout)
|
|
362
351
|
|
|
363
352
|
def is_connected(self) -> bool:
|
|
364
353
|
return self.state == StreamState.CONNECTED
|
|
365
|
-
|
|
366
|
-
@property
|
|
367
|
-
def running(self) -> bool:
|
|
368
|
-
return self._running and self.state != StreamState.STOPPED
|
|
369
|
-
|
|
354
|
+
|
|
370
355
|
def get_state(self) -> StreamState:
|
|
371
356
|
return self.state
|
|
372
|
-
|
|
373
|
-
def is_video_ended(self) -> bool:
|
|
374
|
-
if not self.is_file or not self.capture:
|
|
375
|
-
return False
|
|
376
|
-
try:
|
|
377
|
-
cur = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
|
|
378
|
-
total = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
|
|
379
|
-
return cur >= total - 1
|
|
380
|
-
except Exception:
|
|
381
|
-
return False
|
|
382
|
-
|
|
383
|
-
def stop(self, timeout: float = 5.0) -> None:
|
|
384
|
-
if not self._running:
|
|
385
|
-
return
|
|
386
|
-
logging.info(f"Stopping VideoStream: {self.source}")
|
|
387
|
-
self._running = False
|
|
388
|
-
if self.is_alive():
|
|
389
|
-
self.join(timeout=timeout)
|
|
390
|
-
if self.is_alive():
|
|
391
|
-
logging.warning(f"Stream thread did not exit within {timeout}s")
|
|
392
|
-
|
|
393
|
-
def __enter__(self):
|
|
394
|
-
self.start()
|
|
395
|
-
return self
|
|
396
|
-
|
|
397
|
-
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
398
|
-
self.stop()
|
|
399
|
-
|
|
400
|
-
# ----- teardown & diagnostics -----
|
|
357
|
+
|
|
401
358
|
def _final_cleanup(self) -> None:
|
|
402
359
|
self.state = StreamState.STOPPED
|
|
403
360
|
self._cleanup_capture()
|
|
404
|
-
|
|
405
|
-
self._latest_frame = None
|
|
406
|
-
with self._buffer_lock:
|
|
407
|
-
self._buf_a = None
|
|
408
|
-
self._buf_b = None
|
|
409
|
-
logging.info(f"VideoStream stopped: {self.source}")
|
|
410
|
-
|
|
411
|
-
def _add_error_to_history(self, error_msg: str) -> None:
|
|
412
|
-
t = time.time()
|
|
413
|
-
self._recent_errors.append({"timestamp": t, "error": error_msg})
|
|
414
|
-
if len(self._recent_errors) > self._max_error_history:
|
|
415
|
-
self._recent_errors = self._recent_errors[-self._max_error_history:]
|
|
416
|
-
|
|
417
|
-
def get_recent_errors(self, max_age_seconds: float = 300) -> List[Dict[str, Union[str, float]]]:
|
|
418
|
-
now = time.time()
|
|
419
|
-
return [e for e in self._recent_errors if now - e["timestamp"] <= max_age_seconds]
|
|
420
|
-
|
|
421
|
-
def get_codec_info(self) -> Optional[str]:
|
|
422
|
-
return self._codec_info
|
|
361
|
+
logging.info(f"VideoStream stopped: {self.source}")
|
|
@@ -126,39 +126,35 @@ class VideoStreamManager:
|
|
|
126
126
|
|
|
127
127
|
self._running_evt.clear()
|
|
128
128
|
|
|
129
|
+
def wait_for_stream_ready(self, worker_source_id, timeout: float = 10.0) -> bool:
|
|
130
|
+
"""Waits for a specific stream to signal that it has received its first frame."""
|
|
131
|
+
with self._lock:
|
|
132
|
+
stream = self.streams.get(worker_source_id)
|
|
133
|
+
|
|
134
|
+
if stream and hasattr(stream, 'wait_first_frame'):
|
|
135
|
+
return stream.wait_first_frame(timeout)
|
|
136
|
+
|
|
137
|
+
return False
|
|
138
|
+
|
|
129
139
|
def get_frame(self, worker_source_id):
|
|
130
|
-
"""Returns the latest frame for the stream, or None if not available.
|
|
131
|
-
Non-blocking. No sleeps. Short lock scopes.
|
|
132
|
-
"""
|
|
133
|
-
# Direct device path
|
|
140
|
+
"""Returns the latest frame for the stream, or None if not available."""
|
|
134
141
|
with self._lock:
|
|
135
142
|
if worker_source_id in self.direct_device_streams:
|
|
136
|
-
|
|
137
|
-
pass
|
|
143
|
+
stream = None
|
|
138
144
|
else:
|
|
139
|
-
# Regular stream path
|
|
140
145
|
stream = self.streams.get(worker_source_id)
|
|
141
146
|
|
|
142
|
-
# Direct device?
|
|
143
147
|
if worker_source_id in self.direct_device_streams:
|
|
144
148
|
return self._get_direct_device_frame(worker_source_id)
|
|
145
149
|
|
|
146
|
-
|
|
147
|
-
if stream is None or not getattr(stream, "running", False):
|
|
150
|
+
if stream is None or not hasattr(stream, 'running') or not stream.running:
|
|
148
151
|
return None
|
|
149
152
|
|
|
150
153
|
try:
|
|
151
|
-
|
|
152
|
-
start_time = getattr(stream, "start_time", None)
|
|
153
|
-
if start_time is not None and (time.time() - start_time) < 5.0:
|
|
154
|
-
return None
|
|
155
|
-
|
|
156
|
-
# If it's a file and ended, do not sleep here; let the producer handle restarts.
|
|
157
|
-
if getattr(stream, "is_file", False) and stream.is_video_ended():
|
|
154
|
+
if hasattr(stream, 'is_file') and stream.is_file and hasattr(stream, 'is_video_ended') and stream.is_video_ended():
|
|
158
155
|
logging.debug("Video file %s ended; waiting for producer to restart.", worker_source_id)
|
|
159
156
|
return None
|
|
160
157
|
|
|
161
|
-
# Must return a copy (VideoStream.get_frame() expected to handle copying)
|
|
162
158
|
return stream.get_frame()
|
|
163
159
|
except Exception as e:
|
|
164
160
|
logging.error("Error getting frame from stream %s: %s", worker_source_id, e)
|