mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-25 02:36:11 +00:00
fix(realsense): validate manual color controls
This commit is contained in:
@@ -136,6 +136,10 @@ config = RealSenseCameraConfig(
|
|||||||
height=480,
|
height=480,
|
||||||
color_mode=ColorMode.RGB,
|
color_mode=ColorMode.RGB,
|
||||||
use_depth=True,
|
use_depth=True,
|
||||||
|
# Optional fixed color controls. Omit them to leave the current sensor settings unchanged.
|
||||||
|
exposure=120,
|
||||||
|
gain=64,
|
||||||
|
white_balance=4600,
|
||||||
rotation=Cv2Rotation.NO_ROTATION
|
rotation=Cv2Rotation.NO_ROTATION
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -154,6 +158,12 @@ finally:
|
|||||||
```
|
```
|
||||||
<!-- prettier-ignore-end -->
|
<!-- prettier-ignore-end -->
|
||||||
|
|
||||||
|
Manual color controls disable the corresponding automatic exposure or white-balance mode. Their
|
||||||
|
supported ranges vary by camera model; an invalid value raises an error at connection time that
|
||||||
|
includes the range reported by the sensor. Requesting an unsupported control also raises an error.
|
||||||
|
Omitted controls leave the sensor's existing automatic or manual setting unchanged. These options
|
||||||
|
require `use_rgb=True`.
|
||||||
|
|
||||||
</hfoption>
|
</hfoption>
|
||||||
</hfoptions>
|
</hfoptions>
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ class RealSenseCamera(Camera):
|
|||||||
|
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
|
self.width: int | None = config.width
|
||||||
|
self.height: int | None = config.height
|
||||||
|
|
||||||
if config.serial_number_or_name.isdigit():
|
if config.serial_number_or_name.isdigit():
|
||||||
self.serial_number = config.serial_number_or_name
|
self.serial_number = config.serial_number_or_name
|
||||||
else:
|
else:
|
||||||
@@ -190,23 +193,30 @@ class RealSenseCamera(Camera):
|
|||||||
f"Failed to open {self}.Run `lerobot-find-cameras realsense` to find available cameras."
|
f"Failed to open {self}.Run `lerobot-find-cameras realsense` to find available cameras."
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
self._configure_capture_settings()
|
try:
|
||||||
self._configure_sensor_options()
|
self._configure_capture_settings()
|
||||||
self._start_read_thread()
|
self._configure_sensor_options()
|
||||||
|
self._start_read_thread()
|
||||||
|
|
||||||
# NOTE(Steven/Caroline): Enforcing at least one second of warmup as RS cameras need a bit of time before the first read. If we don't wait, the first read from the warmup will raise.
|
# NOTE(Steven/Caroline): Enforcing at least one second of warmup as RS cameras need a bit of time before the first read. If we don't wait, the first read from the warmup will raise.
|
||||||
self.warmup_s = max(self.warmup_s, 1)
|
self.warmup_s = max(self.warmup_s, 1)
|
||||||
|
|
||||||
warmup_read = self.async_read if self.use_rgb else self.async_read_depth
|
warmup_read = self.async_read if self.use_rgb else self.async_read_depth
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
while time.time() - start_time < self.warmup_s:
|
while time.time() - start_time < self.warmup_s:
|
||||||
warmup_read(timeout_ms=self.warmup_s * 1000)
|
warmup_read(timeout_ms=self.warmup_s * 1000)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
with self.frame_lock:
|
with self.frame_lock:
|
||||||
if (self.use_rgb and self.latest_color_frame is None) or (
|
if (self.use_rgb and self.latest_color_frame is None) or (
|
||||||
self.use_depth and self.latest_depth_frame is None
|
self.use_depth and self.latest_depth_frame is None
|
||||||
):
|
):
|
||||||
raise ConnectionError(f"{self} failed to capture frames during warmup.")
|
raise ConnectionError(f"{self} failed to capture frames during warmup.")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self._cleanup_connection()
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"Failed to clean up {self} after a connection error.")
|
||||||
|
raise
|
||||||
|
|
||||||
logger.info(f"{self} connected.")
|
logger.info(f"{self} connected.")
|
||||||
|
|
||||||
@@ -382,11 +392,13 @@ class RealSenseCamera(Camera):
|
|||||||
"""Applies manual sensor options (exposure, gain, white balance) to the color sensor.
|
"""Applies manual sensor options (exposure, gain, white balance) to the color sensor.
|
||||||
|
|
||||||
When exposure or gain is set, auto-exposure is disabled first. When white_balance
|
When exposure or gain is set, auto-exposure is disabled first. When white_balance
|
||||||
is set, auto white balance is disabled first. Skipped entirely if no options are set.
|
is set, auto white balance is disabled first. An omitted option is left unchanged,
|
||||||
|
and configuration is skipped entirely if all options are omitted.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If a requested value is outside the sensor's supported range. The
|
ValueError: If the sensor does not support a requested option or a requested
|
||||||
error message includes the option name, requested value, and supported range.
|
value is invalid. Invalid-value errors include the option name, requested
|
||||||
|
value, and supported range when available.
|
||||||
"""
|
"""
|
||||||
config = self.config
|
config = self.config
|
||||||
if config.exposure is None and config.gain is None and config.white_balance is None:
|
if config.exposure is None and config.gain is None and config.white_balance is None:
|
||||||
@@ -394,6 +406,21 @@ class RealSenseCamera(Camera):
|
|||||||
|
|
||||||
color_sensor = self._get_color_sensor()
|
color_sensor = self._get_color_sensor()
|
||||||
|
|
||||||
|
requested_options = (
|
||||||
|
(rs.option.exposure, config.exposure, "exposure"),
|
||||||
|
(rs.option.gain, config.gain, "gain"),
|
||||||
|
(rs.option.white_balance, config.white_balance, "white balance"),
|
||||||
|
)
|
||||||
|
unsupported_options = [
|
||||||
|
label
|
||||||
|
for option, value, label in requested_options
|
||||||
|
if value is not None and not color_sensor.supports(option)
|
||||||
|
]
|
||||||
|
if unsupported_options:
|
||||||
|
raise ValueError(
|
||||||
|
f"{self}: color sensor does not support requested manual options: {unsupported_options}."
|
||||||
|
)
|
||||||
|
|
||||||
if (config.exposure is not None or config.gain is not None) and color_sensor.supports(
|
if (config.exposure is not None or config.gain is not None) and color_sensor.supports(
|
||||||
rs.option.enable_auto_exposure
|
rs.option.enable_auto_exposure
|
||||||
):
|
):
|
||||||
@@ -401,18 +428,12 @@ class RealSenseCamera(Camera):
|
|||||||
logger.info(f"{self} auto-exposure disabled.")
|
logger.info(f"{self} auto-exposure disabled.")
|
||||||
|
|
||||||
if config.exposure is not None:
|
if config.exposure is not None:
|
||||||
if color_sensor.supports(rs.option.exposure):
|
self._set_sensor_option(color_sensor, rs.option.exposure, config.exposure, "exposure")
|
||||||
self._set_sensor_option(color_sensor, rs.option.exposure, config.exposure, "exposure")
|
logger.info(f"{self} exposure set to {config.exposure}.")
|
||||||
logger.info(f"{self} exposure set to {config.exposure}.")
|
|
||||||
else:
|
|
||||||
logger.warning(f"{self} sensor does not support manual exposure.")
|
|
||||||
|
|
||||||
if config.gain is not None:
|
if config.gain is not None:
|
||||||
if color_sensor.supports(rs.option.gain):
|
self._set_sensor_option(color_sensor, rs.option.gain, config.gain, "gain")
|
||||||
self._set_sensor_option(color_sensor, rs.option.gain, config.gain, "gain")
|
logger.info(f"{self} gain set to {config.gain}.")
|
||||||
logger.info(f"{self} gain set to {config.gain}.")
|
|
||||||
else:
|
|
||||||
logger.warning(f"{self} sensor does not support manual gain.")
|
|
||||||
|
|
||||||
if config.white_balance is not None:
|
if config.white_balance is not None:
|
||||||
if color_sensor.supports(rs.option.enable_auto_white_balance):
|
if color_sensor.supports(rs.option.enable_auto_white_balance):
|
||||||
@@ -420,13 +441,10 @@ class RealSenseCamera(Camera):
|
|||||||
color_sensor, rs.option.enable_auto_white_balance, 0, "auto white balance"
|
color_sensor, rs.option.enable_auto_white_balance, 0, "auto white balance"
|
||||||
)
|
)
|
||||||
logger.info(f"{self} auto white balance disabled.")
|
logger.info(f"{self} auto white balance disabled.")
|
||||||
if color_sensor.supports(rs.option.white_balance):
|
self._set_sensor_option(
|
||||||
self._set_sensor_option(
|
color_sensor, rs.option.white_balance, config.white_balance, "white balance"
|
||||||
color_sensor, rs.option.white_balance, config.white_balance, "white balance"
|
)
|
||||||
)
|
logger.info(f"{self} white balance set to {config.white_balance}.")
|
||||||
logger.info(f"{self} white balance set to {config.white_balance}.")
|
|
||||||
else:
|
|
||||||
logger.warning(f"{self} sensor does not support manual white balance.")
|
|
||||||
|
|
||||||
@check_if_not_connected
|
@check_if_not_connected
|
||||||
def read_depth(self, timeout_ms: int = 200) -> NDArray[Any]:
|
def read_depth(self, timeout_ms: int = 200) -> NDArray[Any]:
|
||||||
@@ -773,13 +791,17 @@ class RealSenseCamera(Camera):
|
|||||||
f"Attempted to disconnect {self}, but it appears already disconnected."
|
f"Attempted to disconnect {self}, but it appears already disconnected."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._cleanup_connection()
|
||||||
|
logger.info(f"{self} disconnected.")
|
||||||
|
|
||||||
|
def _cleanup_connection(self) -> None:
|
||||||
|
"""Release connection resources and reset state after disconnect or failed connect."""
|
||||||
if self.thread is not None:
|
if self.thread is not None:
|
||||||
self._stop_read_thread()
|
self._stop_read_thread()
|
||||||
|
|
||||||
if self.rs_pipeline is not None:
|
pipeline = self.rs_pipeline
|
||||||
self.rs_pipeline.stop()
|
self.rs_pipeline = None
|
||||||
self.rs_pipeline = None
|
self.rs_profile = None
|
||||||
self.rs_profile = None
|
|
||||||
|
|
||||||
with self.frame_lock:
|
with self.frame_lock:
|
||||||
self.latest_color_frame = None
|
self.latest_color_frame = None
|
||||||
@@ -787,4 +809,5 @@ class RealSenseCamera(Camera):
|
|||||||
self.latest_timestamp = None
|
self.latest_timestamp = None
|
||||||
self.new_frame_event.clear()
|
self.new_frame_event.clear()
|
||||||
|
|
||||||
logger.info(f"{self} disconnected.")
|
if pipeline is not None:
|
||||||
|
pipeline.stop()
|
||||||
|
|||||||
@@ -48,13 +48,14 @@ class RealSenseCameraConfig(CameraConfig):
|
|||||||
warmup_s: Time reading frames before returning from connect (in seconds)
|
warmup_s: Time reading frames before returning from connect (in seconds)
|
||||||
exposure: Manual exposure value for the color sensor. When set, auto-exposure is
|
exposure: Manual exposure value for the color sensor. When set, auto-exposure is
|
||||||
disabled and this fixed value is used. Valid range depends on the camera model
|
disabled and this fixed value is used. Valid range depends on the camera model
|
||||||
(e.g., 1-10000 for D400 series). Defaults to None (auto-exposure).
|
(e.g., 1-10000 for D400 series). Defaults to None (leave unchanged).
|
||||||
gain: Manual gain value for the color sensor. When set, auto-exposure is disabled
|
gain: Manual gain value for the color sensor. When set, auto-exposure is disabled
|
||||||
and this fixed gain is used. Valid range depends on the camera model
|
and this fixed gain is used. Valid range depends on the camera model
|
||||||
(e.g., 16-248 for D400 series). Defaults to None (auto).
|
(e.g., 16-248 for D400 series). Defaults to None (leave unchanged).
|
||||||
white_balance: Manual white balance value for the color sensor. When set, auto
|
white_balance: Manual white balance value for the color sensor. When set, auto
|
||||||
white balance is disabled and this fixed value is used. Valid range depends on
|
white balance is disabled and this fixed value is used. Valid range depends on
|
||||||
the camera model (e.g., 2800-6500 for D400 series). Defaults to None (auto).
|
the camera model (e.g., 2800-6500 for D400 series). Defaults to None
|
||||||
|
(leave unchanged).
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
- Either name or serial_number must be specified.
|
- Either name or serial_number must be specified.
|
||||||
@@ -81,6 +82,18 @@ class RealSenseCameraConfig(CameraConfig):
|
|||||||
if not self.use_rgb and not self.use_depth:
|
if not self.use_rgb and not self.use_depth:
|
||||||
raise ValueError("At least one of `use_rgb` or `use_depth` must be enabled.")
|
raise ValueError("At least one of `use_rgb` or `use_depth` must be enabled.")
|
||||||
|
|
||||||
|
manual_color_options = {
|
||||||
|
"exposure": self.exposure,
|
||||||
|
"gain": self.gain,
|
||||||
|
"white_balance": self.white_balance,
|
||||||
|
}
|
||||||
|
configured_color_options = [name for name, value in manual_color_options.items() if value is not None]
|
||||||
|
if configured_color_options and not self.use_rgb:
|
||||||
|
raise ValueError(
|
||||||
|
"Manual color sensor options require `use_rgb=True`. "
|
||||||
|
f"Configured options: {configured_color_options}."
|
||||||
|
)
|
||||||
|
|
||||||
values = (self.fps, self.width, self.height)
|
values = (self.fps, self.width, self.height)
|
||||||
if any(v is not None for v in values) and any(v is None for v in values):
|
if any(v is not None for v in values) and any(v is None for v in values):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|||||||
@@ -63,6 +63,17 @@ def test_abc_implementation():
|
|||||||
_ = RealSenseCamera(config)
|
_ = RealSenseCamera(config)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("option", ["exposure", "gain", "white_balance"])
|
||||||
|
def test_manual_color_option_requires_rgb(option):
|
||||||
|
with pytest.raises(ValueError, match="use_rgb=True"):
|
||||||
|
RealSenseCameraConfig(
|
||||||
|
serial_number_or_name="042",
|
||||||
|
use_rgb=False,
|
||||||
|
use_depth=True,
|
||||||
|
**{option: 100},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_connect():
|
def test_connect():
|
||||||
config = RealSenseCameraConfig(serial_number_or_name="042", warmup_s=0)
|
config = RealSenseCameraConfig(serial_number_or_name="042", warmup_s=0)
|
||||||
|
|
||||||
@@ -85,6 +96,27 @@ def test_connect_invalid_camera_path(patch_realsense):
|
|||||||
camera.connect(warmup=False)
|
camera.connect(warmup=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_cleans_up_when_sensor_configuration_fails():
|
||||||
|
config = RealSenseCameraConfig(serial_number_or_name="042", exposure=120)
|
||||||
|
camera = RealSenseCamera(config)
|
||||||
|
pipeline = MagicMock()
|
||||||
|
pipeline.start.return_value = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("lerobot.cameras.realsense.camera_realsense.rs.pipeline", return_value=pipeline),
|
||||||
|
patch.object(camera, "_configure_rs_pipeline_config"),
|
||||||
|
patch.object(camera, "_configure_capture_settings"),
|
||||||
|
patch.object(camera, "_configure_sensor_options", side_effect=ValueError("invalid exposure")),
|
||||||
|
pytest.raises(ValueError, match="invalid exposure"),
|
||||||
|
):
|
||||||
|
camera.connect(warmup=False)
|
||||||
|
|
||||||
|
pipeline.stop.assert_called_once_with()
|
||||||
|
assert camera.rs_pipeline is None
|
||||||
|
assert camera.rs_profile is None
|
||||||
|
assert not camera.is_connected
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_width_connect():
|
def test_invalid_width_connect():
|
||||||
config = RealSenseCameraConfig(serial_number_or_name="042", width=99999, height=480, fps=30)
|
config = RealSenseCameraConfig(serial_number_or_name="042", width=99999, height=480, fps=30)
|
||||||
camera = RealSenseCamera(config)
|
camera = RealSenseCamera(config)
|
||||||
@@ -293,20 +325,26 @@ def test_configure_sensor_options_applies_all_values():
|
|||||||
sensor.set_option.assert_any_call(rs.option.white_balance, 4600)
|
sensor.set_option.assert_any_call(rs.option.white_balance, 4600)
|
||||||
|
|
||||||
|
|
||||||
def test_configure_sensor_options_warns_when_unsupported(caplog):
|
@pytest.mark.parametrize(
|
||||||
config = RealSenseCameraConfig(serial_number_or_name="042", exposure=120, gain=64, white_balance=4600)
|
("config_field", "option", "label"),
|
||||||
|
[
|
||||||
|
("exposure", rs.option.exposure, "exposure"),
|
||||||
|
("gain", rs.option.gain, "gain"),
|
||||||
|
("white_balance", rs.option.white_balance, "white balance"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_configure_sensor_options_raises_when_requested_option_is_unsupported(config_field, option, label):
|
||||||
|
config = RealSenseCameraConfig(serial_number_or_name="042", **{config_field: 100})
|
||||||
camera = RealSenseCamera(config)
|
camera = RealSenseCamera(config)
|
||||||
|
|
||||||
sensor = _make_mock_sensor("RGB Camera", supported_options=set())
|
sensor = _make_mock_sensor("RGB Camera", supported_options=set())
|
||||||
_attach_mock_color_sensor(camera, sensor)
|
_attach_mock_color_sensor(camera, sensor)
|
||||||
|
|
||||||
with caplog.at_level("WARNING"):
|
with pytest.raises(ValueError, match=label):
|
||||||
camera._configure_sensor_options()
|
camera._configure_sensor_options()
|
||||||
|
|
||||||
|
sensor.supports.assert_any_call(option)
|
||||||
sensor.set_option.assert_not_called()
|
sensor.set_option.assert_not_called()
|
||||||
assert "does not support manual exposure" in caplog.text
|
|
||||||
assert "does not support manual gain" in caplog.text
|
|
||||||
assert "does not support manual white balance" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_configure_sensor_options_only_exposure_disables_auto_exposure():
|
def test_configure_sensor_options_only_exposure_disables_auto_exposure():
|
||||||
|
|||||||
Reference in New Issue
Block a user