nedo-vision-worker-core 0.3.0__py3-none-any.whl → 0.3.2__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.

@@ -1,12 +1,13 @@
1
1
  import os
2
2
  import cv2
3
- import threading
4
3
  import time
4
+ import threading
5
5
  import logging
6
- from typing import Optional, Union
6
+ from typing import Optional, Union, List, Dict
7
7
  from enum import Enum
8
8
 
9
9
 
10
+ # ---------- States ----------
10
11
  class StreamState(Enum):
11
12
  DISCONNECTED = "disconnected"
12
13
  CONNECTING = "connecting"
@@ -15,100 +16,191 @@ class StreamState(Enum):
15
16
  STOPPED = "stopped"
16
17
 
17
18
 
19
+ # ---------- FFmpeg / RTSP tuning (more tolerant; avoids post-keyframe freeze) ----------
20
+ def set_ffmpeg_rtsp_env(
21
+ *,
22
+ prefer_tcp: bool = True,
23
+ probesize: str = "256k",
24
+ analyzeduration_us: int = 1_000_000, # 1s (non-zero)
25
+ buffer_size: str = "256k",
26
+ max_delay_us: int = 700_000, # 0.7s
27
+ stimeout_us: int = 5_000_000 # 5s socket timeout
28
+ ) -> None:
29
+ opts = [
30
+ f"rtsp_transport;{'tcp' if prefer_tcp else 'udp'}",
31
+ f"probesize;{probesize}",
32
+ f"analyzeduration;{analyzeduration_us}",
33
+ f"buffer_size;{buffer_size}",
34
+ f"max_delay;{max_delay_us}",
35
+ f"stimeout;{stimeout_us}",
36
+ "flags;low_delay",
37
+ "rtsp_flags;prefer_tcp" if prefer_tcp else "",
38
+ # NOTE: do NOT set reorder_queue_size=0 here; let ffmpeg reorder if needed.
39
+ ]
40
+ os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = ";".join([o for o in opts if o])
41
+
42
+
43
+ # ---------- VideoStream (low-latency, freeze-safe) ----------
18
44
  class VideoStream(threading.Thread):
19
- """Thread-safe video capture with automatic reconnection and error handling."""
20
-
45
+ """
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'.
51
+ """
52
+
21
53
  def __init__(
22
- self,
23
- source: Union[str, int],
54
+ self,
55
+ source: Union[str, int],
56
+ *,
24
57
  reconnect_interval: float = 5.0,
25
58
  max_failures: int = 5,
26
59
  max_reconnect_attempts: int = 10,
27
60
  backoff_factor: float = 1.5,
28
- target_fps: Optional[float] = None
61
+ 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
64
+ ffmpeg_prefer_tcp: bool = True
29
65
  ):
30
66
  super().__init__(daemon=True)
31
-
67
+
68
+ # config
32
69
  self.source = source
33
70
  self.reconnect_interval = reconnect_interval
34
71
  self.max_failures = max_failures
35
72
  self.max_reconnect_attempts = max_reconnect_attempts
36
73
  self.backoff_factor = backoff_factor
74
+ self.max_sleep_backoff = max_sleep_backoff
37
75
  self.target_fps = target_fps
38
-
39
- self.capture = None
40
- self.state = StreamState.DISCONNECTED
41
- self.fps = 30.0
42
- self.frame_count = 0
43
- self.start_time = time.time()
44
-
45
- self._running = True
46
- self._lock = threading.Lock()
47
- self._latest_frame = None
76
+ self._drain_backlog = enable_backlog_drain
77
+
78
+ # runtime
79
+ self.capture: Optional[cv2.VideoCapture] = None
80
+ self.state: StreamState = StreamState.DISCONNECTED
81
+ self.fps: float = 30.0
82
+ self.frame_count: int = 0
83
+ self.start_time: float = time.time()
84
+ self._running: bool = True
85
+
86
+ # first-frame signaling
87
+ self._first_frame_evt = threading.Event()
88
+
89
+ # latest-frame (short lock)
90
+ self._latest_frame_lock = threading.Lock()
91
+ self._latest_frame: Optional[cv2.Mat] = None
92
+
93
+ # double buffer (very short lock)
94
+ self._buffer_lock = threading.Lock()
95
+ self._buf_a: Optional[cv2.Mat] = None
96
+ self._buf_b: Optional[cv2.Mat] = None
97
+ self._active_buf: str = "a"
98
+
99
+ # reconnect backoff
48
100
  self._reconnect_attempts = 0
