You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

249 lines
8.1 KiB

"""
Camera viewer with manual recording support.
This script provides a camera viewer that can display multiple camera streams
and record them to video files with manual start/stop controls.
Features:
- Onscreen mode: Display camera feeds with optional recording
- Offscreen mode: No display, recording only when triggered
- Manual recording control with keyboard (R key to start/stop)
Usage Examples:
1. Basic onscreen viewing (with recording capability):
python run_camera_viewer.py --camera-host localhost --camera-port 5555
2. Offscreen mode (no display, recording only):
python run_camera_viewer.py --offscreen --camera-host localhost --camera-port 5555
3. Custom output directory:
python run_camera_viewer.py --output-path ./my_recordings --camera-host localhost
Controls:
- R key: Start/Stop recording
- Q key: Quit application
Output Structure:
camera_output_20241211_143052/
├── rec_143205/
│ ├── ego_view_color_image.mp4
│ ├── head_left_color_image.mp4
│ └── head_right_color_image.mp4
└── rec_143410/
├── ego_view_color_image.mp4
└── head_left_color_image.mp4
"""
from dataclasses import dataclass
from pathlib import Path
import threading
import time
from typing import Any, Optional
import cv2
import rclpy
from sshkeyboard import listen_keyboard, stop_listening
import tyro
from decoupled_wbc.control.main.teleop.configs.configs import ComposedCameraClientConfig
from decoupled_wbc.control.sensor.composed_camera import ComposedCameraClientSensor
from decoupled_wbc.control.utils.img_viewer import ImageViewer
@dataclass
class CameraViewerConfig(ComposedCameraClientConfig):
"""Config for running the camera viewer with recording support."""
offscreen: bool = False
"""Run in offscreen mode (no display, manual recording with R key)."""
output_path: Optional[str] = None
"""Output path for saving videos. If None, auto-generates path."""
codec: str = "mp4v"
"""Video codec to use for saving (e.g., 'mp4v', 'XVID')."""
ArgsConfig = CameraViewerConfig
def _get_camera_titles(image_data: dict[str, Any]) -> list[str]:
"""
Detect all the individual camera streams from the image data.
schema format:
{
"timestamps": {"ego_view": 123.45, "ego_view_left_mono": 123.46},
"images": {"ego_view": np.ndarray, "ego_view_left_mono": np.ndarray}
}
Returns list of camera keys (e.g., ["ego_view", "ego_view_left_mono", "ego_view_right_mono"])
"""
# Extract all camera keys from the images dictionary
camera_titles = list(image_data.get("images", {}).keys())
return camera_titles
def main(config: ArgsConfig):
"""Main function to run the camera viewer."""
# Initialize ROS
rclpy.init(args=None)
node = rclpy.create_node("camera_viewer")
# Start ROS spin in a separate thread
thread = threading.Thread(target=rclpy.spin, args=(node,), daemon=True)
thread.start()
image_sub = ComposedCameraClientSensor(server_ip=config.camera_host, port=config.camera_port)
# pre-fetch a sample image to get the number of camera angles
retry_count = 0
while True:
_sample_image = image_sub.read()
if _sample_image:
break
retry_count += 1
time.sleep(0.1)
if retry_count > 10:
raise Exception("Failed to get sample image")
camera_titles = _get_camera_titles(_sample_image)
# Setup output directory
if config.output_path is None:
output_dir = Path("camera_recordings")
else:
output_dir = Path(config.output_path)
# Recording state
is_recording = False
video_writers = {}
frame_count = 0
recording_start_time = None
should_quit = False
def on_press(key):
nonlocal is_recording, video_writers, frame_count, recording_start_time, should_quit
if key == "r":
if not is_recording:
# Start recording
recording_dir = output_dir / f"rec_{time.strftime('%Y%m%d_%H%M%S')}"
recording_dir.mkdir(parents=True, exist_ok=True)
# Create video writers
fourcc = cv2.VideoWriter_fourcc(*config.codec)
video_writers = {}
for title in camera_titles:
img = _sample_image["images"].get(title)
if img is not None:
height, width = img.shape[:2]
video_path = recording_dir / f"{title}.mp4"
writer = cv2.VideoWriter(
str(video_path), fourcc, config.fps, (width, height)
)
video_writers[title] = writer
is_recording = True
recording_start_time = time.time()
frame_count = 0
print(f"🔴 Recording started: {recording_dir}")
else:
# Stop recording
is_recording = False
for title, writer in video_writers.items():
writer.release()
video_writers = {}
duration = time.time() - recording_start_time if recording_start_time else 0
print(f"⏹️ Recording stopped - {duration:.1f}s, {frame_count} frames")
elif key == "q":
should_quit = True
stop_listening()
# Setup keyboard listener in a separate thread
keyboard_thread = threading.Thread(
target=lambda: listen_keyboard(on_press=on_press), daemon=True
)
keyboard_thread.start()
# Setup viewer for onscreen mode
viewer = None
if not config.offscreen:
viewer = ImageViewer(
title="Camera Viewer",
figsize=(10, 8),
num_images=len(camera_titles),
image_titles=camera_titles,
)
# Print instructions
mode = "Offscreen" if config.offscreen else "Onscreen"
print(f"{mode} mode - Target FPS: {config.fps}")
print(f"Videos will be saved to: {output_dir}")
print("Controls: R key to start/stop recording, Q key to quit, Ctrl+C to exit")
# Create ROS rate controller
rate = node.create_rate(config.fps)
try:
while rclpy.ok() and not should_quit:
# Get images from all subscribers
images = []
image_data = image_sub.read()
if image_data:
for title in camera_titles:
img = image_data["images"].get(title)
images.append(img)
# Save frame if recording
if is_recording and img is not None and title in video_writers:
# Convert from RGB to BGR for OpenCV
if len(img.shape) == 3 and img.shape[2] == 3:
img_bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
else:
img_bgr = img
video_writers[title].write(img_bgr)
# Display images if not offscreen
if not config.offscreen and viewer and any(img is not None for img in images):
status = "🔴 REC" if is_recording else "⏸️ Ready"
viewer._fig.suptitle(f"Camera Viewer - {status}")
viewer.show_multiple(images)
# Progress feedback
if is_recording:
frame_count += 1
if frame_count % 100 == 0:
duration = time.time() - recording_start_time
print(f"Recording: {frame_count} frames ({duration:.1f}s)")
rate.sleep()
except KeyboardInterrupt:
print("\nExiting...")
finally:
# Cleanup
try:
stop_listening()
except Exception:
pass
if video_writers:
for title, writer in video_writers.items():
writer.release()
if is_recording:
duration = time.time() - recording_start_time
print(f"Final: {duration:.1f}s, {frame_count} frames")
if viewer:
viewer.close()
rclpy.shutdown()
if __name__ == "__main__":
config = tyro.cli(ArgsConfig)
main(config)