Skip to content

Commit 28dc2b0

Browse files
authored
Repair camera module (#929)
* camera module fixes * small cleanup, vibed sensor.py deleted * sharpness barrier fix * moved gstreamer into separate dir, removed fake zed * removed all typing ignores * mypy fix * better video stream skill Former-commit-id: ba57179 [formerly 45c4436] Former-commit-id: 4659838
1 parent 3b5e003 commit 28dc2b0

11 files changed

Lines changed: 114 additions & 167 deletions

File tree

dimos/agents/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@
1111
from dimos.agents.spec import AgentSpec
1212
from dimos.protocol.skill.skill import skill
1313
from dimos.protocol.skill.type import Output, Reducer, Stream
14+
15+
__all__ = ["Agent", "AgentSpec", "Output", "Reducer", "Stream", "deploy", "skill"]
File renamed without changes.

dimos/hardware/sensors/gstreamer_camera_test_script.py renamed to dimos/hardware/sensors/camera/gstreamer/gstreamer_camera_test_script.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import time
2020

2121
from dimos import core
22-
from dimos.hardware.sensors.gstreamer_camera import GstreamerCameraModule
22+
from dimos.hardware.sensors.camera.gstreamer.gstreamer_camera import GstreamerCameraModule
2323
from dimos.msgs.sensor_msgs import Image
2424
from dimos.protocol import pubsub
2525

File renamed without changes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This gstreamer stuff is obsoleted but could be adopted as an alternative hardware for camera module if needed

dimos/hardware/sensors/camera/module.py

Lines changed: 32 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,27 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from collections.abc import Callable
15+
from collections.abc import Callable, Generator
1616
from dataclasses import dataclass, field
17-
import queue
1817
import time
18+
from typing import Any
1919

2020
import reactivex as rx
2121
from reactivex import operators as ops
22-
from reactivex.disposable import Disposable
2322
from reactivex.observable import Observable
2423

25-
from dimos import spec
26-
from dimos.agents import Output, Reducer, Stream, skill # type: ignore[attr-defined]
24+
from dimos.agents import Output, Reducer, Stream, skill
2725
from dimos.core import Module, ModuleConfig, Out, rpc
2826
from dimos.hardware.sensors.camera.spec import CameraHardware
2927
from dimos.hardware.sensors.camera.webcam import Webcam
3028
from dimos.msgs.geometry_msgs import Quaternion, Transform, Vector3
31-
from dimos.msgs.sensor_msgs import Image
3229
from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo
3330
from dimos.msgs.sensor_msgs.Image import Image, sharpness_barrier
34-
from dimos.spec import perception as spec # type: ignore[no-redef]
31+
from dimos.spec import perception
32+
from dimos.utils.reactive import iter_observable
3533

3634

37-
def default_transform(): # type: ignore[no-untyped-def]
35+
def default_transform() -> Transform:
3836
return Transform(
3937
translation=Vector3(0.0, 0.0, 0.0),
4038
rotation=Quaternion(0.0, 0.0, 0.0, 1.0),
@@ -47,81 +45,52 @@ def default_transform(): # type: ignore[no-untyped-def]
4745
class CameraModuleConfig(ModuleConfig):
4846
frame_id: str = "camera_link"
4947
transform: Transform | None = field(default_factory=default_transform)
50-
hardware: Callable[[], CameraHardware] | CameraHardware = Webcam # type: ignore[type-arg]
51-
frequency: float = 5.0
48+
hardware: Callable[[], CameraHardware[Any]] | CameraHardware[Any] = Webcam
49+
frequency: float = 0.0 # Hz, 0 means no limit
5250

5351

54-
class CameraModule(Module[CameraModuleConfig], spec.Camera):
52+
class CameraModule(Module[CameraModuleConfig], perception.Camera):
5553
color_image: Out[Image]
5654
camera_info: Out[CameraInfo]
5755

58-
hardware: CameraHardware = None # type: ignore[assignment, type-arg]
59-
_module_subscription: Disposable | None = None
60-
_camera_info_subscription: Disposable | None = None
61-
_skill_stream: Observable[Image] | None = None
56+
hardware: CameraHardware[Any]
6257

6358
config: CameraModuleConfig
6459
default_config = CameraModuleConfig
6560

66-
def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def]
61+
def __init__(self, *args: Any, **kwargs: Any) -> None:
6762
super().__init__(*args, **kwargs)
6863

69-
@property
70-
def hardware_camera_info(self) -> CameraInfo:
71-
return self.hardware.camera_info
72-
7364
@rpc
74-
def start(self): # type: ignore[no-untyped-def]
65+
def start(self) -> None:
7566
if callable(self.config.hardware):
7667
self.hardware = self.config.hardware()
7768
else:
7869
self.hardware = self.config.hardware
7970

80-
if self._module_subscription:
81-
return "already started"
82-
83-
stream = self.hardware.image_stream().pipe(sharpness_barrier(self.config.frequency)) # type: ignore[attr-defined]
84-
self._disposables.add(stream.subscribe(self.color_image.publish))
85-
86-
# camera_info_stream = self.camera_info_stream(frequency=5.0)
87-
88-
def publish_info(camera_info: CameraInfo) -> None:
89-
self.camera_info.publish(camera_info)
90-
91-
if self.config.transform is None:
92-
return
93-
94-
camera_link = self.config.transform
95-
camera_link.ts = camera_info.ts
96-
camera_optical = Transform(
97-
translation=Vector3(0.0, 0.0, 0.0),
98-
rotation=Quaternion(-0.5, 0.5, -0.5, 0.5),
99-
frame_id="camera_link",
100-
child_frame_id="camera_optical",
101-
ts=camera_link.ts,
102-
)
103-
104-
self.tf.publish(camera_link, camera_optical)
71+
stream = self.hardware.image_stream()
10572

106-
self._camera_info_subscription = self.camera_info_stream().subscribe(publish_info) # type: ignore[assignment]
107-
self._module_subscription = stream.subscribe(self.color_image.publish) # type: ignore[attr-defined]
73+
if self.config.frequency > 0:
74+
stream = stream.pipe(sharpness_barrier(self.config.frequency))
10875

109-
@skill(stream=Stream.passive, output=Output.image, reducer=Reducer.latest) # type: ignore[arg-type]
110-
def video_stream(self) -> Image: # type: ignore[misc]
111-
"""implicit video stream skill"""
112-
_queue = queue.Queue(maxsize=1) # type: ignore[var-annotated]
113-
self.hardware.image_stream().subscribe(_queue.put)
76+
self._disposables.add(
77+
stream.subscribe(self.color_image.publish),
78+
)
11479

115-
yield from iter(_queue.get, None)
80+
self._disposables.add(
81+
rx.interval(1.0).subscribe(lambda _: self.publish_metadata()),
82+
)
11683

117-
def publish_info(self, camera_info: CameraInfo) -> None:
84+
def publish_metadata(self) -> None:
85+
camera_info = self.hardware.camera_info.with_ts(time.time())
11886
self.camera_info.publish(camera_info)
11987

120-
if self.config.transform is None:
88+
if not self.config.transform:
12189
return
12290

12391
camera_link = self.config.transform
12492
camera_link.ts = camera_info.ts
93+
12594
camera_optical = Transform(
12695
translation=Vector3(0.0, 0.0, 0.0),
12796
rotation=Quaternion(-0.5, 0.5, -0.5, 0.5),
@@ -132,21 +101,13 @@ def publish_info(self, camera_info: CameraInfo) -> None:
132101

133102
self.tf.publish(camera_link, camera_optical)
134103

135-
def camera_info_stream(self, frequency: float = 1.0) -> Observable[CameraInfo]:
136-
def camera_info(_) -> CameraInfo: # type: ignore[no-untyped-def]
137-
self.hardware.camera_info.ts = time.time()
138-
return self.hardware.camera_info
139-
140-
return rx.interval(1.0 / frequency).pipe(ops.map(camera_info))
141-
142-
def stop(self): # type: ignore[no-untyped-def]
143-
if self._module_subscription:
144-
self._module_subscription.dispose()
145-
self._module_subscription = None
146-
if self._camera_info_subscription:
147-
self._camera_info_subscription.dispose()
148-
self._camera_info_subscription = None
149-
# Also stop the hardware if it has a stop method
104+
# actually skills should support on_demand passive skills so we don't emit this periodically
105+
# but just provide the latest frame on demand
106+
@skill(stream=Stream.passive, output=Output.image, reducer=Reducer.latest) # type: ignore[arg-type]
107+
def video_stream(self) -> Generator[Image, None, None]:
108+
yield from iter_observable(self.hardware.image_stream().pipe(ops.sample(1.0)))
109+
110+
def stop(self) -> None:
150111
if self.hardware and hasattr(self.hardware, "stop"):
151112
self.hardware.stop()
152113
super().stop()

dimos/hardware/sensors/camera/test_webcam.py

Lines changed: 11 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,15 @@
2424
from dimos.msgs.sensor_msgs import CameraInfo, Image
2525

2626

27-
@pytest.mark.tool
28-
def test_streaming_single() -> None:
29-
dimos = core.start(1)
27+
@pytest.fixture
28+
def dimos():
29+
dimos_instance = core.start(1)
30+
yield dimos_instance
31+
dimos_instance.stop()
32+
3033

34+
@pytest.mark.tool
35+
def test_streaming_single(dimos) -> None:
3136
camera = dimos.deploy(
3237
CameraModule,
3338
transform=Transform(
@@ -37,15 +42,14 @@ def test_streaming_single() -> None:
3742
child_frame_id="camera_link",
3843
),
3944
hardware=lambda: Webcam(
40-
stereo_slice="left",
4145
camera_index=0,
42-
frequency=15,
46+
frequency=0.0, # full speed but set something to test sharpness barrier
4347
camera_info=zed.CameraInfo.SingleWebcam,
4448
),
4549
)
4650

47-
camera.image.transport = core.LCMTransport("/image1", Image)
48-
camera.camera_info.transport = core.LCMTransport("/image1/camera_info", CameraInfo)
51+
camera.color_image.transport = core.LCMTransport("/color_image", Image)
52+
camera.camera_info.transport = core.LCMTransport("/camera_info", CameraInfo)
4953
camera.start()
5054

5155
try:
@@ -54,55 +58,3 @@ def test_streaming_single() -> None:
5458
except KeyboardInterrupt:
5559
camera.stop()
5660
dimos.stop()
57-
58-
59-
@pytest.mark.tool
60-
def test_streaming_double() -> None:
61-
dimos = core.start(2)
62-
63-
camera1 = dimos.deploy(
64-
CameraModule,
65-
transform=Transform(
66-
translation=Vector3(0.05, 0.0, 0.0),
67-
rotation=Quaternion(0.0, 0.0, 0.0, 1.0),
68-
frame_id="sensor",
69-
child_frame_id="camera_link",
70-
),
71-
hardware=lambda: Webcam(
72-
stereo_slice="left",
73-
camera_index=0,
74-
frequency=15,
75-
camera_info=zed.CameraInfo.SingleWebcam,
76-
),
77-
)
78-
79-
camera2 = dimos.deploy(
80-
CameraModule,
81-
transform=Transform(
82-
translation=Vector3(0.05, 0.0, 0.0),
83-
rotation=Quaternion(0.0, 0.0, 0.0, 1.0),
84-
frame_id="sensor",
85-
child_frame_id="camera_link",
86-
),
87-
hardware=lambda: Webcam(
88-
camera_index=4,
89-
frequency=15,
90-
stereo_slice="left",
91-
camera_info=zed.CameraInfo.SingleWebcam,
92-
),
93-
)
94-
95-
camera1.image.transport = core.LCMTransport("/image1", Image)
96-
camera1.camera_info.transport = core.LCMTransport("/image1/camera_info", CameraInfo)
97-
camera1.start()
98-
camera2.image.transport = core.LCMTransport("/image2", Image)
99-
camera2.camera_info.transport = core.LCMTransport("/image2/camera_info", CameraInfo)
100-
camera2.start()
101-
102-
try:
103-
while True:
104-
time.sleep(1)
105-
except KeyboardInterrupt:
106-
camera1.stop()
107-
camera2.stop()
108-
dimos.stop()

dimos/hardware/sensors/sensor.py

Lines changed: 0 additions & 35 deletions
This file was deleted.

dimos/msgs/sensor_msgs/CameraInfo.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,29 @@ def __init__(
9999
self.roi_width = 0
100100
self.roi_do_rectify = False
101101

102+
def with_ts(self, ts: float) -> CameraInfo:
103+
"""Return a copy of this CameraInfo with the given timestamp.
104+
105+
Args:
106+
ts: New timestamp
107+
108+
Returns:
109+
New CameraInfo instance with updated timestamp
110+
"""
111+
return CameraInfo(
112+
height=self.height,
113+
width=self.width,
114+
distortion_model=self.distortion_model,
115+
D=self.D.copy(),
116+
K=self.K.copy(),
117+
R=self.R.copy(),
118+
P=self.P.copy(),
119+
binning_x=self.binning_x,
120+
binning_y=self.binning_y,
121+
frame_id=self.frame_id,
122+
ts=ts,
123+
)
124+
102125
@classmethod
103126
def from_yaml(cls, yaml_file: str) -> CameraInfo:
104127
"""Create CameraInfo from YAML file.

dimos/utils/reactive.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from collections.abc import Callable
15+
from collections.abc import Callable, Generator
16+
from queue import Queue
1617
import threading
1718
from typing import Any, Generic, TypeVar
1819

@@ -228,3 +229,35 @@ def _quality_barrier(source: Observable[T]) -> Observable[T]:
228229
)
229230

230231
return _quality_barrier
232+
233+
234+
def iter_observable(observable: Observable[T]) -> Generator[T, None, None]:
235+
"""Convert an Observable to a blocking iterator.
236+
237+
Yields items as they arrive from the observable. Properly disposes
238+
the subscription when the generator is closed.
239+
"""
240+
q: Queue[T | None] = Queue()
241+
done = threading.Event()
242+
243+
def on_next(value: T) -> None:
244+
q.put(value)
245+
246+
def on_complete() -> None:
247+
done.set()
248+
q.put(None)
249+
250+
def on_error(e: Exception) -> None:
251+
done.set()
252+
q.put(None)
253+
254+
sub = observable.subscribe(on_next=on_next, on_completed=on_complete, on_error=on_error)
255+
256+
try:
257+
while not done.is_set() or not q.empty():
258+
item = q.get()
259+
if item is None and done.is_set():
260+
break
261+
yield item # type: ignore[misc]
262+
finally:
263+
sub.dispose()

0 commit comments

Comments
 (0)