49
101
  self._current_interval = reconnect_interval
50
-
102
+
103
+ # diagnostics
104
+ self._recent_errors: List[Dict[str, Union[str, float]]] = []
105
+ self._max_error_history = 50
106
+ self._codec_info: Optional[str] = None
107
+
108
+ # progress watchdog
109
+ self._last_frame_ts: float = 0.0
110
+
111
+ # type
51
112
  self.is_file = self._is_file_source()
52
-
113
+
114
+ # set FFmpeg/RTSP options (tolerant profile)
115
+ set_ffmpeg_rtsp_env(prefer_tcp=ffmpeg_prefer_tcp)
116
+
117
+ # ----- helpers -----
53
118
  def _is_file_source(self) -> bool:
54
- """Check if source is a file path."""
55
119
  if isinstance(self.source, int):
56
120
  return False
57
121
  return isinstance(self.source, (str, bytes, os.PathLike)) and os.path.isfile(str(self.source))
58
-
122
+
59
123
  def _get_source_for_cv2(self) -> Union[str, int]:
60
- """Convert source to format suitable for cv2.VideoCapture."""
61
124
  if isinstance(self.source, str) and self.source.isdigit():
62
125
  return int(self.source)
63
126
  return self.source
64
-
127
+
65
128
  def _initialize_capture(self) -> bool:
66
- """Initialize video capture device."""
67
129
  try:
68
130
  self.state = StreamState.CONNECTING
69
131
  logging.info(f"Connecting to {self.source} (attempt {self._reconnect_attempts + 1})")
70
-
132
+
71
133
  if self.capture:
72
134
  self.capture.release()
73
-
74
- self.capture = cv2.VideoCapture(self._get_source_for_cv2())
75
-
135
+
136
+ self.capture = cv2.VideoCapture(self._get_source_for_cv2(), cv2.CAP_FFMPEG)
76
137
  if not self.capture.isOpened():
77
138
  logging.error(f"Failed to open video source: {self.source}")
78
139
  return False
79
-
140
+
80
141
  self._configure_capture()
81
142
  self.state = StreamState.CONNECTED
143
+
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).")
147
+
82
148
  return True
83
-
149
+
84
150
  except Exception as e:
85
151
  logging.error(f"Error initializing capture: {e}")
86
152
  self._cleanup_capture()
87
153
  return False
88
-
89
- def _configure_capture(self):
90
- """Configure capture properties and determine FPS."""
154
+
155
+ 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
188
+
91
189
  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:
190
+ if detected_fps and 0 < detected_fps <= 240:
98
191
  self.fps = detected_fps
99
192
  else:
100
193
  self.fps = 30.0
101
- logging.warning(f"Invalid FPS detected ({detected_fps}), using {self.fps}")
102
-
194
+ logging.warning(f"Unknown/invalid FPS ({detected_fps}); defaulting to {self.fps}")
195
+
103
196
  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")
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")
107
200
  else:
108
- logging.info(f"Stream connected: {self.fps:.1f} FPS")
109
-
110
- def _cleanup_capture(self):
111
- """Clean up capture resources."""
201
+ logging.info(f"Stream connected at ~{self.fps:.1f} FPS")
202
+
203
+ def _cleanup_capture(self) -> None:
112
204
  if self.capture:
113
205
  try:
114
206
  self.capture.release()
