nedo-vision-worker 1.1.3__py3-none-any.whl → 1.2.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.
Files changed (44) hide show
  1. nedo_vision_worker/__init__.py +1 -1
  2. nedo_vision_worker/cli.py +196 -167
  3. nedo_vision_worker/database/DatabaseManager.py +3 -3
  4. nedo_vision_worker/doctor.py +1066 -386
  5. nedo_vision_worker/models/ai_model.py +35 -2
  6. nedo_vision_worker/protos/AIModelService_pb2.py +12 -10
  7. nedo_vision_worker/protos/AIModelService_pb2_grpc.py +1 -1
  8. nedo_vision_worker/protos/DatasetSourceService_pb2.py +2 -2
  9. nedo_vision_worker/protos/DatasetSourceService_pb2_grpc.py +1 -1
  10. nedo_vision_worker/protos/HumanDetectionService_pb2.py +2 -2
  11. nedo_vision_worker/protos/HumanDetectionService_pb2_grpc.py +1 -1
  12. nedo_vision_worker/protos/PPEDetectionService_pb2.py +2 -2
  13. nedo_vision_worker/protos/PPEDetectionService_pb2_grpc.py +1 -1
  14. nedo_vision_worker/protos/VisionWorkerService_pb2.py +2 -2
  15. nedo_vision_worker/protos/VisionWorkerService_pb2_grpc.py +1 -1
  16. nedo_vision_worker/protos/WorkerSourcePipelineService_pb2.py +2 -2
  17. nedo_vision_worker/protos/WorkerSourcePipelineService_pb2_grpc.py +1 -1
  18. nedo_vision_worker/protos/WorkerSourceService_pb2.py +2 -2
  19. nedo_vision_worker/protos/WorkerSourceService_pb2_grpc.py +1 -1
  20. nedo_vision_worker/services/AIModelClient.py +184 -160
  21. nedo_vision_worker/services/DirectDeviceToRTMPStreamer.py +534 -0
  22. nedo_vision_worker/services/GrpcClientBase.py +142 -108
  23. nedo_vision_worker/services/PPEDetectionClient.py +0 -7
  24. nedo_vision_worker/services/RestrictedAreaClient.py +0 -5
  25. nedo_vision_worker/services/SharedDirectDeviceClient.py +278 -0
  26. nedo_vision_worker/services/SharedVideoStreamServer.py +315 -0
  27. nedo_vision_worker/services/SystemWideDeviceCoordinator.py +236 -0
  28. nedo_vision_worker/services/VideoSharingDaemon.py +832 -0
  29. nedo_vision_worker/services/VideoStreamClient.py +43 -20
  30. nedo_vision_worker/services/WorkerSourceClient.py +1 -1
  31. nedo_vision_worker/services/WorkerSourcePipelineClient.py +35 -12
  32. nedo_vision_worker/services/WorkerSourceUpdater.py +30 -3
  33. nedo_vision_worker/util/FFmpegUtil.py +124 -0
  34. nedo_vision_worker/util/VideoProbeUtil.py +227 -17
  35. nedo_vision_worker/worker/DataSyncWorker.py +1 -0
  36. nedo_vision_worker/worker/PipelineImageWorker.py +1 -1
  37. nedo_vision_worker/worker/VideoStreamWorker.py +27 -3
  38. nedo_vision_worker/worker/WorkerManager.py +2 -29
  39. nedo_vision_worker/worker_service.py +22 -5
  40. {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.1.dist-info}/METADATA +1 -3
  41. {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.1.dist-info}/RECORD +44 -38
  42. {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.1.dist-info}/WHEEL +0 -0
  43. {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.1.dist-info}/entry_points.txt +0 -0
  44. {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,315 @@
1
+ import logging
2
+ import threading
3
+ import time
4
+ import cv2
5
+ import queue
6
+ import uuid
7
+ from typing import Dict, Optional, Callable, List
8
+ from pathlib import Path
9
+ import tempfile
10
+ import json
11
+ import os
12
+ import socket
13
+ import struct
14
+
15
+
16
+ class SharedVideoStreamServer:
17
+ """
18
+ A shared video stream server that captures from a video device once
19
+ and distributes frames to multiple consumers (both services).
20
+ """
21
+
22
+ _instances: Dict[int, 'SharedVideoStreamServer'] = {}
23
+ _lock = threading.Lock()
24
+
25
+ def __new__(cls, device_index: int):
26
+ with cls._lock:
27
+ if device_index not in cls._instances:
28
+ cls._instances[device_index] = super().__new__(cls)
29
+ cls._instances[device_index]._initialized = False
30
+ return cls._instances[device_index]
31
+
32
+ def __init__(self, device_index: int):
33
+ if self._initialized:
34
+ return
35
+
36
+ self._initialized = True
37
+ self.device_index = device_index
38
+ self.cap = None
39
+ self.running = False
40
+ self.capture_thread = None
41
+ self.frame_lock = threading.Lock()
42
+ self.latest_frame = None
43
+ self.frame_timestamp = 0
44
+
45
+ # Consumer management
46
+ self.consumers: Dict[str, Dict] = {} # consumer_id -> {callback, last_frame_time}
47
+ self.consumers_lock = threading.Lock()
48
+
49
+ # Device properties
50
+ self.width = 640
51
+ self.height = 480
52
+ self.fps = 30.0
53
+
54
+ # Shared state file for cross-service coordination
55
+ self.state_file = self._get_state_file_path()
56
+
57
+ logging.info(f"SharedVideoStreamServer initialized for device {device_index}")
58
+
59
+ def _get_state_file_path(self) -> Path:
60
+ """Get the path for the shared state file."""
61
+ temp_dir = tempfile.gettempdir()
62
+ state_dir = Path(temp_dir) / "nedo-vision-shared-streams"
63
+ state_dir.mkdir(exist_ok=True)
64
+ return state_dir / f"device_{self.device_index}_state.json"
65
+
66
+ def _update_state_file(self):
67
+ """Update the shared state file with current process info."""
68
+ try:
69
+ state = {
70
+ 'device_index': self.device_index,
71
+ 'process_id': os.getpid(),
72
+ 'width': self.width,
73
+ 'height': self.height,
74
+ 'fps': self.fps,
75
+ 'running': self.running,
76
+ 'consumer_count': len(self.consumers),
77
+ 'last_update': time.time()
78
+ }
79
+
80
+ # Ensure directory exists
81
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
82
+
83
+ with open(self.state_file, 'w') as f:
84
+ json.dump(state, f)
85
+
86
+ except Exception as e:
87
+ logging.warning(f"Failed to update state file: {e}")
88
+
89
+ def _read_state_file(self) -> Optional[Dict]:
90
+ """Read the shared state file."""
91
+ try:
92
+ if self.state_file.exists():
93
+ with open(self.state_file, 'r') as f:
94
+ return json.load(f)
95
+ except Exception:
96
+ pass
97
+ return None
98
+
99
+ def start_capture(self) -> bool:
100
+ """Start capturing from the video device."""
101
+ if self.running:
102
+ logging.info(f"Device {self.device_index} capture already running")
103
+ return True
104
+
105
+ # First check if another process is already using this device
106
+ existing_process = self._check_existing_process()
107
+ if existing_process:
108
+ logging.info(f"Device {self.device_index} is already being used by another process (PID: {existing_process})")
109
+ # Try to connect to the existing process's stream server
110
+ return self._connect_to_existing_server()
111
+
112
+ try:
113
+ # Try to open the device
114
+ self.cap = cv2.VideoCapture(self.device_index)
115
+
116
+ if not self.cap.isOpened():
117
+ logging.error(f"Cannot open video device {self.device_index}")
118
+ return False
119
+
120
+ # Get device properties
121
+ self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
122
+ self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
123
+ self.fps = float(self.cap.get(cv2.CAP_PROP_FPS))
124
+
125
+ if self.fps <= 0 or self.fps > 240:
126
+ self.fps = 30.0
127
+
128
+ logging.info(f"Device {self.device_index} opened: {self.width}x{self.height} @ {self.fps}fps")
129
+
130
+ # Start capture thread
131
+ self.running = True
132
+ self.capture_thread = threading.Thread(target=self._capture_loop, daemon=True)
133
+ self.capture_thread.start()
134
+
135
+ # Update state file to indicate this process is using the device
136
+ self._update_state_file()
137
+
138
+ return True
139
+
140
+ except Exception as e:
141
+ logging.error(f"Error starting capture for device {self.device_index}: {e}")
142
+ return False
143
+
144
+ def _check_existing_process(self) -> Optional[int]:
145
+ """Check if another process is already using this device."""
146
+ try:
147
+ state = self._read_state_file()
148
+ if state and 'process_id' in state:
149
+ pid = state['process_id']
150
+ # Check if the process is still running
151
+ try:
152
+ os.kill(pid, 0) # Signal 0 just checks if process exists
153
+ return pid
154
+ except (OSError, ProcessLookupError):
155
+ # Process is dead, clean up the state file
156
+ try:
157
+ self.state_file.unlink()
158
+ except:
159
+ pass
160
+ except Exception:
161
+ pass
162
+ return None
163
+
164
+ def _connect_to_existing_server(self) -> bool:
165
+ """Connect to an existing stream server in another process."""
166
+ # For now, return False to indicate we can't connect
167
+ # In a full implementation, you'd implement IPC (named pipes, sockets, etc.)
168
+ logging.warning(f"Device {self.device_index} is in use by another process. Cannot share across processes yet.")
169
+ return False
170
+
171
+ def stop_capture(self):
172
+ """Stop capturing from the video device."""
173
+ if not self.running:
174
+ return
175
+
176
+ logging.info(f"Stopping capture for device {self.device_index}")
177
+
178
+ self.running = False
179
+
180
+ if self.capture_thread:
181
+ self.capture_thread.join(timeout=5)
182
+
183
+ if self.cap:
184
+ self.cap.release()
185
+ self.cap = None
186
+
187
+ # Clean up state file
188
+ try:
189
+ if self.state_file.exists():
190
+ self.state_file.unlink()
191
+ except Exception:
192
+ pass
193
+
194
+ # Remove from instances
195
+ with self._lock:
196
+ if self.device_index in self._instances:
197
+ del self._instances[self.device_index]
198
+
199
+ def _capture_loop(self):
200
+ """Main capture loop that reads frames and distributes to consumers."""
201
+ frame_interval = 1.0 / self.fps
202
+ last_frame_time = 0
203
+
204
+ while self.running and self.cap and self.cap.isOpened():
205
+ try:
206
+ current_time = time.time()
207
+
208
+ # Throttle frame rate
209
+ if current_time - last_frame_time < frame_interval:
210
+ time.sleep(0.001)
211
+ continue
212
+
213
+ ret, frame = self.cap.read()
214
+
215
+ if not ret or frame is None:
216
+ logging.warning(f"Failed to read frame from device {self.device_index}")
217
+ time.sleep(0.1)
218
+ continue
219
+
220
+ # Update latest frame
221
+ with self.frame_lock:
222
+ self.latest_frame = frame.copy()
223
+ self.frame_timestamp = current_time
224
+
225
+ # Distribute frame to consumers
226
+ self._distribute_frame(frame, current_time)
227
+
228
+ last_frame_time = current_time
229
+
230
+ # Update state file periodically
231
+ if int(current_time) % 5 == 0: # Every 5 seconds
232
+ self._update_state_file()
233
+
234
+ except Exception as e:
235
+ logging.error(f"Error in capture loop for device {self.device_index}: {e}")
236
+ time.sleep(0.1)
237
+
238
+ logging.info(f"Capture loop ended for device {self.device_index}")
239
+
240
+ def _distribute_frame(self, frame, timestamp):
241
+ """Distribute frame to all registered consumers."""
242
+ with self.consumers_lock:
243
+ dead_consumers = []
244
+
245
+ for consumer_id, consumer_info in self.consumers.items():
246
+ try:
247
+ callback = consumer_info['callback']
248
+ callback(frame, timestamp)
249
+ consumer_info['last_frame_time'] = timestamp
250
+
251
+ except Exception as e:
252
+ logging.warning(f"Error delivering frame to consumer {consumer_id}: {e}")
253
+ dead_consumers.append(consumer_id)
254
+
255
+ # Remove dead consumers
256
+ for consumer_id in dead_consumers:
257
+ del self.consumers[consumer_id]
258
+ logging.info(f"Removed dead consumer: {consumer_id}")
259
+
260
+ def add_consumer(self, callback: Callable, consumer_id: str = None) -> str:
261
+ """Add a consumer that will receive frames."""
262
+ if consumer_id is None:
263
+ consumer_id = str(uuid.uuid4())
264
+
265
+ with self.consumers_lock:
266
+ self.consumers[consumer_id] = {
267
+ 'callback': callback,
268
+ 'added_time': time.time(),
269
+ 'last_frame_time': 0
270
+ }
271
+
272
+ logging.info(f"Added consumer {consumer_id} for device {self.device_index}")
273
+
274
+ # Start capture if this is the first consumer
275
+ if len(self.consumers) == 1 and not self.running:
276
+ self.start_capture()
277
+
278
+ return consumer_id
279
+
280
+ def remove_consumer(self, consumer_id: str):
281
+ """Remove a consumer."""
282
+ with self.consumers_lock:
283
+ if consumer_id in self.consumers:
284
+ del self.consumers[consumer_id]
285
+ logging.info(f"Removed consumer {consumer_id} for device {self.device_index}")
286
+
287
+ # Stop capture if no more consumers
288
+ if len(self.consumers) == 0 and self.running:
289
+ self.stop_capture()
290
+
291
+ def get_latest_frame(self):
292
+ """Get the latest captured frame."""
293
+ with self.frame_lock:
294
+ if self.latest_frame is not None:
295
+ return self.latest_frame.copy(), self.frame_timestamp
296
+ return None, 0
297
+
298
+ def get_device_properties(self) -> tuple:
299
+ """Get device properties."""
300
+ return self.width, self.height, self.fps, "rgb24"
301
+
302
+ def is_running(self) -> bool:
303
+ """Check if the server is running."""
304
+ return self.running
305
+
306
+ def get_consumer_count(self) -> int:
307
+ """Get the number of active consumers."""
308
+ with self.consumers_lock:
309
+ return len(self.consumers)
310
+
311
+
312
+ # Global function to get or create shared stream server
313
+ def get_shared_stream_server(device_index: int) -> SharedVideoStreamServer:
314
+ """Get or create a shared stream server for a device."""
315
+ return SharedVideoStreamServer(device_index)
@@ -0,0 +1,236 @@
1
+ import os
2
+ import time
3
+ import logging
4
+ import threading
5
+ import platform
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Dict, Optional
9
+
10
+ # Platform-specific imports
11
+ if platform.system().lower() == "windows":
12
+ import msvcrt
13
+ else:
14
+ import fcntl
15
+
16
+
17
+ class SystemWideDeviceCoordinator:
18
+ """
19
+ System-wide device coordinator that works across multiple processes/services.
20
+ Uses file locks to coordinate device access between different services.
21
+ """
22
+
23
+ def __init__(self, lock_dir: str = None):
24
+ # Use platform-appropriate temporary directory if none specified
25
+ if lock_dir is None:
26
+ system_temp = tempfile.gettempdir()
27
+ lock_dir = os.path.join(system_temp, "nedo-vision-device-locks")
28
+
29
+ self.lock_dir = Path(lock_dir)
30
+ self.lock_dir.mkdir(exist_ok=True)
31
+ self.active_locks: Dict[int, any] = {}
32
+ self.lock = threading.Lock()
33
+
34
+ logging.info(f"SystemWideDeviceCoordinator initialized with lock directory: {self.lock_dir}")
35
+
36
+ def acquire_device_lock(self, device_index: int, timeout: float = 5.0) -> bool:
37
+ """
38
+ Acquire exclusive lock for a video device across all services.
39
+
40
+ Args:
41
+ device_index: The video device index (e.g., 0 for /dev/video0)
42
+ timeout: Maximum time to wait for lock acquisition
43
+
44
+ Returns:
45
+ True if lock acquired successfully, False otherwise
46
+ """
47
+ lock_file_path = self.lock_dir / f"video_device_{device_index}.lock"
48
+
49
+ try:
50
+ # Open lock file
51
+ lock_file = open(lock_file_path, 'w')
52
+ lock_file.write(f"pid:{os.getpid()}\nservice:worker-service\ntime:{time.time()}\n")
53
+ lock_file.flush()
54
+
55
+ # Platform-specific locking
56
+ if platform.system().lower() == "windows":
57
+ return self._acquire_lock_windows(lock_file, device_index, timeout)
58
+ else:
59
+ return self._acquire_lock_unix(lock_file, device_index, timeout)
60
+
61
+ except Exception as e:
62
+ logging.error(f"❌ Error acquiring device lock for device {device_index}: {e}")
63
+ return False
64
+
65
+ def _acquire_lock_unix(self, lock_file, device_index: int, timeout: float) -> bool:
66
+ """Acquire lock using POSIX fcntl (Linux/macOS)."""
67
+ start_time = time.time()
68
+ while time.time() - start_time < timeout:
69
+ try:
70
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
71
+
72
+ # Lock acquired successfully
73
+ with self.lock:
74
+ self.active_locks[device_index] = lock_file
75
+
76
+ logging.info(f"✅ Acquired system-wide lock for video device {device_index}")
77
+ return True
78
+
79
+ except BlockingIOError:
80
+ # Lock is held by another process, wait and retry
81
+ time.sleep(0.1)
82
+ continue
83
+
84
+ # Timeout reached
85
+ lock_file.close()
86
+ logging.warning(f"⏱️ Timeout acquiring lock for video device {device_index}")
87
+ return False
88
+
89
+ def _acquire_lock_windows(self, lock_file, device_index: int, timeout: float) -> bool:
90
+ """Acquire lock using Windows msvcrt (Windows)."""
91
+ start_time = time.time()
92
+ while time.time() - start_time < timeout:
93
+ try:
94
+ # Try to lock the file
95
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
96
+
97
+ # Lock acquired successfully
98
+ with self.lock:
99
+ self.active_locks[device_index] = lock_file
100
+
101
+ logging.info(f"✅ Acquired system-wide lock for video device {device_index}")
102
+ return True
103
+
104
+ except OSError:
105
+ # Lock is held by another process, wait and retry
106
+ time.sleep(0.1)
107
+ continue
108
+
109
+ # Timeout reached
110
+ lock_file.close()
111
+ logging.warning(f"⏱️ Timeout acquiring lock for video device {device_index}")
112
+ return False
113
+
114
+ def release_device_lock(self, device_index: int):
115
+ """Release the lock for a video device."""
116
+ with self.lock:
117
+ if device_index in self.active_locks:
118
+ try:
119
+ lock_file = self.active_locks[device_index]
120
+
121
+ # Platform-specific unlocking
122
+ if platform.system().lower() == "windows":
123
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
124
+ else:
125
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
126
+
127
+ lock_file.close()
128
+ del self.active_locks[device_index]
129
+
130
+ logging.info(f"🔓 Released system-wide lock for video device {device_index}")
131
+
132
+ except Exception as e:
133
+ logging.error(f"❌ Error releasing device lock for device {device_index}: {e}")
134
+
135
+ def is_device_locked(self, device_index: int) -> bool:
136
+ """Check if a device is currently locked by any service."""
137
+ lock_file_path = self.lock_dir / f"video_device_{device_index}.lock"
138
+
139
+ if not lock_file_path.exists():
140
+ return False
141
+
142
+ try:
143
+ test_file = open(lock_file_path, 'r')
144
+
145
+ # Platform-specific lock testing
146
+ if platform.system().lower() == "windows":
147
+ try:
148
+ msvcrt.locking(test_file.fileno(), msvcrt.LK_NBLCK, 1)
149
+ msvcrt.locking(test_file.fileno(), msvcrt.LK_UNLCK, 1)
150
+ test_file.close()
151
+ return False # Lock is available
152
+ except OSError:
153
+ test_file.close()
154
+ return True # Lock is held by another process
155
+ else:
156
+ try:
157
+ fcntl.flock(test_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
158
+ fcntl.flock(test_file.fileno(), fcntl.LOCK_UN)
159
+ test_file.close()
160
+ return False # Lock is available
161
+ except BlockingIOError:
162
+ test_file.close()
163
+ return True # Lock is held by another process
164
+
165
+ except Exception:
166
+ return False # Assume available on error
167
+
168
+ def get_device_lock_info(self, device_index: int) -> Optional[Dict]:
169
+ """Get information about who is holding the device lock."""
170
+ lock_file_path = self.lock_dir / f"video_device_{device_index}.lock"
171
+
172
+ if not lock_file_path.exists():
173
+ return None
174
+
175
+ try:
176
+ with open(lock_file_path, 'r') as f:
177
+ content = f.read().strip()
178
+ info = {}
179
+ for line in content.split('\n'):
180
+ if ':' in line:
181
+ key, value = line.split(':', 1)
182
+ info[key] = value
183
+ return info
184
+ except Exception:
185
+ return None
186
+
187
+ def cleanup_stale_locks(self, max_age: float = 300.0):
188
+ """Clean up stale lock files older than max_age seconds."""
189
+ current_time = time.time()
190
+
191
+ for lock_file_path in self.lock_dir.glob("video_device_*.lock"):
192
+ try:
193
+ # Check if lock file is stale
194
+ if current_time - lock_file_path.stat().st_mtime > max_age:
195
+ # Try to acquire lock to see if it's really stale
196
+ try:
197
+ with open(lock_file_path, 'r') as f:
198
+ test_file = open(lock_file_path, 'r')
199
+ fcntl.flock(test_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
200
+ fcntl.flock(test_file.fileno(), fcntl.LOCK_UN)
201
+ test_file.close()
202
+
203
+ # Lock is available, remove stale file
204
+ lock_file_path.unlink()
205
+ logging.info(f"🧹 Cleaned up stale lock file: {lock_file_path}")
206
+
207
+ except BlockingIOError:
208
+ # Lock is still active, keep it
209
+ pass
210
+
211
+ except Exception as e:
212
+ logging.warning(f"⚠️ Error checking stale lock {lock_file_path}: {e}")
213
+
214
+ def shutdown(self):
215
+ """Release all locks and cleanup."""
216
+ logging.info("🛑 Shutting down SystemWideDeviceCoordinator")
217
+
218
+ with self.lock:
219
+ for device_index in list(self.active_locks.keys()):
220
+ self.release_device_lock(device_index)
221
+
222
+
223
+ # Global instance
224
+ _system_coordinator = None
225
+ _coordinator_lock = threading.Lock()
226
+
227
+ def get_system_coordinator() -> SystemWideDeviceCoordinator:
228
+ """Get the global system-wide device coordinator instance."""
229
+ global _system_coordinator
230
+
231
+ if _system_coordinator is None:
232
+ with _coordinator_lock:
233
+ if _system_coordinator is None:
234
+ _system_coordinator = SystemWideDeviceCoordinator()
235
+
236
+ return _system_coordinator