rocket-welder-sdk 1.1.27__py3-none-any.whl → 1.1.28__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.
@@ -11,9 +11,11 @@ from .bytes_size import BytesSize
11
11
  from .connection_string import ConnectionMode, ConnectionString, Protocol
12
12
  from .controllers import DuplexShmController, IController, OneWayShmController
13
13
  from .gst_metadata import GstCaps, GstMetadata
14
+ from .opencv_controller import OpenCvController
15
+ from .periodic_timer import PeriodicTimer, PeriodicTimerSync
14
16
  from .rocket_welder_client import RocketWelderClient
15
17
 
16
- # Alias for backward compatibility
18
+ # Alias for backward compatibility and README examples
17
19
  Client = RocketWelderClient
18
20
 
19
21
  __version__ = "1.1.0"
@@ -50,6 +52,10 @@ __all__ = [
50
52
  # Controllers
51
53
  "IController",
52
54
  "OneWayShmController",
55
+ "OpenCvController",
56
+ # Timers
57
+ "PeriodicTimer",
58
+ "PeriodicTimerSync",
53
59
  "Protocol",
54
60
  # Main client
55
61
  "RocketWelderClient",
@@ -21,6 +21,7 @@ class Protocol(Flag):
21
21
  MJPEG = auto() # Motion JPEG
22
22
  HTTP = auto() # HTTP protocol
23
23
  TCP = auto() # TCP protocol
24
+ FILE = auto() # File protocol
24
25
 
25
26
 
26
27
  class ConnectionMode(Enum):
@@ -43,12 +44,15 @@ class ConnectionString:
43
44
  - shm://buffer_name?size=256MB&metadata=4KB&mode=Duplex
44
45
  - mjpeg://192.168.1.100:8080
45
46
  - mjpeg+http://camera.local:80
47
+ - file:///path/to/video.mp4?loop=true
46
48
  """
47
49
 
48
50
  protocol: Protocol
49
51
  host: str | None = None
50
52
  port: int | None = None
51
53
  buffer_name: str | None = None
54
+ file_path: str | None = None
55
+ parameters: dict[str, str] = field(default_factory=dict)
52
56
  buffer_size: BytesSize = field(default_factory=lambda: BytesSize.parse("256MB"))
53
57
  metadata_size: BytesSize = field(default_factory=lambda: BytesSize.parse("4KB"))
54
58
  connection_mode: ConnectionMode = ConnectionMode.ONE_WAY
@@ -82,6 +86,8 @@ class ConnectionString:
82
86
  # Parse based on protocol type
83
87
  if protocol == Protocol.SHM:
84
88
  return cls._parse_shm(protocol, remainder)
89
+ elif protocol == Protocol.FILE:
90
+ return cls._parse_file(protocol, remainder)
85
91
  elif bool(protocol & Protocol.MJPEG): # type: ignore[operator]
86
92
  return cls._parse_mjpeg(protocol, remainder)
87
93
  else:
@@ -110,6 +116,7 @@ class ConnectionString:
110
116
  "mjpeg": Protocol.MJPEG,
111
117
  "http": Protocol.HTTP,
112
118
  "tcp": Protocol.TCP,
119
+ "file": Protocol.FILE,
113
120
  }
114
121
 
115
122
  protocol = protocol_map.get(protocol_str, Protocol.NONE)
@@ -157,6 +164,43 @@ class ConnectionString:
157
164
  timeout_ms=timeout_ms,
158
165
  )
159
166
 
167
+ @classmethod
168
+ def _parse_file(cls, protocol: Protocol, remainder: str) -> ConnectionString:
169
+ """Parse file protocol connection string."""
170
+ # Split file path and query parameters
171
+ if "?" in remainder:
172
+ file_path, query_string = remainder.split("?", 1)
173
+ params = cls._parse_query_params(query_string)
174
+ else:
175
+ file_path = remainder
176
+ params = {}
177
+
178
+ # Handle file:///absolute/path and file://relative/path
179
+ if not file_path.startswith("/"):
180
+ file_path = "/" + file_path
181
+
182
+ # Parse common parameters
183
+ connection_mode = ConnectionMode.ONE_WAY
184
+ timeout_ms = 5000
185
+
186
+ if "mode" in params:
187
+ mode_str = params["mode"].upper()
188
+ if mode_str == "DUPLEX":
189
+ connection_mode = ConnectionMode.DUPLEX
190
+ elif mode_str in ("ONEWAY", "ONE_WAY"):
191
+ connection_mode = ConnectionMode.ONE_WAY
192
+ if "timeout" in params:
193
+ with contextlib.suppress(ValueError):
194
+ timeout_ms = int(params["timeout"])
195
+
196
+ return cls(
197
+ protocol=protocol,
198
+ file_path=file_path,
199
+ parameters=params,
200
+ connection_mode=connection_mode,
201
+ timeout_ms=timeout_ms,
202
+ )
203
+
160
204
  @classmethod
161
205
  def _parse_mjpeg(cls, protocol: Protocol, remainder: str) -> ConnectionString:
162
206
  """Parse MJPEG connection string."""
@@ -195,6 +239,8 @@ class ConnectionString:
195
239
  protocol_parts = []
196
240
  if self.protocol & Protocol.SHM:
197
241
  protocol_parts.append("shm")
242
+ if self.protocol & Protocol.FILE:
243
+ protocol_parts.append("file")
198
244
  if self.protocol & Protocol.MJPEG:
199
245
  protocol_parts.append("mjpeg")
200
246
  if self.protocol & Protocol.HTTP:
@@ -215,6 +261,11 @@ class ConnectionString:
215
261
  params.append(f"timeout={self.timeout_ms}")
216
262
 
217
263
  return f"{protocol_str}://{self.buffer_name}?{'&'.join(params)}"
264
+ elif self.protocol == Protocol.FILE:
265
+ query_string = ""
266
+ if self.parameters:
267
+ query_string = "?" + "&".join(f"{k}={v}" for k, v in self.parameters.items())
268
+ return f"{protocol_str}://{self.file_path}{query_string}"
218
269
  else:
219
270
  return f"{protocol_str}://{self.host}:{self.port}"
220
271
 
@@ -9,7 +9,7 @@ import json
9
9
  import logging
10
10
  import threading
11
11
  from abc import ABC, abstractmethod
12
- from typing import Any, Callable
12
+ from typing import TYPE_CHECKING, Callable, Optional
13
13
 
14
14
  import numpy as np
15
15
  from zerobuffer import BufferConfig, Frame, Reader, Writer
@@ -19,8 +19,12 @@ from zerobuffer.exceptions import WriterDeadException
19
19
  from .connection_string import ConnectionMode, ConnectionString, Protocol
20
20
  from .gst_metadata import GstCaps, GstMetadata
21
21
 
22
- # Type alias for OpenCV Mat
23
- Mat = np.ndarray[Any, Any]
22
+ if TYPE_CHECKING:
23
+ import numpy.typing as npt
24
+
25
+ Mat = npt.NDArray[np.uint8]
26
+ else:
27
+ Mat = np.ndarray # type: ignore[misc]
24
28
 
25
29
  # Module logger
26
30
  logger = logging.getLogger(__name__)
@@ -36,13 +40,15 @@ class IController(ABC):
36
40
  ...
37
41
 
38
42
  @abstractmethod
39
- def get_metadata(self) -> GstMetadata | None:
43
+ def get_metadata(self) -> Optional[GstMetadata]:
40
44
  """Get the current GStreamer metadata."""
41
45
  ...
42
46
 
43
47
  @abstractmethod
44
48
  def start(
45
- self, on_frame: Callable[[Mat], None], cancellation_token: threading.Event | None = None
49
+ self,
50
+ on_frame: Callable[[Mat], None], # type: ignore[valid-type]
51
+ cancellation_token: Optional[threading.Event] = None,
46
52
  ) -> None:
47
53
  """
48
54
  Start the controller with a frame callback.
@@ -80,24 +86,26 @@ class OneWayShmController(IController):
80
86
  )
