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.

@@ -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 (more tolerant; avoids post-keyframe freeze) ----------
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, # 1s (non-zero)
33
+ analyzeduration_us: int = 1_000_000,
25
34
  buffer_size: str = "256k",
26
- max_delay_us: int = 700_000, # 0.7s
27
- stimeout_us: int = 5_000_000 # 5s socket timeout
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"] = ";".join([o for o in opts if o])
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
- - Primes for first frame (bounded) and immediately enters steady-state.
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, # consumer pacing; capture runs free
63
- enable_backlog_drain: bool = False, # keep False; can enable later if stable
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._running: bool = True
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[cv2.Mat] = None
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[cv2.Mat] = None
96
- self._buf_b: Optional[cv2.Mat] = None
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 isinstance(self.source, (str, bytes, os.PathLike)) and os.path.isfile(str(self.source))
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 _initialize_capture(self) -> bool:
129
- try:
130
- self.state = StreamState.CONNECTING
131
- logging.info(f"Connecting to {self.source} (attempt {self._reconnect_attempts + 1})")
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
- if self.capture:
134
- self.capture.release()
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
- self.capture = cv2.VideoCapture(self._get_source_for_cv2(), cv2.CAP_FFMPEG)
137
- if not self.capture.isOpened():
138
- logging.error(f"Failed to open video source: {self.source}")
139
- return False
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
- self._configure_capture()
142
- self.state = StreamState.CONNECTED
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
- # Prime for the first decodable frame (bounded) and immediately step to steady-state
145
- if not self._prime_until_keyframe(timeout=2.0, max_attempts=30):
146
- logging.warning("Connected but no initial frame within 2s (waiting for keyframe).")
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
- return True
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
- except Exception as e:
151
- logging.error(f"Error initializing capture: {e}")
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
- # minimal buffering
157
- try:
158
- self.capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
159
- except Exception:
160
- pass
161
-
162
- # optional timeouts (ignored if unsupported)
163
- for prop, val in [
164
- (getattr(cv2, "CAP_PROP_OPEN_TIMEOUT_MSEC", None), 4000),
165
- (getattr(cv2, "CAP_PROP_READ_TIMEOUT_MSEC", None), 3000),
166
- ]:
167
- if prop is not None:
168
- try:
169
- self.capture.set(prop, val)
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
- logging.warning(f"Unknown/invalid FPS ({detected_fps}); defaulting to {self.fps}")
234
+
235
+ logging.info(f"Stream connected at ~{self.fps:.1f} FPS. Codec: {self._codec_info or 'N/A'}")
195
236
 
196
- if self.is_file:
197
- total = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
198
- dur = total / self.fps if self.fps > 0 else 0
199
- logging.info(f"Video file: {self.fps:.1f} FPS, {int(total)} frames, {dur:.1f}s")
200
- else:
201
- logging.info(f"Stream connected at ~{self.fps:.1f} FPS")
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._running and time.perf_counter() < end:
297
+ while self.running and time.perf_counter() < end:
216
298
  time.sleep(0.05)
217
- return self._running
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
- try:
233
- cur = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
234
- total = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
235
- if cur >= total - 1:
236
- self.capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
237
- return True
238
- except Exception as e:
239
- logging.error(f"Error handling file end: {e}")
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
- # publish freshest
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
- # bounded priming + immediate read()
259
- def _prime_until_keyframe(self, *, timeout: float, max_attempts: int) -> bool:
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 None if self._latest_frame is None else self._latest_frame.copy()
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 = 5.0) -> bool:
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
- with self._latest_frame_lock:
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
- # fall through to direct getter outside the manager lock
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
- # Regular stream
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
- # Soft warm-up: your original code suppressed frames for the first 5s
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)