Post 4 - Giving it eyes (a live camera feed)
Hi again, it’s Hambreros and Tamadillo. Last post ended with a promise: the joystick makes driving nicer, but you’re still driving blind. So a USB webcam went on, and the control page now shows what the robot sees.
What we’ve actually built
A live video feed right on the same page as the joystick — the “puppy on a leash from your phone” post the original plan called for, minus the actual leash. Point the robot somewhere without needing to be in the room with it.
Checking what the framework already gives you
Same move as the TTS detour: before writing any camera code,
went and read arduino/app-bricks-py to see what Arduino
already ships. Turned out app_peripherals/camera is a whole unified
abstraction — one Camera class covering CSI, USB (V4L), IP, and even
WebSocket sources, same family as the Speaker peripheral the sound system
already uses:
from arduino.app_peripherals.camera import Camera
camera = Camera("usb:0", resolution=(640, 480), fps=15)
camera.start()
frame = camera.capture() # numpy array, or None
# or: for frame in camera.stream(): ...
Since Speaker was already confirmed bundled in this app’s base container
with zero extra install, betting Camera is too — no new brick, no sidecar
container, just a normal peripheral call from our own code. The only actual
new dependency is opencv-python-headless, for turning each frame into a
JPEG.
The dumbest frontend that works
The tempting-but-overbuilt version of this involves a <canvas>, a
WebSocket, and a JS render loop pulling frames off it. Skipped all of that:
<img id="cameraFeed" src="/api/camera/stream" alt="Live camera feed">
That’s the entire client-side video pipeline. GET /api/camera/stream
returns multipart/x-mixed-replace — a boundary-delimited stream of JPEG
frames — and browsers have known how to render that straight into an
<img> tag since basically forever. No JS needed for the video itself, just
a listener on the error event for when there’s no camera to show:
cameraFeed.addEventListener('error', () => {
cameraFeed.style.display = 'none';
fetch('/api/camera/status').then(r => r.json()).then(data => {
cameraError.hidden = false;
cameraError.textContent = data.error || 'camera stream unavailable';
});
});
Same “surface the real reason, not a cryptic broken icon” instinct as the sound system’s error banner from a couple of posts back.
Starting the camera without blocking everything else
Camera.start() has its own connection retry loop with exponential
backoff — reasonable for “give the USB device a moment to enumerate,” bad
if it’s sitting on the same startup path as the wheel and sound APIs. A
slow or missing camera shouldn’t hold up driving the robot.
So python/camera.py kicks off Camera(...).start() on a background
thread at import time, mirroring sounds.py’s general shape:
def _start():
global _camera, _error
try:
cam = Camera(SOURCE, resolution=RESOLUTION, fps=FPS)
cam.start()
_camera = cam
except Exception as e:
_error = str(e)
threading.Thread(target=_start, daemon=True).start()
status() reports (ok, error) off that shared state, and the stream route
just checks it before handing back the actual multipart response:
@web.route('/api/camera/stream')
def camera_stream():
ok, error = camera.status()
if not ok:
return jsonify({'ok': False, 'error': error}), 503
return Response(camera.mjpeg_frames(),
mimetype='multipart/x-mixed-replace; boundary=frame')
mjpeg_frames() itself is a small generator wrapping Camera.stream(),
JPEG-encoding (cv2.imencode) each frame as it comes.
What’s next
Camera’s on, joystick works — next logical step is doing something with the
two together: point-and-drive, or finally trying that on-device object
detection now that we know UNO Q supports in it’s demo bricks.