81
87
 
82
88
  self._connection = connection
83
- self._reader: Reader | None = None
84
- self._gst_caps: GstCaps | None = None
85
- self._metadata: GstMetadata | None = None
89
+ self._reader: Optional[Reader] = None
90
+ self._gst_caps: Optional[GstCaps] = None
91
+ self._metadata: Optional[GstMetadata] = None
86
92
  self._is_running = False
87
- self._worker_thread: threading.Thread | None = None
88
- self._cancellation_token: threading.Event | None = None
93
+ self._worker_thread: Optional[threading.Thread] = None
94
+ self._cancellation_token: Optional[threading.Event] = None
89
95
 
90
96
  @property
91
97
  def is_running(self) -> bool:
92
98
  """Check if the controller is running."""
93
99
  return self._is_running
94
100
 
95
- def get_metadata(self) -> GstMetadata | None:
101
+ def get_metadata(self) -> Optional[GstMetadata]:
96
102
  """Get the current GStreamer metadata."""
97
103
  return self._metadata
98
104
 
99
105
  def start(
100
- self, on_frame: Callable[[Mat], None], cancellation_token: threading.Event | None = None
106
+ self,
107
+ on_frame: Callable[[Mat], None], # type: ignore[valid-type]
108
+ cancellation_token: Optional[threading.Event] = None,
101
109
  ) -> None:
102
110
  """
103
111
  Start receiving frames from shared memory.
@@ -161,7 +169,7 @@ class OneWayShmController(IController):
161
169
  self._worker_thread = None
162
170
  logger.info("Stopped controller for buffer '%s'", self._connection.buffer_name)
163
171
 
164
- def _process_frames(self, on_frame: Callable[[Mat], None]) -> None:
172
+ def _process_frames(self, on_frame: Callable[[Mat], None]) -> None: # type: ignore[valid-type]
165
173
  """
166
174
  Process frames from shared memory.
167
175
 
@@ -236,7 +244,7 @@ class OneWayShmController(IController):
236
244
  logger.error("Fatal error in frame processing loop: %s", e)
237
245
  self._is_running = False
238
246
 
239
- def _on_first_frame(self, on_frame: Callable[[Mat], None]) -> None:
247
+ def _on_first_frame(self, on_frame: Callable[[Mat], None]) -> None: # type: ignore[valid-type]
240
248
  """
241
249
  Process the first frame and extract metadata.
242
250
  Matches C# OnFirstFrame behavior - loops until valid frame received.
@@ -342,7 +350,7 @@ class OneWayShmController(IController):
342
350
  if not self._is_running:
343
351
  break
344
352
 
345
- def _create_mat_from_frame(self, frame: Frame) -> Mat | None:
353
+ def _create_mat_from_frame(self, frame: Frame) -> Optional[Mat]: # type: ignore[valid-type]
346
354
  """
347
355
  Create OpenCV Mat from frame data using GstCaps.
348
356
  Matches C# CreateMat behavior - creates Mat wrapping the data.
@@ -440,7 +448,7 @@ class OneWayShmController(IController):
440
448
  logger.error("Failed to convert frame to Mat: %s", e)
441
449
  return None
442
450
 
443
- def _infer_caps_from_frame(self, mat: Mat) -> None:
451
+ def _infer_caps_from_frame(self, mat: Mat) -> None: # type: ignore[valid-type]
444
452
  """
445
453
  Infer GStreamer caps from OpenCV Mat.
446
454
 
@@ -487,11 +495,11 @@ class DuplexShmController(IController):
487
495
  )
488
496
 
489
497
  self._connection = connection
490
- self._duplex_server: IImmutableDuplexServer | None = None
491
- self._gst_caps: GstCaps | None = None
492
- self._metadata: GstMetadata | None = None
498
+ self._duplex_server: Optional[IImmutableDuplexServer] = None
499
+ self._gst_caps: Optional[GstCaps] = None
500
+ self._metadata: Optional[GstMetadata] = None
493
501
  self._is_running = False
494
- self._on_frame_callback: Callable[[Mat, Mat], None] | None = None
502
+ self._on_frame_callback: Optional[Callable[[Mat, Mat], None]] = None # type: ignore[valid-type]
495
503
  self._frame_count = 0
496
504
 
497
505
  @property
@@ -499,14 +507,14 @@ class DuplexShmController(IController):
499
507
  """Check if the controller is running."""
500
508
  return self._is_running
501
509
 
502
- def get_metadata(self) -> GstMetadata | None:
510
+ def get_metadata(self) -> Optional[GstMetadata]:
503
511
  """Get the current GStreamer metadata."""
504
512
  return self._metadata
505
513
 
506
514
  def start(
507
515
  self,
508
- on_frame: Callable[[Mat, Mat], None], # type: ignore[override]
509
- cancellation_token: threading.Event | None = None,
516
+ on_frame: Callable[[Mat, Mat], None], # type: ignore[override,valid-type]
517
+ cancellation_token: Optional[threading.Event] = None,
510
518
  ) -> None:
511
519
  """
512
520
  Start duplex frame processing.
@@ -662,7 +670,7 @@ class DuplexShmController(IController):
662
670
  except Exception as e:
663
671
  logger.error("Error processing duplex frame: %s", e)
664
672
 
665
- def _frame_to_mat(self, frame: Frame) -> Mat | None:
673
+ def _frame_to_mat(self, frame: Frame) -> Optional[Mat]: # type: ignore[valid-type]
666
674
  """Convert frame to OpenCV Mat (reuse from OneWayShmController)."""
667
675
  # Implementation is same as OneWayShmController
668
676
  return OneWayShmController._create_mat_from_frame(self, frame) # type: ignore[arg-type]
@@ -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