@@ -117,50 +209,77 @@ class VideoStream(threading.Thread):
117
209
  finally:
118
210
  self.capture = None
119
211
  self.state = StreamState.DISCONNECTED
120
-
212
+
213
+ def _sleep_interruptible(self, duration: float) -> bool:
214
+ end = time.perf_counter() + duration
215
+ while self._running and time.perf_counter() < end:
216
+ time.sleep(0.05)
217
+ return self._running
218
+
121
219
  def _handle_reconnection(self) -> bool:
122
- """Handle reconnection logic with backoff."""
123
220
  if self._reconnect_attempts >= self.max_reconnect_attempts:
124
221
  logging.error(f"Max reconnection attempts reached for {self.source}")
125
222
  return False
126
-
127
223
  self._reconnect_attempts += 1
128
224
  self.state = StreamState.RECONNECTING
129
- self._current_interval = min(self._current_interval * self.backoff_factor, 60.0)
130
-
225
+ self._current_interval = min(self._current_interval * self.backoff_factor, self.max_sleep_backoff)
131
226
  logging.warning(f"Reconnecting in {self._current_interval:.1f}s...")
132
227
  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
-
228
+
141
229
  def _handle_file_end(self) -> bool:
142
- """Handle video file reaching end."""
143
230
  if not self.is_file:
144
231
  return False
145
-
146
232
  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}")
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:
152
236
  self.capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
153
237
  return True
154
238
  except Exception as e:
155
239
  logging.error(f"Error handling file end: {e}")
156
-
157
240
  return False
158
-
159
- def run(self):
160
- """Main capture loop."""
161
- failure_count = 0
162
- frame_interval = 1.0 / self.fps
163
-
241
+
242
+ # publish freshest
243
+ def _publish_latest(self, frame: cv2.Mat) -> None:
244
+ with self._buffer_lock:
245
+ if self._active_buf == "a":
246
+ self._buf_b = frame
247
+ self._active_buf = "b"
248
+ else:
249
+ self._buf_a = frame
250
+ self._active_buf = "a"
251
+ with self._latest_frame_lock:
252
+ src = self._buf_b if self._active_buf == "b" else self._buf_a
253
+ self._latest_frame = None if src is None else src.copy()
254
+ if not self._first_frame_evt.is_set() and self._latest_frame is not None:
255
+ self._first_frame_evt.set()
256
+ self._last_frame_ts = time.perf_counter()
257
+
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
164
283
  while self._running:
165
284
  try:
166
285
  if not self.capture or not self.capture.isOpened():
@@ -168,119 +287,136 @@ class VideoStream(threading.Thread):
168
287
  if not self._handle_reconnection():
169
288
  break
170
289
  continue
171
-
172
- failure_count = 0
290
+ # reset counters
291
+ failures = 0
173
292
  self._reconnect_attempts = 0
174
293
  self._current_interval = self.reconnect_interval
175
- frame_interval = 1.0 / self.fps
176
-
177
- start_time = time.time()
294
+
178
295
  ret, frame = self.capture.read()
179
-
296
+
180
297
  if not ret or frame is None or frame.size == 0:
181
298
  if self._handle_file_end():
182
299
  continue
183
-
184
- failure_count += 1
185
- if failure_count > self.max_failures:
186
- logging.error("Too many consecutive failures, reconnecting...")
300
+ failures += 1
301
+ if failures > self.max_failures:
302
+ logging.error("Too many consecutive read failures; reconnecting.")
187
303
  self._cleanup_capture()
188
- failure_count = 0
304
+ failures = 0
189
305
  continue
190
-
191
- if not self._sleep_interruptible(0.1):
306
+ if not self._sleep_interruptible(0.02):
192
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()
193
313
  continue
194
-
195
- failure_count = 0
314
+
315
+ # success
316
+ failures = 0
196
317
  self.frame_count += 1
