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
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)
|