nedo-vision-worker-core 0.2.0__py3-none-any.whl → 0.3.1__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 +47 -12
- nedo_vision_worker_core/callbacks/DetectionCallbackManager.py +306 -0
- nedo_vision_worker_core/callbacks/DetectionCallbackTypes.py +150 -0
- nedo_vision_worker_core/callbacks/__init__.py +27 -0
- nedo_vision_worker_core/cli.py +24 -34
- nedo_vision_worker_core/core_service.py +121 -55
- nedo_vision_worker_core/database/DatabaseManager.py +2 -2
- nedo_vision_worker_core/detection/BaseDetector.py +2 -1
- nedo_vision_worker_core/detection/DetectionManager.py +2 -2
- nedo_vision_worker_core/detection/RFDETRDetector.py +23 -5
- nedo_vision_worker_core/detection/YOLODetector.py +18 -5
- nedo_vision_worker_core/detection/detection_processing/DetectionProcessor.py +1 -1
- nedo_vision_worker_core/detection/detection_processing/HumanDetectionProcessor.py +57 -3
- nedo_vision_worker_core/detection/detection_processing/PPEDetectionProcessor.py +173 -10
- nedo_vision_worker_core/models/ai_model.py +23 -2
- nedo_vision_worker_core/pipeline/PipelineProcessor.py +299 -14
- nedo_vision_worker_core/pipeline/PipelineSyncThread.py +32 -0
- nedo_vision_worker_core/repositories/PPEDetectionRepository.py +18 -15
- nedo_vision_worker_core/repositories/RestrictedAreaRepository.py +17 -13
- nedo_vision_worker_core/services/SharedVideoStreamServer.py +276 -0
- nedo_vision_worker_core/services/VideoSharingDaemon.py +808 -0
- nedo_vision_worker_core/services/VideoSharingDaemonManager.py +257 -0
- nedo_vision_worker_core/streams/SharedVideoDeviceManager.py +383 -0
- nedo_vision_worker_core/streams/StreamSyncThread.py +16 -2
- nedo_vision_worker_core/streams/VideoStream.py +267 -246
- nedo_vision_worker_core/streams/VideoStreamManager.py +158 -6
- nedo_vision_worker_core/tracker/TrackerManager.py +25 -31
- nedo_vision_worker_core-0.3.1.dist-info/METADATA +444 -0
- {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/RECORD +32 -25
- nedo_vision_worker_core-0.2.0.dist-info/METADATA +0 -347
- {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/WHEEL +0 -0
- {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/entry_points.txt +0 -0
- {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Cross-Platform Video Sharing Daemon - A system-wide service that captures from a video device
|
|
4
|
+
and shares frames with multiple consumers across different processes.
|
|
5
|
+
|
|
6
|
+
Supports Windows (Named Pipes), Linux/macOS (Unix Domain Sockets), and fallback TCP.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import cv2
|
|
10
|
+
import numpy as np
|
|
11
|
+
import socket
|
|
12
|
+
import struct
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import logging
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import platform
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
import tempfile
|
|
22
|
+
from ..database.DatabaseManager import DatabaseManager
|
|
23
|
+
from abc import ABC, abstractmethod
|
|
24
|
+
|
|
25
|
+
# Platform-specific imports
|
|
26
|
+
if platform.system() == 'Windows':
|
|
27
|
+
try:
|
|
28
|
+
import win32pipe
|
|
29
|
+
import win32file
|
|
30
|
+
import win32api
|
|
31
|
+
import win32con
|
|
32
|
+
import pywintypes
|
|
33
|
+
WINDOWS_SUPPORT = True
|
|
34
|
+
except ImportError:
|
|
35
|
+
WINDOWS_SUPPORT = False
|
|
36
|
+
logging.warning("pywin32 not available, falling back to TCP sockets")
|
|
37
|
+
else:
|
|
38
|
+
import signal
|
|
39
|
+
WINDOWS_SUPPORT = False
|
|
40
|
+
|
|
41
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_storage_socket_path(device_index, storage_path=None):
|
|
45
|
+
"""Get storage-based socket path for video sharing daemon with length optimization."""
|
|
46
|
+
try:
|
|
47
|
+
if storage_path:
|
|
48
|
+
# Use explicitly provided storage path
|
|
49
|
+
socket_dir = Path(storage_path) / "sockets"
|
|
50
|
+
elif DatabaseManager and hasattr(DatabaseManager, 'STORAGE_PATH') and DatabaseManager.STORAGE_PATH:
|
|
51
|
+
# Use storage path from DatabaseManager if available
|
|
52
|
+
socket_dir = DatabaseManager.STORAGE_PATH / "sockets"
|
|
53
|
+
else:
|
|
54
|
+
# Fallback to temp directory with short path
|
|
55
|
+
return Path(tempfile.gettempdir()) / f"nvw_vd{device_index}.sock"
|
|
56
|
+
|
|
57
|
+
socket_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
socket_path = socket_dir / f"vd{device_index}.sock"
|
|
59
|
+
|
|
60
|
+
# Check if the path is too long for Unix domain sockets (limit ~100-108 chars)
|
|
61
|
+
if len(str(socket_path)) > 100:
|
|
62
|
+
# Try to use a shorter path by using relative path from current directory
|
|
63
|
+
try:
|
|
64
|
+
relative_path = socket_path.relative_to(Path.cwd())
|
|
65
|
+
if len(str(relative_path)) < len(str(socket_path)):
|
|
66
|
+
socket_path = relative_path
|
|
67
|
+
except ValueError:
|
|
68
|
+
# If relative path doesn't work, use temp directory fallback
|
|
69
|
+
logging.warning(f"Socket path too long ({len(str(socket_path))} chars), using temp directory")
|
|
70
|
+
return Path(tempfile.gettempdir()) / f"nvw_vd{device_index}.sock"
|
|
71
|
+
|
|
72
|
+
return socket_path
|
|
73
|
+
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logging.debug(f"Could not use storage path: {e}")
|
|
76
|
+
# Fallback to temp directory with short path
|
|
77
|
+
return Path(tempfile.gettempdir()) / f"nvw_vd{device_index}.sock"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class IPCBackend(ABC):
|
|
81
|
+
"""Abstract base class for IPC backends."""
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def create_server(self, address):
|
|
85
|
+
"""Create and return a server socket/pipe."""
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def accept_client(self, server):
|
|
90
|
+
"""Accept a client connection."""
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def connect_client(self, address):
|
|
95
|
+
"""Connect as a client."""
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def send_data(self, connection, data):
|
|
100
|
+
"""Send data through connection."""
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
@abstractmethod
|
|
104
|
+
def receive_data(self, connection, size):
|
|
105
|
+
"""Receive data from connection."""
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
def close_connection(self, connection):
|
|
110
|
+
"""Close a connection."""
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
@abstractmethod
|
|
114
|
+
def close_server(self, server):
|
|
115
|
+
"""Close server and cleanup."""
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class UnixSocketBackend(IPCBackend):
|
|
120
|
+
"""Unix Domain Socket backend for Linux/macOS."""
|
|
121
|
+
|
|
122
|
+
def __init__(self):
|
|
123
|
+
self.socket_paths = {}
|
|
124
|
+
|
|
125
|
+
def create_server(self, address):
|
|
126
|
+
"""Create Unix domain socket server."""
|
|
127
|
+
# Remove existing socket file
|
|
128
|
+
try:
|
|
129
|
+
os.unlink(address)
|
|
130
|
+
except OSError:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
134
|
+
server_socket.bind(address)
|
|
135
|
+
server_socket.listen(10)
|
|
136
|
+
self.socket_paths[server_socket] = address
|
|
137
|
+
return server_socket
|
|
138
|
+
|
|
139
|
+
def accept_client(self, server):
|
|
140
|
+
"""Accept client connection."""
|
|
141
|
+
return server.accept()[0]
|
|
142
|
+
|
|
143
|
+
def connect_client(self, address):
|
|
144
|
+
"""Connect as client."""
|
|
145
|
+
client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
146
|
+
client_socket.connect(address)
|
|
147
|
+
return client_socket
|
|
148
|
+
|
|
149
|
+
def send_data(self, connection, data):
|
|
150
|
+
"""Send data through socket."""
|
|
151
|
+
connection.sendall(data)
|
|
152
|
+
|
|
153
|
+
def receive_data(self, connection, size):
|
|
154
|
+
"""Receive data from socket."""
|
|
155
|
+
data = b''
|
|
156
|
+
while len(data) < size:
|
|
157
|
+
chunk = connection.recv(size - len(data))
|
|
158
|
+
if not chunk:
|
|
159
|
+
return None
|
|
160
|
+
data += chunk
|
|
161
|
+
return data
|
|
162
|
+
|
|
163
|
+
def close_connection(self, connection):
|
|
164
|
+
"""Close socket connection."""
|
|
165
|
+
try:
|
|
166
|
+
connection.close()
|
|
167
|
+
except:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
def close_server(self, server):
|
|
171
|
+
"""Close server and cleanup socket file."""
|
|
172
|
+
try:
|
|
173
|
+
socket_path = self.socket_paths.get(server)
|
|
174
|
+
server.close()
|
|
175
|
+
if socket_path:
|
|
176
|
+
os.unlink(socket_path)
|
|
177
|
+
del self.socket_paths[server]
|
|
178
|
+
except:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class NamedPipeBackend(IPCBackend):
|
|
183
|
+
"""Named Pipe backend for Windows."""
|
|
184
|
+
|
|
185
|
+
def __init__(self):
|
|
186
|
+
self.pipe_names = {}
|
|
187
|
+
|
|
188
|
+
def create_server(self, address):
|
|
189
|
+
"""Create named pipe server."""
|
|
190
|
+
pipe_name = f"\\\\.\\pipe\\{address}"
|
|
191
|
+
self.pipe_names[pipe_name] = address
|
|
192
|
+
return pipe_name
|
|
193
|
+
|
|
194
|
+
def accept_client(self, server):
|
|
195
|
+
"""Accept client connection on named pipe."""
|
|
196
|
+
try:
|
|
197
|
+
# Create named pipe
|
|
198
|
+
pipe_handle = win32pipe.CreateNamedPipe(
|
|
199
|
+
server,
|
|
200
|
+
win32pipe.PIPE_ACCESS_DUPLEX,
|
|
201
|
+
win32pipe.PIPE_TYPE_BYTE | win32pipe.PIPE_READMODE_BYTE | win32pipe.PIPE_WAIT,
|
|
202
|
+
win32pipe.PIPE_UNLIMITED_INSTANCES,
|
|
203
|
+
65536, # Output buffer size
|
|
204
|
+
65536, # Input buffer size
|
|
205
|
+
0, # Default timeout
|
|
206
|
+
None # Default security
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Wait for client connection
|
|
210
|
+
win32pipe.ConnectNamedPipe(pipe_handle, None)
|
|
211
|
+
return pipe_handle
|
|
212
|
+
|
|
213
|
+
except pywintypes.error as e:
|
|
214
|
+
logging.error(f"Error creating named pipe: {e}")
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
def connect_client(self, address):
|
|
218
|
+
"""Connect as client to named pipe."""
|
|
219
|
+
pipe_name = f"\\\\.\\pipe\\{address}"
|
|
220
|
+
try:
|
|
221
|
+
pipe_handle = win32file.CreateFile(
|
|
222
|
+
pipe_name,
|
|
223
|
+
win32file.GENERIC_READ | win32file.GENERIC_WRITE,
|
|
224
|
+
0,
|
|
225
|
+
None,
|
|
226
|
+
win32file.OPEN_EXISTING,
|
|
227
|
+
0,
|
|
228
|
+
None
|
|
229
|
+
)
|
|
230
|
+
return pipe_handle
|
|
231
|
+
except pywintypes.error as e:
|
|
232
|
+
logging.error(f"Error connecting to named pipe: {e}")
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
def send_data(self, connection, data):
|
|
236
|
+
"""Send data through named pipe."""
|
|
237
|
+
try:
|
|
238
|
+
win32file.WriteFile(connection, data)
|
|
239
|
+
except pywintypes.error as e:
|
|
240
|
+
raise ConnectionError(f"Failed to send data: {e}")
|
|
241
|
+
|
|
242
|
+
def receive_data(self, connection, size):
|
|
243
|
+
"""Receive data from named pipe."""
|
|
244
|
+
try:
|
|
245
|
+
data = b''
|
|
246
|
+
while len(data) < size:
|
|
247
|
+
result, chunk = win32file.ReadFile(connection, size - len(data))
|
|
248
|
+
if not chunk:
|
|
249
|
+
return None
|
|
250
|
+
data += chunk
|
|
251
|
+
return data
|
|
252
|
+
except pywintypes.error as e:
|
|
253
|
+
logging.error(f"Error reading from named pipe: {e}")
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
def close_connection(self, connection):
|
|
257
|
+
"""Close named pipe connection."""
|
|
258
|
+
try:
|
|
259
|
+
win32file.CloseHandle(connection)
|
|
260
|
+
except:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
def close_server(self, server):
|
|
264
|
+
"""Close named pipe server."""
|
|
265
|
+
# Named pipe servers are handled per-connection
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class TCPSocketBackend(IPCBackend):
|
|
270
|
+
"""TCP Socket backend (fallback for all platforms)."""
|
|
271
|
+
|
|
272
|
+
def __init__(self):
|
|
273
|
+
self.base_port = 19000
|
|
274
|
+
self.port_map = {}
|
|
275
|
+
|
|
276
|
+
def create_server(self, address):
|
|
277
|
+
"""Create TCP server socket."""
|
|
278
|
+
# Convert address to port number
|
|
279
|
+
port = self.base_port + hash(address) % 1000
|
|
280
|
+
|
|
281
|
+
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
282
|
+
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
283
|
+
server_socket.bind(('127.0.0.1', port))
|
|
284
|
+
server_socket.listen(10)
|
|
285
|
+
|
|
286
|
+
self.port_map[server_socket] = port
|
|
287
|
+
logging.info(f"TCP server listening on 127.0.0.1:{port}")
|
|
288
|
+
return server_socket
|
|
289
|
+
|
|
290
|
+
def accept_client(self, server):
|
|
291
|
+
"""Accept client connection."""
|
|
292
|
+
return server.accept()[0]
|
|
293
|
+
|
|
294
|
+
def connect_client(self, address):
|
|
295
|
+
"""Connect as TCP client."""
|
|
296
|
+
port = self.base_port + hash(address) % 1000
|
|
297
|
+
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
298
|
+
client_socket.connect(('127.0.0.1', port))
|
|
299
|
+
return client_socket
|
|
300
|
+
|
|
301
|
+
def send_data(self, connection, data):
|
|
302
|
+
"""Send data through TCP socket."""
|
|
303
|
+
connection.sendall(data)
|
|
304
|
+
|
|
305
|
+
def receive_data(self, connection, size):
|
|
306
|
+
"""Receive data from TCP socket."""
|
|
307
|
+
data = b''
|
|
308
|
+
while len(data) < size:
|
|
309
|
+
chunk = connection.recv(size - len(data))
|
|
310
|
+
if not chunk:
|
|
311
|
+
return None
|
|
312
|
+
data += chunk
|
|
313
|
+
return data
|
|
314
|
+
|
|
315
|
+
def close_connection(self, connection):
|
|
316
|
+
"""Close TCP connection."""
|
|
317
|
+
try:
|
|
318
|
+
connection.close()
|
|
319
|
+
except:
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
def close_server(self, server):
|
|
323
|
+
"""Close TCP server."""
|
|
324
|
+
try:
|
|
325
|
+
server.close()
|
|
326
|
+
if server in self.port_map:
|
|
327
|
+
del self.port_map[server]
|
|
328
|
+
except:
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class VideoSharingDaemon:
|
|
333
|
+
"""Video sharing daemon with automatic platform detection."""
|
|
334
|
+
|
|
335
|
+
def __init__(self, device_index=0, backend=None, storage_path=None):
|
|
336
|
+
self.device_index = device_index
|
|
337
|
+
self.socket_path = get_storage_socket_path(device_index, storage_path)
|
|
338
|
+
self.address = str(self.socket_path) if backend != 'tcp' else f"video_share_device_{device_index}"
|
|
339
|
+
self.info_file = self.socket_path.parent / f"vd{device_index}_info.json"
|
|
340
|
+
|
|
341
|
+
# Select IPC backend
|
|
342
|
+
self.backend = self._select_backend(backend)
|
|
343
|
+
|
|
344
|
+
self.cap = None
|
|
345
|
+
self.running = False
|
|
346
|
+
self.clients = []
|
|
347
|
+
self.clients_lock = threading.Lock()
|
|
348
|
+
|
|
349
|
+
# Video properties
|
|
350
|
+
self.width = 640
|
|
351
|
+
self.height = 480
|
|
352
|
+
self.fps = 30.0
|
|
353
|
+
|
|
354
|
+
# Server
|
|
355
|
+
self.server = None
|
|
356
|
+
|
|
357
|
+
def _select_backend(self, backend=None):
|
|
358
|
+
"""Select appropriate IPC backend."""
|
|
359
|
+
if backend:
|
|
360
|
+
return backend
|
|
361
|
+
|
|
362
|
+
system = platform.system()
|
|
363
|
+
|
|
364
|
+
if system == 'Windows' and WINDOWS_SUPPORT:
|
|
365
|
+
logging.info("🖥️ Using Named Pipes backend (Windows)")
|
|
366
|
+
return NamedPipeBackend()
|
|
367
|
+
elif system in ['Linux', 'Darwin'] and hasattr(socket, 'AF_UNIX'):
|
|
368
|
+
logging.info(f"🐧 Using Unix Domain Sockets backend ({system})")
|
|
369
|
+
return UnixSocketBackend()
|
|
370
|
+
else:
|
|
371
|
+
logging.info("🌐 Using TCP Socket backend (fallback)")
|
|
372
|
+
return TCPSocketBackend()
|
|
373
|
+
|
|
374
|
+
def start_daemon(self):
|
|
375
|
+
"""Start the video sharing daemon."""
|
|
376
|
+
try:
|
|
377
|
+
# Check if daemon is already running
|
|
378
|
+
if self._is_daemon_running():
|
|
379
|
+
logging.error(f"Video sharing daemon for device {self.device_index} is already running")
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
# Open video device
|
|
383
|
+
self.cap = cv2.VideoCapture(self.device_index)
|
|
384
|
+
if not self.cap.isOpened():
|
|
385
|
+
logging.error(f"Cannot open video device {self.device_index}")
|
|
386
|
+
return False
|
|
387
|
+
|
|
388
|
+
# Get device properties
|
|
389
|
+
self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
390
|
+
self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
391
|
+
self.fps = float(self.cap.get(cv2.CAP_PROP_FPS))
|
|
392
|
+
|
|
393
|
+
if self.fps <= 0:
|
|
394
|
+
self.fps = 30.0
|
|
395
|
+
|
|
396
|
+
logging.info(f"📹 Device {self.device_index} opened: {self.width}x{self.height} @ {self.fps}fps")
|
|
397
|
+
|
|
398
|
+
# Create server
|
|
399
|
+
self.server = self.backend.create_server(self.address)
|
|
400
|
+
|
|
401
|
+
# Write daemon info file
|
|
402
|
+
self._write_daemon_info()
|
|
403
|
+
|
|
404
|
+
# Start capture and server threads
|
|
405
|
+
self.running = True
|
|
406
|
+
|
|
407
|
+
capture_thread = threading.Thread(target=self._capture_loop, daemon=True)
|
|
408
|
+
server_thread = threading.Thread(target=self._server_loop, daemon=True)
|
|
409
|
+
|
|
410
|
+
capture_thread.start()
|
|
411
|
+
server_thread.start()
|
|
412
|
+
|
|
413
|
+
logging.info(f"🚀 Video sharing daemon started for device {self.device_index}")
|
|
414
|
+
|
|
415
|
+
# Setup shutdown handlers
|
|
416
|
+
self._setup_shutdown_handlers()
|
|
417
|
+
|
|
418
|
+
# Keep daemon running
|
|
419
|
+
while self.running:
|
|
420
|
+
time.sleep(1)
|
|
421
|
+
|
|
422
|
+
return True
|
|
423
|
+
|
|
424
|
+
except Exception as e:
|
|
425
|
+
logging.error(f"Failed to start daemon: {e}")
|
|
426
|
+
self.stop_daemon()
|
|
427
|
+
return False
|
|
428
|
+
|
|
429
|
+
def _setup_shutdown_handlers(self):
|
|
430
|
+
"""Setup platform-appropriate shutdown handlers."""
|
|
431
|
+
try:
|
|
432
|
+
if platform.system() == 'Windows':
|
|
433
|
+
# Windows console control handler
|
|
434
|
+
if WINDOWS_SUPPORT:
|
|
435
|
+
try:
|
|
436
|
+
def ctrl_handler(ctrl_type):
|
|
437
|
+
if ctrl_type in (win32con.CTRL_C_EVENT, win32con.CTRL_CLOSE_EVENT):
|
|
438
|
+
logging.info("Received shutdown signal...")
|
|
439
|
+
self.stop_daemon()
|
|
440
|
+
return True
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
win32api.SetConsoleCtrlHandler(ctrl_handler, True)
|
|
444
|
+
logging.info("📡 Windows console control handler registered")
|
|
445
|
+
except Exception as e:
|
|
446
|
+
logging.warning(f"Could not set up Windows control handler: {e}")
|
|
447
|
+
else:
|
|
448
|
+
# Unix signal handlers (only in main thread)
|
|
449
|
+
if threading.current_thread() is threading.main_thread():
|
|
450
|
+
signal.signal(signal.SIGTERM, self._signal_handler)
|
|
451
|
+
signal.signal(signal.SIGINT, self._signal_handler)
|
|
452
|
+
logging.info("📡 Signal handlers registered (main thread)")
|
|
453
|
+
else:
|
|
454
|
+
logging.info("⚠️ Skipping signal handlers (not main thread)")
|
|
455
|
+
except Exception as e:
|
|
456
|
+
logging.warning(f"⚠️ Could not set up shutdown handlers: {e}")
|
|
457
|
+
|
|
458
|
+
def _signal_handler(self, signum, frame):
|
|
459
|
+
"""Handle Unix signals."""
|
|
460
|
+
logging.info(f"Received signal {signum}, shutting down...")
|
|
461
|
+
self.stop_daemon()
|
|
462
|
+
|
|
463
|
+
def _is_daemon_running(self):
|
|
464
|
+
"""Check if daemon is already running for this device."""
|
|
465
|
+
try:
|
|
466
|
+
if self.info_file.exists():
|
|
467
|
+
with open(self.info_file, 'r') as f:
|
|
468
|
+
info = json.load(f)
|
|
469
|
+
|
|
470
|
+
pid = info.get('pid')
|
|
471
|
+
if pid:
|
|
472
|
+
try:
|
|
473
|
+
if platform.system() == 'Windows':
|
|
474
|
+
# Windows process check
|
|
475
|
+
import subprocess
|
|
476
|
+
result = subprocess.run(['tasklist', '/FI', f'PID eq {pid}'],
|
|
477
|
+
capture_output=True, text=True)
|
|
478
|
+
return str(pid) in result.stdout
|
|
479
|
+
else:
|
|
480
|
+
# Unix process check
|
|
481
|
+
os.kill(pid, 0)
|
|
482
|
+
return True
|
|
483
|
+
except (OSError, ProcessLookupError, subprocess.SubprocessError):
|
|
484
|
+
# Process is dead, clean up
|
|
485
|
+
self.info_file.unlink()
|
|
486
|
+
except Exception:
|
|
487
|
+
pass
|
|
488
|
+
return False
|
|
489
|
+
|
|
490
|
+
def _write_daemon_info(self):
|
|
491
|
+
"""Write daemon information to file."""
|
|
492
|
+
info = {
|
|
493
|
+
'device_index': self.device_index,
|
|
494
|
+
'pid': os.getpid(),
|
|
495
|
+
'address': self.address,
|
|
496
|
+
'backend': self.backend.__class__.__name__,
|
|
497
|
+
'platform': platform.system(),
|
|
498
|
+
'width': self.width,
|
|
499
|
+
'height': self.height,
|
|
500
|
+
'fps': self.fps,
|
|
501
|
+
'started_at': time.time()
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
with open(self.info_file, 'w') as f:
|
|
505
|
+
json.dump(info, f)
|
|
506
|
+
|
|
507
|
+
def _server_loop(self):
|
|
508
|
+
"""Accept and handle client connections."""
|
|
509
|
+
while self.running:
|
|
510
|
+
try:
|
|
511
|
+
client = self.backend.accept_client(self.server)
|
|
512
|
+
if client:
|
|
513
|
+
logging.info(f"📱 New client connected")
|
|
514
|
+
|
|
515
|
+
with self.clients_lock:
|
|
516
|
+
self.clients.append(client)
|
|
517
|
+
|
|
518
|
+
# Handle client in separate thread
|
|
519
|
+
client_thread = threading.Thread(
|
|
520
|
+
target=self._handle_client,
|
|
521
|
+
args=(client,),
|
|
522
|
+
daemon=True
|
|
523
|
+
)
|
|
524
|
+
client_thread.start()
|
|
525
|
+
|
|
526
|
+
except Exception as e:
|
|
527
|
+
if self.running:
|
|
528
|
+
logging.error(f"Error accepting client: {e}")
|
|
529
|
+
|
|
530
|
+
def _handle_client(self, client):
|
|
531
|
+
"""Handle individual client connection."""
|
|
532
|
+
try:
|
|
533
|
+
while self.running:
|
|
534
|
+
# Keep connection alive
|
|
535
|
+
time.sleep(1)
|
|
536
|
+
except Exception as e:
|
|
537
|
+
logging.warning(f"Client disconnected: {e}")
|
|
538
|
+
finally:
|
|
539
|
+
with self.clients_lock:
|
|
540
|
+
if client in self.clients:
|
|
541
|
+
self.clients.remove(client)
|
|
542
|
+
self.backend.close_connection(client)
|
|
543
|
+
|
|
544
|
+
def _capture_loop(self):
|
|
545
|
+
"""Main video capture loop."""
|
|
546
|
+
frame_interval = 1.0 / self.fps
|
|
547
|
+
last_frame_time = 0
|
|
548
|
+
|
|
549
|
+
while self.running:
|
|
550
|
+
try:
|
|
551
|
+
current_time = time.time()
|
|
552
|
+
|
|
553
|
+
# Throttle frame rate
|
|
554
|
+
if current_time - last_frame_time < frame_interval:
|
|
555
|
+
time.sleep(0.001)
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
ret, frame = self.cap.read()
|
|
559
|
+
if not ret:
|
|
560
|
+
logging.warning("Failed to read frame")
|
|
561
|
+
time.sleep(0.1)
|
|
562
|
+
continue
|
|
563
|
+
|
|
564
|
+
# Broadcast frame to all clients
|
|
565
|
+
self._broadcast_frame(frame)
|
|
566
|
+
|
|
567
|
+
last_frame_time = current_time
|
|
568
|
+
|
|
569
|
+
except Exception as e:
|
|
570
|
+
logging.error(f"Error in capture loop: {e}")
|
|
571
|
+
time.sleep(0.1)
|
|
572
|
+
|
|
573
|
+
def _broadcast_frame(self, frame):
|
|
574
|
+
"""Broadcast frame to all connected clients."""
|
|
575
|
+
if not self.clients:
|
|
576
|
+
return
|
|
577
|
+
|
|
578
|
+
try:
|
|
579
|
+
# Serialize frame
|
|
580
|
+
frame_data = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 85])[1].tobytes()
|
|
581
|
+
frame_size = len(frame_data)
|
|
582
|
+
|
|
583
|
+
# Prepare message: [size][width][height][timestamp][frame_data]
|
|
584
|
+
message = struct.pack('!IIIf', frame_size, self.width, self.height, time.time())
|
|
585
|
+
message += frame_data
|
|
586
|
+
|
|
587
|
+
with self.clients_lock:
|
|
588
|
+
dead_clients = []
|
|
589
|
+
|
|
590
|
+
for client in self.clients:
|
|
591
|
+
try:
|
|
592
|
+
self.backend.send_data(client, message)
|
|
593
|
+
except Exception as e:
|
|
594
|
+
dead_clients.append(client)
|
|
595
|
+
|
|
596
|
+
# Remove dead clients
|
|
597
|
+
for client in dead_clients:
|
|
598
|
+
self.clients.remove(client)
|
|
599
|
+
self.backend.close_connection(client)
|
|
600
|
+
|
|
601
|
+
except Exception as e:
|
|
602
|
+
logging.error(f"Error broadcasting frame: {e}")
|
|
603
|
+
|
|
604
|
+
def stop_daemon(self):
|
|
605
|
+
"""Stop the video sharing daemon."""
|
|
606
|
+
logging.info("🛑 Stopping video sharing daemon...")
|
|
607
|
+
|
|
608
|
+
self.running = False
|
|
609
|
+
|
|
610
|
+
# Close all client connections
|
|
611
|
+
with self.clients_lock:
|
|
612
|
+
for client in self.clients:
|
|
613
|
+
self.backend.close_connection(client)
|
|
614
|
+
self.clients.clear()
|
|
615
|
+
|
|
616
|
+
# Close server
|
|
617
|
+
if self.server:
|
|
618
|
+
self.backend.close_server(self.server)
|
|
619
|
+
|
|
620
|
+
# Close video capture
|
|
621
|
+
if self.cap:
|
|
622
|
+
self.cap.release()
|
|
623
|
+
|
|
624
|
+
# Clean up info file
|
|
625
|
+
try:
|
|
626
|
+
self.info_file.unlink()
|
|
627
|
+
except:
|
|
628
|
+
pass
|
|
629
|
+
|
|
630
|
+
logging.info("✅ Video sharing daemon stopped")
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
class VideoSharingClient:
|
|
634
|
+
"""Video sharing client with automatic platform detection."""
|
|
635
|
+
|
|
636
|
+
def __init__(self, device_index=0, backend=None, storage_path=None):
|
|
637
|
+
self.device_index = device_index
|
|
638
|
+
self.socket_path = get_storage_socket_path(device_index, storage_path)
|
|
639
|
+
self.address = str(self.socket_path) if backend != 'tcp' else f"video_share_device_{device_index}"
|
|
640
|
+
self.info_file = self.socket_path.parent / f"vd{device_index}_info.json"
|
|
641
|
+
|
|
642
|
+
# Backend will be determined from daemon info
|
|
643
|
+
self.backend = backend
|
|
644
|
+
self.connection = None
|
|
645
|
+
self.running = False
|
|
646
|
+
self.frame_callback = None
|
|
647
|
+
|
|
648
|
+
# Device properties
|
|
649
|
+
self.width = 640
|
|
650
|
+
self.height = 480
|
|
651
|
+
self.fps = 30.0
|
|
652
|
+
|
|
653
|
+
def connect(self, frame_callback):
|
|
654
|
+
"""Connect to video sharing daemon."""
|
|
655
|
+
try:
|
|
656
|
+
# Check if daemon is running and get info
|
|
657
|
+
if not self._load_daemon_info():
|
|
658
|
+
logging.error(f"Video sharing daemon for device {self.device_index} is not running")
|
|
659
|
+
return False
|
|
660
|
+
|
|
661
|
+
# Create appropriate backend if not provided
|
|
662
|
+
if not self.backend:
|
|
663
|
+
self.backend = self._create_backend_from_info()
|
|
664
|
+
|
|
665
|
+
# Connect to daemon
|
|
666
|
+
self.connection = self.backend.connect_client(self.address)
|
|
667
|
+
if not self.connection:
|
|
668
|
+
return False
|
|
669
|
+
|
|
670
|
+
self.frame_callback = frame_callback
|
|
671
|
+
self.running = True
|
|
672
|
+
|
|
673
|
+
# Start receiving thread
|
|
674
|
+
receive_thread = threading.Thread(target=self._receive_loop, daemon=True)
|
|
675
|
+
receive_thread.start()
|
|
676
|
+
|
|
677
|
+
logging.info(f"📱 Connected to video sharing daemon for device {self.device_index}")
|
|
678
|
+
return True
|
|
679
|
+
|
|
680
|
+
except Exception as e:
|
|
681
|
+
logging.error(f"Failed to connect to daemon: {e}")
|
|
682
|
+
return False
|
|
683
|
+
|
|
684
|
+
def _load_daemon_info(self):
|
|
685
|
+
"""Load daemon information from file."""
|
|
686
|
+
try:
|
|
687
|
+
if not self.info_file.exists():
|
|
688
|
+
return False
|
|
689
|
+
|
|
690
|
+
with open(self.info_file, 'r') as f:
|
|
691
|
+
info = json.load(f)
|
|
692
|
+
|
|
693
|
+
self.width = info['width']
|
|
694
|
+
self.height = info['height']
|
|
695
|
+
self.fps = info['fps']
|
|
696
|
+
self.daemon_platform = info.get('platform', platform.system())
|
|
697
|
+
self.daemon_backend = info.get('backend', 'UnixSocketBackend')
|
|
698
|
+
|
|
699
|
+
return True
|
|
700
|
+
|
|
701
|
+
except Exception as e:
|
|
702
|
+
logging.error(f"Error loading daemon info: {e}")
|
|
703
|
+
return False
|
|
704
|
+
|
|
705
|
+
def _create_backend_from_info(self):
|
|
706
|
+
"""Create backend based on daemon info."""
|
|
707
|
+
if self.daemon_backend == 'NamedPipeBackend':
|
|
708
|
+
return NamedPipeBackend()
|
|
709
|
+
elif self.daemon_backend == 'UnixSocketBackend':
|
|
710
|
+
return UnixSocketBackend()
|
|
711
|
+
else:
|
|
712
|
+
return TCPSocketBackend()
|
|
713
|
+
|
|
714
|
+
def _receive_loop(self):
|
|
715
|
+
"""Receive frames from daemon."""
|
|
716
|
+
while self.running:
|
|
717
|
+
try:
|
|
718
|
+
# Receive header: [size][width][height][timestamp]
|
|
719
|
+
header = self.backend.receive_data(self.connection, 16) # 4 + 4 + 4 + 4 bytes
|
|
720
|
+
if not header:
|
|
721
|
+
break
|
|
722
|
+
|
|
723
|
+
frame_size, width, height, timestamp = struct.unpack('!IIIf', header)
|
|
724
|
+
|
|
725
|
+
# Receive frame data
|
|
726
|
+
frame_data = self.backend.receive_data(self.connection, frame_size)
|
|
727
|
+
if not frame_data:
|
|
728
|
+
break
|
|
729
|
+
|
|
730
|
+
# Decode frame
|
|
731
|
+
frame_array = np.frombuffer(frame_data, dtype=np.uint8)
|
|
732
|
+
frame = cv2.imdecode(frame_array, cv2.IMREAD_COLOR)
|
|
733
|
+
|
|
734
|
+
if frame is not None and self.frame_callback:
|
|
735
|
+
self.frame_callback(frame, timestamp)
|
|
736
|
+
|
|
737
|
+
except Exception as e:
|
|
738
|
+
if self.running:
|
|
739
|
+
logging.error(f"Error receiving frame: {e}")
|
|
740
|
+
break
|
|
741
|
+
|
|
742
|
+
def disconnect(self):
|
|
743
|
+
"""Disconnect from daemon."""
|
|
744
|
+
self.running = False
|
|
745
|
+
if self.connection and self.backend:
|
|
746
|
+
self.backend.close_connection(self.connection)
|
|
747
|
+
logging.info("📱 Disconnected from video sharing daemon")
|
|
748
|
+
|
|
749
|
+
def get_device_properties(self):
|
|
750
|
+
"""Get device properties."""
|
|
751
|
+
return self.width, self.height, self.fps, "bgr24"
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def main():
|
|
755
|
+
"""Main function to run daemon or test client."""
|
|
756
|
+
import argparse
|
|
757
|
+
|
|
758
|
+
parser = argparse.ArgumentParser(description='Video Sharing Daemon')
|
|
759
|
+
parser.add_argument('--device', type=int, default=0, help='Video device index')
|
|
760
|
+
parser.add_argument('--daemon', action='store_true', help='Run as daemon')
|
|
761
|
+
parser.add_argument('--test-client', action='store_true', help='Test client mode')
|
|
762
|
+
parser.add_argument('--backend', choices=['unix', 'namedpipe', 'tcp'],
|
|
763
|
+
help='Force specific backend')
|
|
764
|
+
|
|
765
|
+
args = parser.parse_args()
|
|
766
|
+
|
|
767
|
+
# Create backend if specified
|
|
768
|
+
backend = None
|
|
769
|
+
if args.backend:
|
|
770
|
+
if args.backend == 'unix':
|
|
771
|
+
backend = UnixSocketBackend()
|
|
772
|
+
elif args.backend == 'namedpipe':
|
|
773
|
+
backend = NamedPipeBackend()
|
|
774
|
+
elif args.backend == 'tcp':
|
|
775
|
+
backend = TCPSocketBackend()
|
|
776
|
+
|
|
777
|
+
if args.daemon:
|
|
778
|
+
# Run as daemon
|
|
779
|
+
daemon = VideoSharingDaemon(args.device, backend)
|
|
780
|
+
daemon.start_daemon()
|
|
781
|
+
elif args.test_client:
|
|
782
|
+
# Test client
|
|
783
|
+
def on_frame(frame, timestamp):
|
|
784
|
+
print(f"📹 Received frame: {frame.shape}, timestamp: {timestamp}")
|
|
785
|
+
|
|
786
|
+
client = VideoSharingClient(args.device, backend)
|
|
787
|
+
if client.connect(on_frame):
|
|
788
|
+
try:
|
|
789
|
+
while True:
|
|
790
|
+
time.sleep(1)
|
|
791
|
+
except KeyboardInterrupt:
|
|
792
|
+
client.disconnect()
|
|
793
|
+
else:
|
|
794
|
+
system = platform.system()
|
|
795
|
+
backend_info = "Named Pipes" if system == 'Windows' and WINDOWS_SUPPORT else \
|
|
796
|
+
"Unix Domain Sockets" if hasattr(socket, 'AF_UNIX') else "TCP Sockets"
|
|
797
|
+
|
|
798
|
+
print(f"Video Sharing Daemon")
|
|
799
|
+
print(f"Platform: {system}")
|
|
800
|
+
print(f"IPC Backend: {backend_info}")
|
|
801
|
+
print(f"Usage: python {sys.argv[0]} --daemon OR --test-client")
|
|
802
|
+
|
|
803
|
+
if system == 'Windows' and not WINDOWS_SUPPORT:
|
|
804
|
+
print("Note: Install pywin32 for optimal Windows support (pip install pywin32)")
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
if __name__ == "__main__":
|
|
808
|
+
main()
|