rocket-welder-sdk 1.1.26__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.
- rocket_welder_sdk/__init__.py +7 -1
- rocket_welder_sdk/connection_string.py +51 -0
- rocket_welder_sdk/controllers.py +32 -24
- rocket_welder_sdk/opencv_controller.py +278 -0
- rocket_welder_sdk/periodic_timer.py +303 -0
- rocket_welder_sdk/rocket_welder_client.py +246 -9
- {rocket_welder_sdk-1.1.26.dist-info → rocket_welder_sdk-1.1.28.dist-info}/METADATA +130 -1
- {rocket_welder_sdk-1.1.26.dist-info → rocket_welder_sdk-1.1.28.dist-info}/RECORD +10 -8
- {rocket_welder_sdk-1.1.26.dist-info → rocket_welder_sdk-1.1.28.dist-info}/WHEEL +0 -0
- {rocket_welder_sdk-1.1.26.dist-info → rocket_welder_sdk-1.1.28.dist-info}/top_level.txt +0 -0
rocket_welder_sdk/__init__.py
CHANGED
|
@@ -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
|
|
rocket_welder_sdk/controllers.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
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,
|
|
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
|
|
84
|
-
self._gst_caps: GstCaps
|
|
85
|
-
self._metadata: GstMetadata
|
|
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
|
|
88
|
-
self._cancellation_token: threading.Event
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
491
|
-
self._gst_caps: GstCaps
|
|
492
|
-
self._metadata: GstMetadata
|
|
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]
|
|
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
|
|
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
|
|
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
|
|
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
|