rocket-welder-sdk 1.1.36.dev14__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 (40) hide show
  1. rocket_welder_sdk/__init__.py +95 -0
  2. rocket_welder_sdk/bytes_size.py +234 -0
  3. rocket_welder_sdk/connection_string.py +291 -0
  4. rocket_welder_sdk/controllers.py +831 -0
  5. rocket_welder_sdk/external_controls/__init__.py +30 -0
  6. rocket_welder_sdk/external_controls/contracts.py +100 -0
  7. rocket_welder_sdk/external_controls/contracts_old.py +105 -0
  8. rocket_welder_sdk/frame_metadata.py +138 -0
  9. rocket_welder_sdk/gst_metadata.py +411 -0
  10. rocket_welder_sdk/high_level/__init__.py +54 -0
  11. rocket_welder_sdk/high_level/client.py +235 -0
  12. rocket_welder_sdk/high_level/connection_strings.py +331 -0
  13. rocket_welder_sdk/high_level/data_context.py +169 -0
  14. rocket_welder_sdk/high_level/frame_sink_factory.py +118 -0
  15. rocket_welder_sdk/high_level/schema.py +195 -0
  16. rocket_welder_sdk/high_level/transport_protocol.py +238 -0
  17. rocket_welder_sdk/keypoints_protocol.py +642 -0
  18. rocket_welder_sdk/opencv_controller.py +278 -0
  19. rocket_welder_sdk/periodic_timer.py +303 -0
  20. rocket_welder_sdk/py.typed +2 -0
  21. rocket_welder_sdk/rocket_welder_client.py +497 -0
  22. rocket_welder_sdk/segmentation_result.py +420 -0
  23. rocket_welder_sdk/session_id.py +238 -0
  24. rocket_welder_sdk/transport/__init__.py +31 -0
  25. rocket_welder_sdk/transport/frame_sink.py +122 -0
  26. rocket_welder_sdk/transport/frame_source.py +74 -0
  27. rocket_welder_sdk/transport/nng_transport.py +197 -0
  28. rocket_welder_sdk/transport/stream_transport.py +193 -0
  29. rocket_welder_sdk/transport/tcp_transport.py +154 -0
  30. rocket_welder_sdk/transport/unix_socket_transport.py +339 -0
  31. rocket_welder_sdk/ui/__init__.py +48 -0
  32. rocket_welder_sdk/ui/controls.py +362 -0
  33. rocket_welder_sdk/ui/icons.py +21628 -0
  34. rocket_welder_sdk/ui/ui_events_projection.py +226 -0
  35. rocket_welder_sdk/ui/ui_service.py +358 -0
  36. rocket_welder_sdk/ui/value_types.py +72 -0
  37. rocket_welder_sdk-1.1.36.dev14.dist-info/METADATA +845 -0
  38. rocket_welder_sdk-1.1.36.dev14.dist-info/RECORD +40 -0
  39. rocket_welder_sdk-1.1.36.dev14.dist-info/WHEEL +5 -0
  40. rocket_welder_sdk-1.1.36.dev14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,278 @@
