nedo-vision-worker-core 0.2.0__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 +23 -0
- nedo_vision_worker_core/ai/FrameDrawer.py +144 -0
- nedo_vision_worker_core/ai/ImageDebugger.py +126 -0
- nedo_vision_worker_core/ai/VideoDebugger.py +69 -0
- nedo_vision_worker_core/ai/__init__.py +1 -0
- nedo_vision_worker_core/cli.py +197 -0
- nedo_vision_worker_core/config/ConfigurationManager.py +173 -0
- nedo_vision_worker_core/config/__init__.py +1 -0
- nedo_vision_worker_core/core_service.py +237 -0
- nedo_vision_worker_core/database/DatabaseManager.py +236 -0
- nedo_vision_worker_core/database/__init__.py +1 -0
- nedo_vision_worker_core/detection/BaseDetector.py +22 -0
- nedo_vision_worker_core/detection/DetectionManager.py +83 -0
- nedo_vision_worker_core/detection/RFDETRDetector.py +62 -0
- nedo_vision_worker_core/detection/YOLODetector.py +57 -0
- nedo_vision_worker_core/detection/__init__.py +1 -0
- nedo_vision_worker_core/detection/detection_processing/DetectionProcessor.py +29 -0
- nedo_vision_worker_core/detection/detection_processing/HumanDetectionProcessor.py +47 -0
- nedo_vision_worker_core/detection/detection_processing/PPEDetectionProcessor.py +44 -0
- nedo_vision_worker_core/detection/detection_processing/__init__.py +1 -0
- nedo_vision_worker_core/doctor.py +342 -0
- nedo_vision_worker_core/drawing_assets/blue/inner_corner.png +0 -0
- nedo_vision_worker_core/drawing_assets/blue/inner_frame.png +0 -0
- nedo_vision_worker_core/drawing_assets/blue/line.png +0 -0
- nedo_vision_worker_core/drawing_assets/blue/top_left.png +0 -0
- nedo_vision_worker_core/drawing_assets/blue/top_right.png +0 -0
- nedo_vision_worker_core/drawing_assets/red/inner_corner.png +0 -0
- nedo_vision_worker_core/drawing_assets/red/inner_frame.png +0 -0
- nedo_vision_worker_core/drawing_assets/red/line.png +0 -0
- nedo_vision_worker_core/drawing_assets/red/top_left.png +0 -0
- nedo_vision_worker_core/drawing_assets/red/top_right.png +0 -0
- nedo_vision_worker_core/icons/boots-green.png +0 -0
- nedo_vision_worker_core/icons/boots-red.png +0 -0
- nedo_vision_worker_core/icons/gloves-green.png +0 -0
- nedo_vision_worker_core/icons/gloves-red.png +0 -0
- nedo_vision_worker_core/icons/goggles-green.png +0 -0
- nedo_vision_worker_core/icons/goggles-red.png +0 -0
- nedo_vision_worker_core/icons/helmet-green.png +0 -0
- nedo_vision_worker_core/icons/helmet-red.png +0 -0
- nedo_vision_worker_core/icons/mask-red.png +0 -0
- nedo_vision_worker_core/icons/vest-green.png +0 -0
- nedo_vision_worker_core/icons/vest-red.png +0 -0
- nedo_vision_worker_core/models/__init__.py +20 -0
- nedo_vision_worker_core/models/ai_model.py +41 -0
- nedo_vision_worker_core/models/auth.py +14 -0
- nedo_vision_worker_core/models/config.py +9 -0
- nedo_vision_worker_core/models/dataset_source.py +30 -0
- nedo_vision_worker_core/models/logs.py +9 -0
- nedo_vision_worker_core/models/ppe_detection.py +39 -0
- nedo_vision_worker_core/models/ppe_detection_label.py +20 -0
- nedo_vision_worker_core/models/restricted_area_violation.py +20 -0
- nedo_vision_worker_core/models/user.py +10 -0
- nedo_vision_worker_core/models/worker_source.py +19 -0
- nedo_vision_worker_core/models/worker_source_pipeline.py +21 -0
- nedo_vision_worker_core/models/worker_source_pipeline_config.py +24 -0
- nedo_vision_worker_core/models/worker_source_pipeline_debug.py +15 -0
- nedo_vision_worker_core/models/worker_source_pipeline_detection.py +14 -0
- nedo_vision_worker_core/pipeline/PipelineConfigManager.py +32 -0
- nedo_vision_worker_core/pipeline/PipelineManager.py +133 -0
- nedo_vision_worker_core/pipeline/PipelinePrepocessor.py +40 -0
- nedo_vision_worker_core/pipeline/PipelineProcessor.py +338 -0
- nedo_vision_worker_core/pipeline/PipelineSyncThread.py +202 -0
- nedo_vision_worker_core/pipeline/__init__.py +1 -0
- nedo_vision_worker_core/preprocessing/ImageResizer.py +42 -0
- nedo_vision_worker_core/preprocessing/ImageRoi.py +61 -0
- nedo_vision_worker_core/preprocessing/Preprocessor.py +16 -0
- nedo_vision_worker_core/preprocessing/__init__.py +1 -0
- nedo_vision_worker_core/repositories/AIModelRepository.py +31 -0
- nedo_vision_worker_core/repositories/PPEDetectionRepository.py +146 -0
- nedo_vision_worker_core/repositories/RestrictedAreaRepository.py +90 -0
- nedo_vision_worker_core/repositories/WorkerSourcePipelineDebugRepository.py +81 -0
- nedo_vision_worker_core/repositories/WorkerSourcePipelineDetectionRepository.py +71 -0
- nedo_vision_worker_core/repositories/WorkerSourcePipelineRepository.py +79 -0
- nedo_vision_worker_core/repositories/WorkerSourceRepository.py +19 -0
- nedo_vision_worker_core/repositories/__init__.py +1 -0
- nedo_vision_worker_core/streams/RTMPStreamer.py +146 -0
- nedo_vision_worker_core/streams/StreamSyncThread.py +66 -0
- nedo_vision_worker_core/streams/VideoStream.py +324 -0
- nedo_vision_worker_core/streams/VideoStreamManager.py +121 -0
- nedo_vision_worker_core/streams/__init__.py +1 -0
- nedo_vision_worker_core/tracker/SFSORT.py +325 -0
- nedo_vision_worker_core/tracker/TrackerManager.py +163 -0
- nedo_vision_worker_core/tracker/__init__.py +1 -0
- nedo_vision_worker_core/util/BoundingBoxMetrics.py +53 -0
- nedo_vision_worker_core/util/DrawingUtils.py +354 -0
- nedo_vision_worker_core/util/ModelReadinessChecker.py +188 -0
- nedo_vision_worker_core/util/PersonAttributeMatcher.py +70 -0
- nedo_vision_worker_core/util/PersonRestrictedAreaMatcher.py +45 -0
- nedo_vision_worker_core/util/TablePrinter.py +28 -0
- nedo_vision_worker_core/util/__init__.py +1 -0
- nedo_vision_worker_core-0.2.0.dist-info/METADATA +347 -0
- nedo_vision_worker_core-0.2.0.dist-info/RECORD +95 -0
- nedo_vision_worker_core-0.2.0.dist-info/WHEEL +5 -0
- nedo_vision_worker_core-0.2.0.dist-info/entry_points.txt +2 -0
- nedo_vision_worker_core-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import threading
|
|
5
|
+
from ..database.DatabaseManager import DatabaseManager
|
|
6
|
+
from ..repositories.WorkerSourceRepository import WorkerSourceRepository
|
|
7
|
+
from .VideoStreamManager import VideoStreamManager
|
|
8
|
+
|
|
9
|
+
class StreamSyncThread(threading.Thread):
|
|
10
|
+
"""Thread responsible for synchronizing video streams from the database in real-time."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, manager: VideoStreamManager, polling_interval=5):
|
|
13
|
+
super().__init__() # Set as a daemon so it stops with the main process
|
|
14
|
+
|
|
15
|
+
self.source_file_path = DatabaseManager.STORAGE_PATHS["files"] / "source_files"
|
|
16
|
+
|
|
17
|
+
self.manager = manager
|
|
18
|
+
self.polling_interval = polling_interval
|
|
19
|
+
self.worker_source_repo = WorkerSourceRepository()
|
|
20
|
+
self.running = True
|
|
21
|
+
|
|
22
|
+
def _get_source_file_path(self, file):
|
|
23
|
+
"""Returns the file path for a given source file."""
|
|
24
|
+
return self.source_file_path / os.path.basename(file)
|
|
25
|
+
|
|
26
|
+
def run(self):
|
|
27
|
+
"""Continuously updates the VideoStreamManager with database changes."""
|
|
28
|
+
while self.running:
|
|
29
|
+
try:
|
|
30
|
+
sources = self.worker_source_repo.get_worker_sources()
|
|
31
|
+
db_sources = {
|
|
32
|
+
source.id: (source.url if source.type_code == "live" else self._get_source_file_path(source.file_path), source.status_code) for source in sources
|
|
33
|
+
} # Store latest sources
|
|
34
|
+
active_stream_ids = set(self.manager.get_active_stream_ids())
|
|
35
|
+
|
|
36
|
+
# **1️⃣ Add new streams**
|
|
37
|
+
for source_id, (url, status_code) in db_sources.items():
|
|
38
|
+
if source_id not in active_stream_ids and status_code == "connected":
|
|
39
|
+
logging.info(f"🟢 Adding new stream: {source_id} ({url})")
|
|
40
|
+
self.manager.add_stream(source_id, url)
|
|
41
|
+
|
|
42
|
+
# **2️⃣ Remove deleted streams**
|
|
43
|
+
for stream_id in active_stream_ids:
|
|
44
|
+
if stream_id not in db_sources or db_sources[stream_id][1] != "connected":
|
|
45
|
+
logging.info(f"🔴 Removing deleted stream: {stream_id}")
|
|
46
|
+
self.manager.remove_stream(stream_id)
|
|
47
|
+
|
|
48
|
+
active_stream_ids = set(self.manager.get_active_stream_ids())
|
|
49
|
+
|
|
50
|
+
# **3️⃣ Update streams if URL has changed**
|
|
51
|
+
for source_id, (url, status_code) in db_sources.items():
|
|
52
|
+
if source_id in active_stream_ids:
|
|
53
|
+
existing_url = self.manager.get_stream_url(source_id)
|
|
54
|
+
if existing_url != url:
|
|
55
|
+
logging.info(f"🟡 Updating stream {source_id}: New URL {url}")
|
|
56
|
+
self.manager.remove_stream(source_id)
|
|
57
|
+
self.manager.add_stream(source_id, url)
|
|
58
|
+
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logging.error(f"⚠️ Error syncing streams from database: {e}")
|
|
61
|
+
|
|
62
|
+
time.sleep(self.polling_interval) # Poll every X seconds
|
|
63
|
+
|
|
64
|
+
def stop(self):
|
|
65
|
+
"""Stops the synchronization thread."""
|
|
66
|
+
self.running = False
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import cv2
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class VideoStream(threading.Thread):
|
|
10
|
+
"""Threaded class for capturing video from a source, with automatic reconnection.
|
|
11
|
+
|
|
12
|
+
This class provides a thread-safe way to capture video frames from various sources
|
|
13
|
+
(cameras, files, network streams) with automatic reconnection on failure.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
source: The video source (int for webcams, string for files/URLs)
|
|
17
|
+
reconnect_interval: Time in seconds to wait before reconnecting on failure
|
|
18
|
+
retry_limit: Maximum number of consecutive retries before giving up
|
|
19
|
+
"""
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
source: str,
|
|
23
|
+
reconnect_interval: int = 5,
|
|
24
|
+
retry_limit: int = 5,
|
|
25
|
+
max_reconnect_attempts: int = 10,
|
|
26
|
+
reconnect_backoff_factor: float = 1.5
|
|
27
|
+
):
|
|
28
|
+
super().__init__()
|
|
29
|
+
|
|
30
|
+
# Stream configuration
|
|
31
|
+
self.source = source
|
|
32
|
+
self.reconnect_interval = reconnect_interval
|
|
33
|
+
self.retry_limit = retry_limit
|
|
34
|
+
|
|
35
|
+
# Stream state
|
|
36
|
+
self.capture = None
|
|
37
|
+
self.running = True
|
|
38
|
+
self.connected = False
|
|
39
|
+
self.start_time = time.time()
|
|
40
|
+
self.is_file = isinstance(source, (int, str, bytes, os.PathLike)) and os.path.isfile(source)
|
|
41
|
+
self.fps = 30 # Default FPS until determined
|
|
42
|
+
self.frame_count = 0
|
|
43
|
+
|
|
44
|
+
# Reconnection control
|
|
45
|
+
self.max_reconnect_attempts = max_reconnect_attempts
|
|
46
|
+
self.reconnect_attempts = 0
|
|
47
|
+
self.reconnect_backoff_factor = reconnect_backoff_factor
|
|
48
|
+
self.current_reconnect_interval = reconnect_interval
|
|
49
|
+
|
|
50
|
+
# Thread synchronization
|
|
51
|
+
self.lock = threading.Lock()
|
|
52
|
+
self._latest_frame = None
|
|
53
|
+
|
|
54
|
+
# Start the capture thread
|
|
55
|
+
self.start()
|
|
56
|
+
|
|
57
|
+
def _initialize_capture(self) -> bool:
|
|
58
|
+
"""Initialize or reinitialize the capture device.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
True if successful, False otherwise
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
logging.info(f"🔄 Attempting to connect to stream: {self.source} (attempt {self.reconnect_attempts + 1}/{self.max_reconnect_attempts})")
|
|
65
|
+
|
|
66
|
+
# Clean up existing capture if needed
|
|
67
|
+
if self.capture:
|
|
68
|
+
self.capture.release()
|
|
69
|
+
|
|
70
|
+
# Create new capture object
|
|
71
|
+
self.capture = cv2.VideoCapture(self.source)
|
|
72
|
+
|
|
73
|
+
if not self.capture.isOpened():
|
|
74
|
+
logging.error(f"❌ Failed to open video source: {self.source}")
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
# Get FPS if available or estimate it
|
|
78
|
+
self.fps = self.capture.get(cv2.CAP_PROP_FPS)
|
|
79
|
+
|
|
80
|
+
# Check FPS validity and estimate if needed
|
|
81
|
+
if not self.fps or self.fps <= 0 or self.fps > 240:
|
|
82
|
+
if self.reconnect_attempts:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
logging.warning(f"⚠️ Invalid FPS reported ({self.fps}). Using default 30 FPS.")
|
|
86
|
+
self.fps = 30
|
|
87
|
+
|
|
88
|
+
if self.is_file:
|
|
89
|
+
total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
|
|
90
|
+
duration = (total_frames / self.fps) if self.fps > 0 and total_frames > 0 else 0
|
|
91
|
+
logging.info(f"✅ Connected to video file {self.source} at {self.fps:.2f} FPS, {total_frames} frames, {duration:.2f}s duration")
|
|
92
|
+
else:
|
|
93
|
+
logging.info(f"✅ Connected to stream {self.source} at {self.fps:.2f} FPS")
|
|
94
|
+
|
|
95
|
+
self.connected = True
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logging.error(f"❌ Error initializing capture for {self.source}: {e}")
|
|
100
|
+
self.connected = False
|
|
101
|
+
if self.capture:
|
|
102
|
+
try:
|
|
103
|
+
self.capture.release()
|
|
104
|
+
except:
|
|
105
|
+
pass
|
|
106
|
+
self.capture = None
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
def run(self):
|
|
110
|
+
"""Main thread loop that continuously captures frames with automatic reconnection."""
|
|
111
|
+
retry_count = 0
|
|
112
|
+
frame_interval = 0 # Will be calculated once we know the FPS
|
|
113
|
+
|
|
114
|
+
while self.running:
|
|
115
|
+
try:
|
|
116
|
+
# Check if we should exit
|
|
117
|
+
if not self.running:
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
# (Re)connect if needed
|
|
121
|
+
if self.capture is None or not self.capture.isOpened():
|
|
122
|
+
# Check if we've exceeded the maximum reconnection attempts
|
|
123
|
+
if self.reconnect_attempts >= self.max_reconnect_attempts:
|
|
124
|
+
logging.error(f"❌ Exceeded maximum reconnection attempts ({self.max_reconnect_attempts}) for {self.source}. Giving up.")
|
|
125
|
+
self.running = False
|
|
126
|
+
break
|
|
127
|
+
|
|
128
|
+
if retry_count:
|
|
129
|
+
self.reconnect_attempts += 1
|
|
130
|
+
|
|
131
|
+
if not self._initialize_capture():
|
|
132
|
+
# Apply exponential backoff to reconnection interval
|
|
133
|
+
self.current_reconnect_interval *= self.reconnect_backoff_factor
|
|
134
|
+
|
|
135
|
+
logging.warning(f"⚠️ Reconnection attempt {self.reconnect_attempts}/{self.max_reconnect_attempts} "
|
|
136
|
+
f"failed. Next attempt in {self.current_reconnect_interval:.1f}s...")
|
|
137
|
+
|
|
138
|
+
# Check for thread stop before sleeping
|
|
139
|
+
if not self.running:
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
time.sleep(self.current_reconnect_interval)
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
# Reset counters on successful connection
|
|
146
|
+
retry_count = 0
|
|
147
|
+
# Only reset reconnect_attempts if we've been connected for a meaningful duration
|
|
148
|
+
# This prevents rapid success/fail cycles from resetting the counter
|
|
149
|
+
if self.connected:
|
|
150
|
+
self.reconnect_attempts = 0
|
|
151
|
+
self.current_reconnect_interval = self.reconnect_interval
|
|
152
|
+
|
|
153
|
+
frame_interval = 1.0 / self.fps if self.fps > 0 else 0.033
|
|
154
|
+
|
|
155
|
+
# Check if we should exit before reading frame
|
|
156
|
+
if not self.running:
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
# Read the next frame
|
|
160
|
+
read_start = time.time()
|
|
161
|
+
ret, frame = self.capture.read()
|
|
162
|
+
|
|
163
|
+
# Handle frame read failure
|
|
164
|
+
if not ret or frame is None or frame.size == 0:
|
|
165
|
+
if self.is_file:
|
|
166
|
+
# For video files, check if we've reached the end
|
|
167
|
+
current_pos = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
|
|
168
|
+
total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
|
|
169
|
+
|
|
170
|
+
# If we're at or beyond the end of the video, restart from beginning
|
|
171
|
+
if current_pos >= total_frames - 1:
|
|
172
|
+
logging.info(f"🔄 Video file {self.source} reached end. Restarting from beginning...")
|
|
173
|
+
self.capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
174
|
+
# Reset retry count for video files when restarting
|
|
175
|
+
retry_count = 0
|
|
176
|
+
continue
|
|
177
|
+
else:
|
|
178
|
+
# We're not at the end, this might be a real error
|
|
179
|
+
retry_count += 1
|
|
180
|
+
logging.warning(f"⚠️ Failed to read frame from {self.source} at position {current_pos}/{total_frames} (attempt {retry_count}/{self.retry_limit})")
|
|
181
|
+
else:
|
|
182
|
+
# For non-file sources (cameras, streams), increment retry count
|
|
183
|
+
retry_count += 1
|
|
184
|
+
logging.warning(f"⚠️ Failed to read frame from {self.source} (attempt {retry_count}/{self.retry_limit})")
|
|
185
|
+
|
|
186
|
+
if retry_count > self.retry_limit:
|
|
187
|
+
logging.error(f"❌ Too many consecutive frame failures. Reconnecting...")
|
|
188
|
+
self.connected = False
|
|
189
|
+
if self.capture and self.running: # Only release if we're still running
|
|
190
|
+
try:
|
|
191
|
+
self.capture.release()
|
|
192
|
+
self.capture = None
|
|
193
|
+
except Exception as e:
|
|
194
|
+
logging.error(f"Error releasing capture during failure: {e}")
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
if not self.running:
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
time.sleep(0.1)
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
# Reset retry count on successful frame
|
|
204
|
+
retry_count = 0
|
|
205
|
+
self.frame_count += 1
|
|
206
|
+
|
|
207
|
+
# Store the frame - but make sure we're still running first
|
|
208
|
+
if not self.running:
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
with self.lock:
|
|
212
|
+
self._latest_frame = frame.copy()
|
|
213
|
+
|
|
214
|
+
# Regulate frame rate to match source FPS for efficiency
|
|
215
|
+
# This helps prevent CPU overuse when reading from fast sources
|
|
216
|
+
elapsed = time.time() - read_start
|
|
217
|
+
sleep_time = max(0, frame_interval - elapsed)
|
|
218
|
+
if sleep_time > 0 and self.running: # Check running before sleep
|
|
219
|
+
time.sleep(sleep_time)
|
|
220
|
+
|
|
221
|
+
except cv2.error as cv_err:
|
|
222
|
+
logging.error(f"❌ OpenCV error in frame processing: {cv_err}")
|
|
223
|
+
self.connected = False
|
|
224
|
+
if not self.running:
|
|
225
|
+
break
|
|
226
|
+
time.sleep(1) # Brief pause before retry
|
|
227
|
+
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logging.error(f"❌ Error in VideoStream {self.source}: {e}", exc_info=True)
|
|
230
|
+
self.connected = False
|
|
231
|
+
if not self.running:
|
|
232
|
+
break
|
|
233
|
+
time.sleep(self.reconnect_interval)
|
|
234
|
+
|
|
235
|
+
# Final cleanup
|
|
236
|
+
self._cleanup()
|
|
237
|
+
|
|
238
|
+
def _cleanup(self):
|
|
239
|
+
"""Release resources and perform cleanup."""
|
|
240
|
+
try:
|
|
241
|
+
if self.capture:
|
|
242
|
+
self.capture.release()
|
|
243
|
+
self.capture = None
|
|
244
|
+
|
|
245
|
+
with self.lock:
|
|
246
|
+
self._latest_frame = None
|
|
247
|
+
|
|
248
|
+
self.connected = False
|
|
249
|
+
logging.info(f"🔴 Stream {self.source} stopped and cleaned up.")
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
logging.error(f"Error during final VideoStream cleanup: {e}")
|
|
253
|
+
|
|
254
|
+
def get_frame(self) -> Optional[cv2.Mat]:
|
|
255
|
+
"""Returns the latest available frame (thread-safe).
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
A copy of the latest frame or None if no frames are available
|
|
259
|
+
"""
|
|
260
|
+
if not self.running:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
with self.lock:
|
|
264
|
+
if self._latest_frame is not None:
|
|
265
|
+
return self._latest_frame.copy()
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
def is_video_ended(self) -> bool:
|
|
269
|
+
"""Check if a video file has reached its end (only for video files).
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
True if video file has ended, False otherwise
|
|
273
|
+
"""
|
|
274
|
+
if not self.is_file or not self.capture or not self.capture.isOpened():
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
current_pos = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
|
|
279
|
+
total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
|
|
280
|
+
return current_pos >= total_frames - 1
|
|
281
|
+
except Exception:
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
def stop(self):
|
|
285
|
+
"""Stops the video stream and releases resources safely."""
|
|
286
|
+
if not self.running: # Prevent multiple stops
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
# Step 1: Signal the thread to stop
|
|
290
|
+
self.running = False
|
|
291
|
+
|
|
292
|
+
# Step 2: Wait for any ongoing OpenCV operations to complete
|
|
293
|
+
time.sleep(0.2) # Short delay to let operations finish
|
|
294
|
+
|
|
295
|
+
# Step 3: Explicitly release capture device before joining thread
|
|
296
|
+
# This is critical to avoid segmentation faults in some OpenCV implementations
|
|
297
|
+
if self.capture:
|
|
298
|
+
try:
|
|
299
|
+
logging.debug(f"Releasing capture device for {self.source}")
|
|
300
|
+
with self.lock: # Use lock to ensure thread isn't accessing capture during release
|
|
301
|
+
if self.capture:
|
|
302
|
+
self.capture.release()
|
|
303
|
+
self.capture = None
|
|
304
|
+
except Exception as e:
|
|
305
|
+
logging.error(f"Error releasing capture device: {e}")
|
|
306
|
+
|
|
307
|
+
# Step 4: Clear any references to frames to help garbage collection
|
|
308
|
+
with self.lock:
|
|
309
|
+
self._latest_frame = None
|
|
310
|
+
|
|
311
|
+
# Step 5: Wait for thread to exit
|
|
312
|
+
try:
|
|
313
|
+
if self.is_alive():
|
|
314
|
+
logging.debug(f"Waiting for thread to exit for stream {self.source}")
|
|
315
|
+
self.join(timeout=30) # Reduced timeout since we already released resources
|
|
316
|
+
|
|
317
|
+
# Check if thread is still alive
|
|
318
|
+
if self.is_alive():
|
|
319
|
+
logging.warning(f"⚠️ Stream {self.source} thread did not exit cleanly within timeout")
|
|
320
|
+
except Exception as e:
|
|
321
|
+
logging.error(f"Error joining thread: {e}")
|
|
322
|
+
|
|
323
|
+
# Final status update
|
|
324
|
+
self.connected = False
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from .VideoStream import VideoStream
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
class VideoStreamManager:
|
|
7
|
+
"""Manages multiple video streams dynamically using VideoStream threads."""
|
|
8
|
+
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.streams = {} # Store streams as {worker_source_id: VideoStream}
|
|
11
|
+
self.running = False
|
|
12
|
+
self.lock = threading.Lock() # Add thread lock
|
|
13
|
+
|
|
14
|
+
def add_stream(self, worker_source_id, url):
|
|
15
|
+
"""Adds a new video stream if it's not already active."""
|
|
16
|
+
if worker_source_id not in self.streams:
|
|
17
|
+
self.streams[worker_source_id] = VideoStream(url) # Create and start the VideoStream thread
|
|
18
|
+
|
|
19
|
+
else:
|
|
20
|
+
logging.warning(f"⚠️ Stream {worker_source_id} is already active.")
|
|
21
|
+
|
|
22
|
+
def remove_stream(self, worker_source_id):
|
|
23
|
+
"""Removes and stops a video stream."""
|
|
24
|
+
if not worker_source_id:
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
with self.lock:
|
|
28
|
+
if worker_source_id not in self.streams:
|
|
29
|
+
logging.warning(f"⚠️ Stream {worker_source_id} not found in manager.")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
logging.info(f"🛑 Removing video stream: {worker_source_id}")
|
|
33
|
+
|
|
34
|
+
# Get reference before removing from dict
|
|
35
|
+
stream = self.streams.pop(worker_source_id, None)
|
|
36
|
+
|
|
37
|
+
if stream:
|
|
38
|
+
try:
|
|
39
|
+
stream.stop()
|
|
40
|
+
|
|
41
|
+
except Exception as e:
|
|
42
|
+
logging.error(f"❌ Error stopping stream {worker_source_id}: {e}")
|
|
43
|
+
finally:
|
|
44
|
+
stream = None # Ensure cleanup
|
|
45
|
+
|
|
46
|
+
logging.info(f"✅ Stream {worker_source_id} removed successfully.")
|
|
47
|
+
|
|
48
|
+
def start_all(self):
|
|
49
|
+
"""Starts all video streams."""
|
|
50
|
+
logging.info("🔄 Starting all video streams...")
|
|
51
|
+
for stream in self.streams.values():
|
|
52
|
+
if not stream.is_alive():
|
|
53
|
+
stream.start() # Start thread if not already running
|
|
54
|
+
self.running = True
|
|
55
|
+
def stop_all(self):
|
|
56
|
+
"""Stops all video streams."""
|
|
57
|
+
logging.info("🛑 Stopping all video streams...")
|
|
58
|
+
|
|
59
|
+
with self.lock:
|
|
60
|
+
# Get a list of IDs to avoid modification during iteration
|
|
61
|
+
stream_ids = list(self.streams.keys())
|
|
62
|
+
|
|
63
|
+
# Stop each stream
|
|
64
|
+
for worker_source_id in stream_ids:
|
|
65
|
+
try:
|
|
66
|
+
self.remove_stream(worker_source_id)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logging.error(f"Error stopping stream {worker_source_id}: {e}")
|
|
69
|
+
|
|
70
|
+
self.running = False
|
|
71
|
+
|
|
72
|
+
def get_frame(self, worker_source_id):
|
|
73
|
+
"""Retrieves the latest frame for a specific stream."""
|
|
74
|
+
with self.lock: # Add lock protection for stream access
|
|
75
|
+
stream = self.streams.get(worker_source_id)
|
|
76
|
+
if stream is None:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
# Check if stream is still running
|
|
80
|
+
if not stream.running:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
# **Ignore warnings for the first 5 seconds**
|
|
85
|
+
elapsed_time = time.time() - stream.start_time
|
|
86
|
+
if elapsed_time < 5:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
# Check if video file has ended
|
|
90
|
+
if stream.is_file and stream.is_video_ended():
|
|
91
|
+
logging.debug(f"Video file {worker_source_id} has ended, waiting for restart...")
|
|
92
|
+
# Small delay to allow the video to restart
|
|
93
|
+
time.sleep(0.1)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
return stream.get_frame() # Already returns a copy
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logging.error(f"Error getting frame from stream {worker_source_id}: {e}")
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
def get_active_stream_ids(self):
|
|
102
|
+
"""Returns a list of active stream IDs."""
|
|
103
|
+
return list(self.streams.keys())
|
|
104
|
+
|
|
105
|
+
def get_stream_url(self, worker_source_id):
|
|
106
|
+
"""Returns the URL of a specific stream."""
|
|
107
|
+
stream = self.streams.get(worker_source_id)
|
|
108
|
+
return stream.source if stream else None
|
|
109
|
+
|
|
110
|
+
def has_stream(self, worker_source_id):
|
|
111
|
+
"""Checks if a stream is active."""
|
|
112
|
+
return worker_source_id in self.streams
|
|
113
|
+
|
|
114
|
+
def is_running(self):
|
|
115
|
+
"""Checks if the manager is running."""
|
|
116
|
+
return self.running
|
|
117
|
+
|
|
118
|
+
def is_video_file(self, worker_source_id):
|
|
119
|
+
"""Check if a stream is a video file."""
|
|
120
|
+
stream = self.streams.get(worker_source_id)
|
|
121
|
+
return stream.is_file if stream else False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|