nedo-vision-worker-core 0.3.1__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.
- nedo_vision_worker_core/__init__.py +1 -1
- nedo_vision_worker_core/doctor.py +1 -1
- nedo_vision_worker_core/pipeline/PipelineProcessor.py +116 -185
- nedo_vision_worker_core/streams/RTMPStreamer.py +239 -101
- nedo_vision_worker_core/streams/VideoStream.py +279 -202
- nedo_vision_worker_core/streams/VideoStreamManager.py +240 -211
- {nedo_vision_worker_core-0.3.1.dist-info → nedo_vision_worker_core-0.3.2.dist-info}/METADATA +2 -35
- {nedo_vision_worker_core-0.3.1.dist-info → nedo_vision_worker_core-0.3.2.dist-info}/RECORD +11 -11
- {nedo_vision_worker_core-0.3.1.dist-info → nedo_vision_worker_core-0.3.2.dist-info}/WHEEL +0 -0
- {nedo_vision_worker_core-0.3.1.dist-info → nedo_vision_worker_core-0.3.2.dist-info}/entry_points.txt +0 -0
- {nedo_vision_worker_core-0.3.1.dist-info → nedo_vision_worker_core-0.3.2.dist-info}/top_level.txt +0 -0
|
@@ -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,131 +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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
self.
|
|
42
|
-
self.
|
|
43
|
-
self.
|
|
44
|
-
|
|
45
|
-
self.
|
|
46
|
-
self.
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
# Error tracking for diagnostics
|
|
54
|
-
self._recent_errors = []
|
|
102
|
+
|
|
103
|
+
# diagnostics
|
|
104
|
+
self._recent_errors: List[Dict[str, Union[str, float]]] = []
|
|
55
105
|
self._max_error_history = 50
|
|
56
|
-
self._codec_info = None
|
|
57
|
-
|
|
106
|
+
self._codec_info: Optional[str] = None
|
|
107
|
+
|
|
108
|
+
# progress watchdog
|
|
109
|
+
self._last_frame_ts: float = 0.0
|
|
110
|
+
|
|
111
|
+
# type
|
|
112
|
+
self.is_file = self._is_file_source()
|
|
113
|
+
|
|
114
|
+
# set FFmpeg/RTSP options (tolerant profile)
|
|
115
|
+
set_ffmpeg_rtsp_env(prefer_tcp=ffmpeg_prefer_tcp)
|
|
116
|
+
|
|
117
|
+
# ----- helpers -----
|
|
58
118
|
def _is_file_source(self) -> bool:
|
|
59
|
-
"""Check if source is a file path."""
|
|
60
119
|
if isinstance(self.source, int):
|
|
61
120
|
return False
|
|
62
121
|
return isinstance(self.source, (str, bytes, os.PathLike)) and os.path.isfile(str(self.source))
|
|
63
|
-
|
|
122
|
+
|
|
64
123
|
def _get_source_for_cv2(self) -> Union[str, int]:
|
|
65
|
-
"""Convert source to format suitable for cv2.VideoCapture."""
|
|
66
124
|
if isinstance(self.source, str) and self.source.isdigit():
|
|
67
125
|
return int(self.source)
|
|
68
126
|
return self.source
|
|
69
|
-
|
|
127
|
+
|
|
70
128
|
def _initialize_capture(self) -> bool:
|
|
71
|
-
"""Initialize video capture device."""
|
|
72
129
|
try:
|
|
73
130
|
self.state = StreamState.CONNECTING
|
|
74
131
|
logging.info(f"Connecting to {self.source} (attempt {self._reconnect_attempts + 1})")
|
|
75
|
-
|
|
132
|
+
|
|
76
133
|
if self.capture:
|
|
77
134
|
self.capture.release()
|
|
78
|
-
|
|
79
|
-
self.capture = cv2.VideoCapture(self._get_source_for_cv2())
|
|
80
|
-
|
|
135
|
+
|
|
136
|
+
self.capture = cv2.VideoCapture(self._get_source_for_cv2(), cv2.CAP_FFMPEG)
|
|
81
137
|
if not self.capture.isOpened():
|
|
82
138
|
logging.error(f"Failed to open video source: {self.source}")
|
|
83
139
|
return False
|
|
84
|
-
|
|
140
|
+
|
|
85
141
|
self._configure_capture()
|
|
86
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
|
+
|
|
87
148
|
return True
|
|
88
|
-
|
|
149
|
+
|
|
89
150
|
except Exception as e:
|
|
90
151
|
logging.error(f"Error initializing capture: {e}")
|
|
91
152
|
self._cleanup_capture()
|
|
92
153
|
return False
|
|
93
|
-
|
|
94
|
-
def _configure_capture(self):
|
|
95
|
-
|
|
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
|
+
|
|
96
189
|
detected_fps = self.capture.get(cv2.CAP_PROP_FPS)
|
|
97
|
-
|
|
98
|
-
# Configure error-resilient settings for video streams
|
|
99
|
-
if not self.is_file:
|
|
100
|
-
try:
|
|
101
|
-
# Set buffer size to reduce latency and handle corrupted frames better
|
|
102
|
-
self.capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
|
103
|
-
|
|
104
|
-
# Set codec-specific properties for better error handling
|
|
105
|
-
# These help with HEVC streams that have QP delta and POC reference errors
|
|
106
|
-
if hasattr(cv2, 'CAP_PROP_FOURCC'):
|
|
107
|
-
# Try to get current codec
|
|
108
|
-
fourcc = self.capture.get(cv2.CAP_PROP_FOURCC)
|
|
109
|
-
codec_str = "".join([chr((int(fourcc) >> 8 * i) & 0xFF) for i in range(4)])
|
|
110
|
-
|
|
111
|
-
# Log codec information for debugging
|
|
112
|
-
logging.info(f"Stream codec detected: {codec_str} (fourcc: {int(fourcc)})")
|
|
113
|
-
self._codec_info = codec_str
|
|
114
|
-
|
|
115
|
-
# For HEVC streams, we can't change the codec but we can optimize buffering
|
|
116
|
-
if 'hevc' in codec_str.lower() or 'h265' in codec_str.lower():
|
|
117
|
-
logging.info("HEVC stream detected - applying error-resilient settings")
|
|
118
|
-
# Reduce buffer to minimize impact of corrupted frames
|
|
119
|
-
self.capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
|
120
|
-
|
|
121
|
-
except Exception as e:
|
|
122
|
-
logging.warning(f"Could not configure capture properties: {e}")
|
|
123
|
-
|
|
124
|
-
if self.target_fps:
|
|
125
|
-
self.fps = self.target_fps
|
|
126
|
-
if hasattr(cv2, 'CAP_PROP_FPS'):
|
|
127
|
-
self.capture.set(cv2.CAP_PROP_FPS, self.target_fps)
|
|
128
|
-
elif detected_fps and 0 < detected_fps <= 240:
|
|
190
|
+
if detected_fps and 0 < detected_fps <= 240:
|
|
129
191
|
self.fps = detected_fps
|
|
130
192
|
else:
|
|
131
193
|
self.fps = 30.0
|
|
132
|
-
logging.warning(f"
|
|
133
|
-
|
|
194
|
+
logging.warning(f"Unknown/invalid FPS ({detected_fps}); defaulting to {self.fps}")
|
|
195
|
+
|
|
134
196
|
if self.is_file:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
logging.info(f"Video file: {self.fps:.1f} FPS, {int(
|
|
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")
|
|
138
200
|
else:
|
|
139
|
-
logging.info(f"Stream connected
|
|
140
|
-
|
|
141
|
-
def _cleanup_capture(self):
|
|
142
|
-
"""Clean up capture resources."""
|
|
201
|
+
logging.info(f"Stream connected at ~{self.fps:.1f} FPS")
|
|
202
|
+
|
|
203
|
+
def _cleanup_capture(self) -> None:
|
|
143
204
|
if self.capture:
|
|
144
205
|
try:
|
|
145
206
|
self.capture.release()
|
|
@@ -148,50 +209,77 @@ class VideoStream(threading.Thread):
|
|
|
148
209
|
finally:
|
|
149
210
|
self.capture = None
|
|
150
211
|
self.state = StreamState.DISCONNECTED
|
|
151
|
-
|
|
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
|
+
|
|
152
219
|
def _handle_reconnection(self) -> bool:
|
|
153
|
-
"""Handle reconnection logic with backoff."""
|
|
154
220
|
if self._reconnect_attempts >= self.max_reconnect_attempts:
|
|
155
221
|
logging.error(f"Max reconnection attempts reached for {self.source}")
|
|
156
222
|
return False
|
|
157
|
-
|
|
158
223
|
self._reconnect_attempts += 1
|
|
159
224
|
self.state = StreamState.RECONNECTING
|
|
160
|
-
self._current_interval = min(self._current_interval * self.backoff_factor,
|
|
161
|
-
|
|
225
|
+
self._current_interval = min(self._current_interval * self.backoff_factor, self.max_sleep_backoff)
|
|
162
226
|
logging.warning(f"Reconnecting in {self._current_interval:.1f}s...")
|
|
163
227
|
return self._sleep_interruptible(self._current_interval)
|
|
164
|
-
|
|
165
|
-
def _sleep_interruptible(self, duration: float) -> bool:
|
|
166
|
-
"""Sleep with ability to interrupt on stop."""
|
|
167
|
-
end_time = time.time() + duration
|
|
168
|
-
while time.time() < end_time and self._running:
|
|
169
|
-
time.sleep(0.1)
|
|
170
|
-
return self._running
|
|
171
|
-
|
|
228
|
+
|
|
172
229
|
def _handle_file_end(self) -> bool:
|
|
173
|
-
"""Handle video file reaching end."""
|
|
174
230
|
if not self.is_file:
|
|
175
231
|
return False
|
|
176
|
-
|
|
177
232
|
try:
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if current_pos >= total_frames - 1:
|
|
182
|
-
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:
|
|
183
236
|
self.capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
184
237
|
return True
|
|
185
238
|
except Exception as e:
|
|
186
239
|
logging.error(f"Error handling file end: {e}")
|
|
187
|
-
|
|
188
240
|
return False
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
195
283
|
while self._running:
|
|
196
284
|
try:
|
|
197
285
|
if not self.capture or not self.capture.isOpened():
|
|
@@ -199,147 +287,136 @@ class VideoStream(threading.Thread):
|
|
|
199
287
|
if not self._handle_reconnection():
|
|
200
288
|
break
|
|
201
289
|
continue
|
|
202
|
-
|
|
203
|
-
|
|
290
|
+
# reset counters
|
|
291
|
+
failures = 0
|
|
204
292
|
self._reconnect_attempts = 0
|
|
205
293
|
self._current_interval = self.reconnect_interval
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
start_time = time.time()
|
|
294
|
+
|
|
209
295
|
ret, frame = self.capture.read()
|
|
210
|
-
|
|
296
|
+
|
|
211
297
|
if not ret or frame is None or frame.size == 0:
|
|
212
298
|
if self._handle_file_end():
|
|
213
299
|
continue
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
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.")
|
|
218
303
|
self._cleanup_capture()
|
|
219
|
-
|
|
304
|
+
failures = 0
|
|
220
305
|
continue
|
|
221
|
-
|
|
222
|
-
if not self._sleep_interruptible(0.1):
|
|
306
|
+
if not self._sleep_interruptible(0.02):
|
|
223
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()
|
|
224
313
|
continue
|
|
225
|
-
|
|
226
|
-
|
|
314
|
+
|
|
315
|
+
# success
|
|
316
|
+
failures = 0
|
|
227
317
|
self.frame_count += 1
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
+
|
|
238
336
|
except cv2.error as e:
|
|
239
|
-
|
|
240
|
-
logging.error(
|
|
241
|
-
self._add_error_to_history(
|
|
337
|
+
msg = f"OpenCV error: {e}"
|
|
338
|
+
logging.error(msg)
|
|
339
|
+
self._add_error_to_history(msg)
|
|
242
340
|
self._cleanup_capture()
|
|
243
|
-
if not self._sleep_interruptible(
|
|
341
|
+
if not self._sleep_interruptible(0.5):
|
|
244
342
|
break
|
|
245
|
-
|
|
343
|
+
|
|
246
344
|
except Exception as e:
|
|
247
|
-
|
|
248
|
-
logging.error(
|
|
249
|
-
self._add_error_to_history(
|
|
345
|
+
msg = f"Unexpected error: {e}"
|
|
346
|
+
logging.error(msg, exc_info=True)
|
|
347
|
+
self._add_error_to_history(msg)
|
|
250
348
|
if not self._sleep_interruptible(self.reconnect_interval):
|
|
251
349
|
break
|
|
252
|
-
|
|
350
|
+
|
|
253
351
|
self._final_cleanup()
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
"""Final resource cleanup."""
|
|
257
|
-
self.state = StreamState.STOPPED
|
|
258
|
-
self._cleanup_capture()
|
|
259
|
-
|
|
260
|
-
with self._lock:
|
|
261
|
-
self._latest_frame = None
|
|
262
|
-
|
|
263
|
-
logging.info(f"VideoStream stopped: {self.source}")
|
|
264
|
-
|
|
352
|
+
|
|
353
|
+
# ----- public API -----
|
|
265
354
|
def get_frame(self) -> Optional[cv2.Mat]:
|
|
266
|
-
"""Get the latest frame (thread-safe)."""
|
|
267
355
|
if not self._running or self.state not in (StreamState.CONNECTED, StreamState.RECONNECTING):
|
|
268
356
|
return None
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
|
|
273
363
|
def is_connected(self) -> bool:
|
|
274
|
-
"""Check if stream is currently connected."""
|
|
275
364
|
return self.state == StreamState.CONNECTED
|
|
276
|
-
|
|
365
|
+
|
|
277
366
|
@property
|
|
278
367
|
def running(self) -> bool:
|
|
279
|
-
"""Check if stream is currently running."""
|
|
280
368
|
return self._running and self.state != StreamState.STOPPED
|
|
281
|
-
|
|
369
|
+
|
|
282
370
|
def get_state(self) -> StreamState:
|
|
283
|
-
"""Get current stream state."""
|
|
284
371
|
return self.state
|
|
285
|
-
|
|
372
|
+
|
|
286
373
|
def is_video_ended(self) -> bool:
|
|
287
|
-
"""Check if video file has ended."""
|
|
288
374
|
if not self.is_file or not self.capture:
|
|
289
375
|
return False
|
|
290
|
-
|
|
291
376
|
try:
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
return
|
|
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
|
|
295
380
|
except Exception:
|
|
296
381
|
return False
|
|
297
|
-
|
|
298
|
-
def stop(self, timeout: float = 5.0):
|
|
299
|
-
"""Stop the video stream gracefully."""
|
|
382
|
+
|
|
383
|
+
def stop(self, timeout: float = 5.0) -> None:
|
|
300
384
|
if not self._running:
|
|
301
385
|
return
|
|
302
|
-
|
|
303
386
|
logging.info(f"Stopping VideoStream: {self.source}")
|
|
304
387
|
self._running = False
|
|
305
|
-
|
|
306
|
-
with self._lock:
|
|
307
|
-
self._latest_frame = None
|
|
308
|
-
|
|
309
388
|
if self.is_alive():
|
|
310
389
|
self.join(timeout=timeout)
|
|
311
390
|
if self.is_alive():
|
|
312
391
|
logging.warning(f"Stream thread did not exit within {timeout}s")
|
|
313
|
-
|
|
392
|
+
|
|
314
393
|
def __enter__(self):
|
|
315
|
-
"""Context manager entry."""
|
|
316
394
|
self.start()
|
|
317
395
|
return self
|
|
318
|
-
|
|
396
|
+
|
|
319
397
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
320
|
-
"""Context manager exit."""
|
|
321
398
|
self.stop()
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
+
|
|
343
421
|
def get_codec_info(self) -> Optional[str]:
|
|
344
|
-
"""Get codec information for the stream."""
|
|
345
422
|
return self._codec_info
|