1
+ """
2
+ OpenCV-based controller for video file playback and network streams.
3
+ Provides support for file:// and mjpeg:// protocols.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any, Callable
14
+
15
+ import cv2
16
+ import numpy as np
17
+ import numpy.typing as npt
18
+
19
+ from .connection_string import ConnectionMode, ConnectionString, Protocol
20
+ from .controllers import IController
21
+ from .gst_metadata import GstCaps, GstMetadata
22
+ from .periodic_timer import PeriodicTimerSync
23
+
24
+ if TYPE_CHECKING:
25
+ Mat = npt.NDArray[np.uint8]
26
+ else:
27
+ Mat = np.ndarray # type: ignore[misc]
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class OpenCvController(IController):
33
+ """
34
+ Controller for video sources using OpenCV VideoCapture.
35
+
36
+ Supports:
37
+ - File playback with optional looping
38
+ - MJPEG network streams over HTTP/TCP
39
+ """
40
+
41
+ def __init__(self, connection: ConnectionString) -> None:
42
+ """
43
+ Initialize the OpenCV controller.
44
+
45
+ Args:
46
+ connection: Connection string configuration
47
+ """
48
+ if not (connection.protocol == Protocol.FILE or bool(connection.protocol & Protocol.MJPEG)): # type: ignore[operator]
49
+ raise ValueError(
50
+ f"OpenCvController requires FILE or MJPEG protocol, got {connection.protocol}"
51
+ )
52
+
53
+ self._connection = connection
54
+ self._capture: cv2.VideoCapture | None = None
55
+ self._metadata: GstMetadata | None = None
56
+ self._is_running = False
57
+ self._worker_thread: threading.Thread | None = None
58
+ self._cancellation_token: threading.Event | None = None
59
+
60
+ # Parse parameters for file protocol
61
+ self._loop = (
62
+ connection.protocol == Protocol.FILE
63
+ and connection.parameters.get("loop", "false").lower() == "true"
64
+ )
65
+
66
+ # Note: Preview is now handled at the client level via show() method
67
+ # This avoids X11/WSL threading issues with OpenCV GUI functions
68
+
69
+ @property
70
+ def is_running(self) -> bool:
71
+ """Check if the controller is running."""
72
+ return self._is_running
73
+
74
+ def get_metadata(self) -> GstMetadata | None:
75
+ """Get the current video metadata."""
76
+ return self._metadata
77
+
78
+ def start(
79
+ self,
80
+ on_frame: (
81
+ Callable[[npt.NDArray[Any]], None]
82
+ | Callable[[npt.NDArray[Any], npt.NDArray[Any]], None]
83
+ ),
84
+ cancellation_token: threading.Event | None = None,
85
+ ) -> None:
86
+ """
87
+ Start processing video frames.
88
+
89
+ Args:
90
+ on_frame: Callback for frame processing
91
+ cancellation_token: Optional cancellation token
92
+ """
93
+ if self._is_running:
94
+ raise RuntimeError("Controller is already running")
95
+
96
+ self._is_running = True
97
+ self._cancellation_token = cancellation_token
98
+
99
+ # Get video source
100
+ source = self._get_source()
101
+ logger.info("Opening video source: %s (loop=%s)", source, self._loop)
102
+
103
+ # Create VideoCapture
104
+ self._capture = cv2.VideoCapture(source)
105
+
106
+ if not self._capture.isOpened():
107
+ self._capture.release()
108
+ self._capture = None
109
+ self._is_running = False
110
+ raise RuntimeError(f"Failed to open video source: {source}")
111
+
112
+ # Get video properties
113
+ width = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH))
114
+ height = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
115
+ fps = self._capture.get(cv2.CAP_PROP_FPS)
116
+ frame_count = int(self._capture.get(cv2.CAP_PROP_FRAME_COUNT))
117
+
118
+ # Create metadata
119
+ caps = GstCaps.from_simple(width, height, "RGB")
120
+ self._metadata = GstMetadata(
121
+ type="video",
122
+ version="1.0",
123
+ caps=caps,
124
+ element_name=(
125
+ "file-capture" if self._connection.protocol == Protocol.FILE else "opencv-capture"
126
+ ),
127
+ )
128
+
129
+ logger.info(
130
+ "Video source opened: %dx%d @ %.1ffps, %d frames", width, height, fps, frame_count
131
+ )
132
+
133
+ # Determine callback type and start worker thread
134
+ if self._connection.connection_mode == ConnectionMode.DUPLEX:
135
+ # For duplex mode with file/mjpeg, we allocate output but process as one-way
136
+ def duplex_wrapper(frame: npt.NDArray[Any]) -> None:
137
+ output = np.empty_like(frame)
138
+ on_frame(frame, output) # type: ignore[call-arg]
139
+
140
+ self._worker_thread = threading.Thread(
141
+ target=self._process_frames,
142
+ args=(duplex_wrapper, fps),
143
+ name=f"RocketWelder-OpenCV-{Path(source).stem}",
144
+ )
145
+ else:
146
+ self._worker_thread = threading.Thread(
147
+ target=self._process_frames,
148
+ args=(on_frame, fps),
149
+ name=f"RocketWelder-OpenCV-{Path(source).stem}",
150
+ )
151
+
152
+ self._worker_thread.start()
153
+
154
+ def stop(self) -> None:
155
+ """Stop the controller and clean up resources."""
156
+ if not self._is_running:
157
+ return
158
+
159
+ logger.debug("Stopping OpenCV controller")
160
+ self._is_running = False
161
+
162
+ # Wait for worker thread
163
+ if self._worker_thread and self._worker_thread.is_alive():
164
+ timeout_s = (self._connection.timeout_ms + 50) / 1000.0
165
+ self._worker_thread.join(timeout=timeout_s)
166
+
167
+ # Clean up capture
168
+ if self._capture:
169
+ self._capture.release()
170
+ self._capture = None
171
+
172
+ self._worker_thread = None
173
+ logger.info("Stopped OpenCV controller")
174
+
175
+ def _get_source(self) -> str:
176
+ """
177
+ Get the video source string for OpenCV.
178
+
179
+ Returns:
180
+ Source string for VideoCapture
181
+
182
+ Raises:
183
+ FileNotFoundError: If file doesn't exist
184
+ ValueError: If file path is missing
185
+ """
186
+ if self._connection.protocol == Protocol.FILE:
187
+ if not self._connection.file_path:
188
+ raise ValueError("File path is required for file protocol")
189
+
190
+ if not os.path.exists(self._connection.file_path):
191
+ raise FileNotFoundError(f"Video file not found: {self._connection.file_path}")
192
+
193
+ return self._connection.file_path
194
+
195
+ elif bool(self._connection.protocol & Protocol.MJPEG): # type: ignore[operator]
196
+ # Construct URL from host:port (no path support yet)
197
+ if bool(self._connection.protocol & Protocol.HTTP): # type: ignore[operator]
198
+ return f"http://{self._connection.host}:{self._connection.port}"
199
+ elif bool(self._connection.protocol & Protocol.TCP): # type: ignore[operator]
200
+ return f"tcp://{self._connection.host}:{self._connection.port}"
201
+ else:
202
+ return f"http://{self._connection.host}:{self._connection.port}"
203
+
204
+ else:
205
+ raise ValueError(f"Unsupported protocol: {self._connection.protocol}")
206
+
207
+ def _process_frames(self, on_frame: Callable[[npt.NDArray[Any]], None], fps: float) -> None:
208
+ """
209
+ Process video frames in a loop.
210
+
211
+ Args:
212
+ on_frame: Callback for each frame
213
+ fps: Frames per second for timing
214
+ """
215
+ if not self._capture:
216
+ return
217
+
218
+ # Use PeriodicTimer for precise frame timing (especially important for file playback)
219
+ timer = None
220
+ if self._connection.protocol == Protocol.FILE and fps > 0:
221
+ # Create timer for file playback at specified FPS
222
+ timer = PeriodicTimerSync(1.0 / fps)
223
+ logger.debug("Using PeriodicTimer for file playback at %.1f FPS", fps)
224
+
225
+ try:
226
+ while self._is_running:
227
+ if self._cancellation_token and self._cancellation_token.is_set():
228
+ break
229
+
230
+ try:
231
+ # Read frame
232
+ ret, frame = self._capture.read()
233
+
234
+ if not ret:
235
+ if self._connection.protocol == Protocol.FILE and self._loop:
236
+ # Loop: Reset to beginning
237
+ self._capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
238
+ logger.debug("Looping video from beginning")
239
+ continue
240
+ elif self._connection.protocol == Protocol.FILE:
241
+ # File ended without loop
242
+ logger.info("Video file ended")
243
+ break
244
+ else:
245
+ # Network stream issue
246
+ logger.warning("Failed to read frame from stream")
247
+ time.sleep(0.01)
248
+ continue
249
+
250
+ if hasattr(frame, "size") and frame.size == 0:
251
+ time.sleep(0.01)
252
+ continue
253
+
254
+ # Process frame
255
+ on_frame(frame)
256
+
257
+ # Control frame rate for file playback using PeriodicTimer
258
+ if timer:
259
+ # Wait for next tick - this provides precise timing
260
+ if not timer.wait_for_next_tick():
261
+ # Timer disposed or timed out
262
+ break
263
+ elif self._connection.protocol != Protocol.FILE:
264
+ # For network streams, we process as fast as they arrive
265
+ # No artificial delay needed
266
+ pass
267
+
268
+ except Exception as e:
269
+ logger.error("Error processing frame: %s", e)
270
+ if not self._is_running:
271
+ break
272
+ time.sleep(0.1)
273
+
274
+ finally:
275
+ if timer:
276
+ timer.dispose()
277
+
278
+ self._is_running = False
@@ -0,0 +1,303 @@
1
+ """
2
+ PeriodicTimer implementation for Python, similar to .NET's System.Threading.PeriodicTimer.
3
+
4
+ Provides an async periodic timer that enables waiting asynchronously for timer ticks.
5
+ This is particularly useful for rendering and periodic frame updates.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import time
13
+ from datetime import timedelta
14
+ from typing import TYPE_CHECKING, Any, List, Optional, Union
15
+
16
+ if TYPE_CHECKING:
17
+ from types import TracebackType
18
+ else:
19
+ TracebackType = type
20
+
21
+
22
+ class PeriodicTimer:
23
+ """
24
+ A periodic timer that enables waiting asynchronously for timer ticks.
25
+
26
+ Similar to .NET's PeriodicTimer, this class provides:
27
+ - Async-first design with wait_for_next_tick_async()
28
+ - Single consumer model (only one wait call at a time)
29
+ - Proper cancellation support
30
+ - No callback-based design (work is done in the calling scope)
31
+
32
+ Example:
33
+ async def render_loop():
34
+ timer = PeriodicTimer(timedelta(seconds=1/60)) # 60 FPS
35
+ try:
36
+ while await timer.wait_for_next_tick_async():
37
+ # Render frame
38
+ await render_frame()
39
+ finally:
40
+ timer.dispose()
41
+ """
42
+
43
+ def __init__(self, period: Union[timedelta, float]):
44
+ """
45
+ Initialize a new PeriodicTimer.
46
+
47
+ Args:
48
+ period: Time interval between ticks. Can be timedelta or float (seconds).
49
+
50
+ Raises:
51
+ ValueError: If period is negative or zero.
52
+ """
53
+ if isinstance(period, timedelta):
54
+ self._period_seconds = period.total_seconds()
55
+ else:
56
+ self._period_seconds = float(period)
57
+
58
+ if self._period_seconds <= 0:
59
+ raise ValueError("Period must be positive")
60
+
61
+ self._start_time = time.monotonic()
62
+ self._tick_count = 0
63
+ self._is_disposed = False
64
+ self._waiting = False
65
+ self._dispose_event = asyncio.Event()
66
+
67
+ @property
68
+ def period(self) -> timedelta:
69
+ """Get the current period as a timedelta."""
70
+ return timedelta(seconds=self._period_seconds)
71
+
72
+ @period.setter
73
+ def period(self, value: Union[timedelta, float]) -> None:
74
+ """
75
+ Set a new period (supported in .NET 8+).
76
+
77
+ Args:
78
+ value: New time interval between ticks.
79
+ """
80
+ new_period = value.total_seconds() if isinstance(value, timedelta) else float(value)
81
+
82
+ if new_period <= 0:
83
+ raise ValueError("Period must be positive")
84
+
85
+ self._period_seconds = new_period
86
+
87
+ async def wait_for_next_tick_async(
88
+ self, cancellation_token: Optional[asyncio.Event] = None
89
+ ) -> bool:
90
+ """
91
+ Wait asynchronously for the next timer tick.
92
+
93
+ Args:
94
+ cancellation_token: Optional cancellation token to stop waiting.
95
+
96
+ Returns:
97
+ True if the timer ticked successfully, False if canceled or disposed.
98
+
99
+ Raises:
100
+ RuntimeError: If multiple consumers try to wait simultaneously.
101
+
102
+ Note:
103
+ - Only one call to this method may be in flight at any time
104
+ - If a tick occurred while no one was waiting, the next call
105
+ will complete immediately
106
+ - The timer starts when the instance is created, not when first called
107
+ """
108
+ if self._is_disposed:
109
+ return False
110
+
111
+ if self._waiting:
112
+ raise RuntimeError("Only one consumer may wait on PeriodicTimer at a time")
113
+
114
+ self._waiting = True
115
+ try:
116
+ # Calculate next tick time
117
+ self._tick_count += 1
118
+ next_tick_time = self._start_time + (self._tick_count * self._period_seconds)
119
+
120
+ # Calculate wait time
121
+ current_time = time.monotonic()
122
+ wait_time = max(0, next_tick_time - current_time)
123
+
124
+ # If we're behind schedule, complete immediately
125
+ if wait_time == 0:
126
+ return not self._is_disposed
127
+
128
+ # Create tasks for waiting
129
+ tasks: List[asyncio.Task[Any]] = [asyncio.create_task(asyncio.sleep(wait_time))]
130
+
131
+ # Add dispose event task
132
+ dispose_task = asyncio.create_task(self._dispose_event.wait())
133
+ tasks.append(dispose_task)
134
+
135
+ # Add cancellation token if provided
136
+ if cancellation_token:
137
+ cancel_task = asyncio.create_task(cancellation_token.wait())
138
+ tasks.append(cancel_task)
139
+
140
+ # Wait for first task to complete
141
+ done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
142
+
143
+ # Cancel pending tasks
144
+ for task in pending:
145
+ task.cancel()
146
+ with contextlib.suppress(asyncio.CancelledError):
147
+ await task
148
+
149
+ # Check what completed
150
+ if dispose_task in done or self._is_disposed:
151
+ return False
152
+
153
+ return not (cancellation_token and cancel_task in done)
154
+
155
+ finally:
156
+ self._waiting = False
157
+
158
+ def dispose(self) -> None:
159
+ """
160
+ Dispose of the timer and release resources.
161
+
162
+ This will cause any pending wait_for_next_tick_async() calls to return False.
163
+ """
164
+ if not self._is_disposed:
165
+ self._is_disposed = True
166
+ self._dispose_event.set()
167
+
168
+ def __enter__(self) -> PeriodicTimer:
169
+ """Context manager entry."""
170
+ return self
171
+
172
+ def __exit__(
173
+ self,
174
+ exc_type: Optional[type],
175
+ exc_val: Optional[BaseException],
176
+ exc_tb: Optional[TracebackType],
177
+ ) -> None:
178
+ """Context manager exit - ensures disposal."""
179
+ self.dispose()
180
+
181
+ def __del__(self) -> None:
182
+ """Ensure timer is disposed when garbage collected."""
183
+ self.dispose()
184
+
185
+
186
+ class PeriodicTimerSync:
187
+ """
188
+ Synchronous version of PeriodicTimer for non-async contexts.
189
+
190
+ Provides similar functionality but with blocking wait methods.
191
+ Useful when async is not available or desired.
192
+
193
+ Example:
194
+ timer = PeriodicTimerSync(1.0/60) # 60 FPS
195
+ try:
196
+ while timer.wait_for_next_tick():
197
+ render_frame()
198
+ finally:
199
+ timer.dispose()
200
+ """
201
+
202
+ def __init__(self, period: Union[timedelta, float]):
203
+ """
204
+ Initialize a new synchronous PeriodicTimer.
205
+
206
+ Args:
207
+ period: Time interval between ticks. Can be timedelta or float (seconds).
208
+ """
209
+ if isinstance(period, timedelta):
210
+ self._period_seconds = period.total_seconds()
211
+ else:
212
+ self._period_seconds = float(period)
213
+
214
+ if self._period_seconds <= 0:
215
+ raise ValueError("Period must be positive")
216
+
217
+ self._start_time = time.monotonic()
218
+ self._tick_count = 0
219
+ self._is_disposed = False
220
+ self._waiting = False
221
+
222
+ @property
223
+ def period(self) -> timedelta:
224
+ """Get the current period as a timedelta."""
225
+ return timedelta(seconds=self._period_seconds)
226
+
227
+ @period.setter
228
+ def period(self, value: Union[timedelta, float]) -> None:
229
+ """Set a new period."""
230
+ new_period = value.total_seconds() if isinstance(value, timedelta) else float(value)
231
+
232
+ if new_period <= 0:
233
+ raise ValueError("Period must be positive")
234
+
235
+ self._period_seconds = new_period
236
+
237
+ def wait_for_next_tick(self, timeout: Optional[float] = None) -> bool:
238
+ """
239
+ Wait synchronously for the next timer tick.
240
+
241
+ Args:
242
+ timeout: Optional timeout in seconds. None means wait indefinitely.
243
+
244
+ Returns:
245
+ True if the timer ticked successfully, False if timed out or disposed.
246
+
247
+ Raises:
248
+ RuntimeError: If multiple consumers try to wait simultaneously.
249
+ """
250
+ if self._is_disposed:
251
+ return False
252
+
253
+ if self._waiting:
254
+ raise RuntimeError("Only one consumer may wait on PeriodicTimer at a time")
255
+
256
+ self._waiting = True
257
+ try:
258
+ # Calculate next tick time
259
+ self._tick_count += 1
260
+ next_tick_time = self._start_time + (self._tick_count * self._period_seconds)
261
+
262
+ # Calculate wait time
263
+ current_time = time.monotonic()
264
+ wait_time = max(0, next_tick_time - current_time)
265
+
266
+ # Apply timeout if specified
267
+ if timeout is not None:
268
+ wait_time = min(wait_time, timeout)
269
+
270
+ # If we need to wait, sleep
271
+ if wait_time > 0:
272
+ time.sleep(wait_time)
273
+
274
+ # Check if we're disposed or timed out
275
+ if self._is_disposed:
276
+ return False
277
+
278
+ if timeout is not None:
279
+ actual_time = time.monotonic()
280
+ if actual_time < next_tick_time:
281
+ return False # Timed out
282
+
283
+ return True
284
+
285
+ finally:
286
+ self._waiting = False
287
+
288
+ def dispose(self) -> None:
289
+ """Dispose of the timer."""
290
+ self._is_disposed = True
291
+
292
+ def __enter__(self) -> PeriodicTimerSync:
293
+ """Context manager entry."""
294
+ return self
295
+
296
+ def __exit__(
297
+ self,
298
+ exc_type: Optional[type],
299
+ exc_val: Optional[BaseException],
300
+ exc_tb: Optional[TracebackType],
301
+ ) -> None:
302
+ """Context manager exit."""
303
+ self.dispose()
@@ -0,0 +1,2 @@
1
+ # Marker file for PEP 561
2
+ # This package contains inline type hints