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.
- nedo_vision_worker_core/__init__.py +1 -1
- nedo_vision_worker_core/cli.py +4 -56
- nedo_vision_worker_core/doctor.py +1 -1
- nedo_vision_worker_core/pipeline/PipelineProcessor.py +275 -102
- nedo_vision_worker_core/streams/RTMPStreamer.py +239 -101
- nedo_vision_worker_core/streams/VideoStream.py +282 -146
- nedo_vision_worker_core/streams/VideoStreamManager.py +240 -211
- {nedo_vision_worker_core-0.3.0.dist-info → nedo_vision_worker_core-0.3.2.dist-info}/METADATA +2 -35
- {nedo_vision_worker_core-0.3.0.dist-info → nedo_vision_worker_core-0.3.2.dist-info}/RECORD +12 -12
- {nedo_vision_worker_core-0.3.0.dist-info → nedo_vision_worker_core-0.3.2.dist-info}/WHEEL +0 -0
- {nedo_vision_worker_core-0.3.0.dist-info → nedo_vision_worker_core-0.3.2.dist-info}/entry_points.txt +0 -0
- {nedo_vision_worker_core-0.3.0.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,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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
102
|
-
|
|
194
|
+
logging.warning(f"Unknown/invalid FPS ({detected_fps}); defaulting to {self.fps}")
|
|
195
|
+
|
|
103
196
|
if self.is_file:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
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")
|
|
107
200
|
else:
|
|
108
|
-
logging.info(f"Stream connected
|
|
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,
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
290
|
+
# reset counters
|
|
291
|
+
failures = 0
|
|
173
292
|
self._reconnect_attempts = 0
|
|
174
293
|
self._current_interval = self.reconnect_interval
|
|
175
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
+
|
|
315
|
+
# success
|
|
316
|
+
failures = 0
|
|
196
317
|
self.frame_count += 1
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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(
|
|
341
|
+
if not self._sleep_interruptible(0.5):
|
|
211
342
|
break
|
|
212
|
-
|
|
343
|
+
|
|
213
344
|
except Exception as e:
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
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
|
|
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
|
-
|
|
286
|
-
|
|
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
|