197
-
198
- with self._lock:
199
- if self._running:
200
- self._latest_frame = frame.copy()
201
-
202
- elapsed = time.time() - start_time
203
- sleep_time = max(0, frame_interval - elapsed)
204
- if sleep_time > 0 and not self._sleep_interruptible(sleep_time):
205
- break
206
-
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
+
207
336
  except cv2.error as e:
208
- logging.error(f"OpenCV error: {e}")
337
+ msg = f"OpenCV error: {e}"
338
+ logging.error(msg)
339
+ self._add_error_to_history(msg)
209
340
  self._cleanup_capture()
210
- if not self._sleep_interruptible(1.0):
341
+ if not self._sleep_interruptible(0.5):
211
342
  break
212
-
343
+
213
344
  except Exception as e:
214
- logging.error(f"Unexpected error: {e}", exc_info=True)
345
+ msg = f"Unexpected error: {e}"
346
+ logging.error(msg, exc_info=True)
347
+ self._add_error_to_history(msg)
215
348
  if not self._sleep_interruptible(self.reconnect_interval):
216
349
  break
217
-
350
+
218
351
  self._final_cleanup()
219
-
220
- def _final_cleanup(self):
221
- """Final resource cleanup."""
222
- self.state = StreamState.STOPPED
223
- self._cleanup_capture()
224
-
225
- with self._lock:
226
- self._latest_frame = None
227
-
228
- logging.info(f"VideoStream stopped: {self.source}")
229
-
352
+
353
+ # ----- public API -----
230
354
  def get_frame(self) -> Optional[cv2.Mat]:
231
- """Get the latest frame (thread-safe)."""
232
355
  if not self._running or self.state not in (StreamState.CONNECTED, StreamState.RECONNECTING):
233
356
  return None
234
-
235
- with self._lock:
236
- return self._latest_frame.copy() if self._latest_frame is not None else None
237
-
357
+ with self._latest_frame_lock:
358
+ return None if self._latest_frame is None else self._latest_frame.copy()
359
+
360
+ def wait_first_frame(self, timeout: float = 5.0) -> bool:
361
+ return self._first_frame_evt.wait(timeout)
362
+
238
363
  def is_connected(self) -> bool:
239
- """Check if stream is currently connected."""
240
364
  return self.state == StreamState.CONNECTED
241
-
365
+
242
366
  @property
243
367
  def running(self) -> bool:
244
- """Check if stream is currently running."""
245
368
  return self._running and self.state != StreamState.STOPPED
246
-
369
+
247
370
  def get_state(self) -> StreamState:
248
- """Get current stream state."""
249
371
  return self.state
250
-
372
+
251
373
  def is_video_ended(self) -> bool:
252
- """Check if video file has ended."""
253
374
  if not self.is_file or not self.capture:
254
375
  return False
255
-
256
376
  try:
257
- current_pos = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
258
- total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
259
- return current_pos >= total_frames - 1
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
260
380
  except Exception:
261
381
  return False
262
-
263
- def stop(self, timeout: float = 5.0):
264
- """Stop the video stream gracefully."""
382
+
383
+ def stop(self, timeout: float = 5.0) -> None:
265
384
  if not self._running:
266
385
  return
267
-
268
386
  logging.info(f"Stopping VideoStream: {self.source}")
269
387
  self._running = False
270
-
271
- with self._lock:
272
- self._latest_frame = None
273
-
274
388
  if self.is_alive():
275
389
  self.join(timeout=timeout)
276
390
  if self.is_alive():
277
391
  logging.warning(f"Stream thread did not exit within {timeout}s")
278
-
392
+
279
393
  def __enter__(self):
280
- """Context manager entry."""
281
394
  self.start()
282
395
  return self
283
-
396
+
284
397
  def __exit__(self, exc_type, exc_val, exc_tb):
285
- """Context manager exit."""
286
- self.stop()
398
+ self.stop()
399
+
400
+ # ----- teardown & diagnostics -----
401
+ def _final_cleanup(self) -> None:
402
+ self.state = StreamState.STOPPED
403
+ 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