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') # space added not to look like email address
def camera_stream(): # which flags the post as inappropriate
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.