fix(find-cameras): enforce sequential lifecycle and add configurable warmup (#3593)

* Connect, test and disconnected camera instances sequentially

* Add warmup-s cli argument to lerobot-find-cameras script

* Reduce default record time from 6 to 2 seconds in find_cameras

* Annotate return value of save_image function

* Initialize logging configuration in find_cameras
This commit is contained in:
Anes Benmerzoug
2026-07-29 19:01:44 +02:00
committed by GitHub
parent b49cb50e01
commit 9c32722eb9
+38 -49
View File
@@ -28,7 +28,6 @@ lerobot-find-cameras
# NOTE(Steven): macOS cameras sometimes report different FPS at init time, not an issue here as we don't specify FPS when opening the cameras, but the information displayed might not be truthful. # NOTE(Steven): macOS cameras sometimes report different FPS at init time, not an issue here as we don't specify FPS when opening the cameras, but the information displayed might not be truthful.
import argparse import argparse
import concurrent.futures
import logging import logging
import time import time
from pathlib import Path from pathlib import Path
@@ -133,7 +132,7 @@ def save_image(
camera_identifier: str | int, camera_identifier: str | int,
images_dir: Path, images_dir: Path,
camera_type: str, camera_type: str,
): ) -> None:
""" """
Saves a single image to disk using Pillow. Handles color conversion if necessary. Saves a single image to disk using Pillow. Handles color conversion if necessary.
""" """
@@ -152,7 +151,7 @@ def save_image(
logger.error(f"Failed to save image for camera {camera_identifier} (type {camera_type}): {e}") logger.error(f"Failed to save image for camera {camera_identifier} (type {camera_type}): {e}")
def create_camera_instance(cam_meta: dict[str, Any]) -> dict[str, Any] | None: def create_camera_instance(cam_meta: dict[str, Any], *, warmup_s: int = 1) -> dict[str, Any] | None:
"""Create and connect to a camera instance based on metadata.""" """Create and connect to a camera instance based on metadata."""
cam_type = cam_meta.get("type") cam_type = cam_meta.get("type")
cam_id = cam_meta.get("id") cam_id = cam_meta.get("id")
@@ -165,12 +164,14 @@ def create_camera_instance(cam_meta: dict[str, Any]) -> dict[str, Any] | None:
cv_config = OpenCVCameraConfig( cv_config = OpenCVCameraConfig(
index_or_path=cam_id, index_or_path=cam_id,
color_mode=ColorMode.RGB, color_mode=ColorMode.RGB,
warmup_s=warmup_s,
) )
instance = OpenCVCamera(cv_config) instance = OpenCVCamera(cv_config)
elif cam_type == "RealSense": elif cam_type == "RealSense":
rs_config = RealSenseCameraConfig( rs_config = RealSenseCameraConfig(
serial_number_or_name=cam_id, serial_number_or_name=cam_id,
color_mode=ColorMode.RGB, color_mode=ColorMode.RGB,
warmup_s=warmup_s,
) )
instance = RealSenseCamera(rs_config) instance = RealSenseCamera(rs_config)
else: else:
@@ -188,9 +189,7 @@ def create_camera_instance(cam_meta: dict[str, Any]) -> dict[str, Any] | None:
return None return None
def process_camera_image( def process_camera_image(cam_dict: dict[str, Any], output_dir: Path, current_time: float) -> None:
cam_dict: dict[str, Any], output_dir: Path, current_time: float
) -> concurrent.futures.Future | None:
"""Capture and process an image from a single camera.""" """Capture and process an image from a single camera."""
cam = cam_dict["instance"] cam = cam_dict["instance"]
meta = cam_dict["meta"] meta = cam_dict["meta"]
@@ -200,7 +199,7 @@ def process_camera_image(
try: try:
image_data = cam.read() image_data = cam.read()
return save_image( save_image(
image_data, image_data,
cam_id_str, cam_id_str,
output_dir, output_dir,
@@ -215,21 +214,21 @@ def process_camera_image(
return None return None
def cleanup_cameras(cameras_to_use: list[dict[str, Any]]): def cleanup_camera(cam_dict: dict[str, Any]) -> None:
"""Disconnect all cameras.""" """Disconnect all cameras."""
logger.info(f"Disconnecting {len(cameras_to_use)} cameras...") logger.info(f"Disconnecting camera with ID {cam_dict['meta'].get('id')}...")
for cam_dict in cameras_to_use: try:
try: if cam_dict["instance"] and cam_dict["instance"].is_connected:
if cam_dict["instance"] and cam_dict["instance"].is_connected: cam_dict["instance"].disconnect()
cam_dict["instance"].disconnect() except Exception as e:
except Exception as e: logger.error(f"Error disconnecting camera {cam_dict['meta'].get('id')}: {e}")
logger.error(f"Error disconnecting camera {cam_dict['meta'].get('id')}: {e}")
def save_images_from_all_cameras( def save_images_from_all_cameras(
output_dir: Path, output_dir: Path,
record_time_s: float = 2.0, record_time_s: float = 2.0,
camera_type: str | None = None, camera_type: str | None = None,
warmup_s: int = 1,
): ):
""" """
Connects to detected cameras (optionally filtered by type) and saves images from each. Connects to detected cameras (optionally filtered by type) and saves images from each.
@@ -240,6 +239,7 @@ def save_images_from_all_cameras(
record_time_s: Duration in seconds to record images. record_time_s: Duration in seconds to record images.
camera_type: Optional string to filter cameras ("realsense" or "opencv"). camera_type: Optional string to filter cameras ("realsense" or "opencv").
If None, uses all detected cameras. If None, uses all detected cameras.
warmup_s: Duration in seconds to warmup camera before recording images.
""" """
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Saving images to {output_dir}") logger.info(f"Saving images to {output_dir}")
@@ -249,40 +249,24 @@ def save_images_from_all_cameras(
logger.warning("No cameras detected matching the criteria. Cannot save images.") logger.warning("No cameras detected matching the criteria. Cannot save images.")
return return
cameras_to_use = [] logger.info(
for cam_meta in all_camera_metadata: f"Starting image capture for {record_time_s} seconds from {len(all_camera_metadata)} cameras."
camera_instance = create_camera_instance(cam_meta) )
if camera_instance:
cameras_to_use.append(camera_instance)
if not cameras_to_use: try:
logger.warning("No cameras could be connected. Aborting image save.") for cam_meta in all_camera_metadata:
return cam_dict = create_camera_instance(cam_meta, warmup_s=warmup_s)
if cam_dict is None:
logger.info(f"Starting image capture for {record_time_s} seconds from {len(cameras_to_use)} cameras.") continue
start_time = time.perf_counter() start_time = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=len(cameras_to_use) * 2) as executor:
try:
while time.perf_counter() - start_time < record_time_s: while time.perf_counter() - start_time < record_time_s:
futures = []
current_capture_time = time.perf_counter() current_capture_time = time.perf_counter()
process_camera_image(cam_dict, output_dir, current_capture_time)
for cam_dict in cameras_to_use: cleanup_camera(cam_dict)
future = process_camera_image(cam_dict, output_dir, current_capture_time) except KeyboardInterrupt:
if future: logger.info("Capture interrupted by user.")
futures.append(future) finally:
print(f"Image capture finished. Images saved to {output_dir}")
if futures:
concurrent.futures.wait(futures)
except KeyboardInterrupt:
logger.info("Capture interrupted by user.")
finally:
print("\nFinalizing image saving...")
executor.shutdown(wait=True)
cleanup_cameras(cameras_to_use)
print(f"Image capture finished. Images saved to {output_dir}")
def main(): def main():
@@ -291,7 +275,6 @@ def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Unified camera utility script for listing cameras and capturing images." description="Unified camera utility script for listing cameras and capturing images."
) )
parser.add_argument( parser.add_argument(
"camera_type", "camera_type",
type=str, type=str,
@@ -309,8 +292,14 @@ def main():
parser.add_argument( parser.add_argument(
"--record-time-s", "--record-time-s",
type=float, type=float,
default=6.0, default=2.0,
help="Time duration to attempt capturing frames. Default: 6 seconds.", help="Time duration to attempt capturing frames. Default: 2 seconds.",
)
parser.add_argument(
"--warmup-s",
type=int,
default=1,
help="Time duration to warmup camera before attempting to capture frames. Default: 1 second.",
) )
args = parser.parse_args() args = parser.parse_args()
save_images_from_all_cameras(**vars(args)) save_images_from_all_cameras(**vars(args))