Skip to content

Commit 9ee6817

Browse files
committed
temp refactor camera
1 parent aea090f commit 9ee6817

8 files changed

Lines changed: 192 additions & 136 deletions

File tree

python/rcs/camera/digit_cam.py

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,10 @@
11
from digit_interface.digit import Digit
22
from pydantic import Field
3-
from rcs.camera.hw import BaseHardwareCameraSet, HWCameraSetConfig
4-
from rcs.camera.interface import BaseCameraConfig, CameraFrame, DataFrame, Frame
3+
from rcs.camera.hw import HardwareCamera
4+
from rcs.camera.interface import BaseCameraConfig, BaseCameraSetConfig, CameraFrame, DataFrame, Frame
55

66

7-
class DigitConfig(HWCameraSetConfig):
8-
"""
9-
Configuration for the DIGIT device.
10-
This class is used to define the settings for the DIGIT device.
11-
"""
12-
13-
cameras: dict[str, BaseCameraConfig] = Field(default={})
14-
stream_name: str = "QVGA" # options: "QVGA" (60 and 30 fps), "VGA" (30 and 15 fps)
15-
7+
class DigitCameraConfig(BaseCameraConfig):
168
@property
179
def resolution_width(self) -> int:
1810
return Digit.STREAMS[self.stream_name]["resolution"]["width"]
@@ -21,27 +13,34 @@ def resolution_width(self) -> int:
2113
def resolution_height(self) -> int:
2214
return Digit.STREAMS[self.stream_name]["resolution"]["height"]
2315

16+
17+
class DigitConfig(BaseCameraSetConfig):
18+
"""
19+
Configuration for the DIGIT device.
20+
This class is used to define the settings for the DIGIT device.
21+
"""
22+
cameras: dict[str, DigitCameraConfig] = Field(default={})
23+
# stream_name: str = "QVGA" # options: "QVGA" (60 and 30 fps), "VGA" (30 and 15 fps)
24+
2425

2526

2627

27-
class DigitCam(BaseHardwareCameraSet):
28+
class DigitCam(HardwareCamera):
2829
"""
2930
This module provides an interface to interact with the DIGIT device.
3031
It allows for connecting to the device, changing settings, and retrieving information.
3132
"""
3233

3334
def __init__(self, cfg: DigitConfig):
3435
self._cfg = cfg
35-
super().__init__()
3636
self._cameras: dict[str, Digit] = {}
37-
self.initalize(self.config)
3837

39-
def initalize(self, cfg: HWCameraSetConfig):
38+
def open(self):
4039
"""
4140
Initialize the digit interface with the given configuration.
4241
:param cfg: Configuration for the DIGIT device.
4342
"""
44-
for name, serial in cfg.name_to_identifier.items():
43+
for name, serial in self._cfg.name_to_identifier.items():
4544
digit = Digit(serial, name)
4645
digit.connect()
4746
self._cameras[name] = digit
@@ -57,6 +56,13 @@ def _poll_frame(self, camera_name: str) -> Frame:
5756

5857
return Frame(camera=cf)
5958

60-
@property
61-
def config(self) -> DigitConfig:
62-
return self._cfg
59+
def close(self):
60+
"""
61+
Closes the connection to the DIGIT device.
62+
"""
63+
for digit in self._cameras.values():
64+
digit.disconnect()
65+
self._cameras = {}
66+
67+
def config(self, camera_name) -> DigitCameraConfig:
68+
return self._cfg.cameras[camera_name]

python/rcs/camera/hw.py

Lines changed: 85 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,97 @@
11
import logging
22
import threading
33
import typing
4-
from abc import ABC, abstractmethod
54
from datetime import datetime
65
from pathlib import Path
76
from time import sleep
87

98
import cv2
109
import numpy as np
11-
from pydantic import Field
1210
from rcs.camera.interface import (
1311
BaseCameraConfig,
14-
BaseCameraSetConfig,
12+
BaseCameraSet,
1513
Frame,
1614
FrameSet,
1715
SimpleFrameRate,
1816
)
1917

