Skip to content

Commit 7739d57

Browse files
committed
temp refactor camera
1 parent 6203369 commit 7739d57

8 files changed

Lines changed: 170 additions & 118 deletions

File tree

python/rcs/camera/digit_cam.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,22 @@ class DigitConfig(HWCameraSetConfig):
1212
stream_name: str = "QVGA" # options: "QVGA" (60 and 30 fps), "VGA" (30 and 15 fps)
1313

1414

15-
class DigitCam(BaseHardwareCameraSet):
15+
class DigitCam(HardwareCamera):
1616
"""
1717
This module provides an interface to interact with the DIGIT device.
1818
It allows for connecting to the device, changing settings, and retrieving information.
1919
"""
2020

2121
def __init__(self, cfg: DigitConfig):
2222
self._cfg = cfg
23-
super().__init__()
2423
self._cameras: dict[str, Digit] = {}
25-
self.initalize(self.config)
2624

27-
def initalize(self, cfg: HWCameraSetConfig):
25+
def open(self):
2826
"""
2927
Initialize the digit interface with the given configuration.
3028
:param cfg: Configuration for the DIGIT device.
3129
"""
32-
for name, serial in cfg.name_to_identifier.items():
30+
for name, serial in self._cfg.name_to_identifier.items():
3331
digit = Digit(serial, name)
3432
digit.connect()
3533
self._cameras[name] = digit
@@ -45,6 +43,13 @@ def _poll_frame(self, camera_name: str) -> Frame:
4543

4644
return Frame(camera=cf)
4745

48-
@property
49-
def config(self) -> DigitConfig:
50-
return self._cfg
46+
def close(self):
47+
"""
48+
Closes the connection to the DIGIT device.
49+
"""
50+
for digit in self._cameras.values():
51+
digit.disconnect()
52+
self._cameras = {}
53+
54+
def config(self, camera_name) -> DigitCameraConfig:
55+
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."""

python/rcs/camera/kinect.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
import k4a
22
import numpy as np
3-
from rcs.camera.hw import BaseHardwareCameraSet, HWCameraSetConfig
4-
from rcs.camera.interface import CameraFrame, DataFrame, Frame, IMUFrame
3+
from rcs.camera.hw import HardwareCameraSet
4+
from rcs.camera.interface import BaseCameraSetConfig, CameraFrame, DataFrame, Frame, IMUFrame
55

66

7-
class KinectConfig(HWCameraSetConfig):
7+
class KinectConfig(BaseCameraSetConfig):
88
include_imu: bool = False
99
timeout_ms: int = 2000
1010

1111

12-
class KinectCamera(BaseHardwareCameraSet):
12+
class KinectCamera(HardwareCameraSet):
13+
# ATTENTION: this code is untested
1314
def __init__(self, cfg: KinectConfig) -> None:
14-
super().__init__()
1515
self._cfg = cfg
16+
assert len(cfg.cameras) == 1, "Kinect only supports one camera."
17+
self.camera_names = list(cfg.cameras.keys())
18+
19+
def open(self):
1620
self._device = k4a.Device.open()
1721
device_config = k4a.DEVICE_CONFIG_BGRA32_1080P_NFOV_2X2BINNED_FPS15
1822
if self._device is None:
@@ -29,11 +33,8 @@ def __init__(self, cfg: KinectConfig) -> None:
2933
def config(self) -> KinectConfig:
3034
return self._cfg
3135

32-
@config.setter
33-
def config(self, cfg: KinectConfig) -> None:
34-
self._cfg = cfg
3536

36-
def _poll_frame(self, camera_name: str = "") -> Frame:
37+
def poll_frame(self, camera_name: str = "") -> Frame:
3738
assert camera_name == "kinect", "Kinect code only supports one camera."
3839
capture = self._device.get_capture(self._cfg.timeout_ms)
3940
if capture is None:
@@ -57,3 +58,10 @@ def _poll_frame(self, camera_name: str = "") -> Frame:
5758
temperature=imu_sample.temperature,
5859
)
5960
return Frame(camera=camera_frame, imu=imu_frame, avg_timestamp=None)
61+
62+
def close(self):
63+
if self._device is not None:
64+
self._device.stop_cameras()
65+
if self._cfg.include_imu:
66+
self._device.stop_imu()
67+
self._device = None

0 commit comments

Comments
 (0)