nedo-vision-worker 1.1.3__py3-none-any.whl → 1.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.
- nedo_vision_worker/__init__.py +1 -1
- nedo_vision_worker/cli.py +196 -167
- nedo_vision_worker/database/DatabaseManager.py +3 -3
- nedo_vision_worker/doctor.py +1066 -386
- nedo_vision_worker/models/ai_model.py +35 -2
- nedo_vision_worker/protos/AIModelService_pb2.py +12 -10
- nedo_vision_worker/protos/AIModelService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/DatasetSourceService_pb2.py +2 -2
- nedo_vision_worker/protos/DatasetSourceService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/HumanDetectionService_pb2.py +2 -2
- nedo_vision_worker/protos/HumanDetectionService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/PPEDetectionService_pb2.py +2 -2
- nedo_vision_worker/protos/PPEDetectionService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/VisionWorkerService_pb2.py +2 -2
- nedo_vision_worker/protos/VisionWorkerService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/WorkerSourcePipelineService_pb2.py +2 -2
- nedo_vision_worker/protos/WorkerSourcePipelineService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/WorkerSourceService_pb2.py +2 -2
- nedo_vision_worker/protos/WorkerSourceService_pb2_grpc.py +1 -1
- nedo_vision_worker/services/AIModelClient.py +184 -160
- nedo_vision_worker/services/DirectDeviceToRTMPStreamer.py +534 -0
- nedo_vision_worker/services/GrpcClientBase.py +142 -108
- nedo_vision_worker/services/PPEDetectionClient.py +0 -7
- nedo_vision_worker/services/RestrictedAreaClient.py +0 -5
- nedo_vision_worker/services/SharedDirectDeviceClient.py +278 -0
- nedo_vision_worker/services/SharedVideoStreamServer.py +315 -0
- nedo_vision_worker/services/SystemWideDeviceCoordinator.py +236 -0
- nedo_vision_worker/services/VideoSharingDaemon.py +832 -0
- nedo_vision_worker/services/VideoStreamClient.py +30 -13
- nedo_vision_worker/services/WorkerSourceClient.py +1 -1
- nedo_vision_worker/services/WorkerSourcePipelineClient.py +28 -6
- nedo_vision_worker/services/WorkerSourceUpdater.py +30 -3
- nedo_vision_worker/util/VideoProbeUtil.py +222 -15
- nedo_vision_worker/worker/DataSyncWorker.py +1 -0
- nedo_vision_worker/worker/PipelineImageWorker.py +1 -1
- nedo_vision_worker/worker/VideoStreamWorker.py +27 -3
- nedo_vision_worker/worker/WorkerManager.py +2 -29
- nedo_vision_worker/worker_service.py +22 -5
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/METADATA +1 -3
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/RECORD +43 -38
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/WHEEL +0 -0
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/entry_points.txt +0 -0
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -7,6 +7,7 @@ import ffmpeg
|
|
|
7
7
|
import fractions
|
|
8
8
|
from urllib.parse import urlparse
|
|
9
9
|
from .GrpcClientBase import GrpcClientBase
|
|
10
|
+
from .SharedDirectDeviceClient import SharedDirectDeviceClient
|
|
10
11
|
from ..protos.VisionWorkerService_pb2_grpc import VideoStreamServiceStub
|
|
11
12
|
from ..protos.VisionWorkerService_pb2 import VideoFrame
|
|
12
13
|
|
|
@@ -15,9 +16,12 @@ class VideoStreamClient(GrpcClientBase):
|
|
|
15
16
|
def __init__(self, server_host: str, server_port: int = 50051):
|
|
16
17
|
"""Initialize the video stream client."""
|
|
17
18
|
super().__init__(server_host, server_port)
|
|
19
|
+
self.shared_device_client = SharedDirectDeviceClient()
|
|
18
20
|
|
|
19
21
|
def _detect_stream_type(self, url):
|
|
20
|
-
|
|
22
|
+
if isinstance(url, str) and url.isdigit():
|
|
23
|
+
return "direct"
|
|
24
|
+
|
|
21
25
|
parsed_url = urlparse(url)
|
|
22
26
|
if parsed_url.scheme == "rtsp":
|
|
23
27
|
return "rtsp"
|
|
@@ -27,8 +31,12 @@ class VideoStreamClient(GrpcClientBase):
|
|
|
27
31
|
return "unknown"
|
|
28
32
|
|
|
29
33
|
def _get_video_properties(self, url, stream_type):
|
|
30
|
-
"""Extract FPS, resolution, and pixel format dynamically for RTSP or HLS."""
|
|
31
34
|
try:
|
|
35
|
+
if stream_type == "direct":
|
|
36
|
+
# Use the shared device client for direct devices
|
|
37
|
+
width, height, fps, pixel_format = self.shared_device_client.get_video_properties(url)
|
|
38
|
+
return width, height, fps, pixel_format
|
|
39
|
+
|
|
32
40
|
probe_cmd = [
|
|
33
41
|
"ffprobe",
|
|
34
42
|
"-i", url,
|
|
@@ -59,12 +67,10 @@ class VideoStreamClient(GrpcClientBase):
|
|
|
59
67
|
return None, None, None, None
|
|
60
68
|
|
|
61
69
|
def _get_bytes_per_pixel(self, pixel_format):
|
|
62
|
-
"""Determine bytes per pixel based on pixel format."""
|
|
63
70
|
pixel_map = {"rgb24": 3, "yuv420p": 1.5, "gray": 1}
|
|
64
|
-
return pixel_map.get(pixel_format, 3)
|
|
71
|
+
return pixel_map.get(pixel_format, 3)
|
|
65
72
|
|
|
66
73
|
def _generate_frames(self, url, worker_id, uuid, stream_duration):
|
|
67
|
-
"""Generator function to continuously stream video frames to gRPC."""
|
|
68
74
|
stream_type = self._detect_stream_type(url)
|
|
69
75
|
if stream_type == "unknown":
|
|
70
76
|
logging.error(f"Unsupported stream type: {url}")
|
|
@@ -77,13 +83,20 @@ class VideoStreamClient(GrpcClientBase):
|
|
|
77
83
|
|
|
78
84
|
bytes_per_pixel = self._get_bytes_per_pixel(pixel_format)
|
|
79
85
|
frame_size = int(width * height * bytes_per_pixel)
|
|
80
|
-
frame_interval = 1.0 / fps
|
|
86
|
+
frame_interval = 1.0 / fps
|
|
81
87
|
start_time = time.time()
|
|
82
88
|
empty_frame_count = 0
|
|
83
89
|
|
|
84
90
|
logging.info(f"Streaming {stream_type.upper()} from: {url} for {stream_duration} seconds...")
|
|
85
91
|
|
|
86
|
-
if stream_type == "
|
|
92
|
+
if stream_type == "direct":
|
|
93
|
+
# Use the shared device client for direct devices
|
|
94
|
+
try:
|
|
95
|
+
ffmpeg_input = self.shared_device_client.create_ffmpeg_input(url, width, height, fps)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logging.error(f"Failed to create ffmpeg input for direct device: {e}")
|
|
98
|
+
return
|
|
99
|
+
elif stream_type == "rtsp":
|
|
87
100
|
ffmpeg_input = (
|
|
88
101
|
ffmpeg
|
|
89
102
|
.input(url, rtsp_transport="tcp", fflags="nobuffer", timeout="5000000")
|
|
@@ -112,12 +125,12 @@ class VideoStreamClient(GrpcClientBase):
|
|
|
112
125
|
empty_frame_count += 1
|
|
113
126
|
logging.warning(f"Empty frame received ({empty_frame_count}), retrying...")
|
|
114
127
|
|
|
115
|
-
if empty_frame_count > 5:
|
|
128
|
+
if empty_frame_count > 5:
|
|
116
129
|
logging.error("Too many empty frames, stopping stream...")
|
|
117
130
|
break
|
|
118
|
-
continue
|
|
131
|
+
continue
|
|
119
132
|
|
|
120
|
-
empty_frame_count = 0
|
|
133
|
+
empty_frame_count = 0
|
|
121
134
|
yield VideoFrame(
|
|
122
135
|
worker_id=worker_id,
|
|
123
136
|
uuid=uuid,
|
|
@@ -125,16 +138,20 @@ class VideoStreamClient(GrpcClientBase):
|
|
|
125
138
|
timestamp=int(time.time() * 1000),
|
|
126
139
|
)
|
|
127
140
|
|
|
128
|
-
time.sleep(frame_interval)
|
|
141
|
+
time.sleep(frame_interval)
|
|
129
142
|
|
|
130
143
|
except Exception as e:
|
|
131
144
|
logging.error(f"Streaming error: {e}")
|
|
132
145
|
|
|
133
146
|
finally:
|
|
147
|
+
# Release device access for direct devices
|
|
148
|
+
if stream_type == "direct":
|
|
149
|
+
self.shared_device_client.release_device_access(url)
|
|
150
|
+
|
|
134
151
|
stderr_output = process.stderr.read().decode()
|
|
135
|
-
logging.error(f"FFmpeg stderr: {stderr_output}")
|
|
152
|
+
logging.error(f"FFmpeg stderr: {stderr_output}")
|
|
136
153
|
process.terminate()
|
|
137
|
-
process.wait()
|
|
154
|
+
process.wait()
|
|
138
155
|
|
|
139
156
|
def stream_video(self, worker_id, uuid, url, stream_duration):
|
|
140
157
|
"""
|
|
@@ -87,7 +87,7 @@ class WorkerSourceClient(GrpcClientBase):
|
|
|
87
87
|
if existing_source.worker_id != source.worker_id:
|
|
88
88
|
changes.append(f"worker_id: {existing_source.worker_id} → {source.worker_id}")
|
|
89
89
|
if existing_source.type_code != source.type_code:
|
|
90
|
-
if source.type_code
|
|
90
|
+
if source.type_code in ["live", "direct"]:
|
|
91
91
|
self.delete_local_source_file(existing_source.file_path)
|
|
92
92
|
elif existing_source.file_path == source.file_path:
|
|
93
93
|
self.download_source_file(source)
|
|
@@ -9,6 +9,7 @@ from ..database.DatabaseManager import _get_storage_paths
|
|
|
9
9
|
from ..repositories.WorkerSourcePipelineDebugRepository import WorkerSourcePipelineDebugRepository
|
|
10
10
|
from ..repositories.WorkerSourcePipelineDetectionRepository import WorkerSourcePipelineDetectionRepository
|
|
11
11
|
from .GrpcClientBase import GrpcClientBase
|
|
12
|
+
from .SharedDirectDeviceClient import SharedDirectDeviceClient
|
|
12
13
|
from ..protos.WorkerSourcePipelineService_pb2_grpc import WorkerSourcePipelineServiceStub
|
|
13
14
|
from ..protos.WorkerSourcePipelineService_pb2 import GetListByWorkerIdRequest, SendPipelineImageRequest, UpdatePipelineStatusRequest, SendPipelineDebugRequest, SendPipelineDetectionDataRequest
|
|
14
15
|
from ..repositories.WorkerSourcePipelineRepository import WorkerSourcePipelineRepository
|
|
@@ -22,6 +23,7 @@ class WorkerSourcePipelineClient(GrpcClientBase):
|
|
|
22
23
|
self.detection_repo = WorkerSourcePipelineDetectionRepository()
|
|
23
24
|
storage_paths = _get_storage_paths()
|
|
24
25
|
self.source_file_path = storage_paths["files"] / "source_files"
|
|
26
|
+
self.shared_device_client = SharedDirectDeviceClient()
|
|
25
27
|
|
|
26
28
|
# Track video playback positions and last fetch times
|
|
27
29
|
self.video_positions = {} # {video_path: current_position_in_seconds}
|
|
@@ -34,7 +36,9 @@ class WorkerSourcePipelineClient(GrpcClientBase):
|
|
|
34
36
|
self.stub = None
|
|
35
37
|
|
|
36
38
|
def _detect_stream_type(self, url):
|
|
37
|
-
|
|
39
|
+
if isinstance(url, str) and url.isdigit():
|
|
40
|
+
return "direct"
|
|
41
|
+
|
|
38
42
|
parsed_url = urlparse(url)
|
|
39
43
|
if parsed_url.scheme == "rtsp":
|
|
40
44
|
return "rtsp"
|
|
@@ -169,10 +173,27 @@ class WorkerSourcePipelineClient(GrpcClientBase):
|
|
|
169
173
|
return status
|
|
170
174
|
|
|
171
175
|
def _get_single_frame_bytes(self, url):
|
|
172
|
-
"""Get a single frame from RTSP, HLS, or video file as JPEG bytes."""
|
|
173
176
|
stream_type = self._detect_stream_type(url)
|
|
174
177
|
|
|
175
|
-
if stream_type == "
|
|
178
|
+
if stream_type == "direct":
|
|
179
|
+
device_index = int(url)
|
|
180
|
+
logging.info(f"📹 [APP] Capturing frame from direct video device: {device_index}")
|
|
181
|
+
|
|
182
|
+
# Use the shared device client for direct devices
|
|
183
|
+
try:
|
|
184
|
+
# Get device properties first
|
|
185
|
+
width, height, fps, pixel_format = self.shared_device_client.get_video_properties(url)
|
|
186
|
+
if not width or not height:
|
|
187
|
+
logging.error(f"Failed to get properties for device {device_index}")
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
# Create ffmpeg input using shared device client
|
|
191
|
+
ffmpeg_input = self.shared_device_client.create_ffmpeg_input(url, width, height, fps)
|
|
192
|
+
|
|
193
|
+
except Exception as e:
|
|
194
|
+
logging.error(f"Error setting up direct device {device_index}: {e}")
|
|
195
|
+
return None
|
|
196
|
+
elif stream_type == "rtsp":
|
|
176
197
|
ffmpeg_input = (
|
|
177
198
|
ffmpeg
|
|
178
199
|
.input(url, rtsp_transport="tcp", fflags="nobuffer", timeout="5000000")
|
|
@@ -186,13 +207,11 @@ class WorkerSourcePipelineClient(GrpcClientBase):
|
|
|
186
207
|
file_path = self.source_file_path / os.path.basename(url)
|
|
187
208
|
file_path_str = str(file_path)
|
|
188
209
|
|
|
189
|
-
# Check if file exists
|
|
190
210
|
if not os.path.exists(file_path_str):
|
|
191
211
|
logging.error(f"Video file does not exist: {file_path_str}")
|
|
192
212
|
return None
|
|
193
213
|
|
|
194
214
|
current_position = self._get_current_video_position(file_path_str)
|
|
195
|
-
|
|
196
215
|
logging.info(f"🎬 [APP] Capturing video frame at position {current_position:.2f}s from {file_path_str}")
|
|
197
216
|
|
|
198
217
|
ffmpeg_input = (
|
|
@@ -201,7 +220,6 @@ class WorkerSourcePipelineClient(GrpcClientBase):
|
|
|
201
220
|
)
|
|
202
221
|
elif stream_type == "image_file":
|
|
203
222
|
file_path = self.source_file_path / os.path.basename(url)
|
|
204
|
-
|
|
205
223
|
logging.info(f"🖼️ [APP] Capturing image frame from {file_path}")
|
|
206
224
|
|
|
207
225
|
ffmpeg_input = (
|
|
@@ -246,6 +264,10 @@ class WorkerSourcePipelineClient(GrpcClientBase):
|
|
|
246
264
|
return None
|
|
247
265
|
|
|
248
266
|
finally:
|
|
267
|
+
# Release device access for direct devices
|
|
268
|
+
if stream_type == "direct":
|
|
269
|
+
self.shared_device_client.release_device_access(url)
|
|
270
|
+
|
|
249
271
|
process.terminate()
|
|
250
272
|
process.wait()
|
|
251
273
|
|
|
@@ -27,8 +27,13 @@ class WorkerSourceUpdater:
|
|
|
27
27
|
self._lock = threading.RLock()
|
|
28
28
|
|
|
29
29
|
def _get_source_metadata(self, source):
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
if source.type_code == "live":
|
|
31
|
+
url = source.url
|
|
32
|
+
elif source.type_code == "direct":
|
|
33
|
+
url = source.url
|
|
34
|
+
else:
|
|
35
|
+
url = source.file_path
|
|
36
|
+
|
|
32
37
|
if not url:
|
|
33
38
|
return None
|
|
34
39
|
|
|
@@ -50,7 +55,29 @@ class WorkerSourceUpdater:
|
|
|
50
55
|
for source in worker_sources:
|
|
51
56
|
metadata = self._get_source_metadata(source)
|
|
52
57
|
if not metadata:
|
|
53
|
-
logger.warning(f"⚠️ [APP] Failed to probe video for Worker Source ID {source.id}")
|
|
58
|
+
logger.warning(f"⚠️ [APP] Failed to probe video for Worker Source ID {source.id} (type: {source.type_code}, url: {source.url if hasattr(source, 'url') else 'N/A'})")
|
|
59
|
+
# Set disconnected status for failed probes
|
|
60
|
+
if source.status_code != "disconnected":
|
|
61
|
+
source.status_code = "disconnected"
|
|
62
|
+
source.resolution = None
|
|
63
|
+
source.frame_rate = None
|
|
64
|
+
updated_records.append(source)
|
|
65
|
+
|
|
66
|
+
# Send gRPC update for disconnected status
|
|
67
|
+
worker_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
68
|
+
response = self.client.update_worker_source(
|
|
69
|
+
worker_source_id=source.id,
|
|
70
|
+
resolution=None,
|
|
71
|
+
status_code="disconnected",
|
|
72
|
+
frame_rate=None,
|
|
73
|
+
worker_timestamp=worker_timestamp,
|
|
74
|
+
token=self.token,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if response.get("success"):
|
|
78
|
+
logger.info(f"✅ [APP] Updated Worker Source ID {source.id} to disconnected")
|
|
79
|
+
else:
|
|
80
|
+
logger.error(f"🚨 [APP] Failed to update Worker Source ID {source.id} to disconnected: {response.get('message')}")
|
|
54
81
|
continue
|
|
55
82
|
|
|
56
83
|
# Extract details
|
|
@@ -4,16 +4,35 @@ import logging
|
|
|
4
4
|
import json
|
|
5
5
|
import fractions
|
|
6
6
|
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import platform
|
|
9
|
+
from pathlib import Path
|
|
7
10
|
from urllib.parse import urlparse
|
|
8
11
|
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from nedo_vision_worker.services.VideoSharingDaemon import VideoSharingClient
|
|
15
|
+
except ImportError:
|
|
16
|
+
logging.warning("VideoSharingDaemon not available")
|
|
17
|
+
VideoSharingClient = None
|
|
18
|
+
|
|
19
|
+
# Import DatabaseManager for storage path
|
|
20
|
+
try:
|
|
21
|
+
from nedo_vision_worker.database.DatabaseManager import get_storage_path
|
|
22
|
+
except ImportError:
|
|
23
|
+
get_storage_path = None
|
|
24
|
+
|
|
9
25
|
class VideoProbeUtil:
|
|
10
26
|
"""Utility to extract metadata from video URLs using OpenCV and ffmpeg."""
|
|
11
27
|
|
|
12
28
|
@staticmethod
|
|
13
29
|
def get_video_metadata(video_url: str) -> dict:
|
|
14
|
-
"""Extracts resolution and frame rate from a video URL using OpenCV or ffmpeg."""
|
|
15
30
|
try:
|
|
16
|
-
|
|
31
|
+
if isinstance(video_url, str) and video_url.isdigit():
|
|
32
|
+
metadata = VideoProbeUtil._get_metadata_direct_device(video_url)
|
|
33
|
+
if metadata:
|
|
34
|
+
return metadata
|
|
35
|
+
|
|
17
36
|
metadata = VideoProbeUtil._get_metadata_ffmpeg(video_url)
|
|
18
37
|
return metadata
|
|
19
38
|
|
|
@@ -45,13 +64,208 @@ class VideoProbeUtil:
|
|
|
45
64
|
"timestamp": None
|
|
46
65
|
}
|
|
47
66
|
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _get_metadata_opencv_direct_device(device_idx: int) -> dict:
|
|
69
|
+
"""Fallback method to get metadata directly from camera device using OpenCV."""
|
|
70
|
+
try:
|
|
71
|
+
logging.debug(f"Attempting direct OpenCV access to device {device_idx}")
|
|
72
|
+
cap = cv2.VideoCapture(device_idx)
|
|
73
|
+
|
|
74
|
+
if not cap.isOpened():
|
|
75
|
+
logging.warning(f"⚠️ [APP] OpenCV failed to open device {device_idx}")
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
# Set a reasonable timeout and try to read a frame
|
|
79
|
+
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to get fresh frames
|
|
80
|
+
|
|
81
|
+
# Try multiple attempts to read a frame (some cameras need warm-up)
|
|
82
|
+
ret, frame = False, None
|
|
83
|
+
for attempt in range(3):
|
|
84
|
+
ret, frame = cap.read()
|
|
85
|
+
if ret and frame is not None:
|
|
86
|
+
break
|
|
87
|
+
logging.debug(f"Attempt {attempt + 1} failed to read frame from device {device_idx}")
|
|
88
|
+
|
|
89
|
+
if not ret or frame is None:
|
|
90
|
+
logging.warning(f"⚠️ [APP] OpenCV failed to read frame from device {device_idx} after 3 attempts")
|
|
91
|
+
cap.release()
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
# Get video properties
|
|
95
|
+
height, width = frame.shape[:2]
|
|
96
|
+
frame_rate = cap.get(cv2.CAP_PROP_FPS)
|
|
97
|
+
|
|
98
|
+
# Some cameras report 0 FPS, use a reasonable default
|
|
99
|
+
if frame_rate <= 0:
|
|
100
|
+
frame_rate = 30.0
|
|
101
|
+
else:
|
|
102
|
+
frame_rate = round(frame_rate, 2)
|
|
103
|
+
|
|
104
|
+
cap.release()
|
|
105
|
+
|
|
106
|
+
logging.info(f"✅ [APP] Successfully probed device {device_idx} directly: {width}x{height} @ {frame_rate}fps")
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"resolution": f"{width}x{height}",
|
|
110
|
+
"frame_rate": frame_rate,
|
|
111
|
+
"timestamp": None
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logging.error(f"❌ [APP] Error probing device {device_idx} with OpenCV: {e}")
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _get_metadata_ffmpeg_direct_device(device_idx: int) -> dict:
|
|
120
|
+
"""Fallback method to get metadata from camera device using FFmpeg."""
|
|
121
|
+
if not shutil.which("ffprobe"):
|
|
122
|
+
logging.warning("⚠️ [APP] ffprobe not available for device probing")
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
system = platform.system().lower()
|
|
127
|
+
|
|
128
|
+
# Determine the device input format based on OS
|
|
129
|
+
if system == "linux":
|
|
130
|
+
device_path = f"/dev/video{device_idx}"
|
|
131
|
+
input_format = "v4l2"
|
|
132
|
+
cmd = ["ffprobe", "-f", input_format, "-i", device_path]
|
|
133
|
+
elif system == "windows":
|
|
134
|
+
# Windows DirectShow device
|
|
135
|
+
input_format = "dshow"
|
|
136
|
+
device_name = f"video={device_idx}" # This might need adjustment based on actual device names
|
|
137
|
+
cmd = ["ffprobe", "-f", input_format, "-i", device_name]
|
|
138
|
+
elif system == "darwin": # macOS
|
|
139
|
+
input_format = "avfoundation"
|
|
140
|
+
cmd = ["ffprobe", "-f", input_format, "-i", str(device_idx)]
|
|
141
|
+
else:
|
|
142
|
+
logging.warning(f"⚠️ [APP] Unsupported platform for FFmpeg device access: {system}")
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
# Add common ffprobe arguments
|
|
146
|
+
cmd.extend([
|
|
147
|
+
"-v", "error",
|
|
148
|
+
"-select_streams", "v:0",
|
|
149
|
+
"-show_entries", "stream=width,height,avg_frame_rate",
|
|
150
|
+
"-of", "json"
|
|
151
|
+
])
|
|
152
|
+
|
|
153
|
+
logging.debug(f"Running FFmpeg command: {' '.join(cmd)}")
|
|
154
|
+
|
|
155
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
156
|
+
|
|
157
|
+
if result.returncode != 0:
|
|
158
|
+
logging.warning(f"⚠️ [APP] FFmpeg failed for device {device_idx}: {result.stderr.strip()}")
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
if not result.stdout.strip():
|
|
162
|
+
logging.warning(f"⚠️ [APP] No output from FFmpeg for device {device_idx}")
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
metadata = json.loads(result.stdout)
|
|
166
|
+
streams = metadata.get("streams", [])
|
|
167
|
+
|
|
168
|
+
if not streams:
|
|
169
|
+
logging.warning(f"⚠️ [APP] No video streams found for device {device_idx}")
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
stream = streams[0]
|
|
173
|
+
width = stream.get("width")
|
|
174
|
+
height = stream.get("height")
|
|
175
|
+
avg_fps = stream.get("avg_frame_rate", "30/1")
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
frame_rate = round(float(fractions.Fraction(avg_fps)), 2)
|
|
179
|
+
except (ValueError, ZeroDivisionError):
|
|
180
|
+
frame_rate = 30.0
|
|
181
|
+
|
|
182
|
+
if not width or not height:
|
|
183
|
+
logging.warning(f"⚠️ [APP] Invalid resolution from FFmpeg for device {device_idx}")
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
logging.info(f"✅ [APP] Successfully probed device {device_idx} with FFmpeg: {width}x{height} @ {frame_rate}fps")
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
"resolution": f"{width}x{height}",
|
|
190
|
+
"frame_rate": frame_rate,
|
|
191
|
+
"timestamp": None
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
except subprocess.TimeoutExpired:
|
|
195
|
+
logging.warning(f"⚠️ [APP] FFmpeg timeout for device {device_idx}")
|
|
196
|
+
except json.JSONDecodeError:
|
|
197
|
+
logging.error(f"❌ [APP] Failed to parse FFmpeg output for device {device_idx}")
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logging.error(f"❌ [APP] Error probing device {device_idx} with FFmpeg: {e}")
|
|
200
|
+
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _get_metadata_direct_device(device_index: str) -> dict:
|
|
205
|
+
try:
|
|
206
|
+
device_idx = int(device_index)
|
|
207
|
+
|
|
208
|
+
logging.debug(f"VideoSharingClient available: {VideoSharingClient is not None}")
|
|
209
|
+
|
|
210
|
+
# Try to use VideoSharingClient first for cross-process access
|
|
211
|
+
if VideoSharingClient:
|
|
212
|
+
try:
|
|
213
|
+
logging.debug("Attempting to use VideoSharingClient...")
|
|
214
|
+
|
|
215
|
+
# Get storage path from DatabaseManager
|
|
216
|
+
storage_path = None
|
|
217
|
+
if get_storage_path:
|
|
218
|
+
try:
|
|
219
|
+
storage_path = str(get_storage_path())
|
|
220
|
+
logging.debug(f"Got storage path: {storage_path}")
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logging.debug(f"Could not get storage path: {e}")
|
|
223
|
+
|
|
224
|
+
# Create temporary client to get device properties
|
|
225
|
+
video_client = VideoSharingClient(device_idx, storage_path=storage_path)
|
|
226
|
+
logging.debug(f"Created VideoSharingClient, info_file: {video_client.info_file}")
|
|
227
|
+
|
|
228
|
+
# Load daemon info to get properties without connecting
|
|
229
|
+
if video_client._load_daemon_info():
|
|
230
|
+
width = video_client.width
|
|
231
|
+
height = video_client.height
|
|
232
|
+
fps = video_client.fps
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
"resolution": f"{width}x{height}",
|
|
236
|
+
"frame_rate": round(fps, 2) if fps > 0 else 30.0,
|
|
237
|
+
"timestamp": None
|
|
238
|
+
}
|
|
239
|
+
else:
|
|
240
|
+
logging.debug(f"Video sharing daemon not available for device {device_idx}")
|
|
241
|
+
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logging.debug(f"Video sharing not available for device {device_idx}: {e}")
|
|
244
|
+
import traceback
|
|
245
|
+
logging.debug(traceback.format_exc())
|
|
246
|
+
|
|
247
|
+
# Fallback 1: Try direct OpenCV access
|
|
248
|
+
logging.info(f"🔄 [APP] Daemon not available for device {device_idx}, falling back to direct OpenCV access")
|
|
249
|
+
metadata = VideoProbeUtil._get_metadata_opencv_direct_device(device_idx)
|
|
250
|
+
if metadata:
|
|
251
|
+
return metadata
|
|
252
|
+
|
|
253
|
+
# Fallback 2: Try FFmpeg for device access (Linux v4l2, Windows dshow)
|
|
254
|
+
logging.info(f"🔄 [APP] OpenCV failed for device {device_idx}, trying FFmpeg device access")
|
|
255
|
+
return VideoProbeUtil._get_metadata_ffmpeg_direct_device(device_idx)
|
|
256
|
+
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logging.error(f"❌ [APP] Error getting metadata from direct device {device_index}: {e}")
|
|
259
|
+
return None
|
|
260
|
+
|
|
48
261
|
@staticmethod
|
|
49
262
|
def _detect_stream_type(video_url: str) -> str:
|
|
50
|
-
"""Detect whether the URL is RTSP, local file, or other type."""
|
|
51
|
-
# Convert PosixPath to string if needed
|
|
52
263
|
if hasattr(video_url, '__str__'):
|
|
53
264
|
video_url = str(video_url)
|
|
54
265
|
|
|
266
|
+
if isinstance(video_url, str) and video_url.isdigit():
|
|
267
|
+
return "direct"
|
|
268
|
+
|
|
55
269
|
parsed_url = urlparse(video_url)
|
|
56
270
|
if parsed_url.scheme == "rtsp":
|
|
57
271
|
return "rtsp"
|
|
@@ -62,45 +276,38 @@ class VideoProbeUtil:
|
|
|
62
276
|
|
|
63
277
|
@staticmethod
|
|
64
278
|
def _get_metadata_ffmpeg(video_url: str) -> dict:
|
|
65
|
-
# Check if ffprobe is available
|
|
66
279
|
if not shutil.which("ffprobe"):
|
|
67
280
|
logging.error("⚠️ [APP] ffprobe is not installed or not found in PATH.")
|
|
68
281
|
return None
|
|
69
282
|
|
|
70
|
-
# Detect stream type
|
|
71
283
|
stream_type = VideoProbeUtil._detect_stream_type(video_url)
|
|
72
284
|
|
|
73
|
-
|
|
285
|
+
if stream_type == "direct":
|
|
286
|
+
return VideoProbeUtil._get_metadata_direct_device(video_url)
|
|
287
|
+
|
|
74
288
|
cmd = ["ffprobe", "-v", "error", "-select_streams", "v:0",
|
|
75
289
|
"-show_entries", "stream=width,height,avg_frame_rate", "-of", "json"]
|
|
76
290
|
|
|
77
|
-
# Add RTSP transport option only for RTSP streams
|
|
78
291
|
if stream_type == "rtsp":
|
|
79
292
|
cmd.insert(1, "-rtsp_transport")
|
|
80
293
|
cmd.insert(2, "tcp")
|
|
81
294
|
|
|
82
|
-
# Add the video URL
|
|
83
295
|
cmd.append(video_url)
|
|
84
296
|
|
|
85
297
|
try:
|
|
86
|
-
# Run ffprobe command
|
|
87
298
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
88
299
|
|
|
89
|
-
# Check for errors
|
|
90
300
|
if result.returncode != 0 or not result.stdout.strip():
|
|
91
301
|
logging.warning(f"⚠️ [APP] ffprobe failed for {video_url}: {result.stderr.strip()}")
|
|
92
302
|
return None
|
|
93
303
|
|
|
94
|
-
# Parse JSON output
|
|
95
304
|
metadata = json.loads(result.stdout)
|
|
96
305
|
streams = metadata.get("streams", [{}])[0]
|
|
97
306
|
|
|
98
|
-
# Extract metadata
|
|
99
307
|
width = streams.get("width")
|
|
100
308
|
height = streams.get("height")
|
|
101
309
|
avg_fps = streams.get("avg_frame_rate", "0/1")
|
|
102
310
|
|
|
103
|
-
# Convert FPS safely
|
|
104
311
|
try:
|
|
105
312
|
frame_rate = round(float(fractions.Fraction(avg_fps)), 2)
|
|
106
313
|
except (ValueError, ZeroDivisionError):
|
|
@@ -109,7 +316,7 @@ class VideoProbeUtil:
|
|
|
109
316
|
return {
|
|
110
317
|
"resolution": f"{width}x{height}" if width and height else None,
|
|
111
318
|
"frame_rate": frame_rate,
|
|
112
|
-
"timestamp": None
|
|
319
|
+
"timestamp": None
|
|
113
320
|
}
|
|
114
321
|
|
|
115
322
|
except subprocess.TimeoutExpired:
|
|
@@ -64,6 +64,7 @@ class DataSyncWorker:
|
|
|
64
64
|
"""Stop the data sync worker."""
|
|
65
65
|
logging.info("🛑 [DATA SYNC] Stopping DataSyncWorker.")
|
|
66
66
|
self.running = False
|
|
67
|
+
self.ai_model_client.cleanup_downloads()
|
|
67
68
|
safe_join_thread(self.thread)
|
|
68
69
|
logging.info("🛑 [DATA SYNC] DataSyncWorker stopped.")
|
|
69
70
|
|
|
@@ -112,7 +112,7 @@ class PipelineImageWorker:
|
|
|
112
112
|
response = self.worker_source_pipeline_client.send_pipeline_image(
|
|
113
113
|
worker_source_pipeline_id=worker_source_pipeline_id,
|
|
114
114
|
uuid=uuid,
|
|
115
|
-
url=worker_source.url if worker_source.type_code
|
|
115
|
+
url=worker_source.url if worker_source.type_code in ["live", "direct"] else worker_source.file_path,
|
|
116
116
|
token=self.token
|
|
117
117
|
)
|
|
118
118
|
|
|
@@ -4,6 +4,8 @@ import logging
|
|
|
4
4
|
import json
|
|
5
5
|
from ..database.DatabaseManager import get_storage_path
|
|
6
6
|
from ..services.FileToRTMPServer import FileToRTMPStreamer
|
|
7
|
+
from ..services.DirectDeviceToRTMPStreamer import DirectDeviceToRTMPStreamer
|
|
8
|
+
from ..services.SharedDirectDeviceClient import SharedDirectDeviceClient
|
|
7
9
|
from .RabbitMQListener import RabbitMQListener
|
|
8
10
|
from ..services.RTSPtoRTMPStreamer import RTSPtoRTMPStreamer
|
|
9
11
|
|
|
@@ -44,6 +46,9 @@ class VideoStreamWorker:
|
|
|
44
46
|
self.thread = None
|
|
45
47
|
self.stop_event = threading.Event()
|
|
46
48
|
self.lock = threading.Lock()
|
|
49
|
+
|
|
50
|
+
# Initialize shared device client for direct video devices
|
|
51
|
+
self.shared_device_client = SharedDirectDeviceClient()
|
|
47
52
|
|
|
48
53
|
# Initialize RabbitMQ listener
|
|
49
54
|
self.listener = RabbitMQListener(
|
|
@@ -75,6 +80,11 @@ class VideoStreamWorker:
|
|
|
75
80
|
safe_join_thread(self.thread) # Ensures the thread stops gracefully
|
|
76
81
|
self.thread = None
|
|
77
82
|
logger.info(f"🛑 [APP] Stream Worker stopped (Device: {self.worker_id}).")
|
|
83
|
+
|
|
84
|
+
def _is_direct_device(self, url) -> bool:
|
|
85
|
+
"""Check if the URL is a direct video device."""
|
|
86
|
+
is_device, _ = self.shared_device_client._is_direct_device(url)
|
|
87
|
+
return is_device
|
|
78
88
|
|
|
79
89
|
def _run(self):
|
|
80
90
|
"""Main loop to manage RabbitMQ listener."""
|
|
@@ -108,8 +118,19 @@ class VideoStreamWorker:
|
|
|
108
118
|
|
|
109
119
|
logger.info(f"📡 [APP] Received video preview message ({data})")
|
|
110
120
|
|
|
111
|
-
|
|
112
|
-
|
|
121
|
+
# Validate URL - support RTSP, worker-source files, and direct devices
|
|
122
|
+
if not url:
|
|
123
|
+
logger.error(f"⚠️ [APP] Missing URL in message")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
is_valid_url = (
|
|
127
|
+
url.startswith("rtsp://") or
|
|
128
|
+
url.startswith("worker-source/") or
|
|
129
|
+
self._is_direct_device(url)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if not is_valid_url:
|
|
133
|
+
logger.error(f"⚠️ [APP] Invalid URL: {url} (must be RTSP, worker-source file, or direct device)")
|
|
113
134
|
return
|
|
114
135
|
|
|
115
136
|
if stream_duration <= 0:
|
|
@@ -131,13 +152,16 @@ class VideoStreamWorker:
|
|
|
131
152
|
logger.error("🚨 [APP] Error processing video preview message.", exc_info=True)
|
|
132
153
|
|
|
133
154
|
def _start_stream(self, url, rtmp_server, stream_key, stream_duration, worker_id):
|
|
134
|
-
"""Runs
|
|
155
|
+
"""Runs streaming to RTMP in a separate thread."""
|
|
135
156
|
try:
|
|
136
157
|
logger.info(f"🎥 [APP] Starting RTMP stream (Worker: {worker_id})")
|
|
137
158
|
|
|
138
159
|
if url.startswith("worker-source/"):
|
|
139
160
|
streamer = FileToRTMPStreamer(self.source_file_path / os.path.basename(url), rtmp_server, stream_key, stream_duration)
|
|
161
|
+
elif self._is_direct_device(url):
|
|
162
|
+
streamer = DirectDeviceToRTMPStreamer(url, rtmp_server, stream_key, stream_duration)
|
|
140
163
|
else:
|
|
164
|
+
# Assume RTSP or other supported protocols
|
|
141
165
|
streamer = RTSPtoRTMPStreamer(url, rtmp_server, stream_key, stream_duration)
|
|
142
166
|
|
|
143
167
|
streamer.start_stream()
|