2018

21-
class HWCameraSetConfig(BaseCameraSetConfig):
22-
cameras: dict[str, BaseCameraConfig] = Field(default={})
23-
warm_up_disposal_frames: int = 30 # frames
24-
record_path: str = "camera_frames"
25-
max_buffer_frames: int = 1000
19+
class HardwareCamera(typing.Protocol):
20+
"""Implementation of a hardware camera potentially a set of cameras of the same kind."""
2621

22+
def open(self):
23+
"""Should open the camera and prepare it for polling."""
2724

28-
# TODO(juelg): refactor camera thread into their own class, to avoid a base hardware camera set class
29-
# TODO(juelg): add video recording
30-
class BaseHardwareCameraSet(ABC):
31-
"""This base class should have the ability to poll in a separate thread for all cameras and store them in a buffer.
32-
Implements BaseCameraSet
25+
def close(self):
26+
"""Should close the camera and release all resources."""
27+
28+
def config(self, camera_name: str) -> BaseCameraConfig:
29+
"""Should return the configuration object of the cameras."""
30+
31+
def poll_frame(self, camera_name: str) -> Frame:
32+
"""Should return the latest frame from the camera with the given name.
33+
34+
This method should be thread safe.
35+
"""
36+
37+
@property
38+
def camera_names(self) -> list[str]:
39+
"""Should return a list of the activated human readable names of the cameras."""
40+
41+
42+
class HardwareCameraSet(BaseCameraSet):
43+
"""This base class polls in a separate thread for all cameras and stores them in a buffer.
44+
45+
Cameras can consist of multiple cameras, e.g. RealSense cameras.
3346
"""
3447

35-
def __init__(self):
36-
self._buffer: list[FrameSet | None] = [None for _ in range(self.config.max_buffer_frames)]
48+
def __init__(self, cameras: list[HardwareCamera], warm_up_disposal_frames: int = 30, max_buffer_frames: int = 1000):
49+
self.cameras = cameras
50+
self.camera_dict, self.camera_names = self._cameras_util()
51+
self.name_to_identifier = self._name_to_identifier()
52+
self.frame_rate = self._frames_rate()
53+
self.rate_limiter = SimpleFrameRate(self.frame_rate)
54+
55+
self.warm_up_disposal_frames = warm_up_disposal_frames
56+
self.max_buffer_frames = max_buffer_frames
57+
self._buffer: list[FrameSet | None] = [None for _ in range(self.max_buffer_frames)]
3758
self._buffer_lock = threading.Lock()
3859
self.running = False
3960
self._thread: threading.Thread | None = None
4061
self._logger = logging.getLogger(__name__)
4162
self._next_ring_index = 0
4263
self._buffer_len = 0
4364
self.writer: dict[str, cv2.VideoWriter] = {}
44-
self.rate = SimpleFrameRate()
65+
66+
67+
def _name_to_identifier(self) -> dict[str, str]:
68+
"""Returns a dictionary mapping the camera names to their identifiers."""
69+
name_to_id: dict[str, str] = {}
70+
for camera in self.cameras:
71+
for name in camera.camera_names:
72+
name_to_id[name] = camera.config(name).identifier
73+
return name_to_id
74+
75+
def _frames_rate(self) -> int:
76+
"""Checks if all cameras have the same frame rate."""
77+
frame_rates = set(camera.config(name).frame_rate for camera in self.cameras for name in camera.camera_names)
78+
if len(frame_rates) > 1:
79+
raise ValueError("All cameras must have the same frame rate. Different frames rates are not supported.")
80+
if len(frame_rates) == 0:
81+
self._logger.warning("No camera found, empty polling with 1 fps.")
82+
return 1
83+
return frame_rates[0]
84+
85+
def _cameras_util(self) -> tuple[dict[str, HardwareCamera], list[str]]:
86+
"""Utility function to create a dictionary of cameras and a list of camera names."""
87+
camera_dict: dict[str, HardwareCamera] = {}
88+
camera_names: list[str] = []
89+
for camera in self.cameras:
90+
camera_names.extend(camera.camera_names)
91+
for name in camera.camera_names:
92+
assert name not in camera_dict, f"Camera name {name} not unique."
93+
camera_dict[name] = camera
94+
return camera_dict, camera_names
4595

4696
def buffer_size(self) -> int:
4797
return len(self._buffer) - self._buffer.count(None)
@@ -64,7 +114,7 @@ def get_timestamp_frames(self, ts: datetime) -> FrameSet | None:
64114
# iterate through the buffer and find the closest timestamp
65115
with self._buffer_lock:
66116
for i in range(self._buffer_len):
67-
idx = (self._next_ring_index - i - 1) % self.config.max_buffer_frames # iterate backwards
117+
idx = (self._next_ring_index - i - 1) % self.max_buffer_frames # iterate backwards
68118
assert self._buffer[idx] is not None
69119
item: FrameSet = typing.cast(FrameSet, self._buffer[idx])
70120
assert item.avg_timestamp is not None
@@ -82,6 +132,8 @@ def stop(self):
82132
def close(self):
83133
if self.running and self._thread is not None:
84134
self.stop()
135+
for camera in self.cameras:
136+
camera.close()
85137
self.stop_video()
86138

87139
def start(self, warm_up: bool = True):
@@ -101,8 +153,8 @@ def record_video(self, path: Path, str_id: str):
101153
str(path / f"episode_{str_id}_{camera}.mp4"),
102154
# migh require to install ffmpeg
103155
cv2.VideoWriter_fourcc(*"mp4v"), # type: ignore
104-
self.config.frame_rate,
105-
(self.config.resolution_width, self.config.resolution_height),
156+
self.frame_rate,
157+
(self.config(camera).resolution_width, self.config(camera).resolution_height),
106158
)
107159

108160
def recording_ongoing(self) -> bool:
@@ -117,31 +169,34 @@ def stop_video(self):
117169
self.writer = {}
118170

119171
def warm_up(self):
120-
for _ in range(self.config.warm_up_disposal_frames):
172+
for _ in range(self.warm_up_disposal_frames):
121173
for camera_name in self.camera_names:
122174
self._poll_frame(camera_name)
123-
self.rate(self.config.frame_rate)
175+
self.rate_limiter()
124176

125177
def polling_thread(self, warm_up: bool = True):
178+
for camera in self.cameras:
179+
camera.open()
126180
if warm_up:
127181
self.warm_up()
128182
while self.running:
129183
frame_set = self.poll_frame_set()
130184
# buffering
131185
with self._buffer_lock:
132186
self._buffer[self._next_ring_index] = frame_set
133-
self._next_ring_index = (self._next_ring_index + 1) % self.config.max_buffer_frames
134-
self._buffer_len = max(self._buffer_len + 1, self.config.max_buffer_frames)
187+
self._next_ring_index = (self._next_ring_index + 1) % self.max_buffer_frames
188+
self._buffer_len = max(self._buffer_len + 1, self.max_buffer_frames)
135189
# video recording
136190
for camera_key, writer in self.writer.items():
137191
if frame_set is not None:
138192
writer.write(frame_set.frames[camera_key].camera.color.data[:, :, ::-1])
139-
self.rate(self.config.frame_rate)
193+
self.rate_limiter()
140194

141195
def poll_frame_set(self) -> FrameSet:
142196
"""Gather frames over all available cameras."""
143197
frames: dict[str, Frame] = {}
144198
for camera_name in self.camera_names:
199+
# callback
145200
frame = self._poll_frame(camera_name)
146201
frames[camera_name] = frame
147202
# filter none
@@ -151,29 +206,16 @@ def poll_frame_set(self) -> FrameSet:
151206
def clear_buffer(self):
152207
"""Deletes all frames from the buffer."""
153208
with self._buffer_lock:
154-
self._buffer = [None for _ in range(self.config.max_buffer_frames)]
209+
self._buffer = [None for _ in range(self.max_buffer_frames)]
155210
self._next_ring_index = 0
156211
self._buffer_len = 0
157212
self.wait_for_frames()
158213

159-
@property
160-
@abstractmethod
161-
def config(self) -> HWCameraSetConfig:
162-
"""Should return the configuration object of the cameras."""
163-
164-
@abstractmethod
165-
def _poll_frame(self, camera_name: str) -> Frame:
166-
"""Should return the latest frame from the camera with the given name.
214+
def config(self, camera_name: str) -> BaseCameraConfig:
215+
"""Returns the configuration object of the cameras."""
216+
return self.camera_dict[camera_name].config(camera_name)
167217

