File size: 9,148 Bytes
1f3a93e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
"""
ROS-free camera viewer with optional recording.

Connects to a ZMQ camera server (MuJoCo sim SensorServer or real robot camera)
and displays live camera feeds using OpenCV. Supports recording to MP4.

Virtual environment setup (run from repo root):
    bash install_scripts/install_data_collection.sh
    source .venv_data_collection/bin/activate

Usage:
    python gear_sonic/scripts/run_camera_viewer.py --camera-host localhost --camera-port 5555

Headless recording:
    python gear_sonic/scripts/run_camera_viewer.py --no-display --record --duration 10

Controls (OpenCV window must be focused):
    R - Start/stop recording
    Q - Quit

Output structure:
    camera_recordings/
    └── rec_20260403_143052/
        β”œβ”€β”€ ego_view.mp4
        └── head_left_color_image.mp4
"""

from dataclasses import dataclass
from pathlib import Path
import time
from typing import Optional

import cv2
import numpy as np
import tyro

from gear_sonic.camera.composed_camera import ComposedCameraClientSensor


@dataclass
class CameraViewerConfig:
    """CLI config for the ROS-free camera viewer."""

    camera_host: str = "localhost"
    """Camera server hostname."""

    camera_port: int = 5555
    """Camera server port."""

    fps: int = 30
    """Target display refresh rate (Hz)."""

    output_path: Optional[str] = None
    """Output directory for recordings. Auto-creates 'camera_recordings/' if not set."""

    codec: str = "mp4v"
    """Video codec for recording (e.g., 'mp4v', 'XVID')."""

    max_display_width: int = 640
    """Max width per camera tile in the display window."""

    display: bool = True
    """Show the OpenCV preview window. Disable this on headless servers."""

    record: bool = False
    """Start recording immediately instead of waiting for the R key."""

    duration: Optional[float] = None
    """Stop automatically after this many seconds of recording."""


def main(config: CameraViewerConfig):
    if not config.display and not config.record:
        raise ValueError("--no-display requires --record")
    if config.duration is not None and not config.record:
        raise ValueError("--duration requires --record")
    if config.duration is not None and config.duration <= 0:
        raise ValueError("--duration must be greater than zero")

    client = ComposedCameraClientSensor(server_ip=config.camera_host, port=config.camera_port)

    print("Waiting for first camera frame...")
    sample = None
    for _ in range(100):
        sample = client.read(blocking=False)
        if sample and sample.get("images"):
            break
        time.sleep(0.1)

    if sample is None or not sample.get("images"):
        print("ERROR: No camera frames received after 10s. Check the camera server.")
        return

    camera_names = sorted(sample["images"].keys())
    print(f"Detected {len(camera_names)} camera stream(s): {camera_names}")

    output_dir = Path(config.output_path) if config.output_path else Path("camera_recordings")

    is_recording = False
    video_writers: dict[str, cv2.VideoWriter] = {}
    frame_count = 0
    recording_start_time = 0.0
    recording_dir = Path(".")
    loop_period = 1.0 / config.fps

    window_name = "SONIC Camera Viewer"

    print(f"Target FPS: {config.fps}")
    print(f"Recordings will be saved to: {output_dir}")
    if config.display:
        print("Controls: R = start/stop recording, Q = quit")

    if config.record:
        recording_dir = output_dir / f"rec_{time.strftime('%Y%m%d_%H%M%S')}"
        recording_dir.mkdir(parents=True, exist_ok=True)

        fourcc = cv2.VideoWriter_fourcc(*config.codec)
        for name in camera_names:
            img = sample["images"].get(name)
            if img is not None:
                h, w = img.shape[:2]
                path = recording_dir / f"{name}.mp4"
                writer = cv2.VideoWriter(str(path), fourcc, config.fps, (w, h))
                if not writer.isOpened():
                    raise RuntimeError(f"Failed to open video writer: {path}")
                video_writers[name] = writer

        is_recording = True
        recording_start_time = time.time()
        print(f"Recording started: {recording_dir}")

    try:
        while True:
            t_start = time.monotonic()

            image_data = client.read(blocking=False)
            if image_data is None or not image_data.get("images"):
                elapsed = time.monotonic() - t_start
                remaining = loop_period - elapsed
                if remaining > 0:
                    time.sleep(remaining)
                continue

            tiles = []
            for name in camera_names:
                img = image_data["images"].get(name)
                if img is None:
                    continue

                if img.shape[2] == 3:
                    img_bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
                else:
                    img_bgr = img

                if is_recording and name in video_writers:
                    video_writers[name].write(img_bgr)

                h, w = img_bgr.shape[:2]
                if w > config.max_display_width:
                    scale = config.max_display_width / w
                    img_bgr = cv2.resize(
                        img_bgr, (config.max_display_width, int(h * scale))
                    )

                label = f"{name}"
                if is_recording:
                    label = f"[REC] {name}"
                cv2.putText(
                    img_bgr, label, (10, 25),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2,
                )
                tiles.append(img_bgr)

            if tiles:
                max_h = max(t.shape[0] for t in tiles)
                padded = []
                for t in tiles:
                    if t.shape[0] < max_h:
                        pad = np.zeros(
                            (max_h - t.shape[0], t.shape[1], 3), dtype=np.uint8
                        )
                        t = np.vstack([t, pad])
                    padded.append(t)
                canvas = np.hstack(padded)

                if is_recording:
                    frame_count += 1
                    elapsed_rec = time.time() - recording_start_time
                    status = f"REC {frame_count}f / {elapsed_rec:.1f}s"
                    cv2.putText(
                        canvas, status, (canvas.shape[1] - 300, 25),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2,
                    )

                if config.display:
                    cv2.imshow(window_name, canvas)

            key = cv2.waitKey(1) & 0xFF if config.display else 0xFF

            if key == ord("q"):
                print("Quit requested.")
                break
            elif key == ord("r"):
                if not is_recording:
                    recording_dir = output_dir / f"rec_{time.strftime('%Y%m%d_%H%M%S')}"
                    recording_dir.mkdir(parents=True, exist_ok=True)

                    fourcc = cv2.VideoWriter_fourcc(*config.codec)
                    video_writers = {}
                    for name in camera_names:
                        img = image_data["images"].get(name)
                        if img is not None:
                            h, w = img.shape[:2]
                            path = recording_dir / f"{name}.mp4"
                            video_writers[name] = cv2.VideoWriter(
                                str(path), fourcc, config.fps, (w, h)
                            )

                    is_recording = True
                    recording_start_time = time.time()
                    frame_count = 0
                    print(f"Recording started: {recording_dir}")
                else:
                    is_recording = False
                    for writer in video_writers.values():
                        writer.release()
                    video_writers = {}
                    duration = time.time() - recording_start_time
                    print(
                        f"Recording stopped - {duration:.1f}s, {frame_count} frames "
                        f"-> {recording_dir}"
                    )

            if (
                is_recording
                and config.duration is not None
                and time.time() - recording_start_time >= config.duration
            ):
                print(f"Recording duration reached: {config.duration:.1f}s")
                break

            elapsed = time.monotonic() - t_start
            remaining = loop_period - elapsed
            if remaining > 0:
                time.sleep(remaining)

    except KeyboardInterrupt:
        print("\nExiting...")
    finally:
        if video_writers:
            for writer in video_writers.values():
                writer.release()
            if is_recording:
                duration = time.time() - recording_start_time
                print(f"Final recording: {duration:.1f}s, {frame_count} frames")

        client.close()
        if config.display:
            cv2.destroyAllWindows()


if __name__ == "__main__":
    config = tyro.cli(CameraViewerConfig)
    main(config)