Skip to content

Commit ac62d1f

Browse files
committed
feat: add recording to camera
1 parent b0e9f80 commit ac62d1f

File tree

4 files changed

+225
-19
lines changed

4 files changed

+225
-19
lines changed

src/arduino/app_peripherals/camera/base_camera.py

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ def __init__(
4242
auto_reconnect (bool, optional): Enable automatic reconnection on failure. Default: True.
4343
"""
4444
self.resolution = resolution
45+
if fps <= 0:
46+
raise ValueError("FPS must be a positive integer")
4547
self.fps = fps
4648
self.adjustments = adjustments
4749
self.logger = logger # This will be overridden by subclasses if needed
@@ -176,6 +178,9 @@ def stream(self):
176178
177179
Yields:
178180
np.ndarray: Video frames as numpy arrays.
181+
182+
Raises:
183+
CameraReadError: If the camera is not started.
179184
"""
180185
if not self.is_started():
181186
raise CameraReadError(f"Attempted to acquire stream from {self.name} before starting it.")
@@ -184,9 +189,107 @@ def stream(self):
184189
frame = self.capture()
185190
if frame is not None:
186191
yield frame
187-
else:
188-
# Avoid busy-waiting if no frame available
189-
time.sleep(0.001)
192+
193+
def record(self, duration) -> np.ndarray:
194+
"""
195+
Record video for a specified duration and return it as a numpy array of raw frames.
196+
197+
Args:
198+
duration (float): Recording duration in seconds.
199+
200+
Returns:
201+
np.ndarray: numpy array of raw frames.
202+
203+
Raises:
204+
CameraReadError: If camera is not started or any read error occurs.
205+
ValueError: If duration is not positive.
206+
MemoryError: If memory allocation for the full recording fails.
207+
"""
208+
if duration <= 0:
209+
raise ValueError("Duration must be positive")
210+
211+
total_frames = int(self.fps * duration)
212+
213+
# Get shape and dtype from first frame
214+
first_frame = self.capture()
215+
if first_frame is None:
216+
raise CameraOpenError("Failed to inspect the video stream for metadata.")
217+
frame_shape = first_frame.shape
218+
frame_dtype = first_frame.dtype
219+
220+
try:
221+
frames = np.zeros((total_frames, *frame_shape), dtype=frame_dtype)
222+
except Exception as e:
223+
raise MemoryError(f"Could not allocate memory for {total_frames} frames: {e}")
224+
225+
count = 1
226+
frames[0] = first_frame
227+
while count < total_frames:
228+
frame = self.capture()
229+
if frame is not None:
230+
frames[count] = frame
231+
count += 1
232+
233+
if not frames.any():
234+
raise CameraReadError("No frames captured during recording.")
235+
236+
return frames[:count]
237+
238+
def record_avi(self, duration) -> np.ndarray:
239+
"""
240+
Record video for a specified duration and return as MJPEG in AVI container.
241+
242+
Args:
243+
duration (float): Recording duration in seconds.
244+
245+
Returns:
246+
np.ndarray: AVI file containing MJPEG video.
247+
248+
Raises:
249+
CameraReadError: If camera is not started or any read error occurs.
250+
ValueError: If duration is not positive.
251+
MemoryError: If memory allocation for the full recording fails.
252+
"""
253+
if duration <= 0:
254+
raise ValueError("Duration must be positive")
255+
256+
import os
257+
import tempfile
258+
import cv2
259+
260+
total_frames = int(self.fps * duration)
261+
262+
# Get width and height from first frame
263+
first_frame = self.capture()
264+
if first_frame is None:
265+
raise CameraOpenError("Failed to inspect the video stream for metadata.")
266+
height, width = first_frame.shape[:2]
267+
268+
# Write MJPEG AVI to a temp file
269+
with tempfile.NamedTemporaryFile(suffix=".avi", delete=False) as tmp:
270+
filename = tmp.name
271+
272+
avi_data = np.empty(0, dtype=np.uint8)
273+
try:
274+
fourcc = cv2.VideoWriter.fourcc(*"MJPG")
275+
out = cv2.VideoWriter(filename, fourcc, self.fps, (width, height))
276+
frame = first_frame
277+
for i in range(total_frames):
278+
if frame is not None:
279+
if frame.dtype != np.uint8:
280+
frame = _to_uint8(frame)
281+
out.write(frame)
282+
283+
if i < total_frames - 1:
284+
frame = self.capture()
285+
286+
out.release()
287+
with open(filename, "rb") as f:
288+
avi_data = f.read()
289+
finally:
290+
os.remove(filename)
291+
292+
return np.frombuffer(avi_data, dtype=np.uint8)
190293

191294
def is_started(self) -> bool:
192295
"""Check if the camera has been started."""
@@ -300,3 +403,16 @@ def __enter__(self):
300403
def __exit__(self, exc_type, exc_val, exc_tb):
301404
"""Context manager exit."""
302405
self.stop()
406+
407+
408+
def _to_uint8(frame) -> np.ndarray:
409+
"""Normalize and convert to uint8."""
410+
if np.issubdtype(frame.dtype, np.floating):
411+
# We adopt the OpenCV convention: float images are in [0, 1]
412+
frame = np.clip(frame * 255, 0, 255)
413+
414+
elif np.issubdtype(frame.dtype, np.integer) and frame.dtype != np.uint8:
415+
info = np.iinfo(frame.dtype)
416+
frame = (frame.astype(np.float32) - info.min) / (info.max - info.min) * 255
417+
418+
return frame.astype(np.uint8)

src/arduino/app_peripherals/camera/examples/3_capture_video.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,19 @@
99
from arduino.app_peripherals.camera import Camera
1010

1111

12-
# Capture a video for 5 seconds at 15 FPS
1312
camera = Camera(fps=15)
1413
camera.start()
1514

15+
# You can capture a video by capturing frames in a loop
1616
start_time = time.time()
1717
while time.time() - start_time < 5:
1818
image: np.ndarray = camera.capture()
1919
# You can process the image here if needed, e.g save it
2020

21+
# Or you can obtain the same in a single sentence
22+
recording: np.ndarray = camera.record(duration=5)
23+
24+
# Or you can ask for an AVI recording
25+
recording: np.ndarray = camera.record_avi(duration=5)
26+
2127
camera.stop()

src/arduino/app_peripherals/camera/websocket_camera.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ def __init__(
5858
Initialize WebSocket camera server.
5959
6060
Args:
61-
host (str): Host address to bind the server to (default: "0.0.0.0")
6261
port (int): Port to bind the server to (default: 8080)
6362
timeout (int): Connection timeout in seconds (default: 10)
6463
frame_format (str): Expected frame format from clients ("binary", "base64", "json") (default: "binary")

tests/arduino/app_peripherals/camera/test_base_camera.py

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import pytest
66
import time
77
import numpy as np
8+
import tempfile
9+
import cv2
810

911
from arduino.app_peripherals.camera import BaseCamera, CameraTransformError
1012
from arduino.app_peripherals.usb_camera import CameraReadError
@@ -78,6 +80,12 @@ def test_base_camera_init_custom():
7880
assert not camera.is_started()
7981

8082

83+
def test_base_camera_init_invalid():
84+
"""Test BaseCamera initialization with invalid parameters."""
85+
with pytest.raises(ValueError):
86+
MockedCamera(fps=0)
87+
88+
8189
def test_is_started_state_transitions():
8290
"""Test is_started return value through different state transitions."""
8391
camera = MockedCamera()
@@ -344,20 +352,6 @@ def test_context_manager_with_exception():
344352
assert camera.close_call_count == 1
345353

346354

347-
def test_capture_no_throttling():
348-
"""Test capture behavior with fps=0 (no throttling)."""
349-
camera = MockedCamera(fps=0)
350-
camera.start()
351-
352-
start_time = time.monotonic()
353-
frame = camera.capture()
354-
elapsed = time.monotonic() - start_time
355-
356-
assert elapsed < 0.01
357-
assert frame is not None
358-
assert camera.read_call_count == 1
359-
360-
361355
def test_capture_multiple_adjustments():
362356
"""Test that adjustment pipelines are applied correctly."""
363357

@@ -424,3 +418,94 @@ def event_callback(event, data):
424418
assert "paused" in events[2][0]
425419
assert "streaming" in events[3][0]
426420
assert "disconnected" in events[4][0]
421+
422+
def test_record_zero_duration():
423+
camera = MockedCamera()
424+
camera.start()
425+
with pytest.raises(ValueError):
426+
camera.record(0)
427+
camera.stop()
428+
429+
def test_record():
430+
camera = MockedCamera(fps=5)
431+
camera.frame = np.ones((480, 640, 3), dtype=np.uint8)
432+
camera.start()
433+
434+
duration = 1.0
435+
expected_frames = int(camera.fps * duration)
436+
frames = camera.record(duration)
437+
438+
assert isinstance(frames, np.ndarray)
439+
assert frames.shape[0] == expected_frames
440+
assert frames.shape[1:] == camera.frame.shape
441+
assert frames.dtype == camera.frame.dtype
442+
assert np.all(frames == camera.frame)
443+
444+
camera.stop()
445+
446+
def test_record_avi():
447+
camera = MockedCamera(fps=5)
448+
camera.frame = np.ones((480, 640, 3), dtype=np.uint8)
449+
camera.start()
450+
451+
duration = 1.0
452+
expected_frames = int(camera.fps * duration)
453+
avi_bytes = camera.record_avi(duration)
454+
455+
assert isinstance(avi_bytes, np.ndarray)
456+
assert avi_bytes.dtype == np.uint8
457+
assert avi_bytes.size > 0
458+
459+
with tempfile.NamedTemporaryFile(suffix='.avi') as tmp:
460+
tmp.write(avi_bytes.tobytes())
461+
tmp.flush()
462+
463+
read_count = 0
464+
cap = cv2.VideoCapture(tmp.name)
465+
while True:
466+
ret, frame = cap.read()
467+
if not ret:
468+
break
469+
assert frame is not None
470+
assert frame.dtype == np.uint8
471+
read_count += 1
472+
473+
cap.release()
474+
475+
assert read_count == expected_frames
476+
477+
camera.stop()
478+
479+
def test_record_avi_uint8_conversion():
480+
camera = MockedCamera(fps=5)
481+
# Use float32 frame, should be converted to uint8 in AVI
482+
camera.frame = np.ones((10, 10, 3), dtype=np.float64)
483+
camera.start()
484+
485+
duration = 1.0
486+
expected_frames = int(camera.fps * duration)
487+
avi_bytes = camera.record_avi(duration)
488+
489+
assert isinstance(avi_bytes, np.ndarray)
490+
assert avi_bytes.dtype == np.uint8
491+
assert avi_bytes.size > 0
492+
493+
with tempfile.NamedTemporaryFile(suffix='.avi') as tmp:
494+
tmp.write(avi_bytes.tobytes())
495+
tmp.flush()
496+
497+
read_count = 0
498+
cap = cv2.VideoCapture(tmp.name)
499+
while True:
500+
ret, frame = cap.read()
501+
if not ret:
502+
break
503+
assert frame is not None
504+
assert frame.dtype == np.uint8
505+
read_count += 1
506+
507+
cap.release()
508+
509+
assert read_count == expected_frames
510+
511+
camera.stop()

0 commit comments

Comments
 (0)