168-
This method should be thread safe.
169-
"""
218+
def poll_frame(self, camera_name: str) -> Frame:
219+
return self.camera_dict[camera_name].poll_frame(camera_name)
170220

171-
@property
172-
def camera_names(self) -> list[str]:
173-
"""Should return a list of the activated human readable names of the cameras."""
174-
return list(self.config.cameras)
175221

176-
@property
177-
def name_to_identifier(self) -> dict[str, str]:
178-
# return {key: camera.identifier for key, camera in self._cfg.cameras.items()}
179-
return self.config.name_to_identifier

python/rcs/camera/interface.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,30 @@
99
logger = logging.getLogger(__name__)
1010
logger.setLevel(logging.INFO)
1111

12+
# TODO
13+
# - interface get config should require a key
14+
# - config change should also be in cpp
15+
# - split camera config away from camera set config
16+
17+
1218

1319
class SimpleFrameRate:
14-
def __init__(self):
20+
def __init__(self, frame_rate: int | float):
1521
self.t = None
1622
self._last_print = None
23+
self.frame_rate = frame_rate
1724

1825
def reset(self):
1926
self.t = None
2027

21-
def __call__(self, frame_rate: int | float):
28+
def __call__(self):
2229
if self.t is None:
2330
self.t = time()
2431
self._last_print = self.t
25-
sleep(1 / frame_rate if isinstance(frame_rate, int) else frame_rate)
32+
sleep(1 / self.frame_rate if isinstance(self.frame_rate, int) else self.frame_rate)
2633
return
2734
sleep_time = (
28-
1 / frame_rate - (time() - self.t) if isinstance(frame_rate, int) else frame_rate - (time() - self.t)
35+
1 / self.frame_rate - (time() - self.t) if isinstance(self.frame_rate, int) else self.frame_rate - (time() - self.t)
2936
)
3037
if sleep_time > 0:
3138
sleep(sleep_time)
@@ -36,16 +43,16 @@ def __call__(self, frame_rate: int | float):
3643
self.t = time()
3744

3845

46+
# TODO: this should come from the cpp binding
3947
class BaseCameraConfig(BaseModel):
4048
identifier: str
41-
42-
43-
class BaseCameraSetConfig(BaseModel):
44-
cameras: dict = Field(default={})
4549
resolution_width: int = 1280 # pixels
4650
resolution_height: int = 720 # pixels
4751
frame_rate: int = 15 # Hz
4852

53+
54+
class BaseCameraSetConfig(BaseModel):
55+
cameras: dict[str, BaseCameraConfig] = Field(default={})
4956
@property
5057
def name_to_identifier(self):
5158
return {key: camera.identifier for key, camera in self.cameras.items()}
@@ -104,6 +111,7 @@ def clear_buffer(self):
104111
def close(self):
105112
"""Stops any running threads e.g. for exitting."""
106113

114+
# this should require a key
107115
@property
108116
def config(self) -> BaseCameraSetConfig:
109117
"""Return the configuration object of the cameras."""

0 commit comments

Comments
 (0)