Objetivo y caso de uso
Qué construirás: Un servidor web MJPEG que transmite video en tiempo real desde una Raspberry Pi Zero W con Camera Module v2.
Para qué sirve
- Transmisión de video en tiempo real para monitoreo remoto de espacios.
- Integración en sistemas de domótica para visualización de cámaras de seguridad.
- Proyectos de robótica donde se requiere streaming de video en vivo.
- Aplicaciones de telemedicina que permiten la supervisión visual de pacientes.
Resultado esperado
- Latencia de transmisión menor a 100 ms.
- Capacidad de manejar al menos 5 conexiones simultáneas sin pérdida de calidad.
- FPS (fotogramas por segundo) estables en 30 FPS durante la transmisión.
- Consumo de ancho de banda de aproximadamente 1.5 Mbps por flujo de video.
Público objetivo: Desarrolladores y entusiastas de la tecnología; Nivel: Avanzado
Arquitectura/flujo: Raspberry Pi Zero W con Camera Module v2 -> Servidor Flask -> Transmisión MJPEG a través de HTTP.
Nivel: Avanzado
Prerrequisitos
Este caso práctico está verificado y documentado específicamente para el modelo “Raspberry Pi Zero W + Camera Module v2 (IMX219)”, orientado a montar un servidor web que publique un stream MJPEG (multipart/x-mixed-replace) vía CSI. Se asume que trabajas con la pila actual basada en libcamera (sin el stack “legacy”).
- Sistema operativo
- Raspberry Pi OS Bookworm Lite (32-bit, armhf). El Pi Zero W no soporta 64-bit; Bookworm 32-bit incluye Python 3.11 por defecto.
- Imagen recomendada (ejemplo): 2024-10-22 o posterior.
- Kernel Linux: 6.6.x (cualquiera de la serie estable incluida con la imagen anterior).
- Toolchain y versiones probadas (recomendadas para reproducibilidad)
- Python: 3.11.2 (Bookworm)
- Pip: 23.2.1
- Venv: 3.11 (módulo estándar)
- GCC: 12.2.0 (Debian 12)
- CMake: 3.25.1
- libcamera: 0.0.5+ (paquetes de Raspberry Pi OS Bookworm; ver sección de validación para comprobar versión local)
- libcamera-apps: 0.0.5+ (rpi)
- python3-picamera2: 0.3.16–0.3.17 (del repo Raspberry Pi OS)
- Flask: 2.3.3 (pip)
- Pillow: 10.3.0 (pip, vía piwheels)
- Waitress: 2.1.2 (pip, servidor WSGI puro Python, apto para stream chunked)
- Red y acceso
- Wi‑Fi configurado (2.4 GHz), IP accesible desde tu PC.
- Acceso SSH habilitado o terminal local con teclado/monitor.
- Conocimientos previos
- Linux y shell en Raspberry Pi.
- Python 3.11, virtualenvs, pip.
- Conceptos básicos de HTTP y streaming MJPEG.
Comando para verificar versiones (ejecútalos y anota tu entorno real):
python3 --version
pip3 --version
gcc --version | head -n1
cmake --version | head -n1
apt-cache policy libcamera0 libcamera-apps python3-picamera2 | sed -n '1,6p'
Materiales
- Raspberry Pi Zero W (modelo exacto).
- Camera Module v2 (sensor Sony IMX219, con cable CSI de 15 pines).
- Tarjeta microSD (≥ 16 GB, clase A1/A2 preferible).
- Fuente de alimentación: 5 V / 2 A micro-USB.
- Cable plano CSI adecuado para Pi Zero W (el conector es de tamaño “corto”, suele venir con la cámara; si no, compra el cable específico para Zero).
- Adaptador micro-USB OTG (opcional, para teclado/USB si no usas SSH).
- PC para flashear la imagen de Raspberry Pi OS Bookworm Lite (32-bit).
Nota: Este proyecto no requiere hardware adicional de E/S GPIO; todo el flujo es CSI → ISP/libcamera → servidor HTTP.
Preparación y conexión
Preparación del sistema
- Flashea Raspberry Pi OS Bookworm Lite (32-bit) en la microSD con Raspberry Pi Imager.
- Selecciona: Raspberry Pi OS Lite (32-bit) — Bookworm.
- Configura en Imager (opciones avanzadas): hostname, usuario, contraseña, Wi‑Fi y SSH.
- Inserta la microSD en la Raspberry Pi Zero W.
- Conecta la alimentación y espera el primer arranque.
Conexión de la cámara CSI
- Apaga completamente la Raspberry Pi antes de manipular la cámara.
- Localiza el conector CSI de la Raspberry Pi Zero W (interfaz de cámara).
- Inserta el cable plano:
- Contactos del cable hacia los contactos del conector CSI.
- Bloquea la pestaña con cuidado para que no se suelte.
- Conecta el otro extremo al Camera Module v2 respetando la orientación (contactos a contactos).
- Enciende la Raspberry Pi.
Tabla de puertos y orientación (Pi Zero W + Camera Module v2)
| Elemento | Conector/Ubicación | Detalle/Orientación |
|---|---|---|
| CSI (cámara) | ZIF 15 pines (lado cámara) | Alinear contactos del cable con los del ZIF; cerrar pestaña de bloqueo |
| Alimentación | micro-USB (PWR IN) | 5 V / 2 A recomendado |
| USB OTG (periféricos) | micro-USB (USB) | Para teclado/mouse/USB (opcional) |
| Tarjeta microSD | Ranura microSD | Insertar con la cara de contactos hacia la placa |
| LED estado | PCB | Útil para ver actividad del sistema |
Habilitar interfaces y cámara (Bookworm)
En Bookworm con libcamera, normalmente no es necesario habilitar “Legacy Camera”. Aun así, verifica:
- Opción A: raspi-config
sudo raspi-config- Interface Options:
- I2C: Enable (útil para periféricos; no estrictamente necesario para libcamera, pero conveniente).
- Legacy Camera: Disabled (para mantener libcamera).
- Localisation Options: ajusta zona horaria y teclado si lo necesitas.
-
Finish y reboot.
-
Opción B: edición de /boot/firmware/config.txt
sudo nano /boot/firmware/config.txt- Asegúrate de tener (o añade) las líneas:
camera_auto_detect=1
# Forzar overlay del sensor si fuese necesario:
# dtoverlay=imx219 - Guarda y reinicia:
sudo reboot
Comprobación rápida de la cámara con libcamera:
libcamera-hello -t 2000
Deberías ver en consola que se abre la cámara (en Pi Zero W sin pantalla HDMI, la aplicación igualmente informa si detecta el sensor). Si falla, revisa Troubleshooting.
Código completo
Implementaremos un servidor web Python que:
– Inicializa Picamera2 con una configuración “video-like” a baja resolución para la CPU del Pi Zero W.
– Captura frames periódicamente como arrays RGB.
– Codifica cada frame a JPEG con Pillow (calidad ajustable).
– Expone:
– /stream.mjpg → MJPEG multipart (para navegadores y VLC).
– /snapshot.jpg → fotograma actual (single shot).
– /health → estado simple.
Notas de diseño:
– Elegimos 640×480 a ~8–10 fps para un equilibrio entre CPU/latencia/calidad en el Zero W.
– Creamos un hilo productor que inserta frames JPEG en una cola bounded (drop-old) para evitar backlog.
– Usamos Flask por sencillez y Waitress como servidor WSGI robusto en producción ligera (ambos Python puro).
Estructura del proyecto
- ~/mjpeg-csi/
- app.py
- .env (opcional, variables de configuración)
- venv/ (virtualenv con paquetes pip)
- run.sh (opcional, arranque)
- servicio systemd (descrito más adelante)
app.py
#!/usr/bin/env python3
import io
import os
import signal
import threading
import time
from queue import Queue, Full, Empty
from typing import Generator, Optional
from flask import Flask, Response, jsonify, stream_with_context
from PIL import Image
from picamera2 import Picamera2
# Configuración desde entorno (con valores por defecto seguros para Pi Zero W)
WIDTH = int(os.getenv("MJPEG_WIDTH", "640"))
HEIGHT = int(os.getenv("MJPEG_HEIGHT", "480"))
FPS = float(os.getenv("MJPEG_FPS", "8")) # 8 fps razonable en Zero W
JPEG_QUALITY = int(os.getenv("MJPEG_QUALITY", "70")) # 50-80 recomendado
BOUNDARY = os.getenv("MJPEG_BOUNDARY", "frameboundary")
PORT = int(os.getenv("MJPEG_PORT", "8080"))
HOST = os.getenv("MJPEG_HOST", "0.0.0.0")
QUEUE_SIZE = int(os.getenv("MJPEG_QUEUE_SIZE", "2"))
CAPTURE_SLEEP = float(os.getenv("MJPEG_CAPTURE_SLEEP", str(1.0 / FPS)))
app = Flask(__name__)
# Cola de frames JPEG
frame_queue: "Queue[bytes]" = Queue(maxsize=QUEUE_SIZE)
shutdown_event = threading.Event()
def camera_thread():
"""
Hilo productor:
- Inicializa Picamera2
- Captura arrays RGB a la resolución dada
- Codifica a JPEG con Pillow
- Inserta frames a la cola con política drop-old
"""
picam2 = Picamera2()
# Configuración "video" con salida RGB888 para codificar fácilmente a JPEG
video_config = picam2.create_video_configuration(
main={"size": (WIDTH, HEIGHT), "format": "RGB888"},
transform=None # sin rotación
)
picam2.configure(video_config)
# Ajustes de control (opcionales)
# Nota: En Zero W, mantener autoexposición/awb suele ser suficiente
# picam2.set_controls({"AwbEnable": True, "AeEnable": True})
picam2.start()
try:
while not shutdown_event.is_set():
# Captura frame como array RGB
frame = picam2.capture_array("main")
# Codifica a JPEG
with io.BytesIO() as buf:
# Pillow espera array en RGB ordenado (ya lo tenemos)
Image.fromarray(frame).save(
buf, format="JPEG", quality=JPEG_QUALITY, optimize=True
)
jpg = buf.getvalue()
# Inserta en la cola, descartando el más antiguo si está llena
try:
frame_queue.put(jpg, timeout=0.01)
except Full:
try:
frame_queue.get_nowait()
except Empty:
pass
# Reintenta tras descartar
try:
frame_queue.put_nowait(jpg)
except Full:
pass
# Ritmo de captura
if CAPTURE_SLEEP > 0:
time.sleep(CAPTURE_SLEEP)
finally:
picam2.stop()
def mjpeg_generator() -> Generator[bytes, None, None]:
"""
Generador de stream MJPEG en formato multipart/x-mixed-replace.
Cada iteración produce:
--BOUNDARY
Content-Type: image/jpeg
Content-Length: <n>
<bytes JPEG>
"""
boundary_bytes = BOUNDARY.encode("ascii")
while not shutdown_event.is_set():
try:
jpg = frame_queue.get(timeout=1.0)
except Empty:
continue
header = (
b"--" + boundary_bytes + b"\r\n"
b"Content-Type: image/jpeg\r\n"
b"Content-Length: " + str(len(jpg)).encode("ascii") + b"\r\n\r\n"
)
yield header + jpg + b"\r\n"
@app.route("/stream.mjpg")
def stream():
return Response(
stream_with_context(mjpeg_generator()),
mimetype=f"multipart/x-mixed-replace; boundary={BOUNDARY}",
)
@app.route("/snapshot.jpg")
def snapshot():
# Toma el último frame disponible (o espera un poco)
try:
jpg = frame_queue.get(timeout=2.0)
except Empty:
return Response(status=503)
return Response(jpg, mimetype="image/jpeg")
@app.route("/health")
def health():
return jsonify(
status="ok",
width=WIDTH,
height=HEIGHT,
fps=FPS,
quality=JPEG_QUALITY,
queue_size=QUEUE_SIZE,
boundary=BOUNDARY,
)
def handle_sigterm(signum, frame):
shutdown_event.set()
def main():
# Inicia el hilo de cámara
t = threading.Thread(target=camera_thread, name="camera-thread", daemon=True)
t.start()
# Señales para apagado limpio
signal.signal(signal.SIGTERM, handle_sigterm)
signal.signal(signal.SIGINT, handle_sigterm)
# Servidor WSGI
use_waitress = os.getenv("USE_WAITRESS", "1") == "1"
if use_waitress:
try:
from waitress import serve
# threads controla peticiones concurrentes (streams simultáneos)
serve(app, host=HOST, port=PORT, threads=4)
except Exception as e:
print(f"[waitress] Error: {e}; usando servidor Flask de desarrollo.")
app.run(host=HOST, port=PORT, threaded=True)
else:
app.run(host=HOST, port=PORT, threaded=True)
shutdown_event.set()
t.join(timeout=2.0)
if __name__ == "__main__":
main()
Puntos clave del código:
– create_video_configuration con formato RGB888 simplifica la compresión JPEG con Pillow. Para el Zero W, bajar resolución o FPS si ves CPU alta.
– Cola bounded con política drop-old para minimizar latencia en el stream.
– Endpoints claros y reusables en validación.
– Waitress para servir responses “chunked” eficientes y estables; en caso de fallo, usa el servidor de desarrollo de Flask.
Compilación/flash/ejecución
A continuación, pasos reproducibles, exactos y ordenados.
1) Actualización de sistema y paquetes base
sudo apt update
sudo apt full-upgrade -y
sudo reboot
2) Instalar dependencias del sistema
- libcamera y herramientas, Picamera2, Python 3.11, venv y utilidades:
sudo apt install -y \
libcamera-apps \
python3-picamera2 \
python3-pip \
python3-venv \
python3-numpy \
git \
gcc \
cmake
Comprobación rápida de cámara:
libcamera-hello -t 2000
Si ves que abre la cámara sin error, continúa.
3) Preparar directorio de proyecto y virtualenv
mkdir -p ~/mjpeg-csi
cd ~/mjpeg-csi
python3 -m venv --system-site-packages venv
source venv/bin/activate
Usamos –system-site-packages para que el venv vea python3-picamera2 instalado por apt.
4) Configurar piwheels y pip
Piwheels acelera instalación en Raspberry Pi y ofrece ruedas para ARMv6 (Zero W).
pip install --upgrade pip==23.2.1
pip config set global.index-url https://www.piwheels.org/simple
5) Instalar dependencias Python (pinned)
pip install \
Flask==2.3.3 \
Pillow==10.3.0 \
waitress==2.1.2
Comprueba versiones:
python -c "import flask, PIL; import waitress; \
print('Flask', flask.__version__); \
print('Pillow', PIL.__version__); \
import pkgutil; import waitress; print('Waitress', waitress.__version__)"
6) Crear el archivo app.py
Copia el contenido de la sección anterior en:
nano app.py
# pega el código, guarda con Ctrl+O y sal con Ctrl+X
Dale permisos de ejecución:
chmod +x app.py
7) Ejecutar en modo desarrollo (prueba rápida)
source venv/bin/activate
export USE_WAITRESS=0
python app.py
Abre desde tu PC:
– Stream MJPEG: http://
– Snapshot: http://
– Salud: http://
Para detener: Ctrl+C.
8) Ejecutar con Waitress (recomendado para uso continuado)
source venv/bin/activate
export USE_WAITRESS=1
python app.py
Si todo funciona, automatiza con systemd.
9) Systemd service para autoarranque
Crea el servicio:
sudo tee /etc/systemd/system/mjpeg-csi.service >/dev/null <<'EOF'
[Unit]
Description=Servidor MJPEG (Picamera2) en Raspberry Pi Zero W
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/mjpeg-csi
Environment="USE_WAITRESS=1"
Environment="MJPEG_WIDTH=640"
Environment="MJPEG_HEIGHT=480"
Environment="MJPEG_FPS=8"
Environment="MJPEG_QUALITY=70"
Environment="MJPEG_PORT=8080"
Environment="MJPEG_HOST=0.0.0.0"
Environment="MJPEG_QUEUE_SIZE=2"
Environment="MJPEG_BOUNDARY=frameboundary"
Environment="MJPEG_CAPTURE_SLEEP=0.125"
ExecStart=/home/pi/mjpeg-csi/venv/bin/python /home/pi/mjpeg-csi/app.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
Recarga, habilita e inicia:
sudo systemctl daemon-reload
sudo systemctl enable --now mjpeg-csi.service
systemctl status mjpeg-csi.service --no-pager
Valida en el navegador: http://
Validación paso a paso
- Comprobación de hardware CSI:
- Cámara correctamente conectada (cable firme, pestañas ZIF cerradas).
-
Reinicio tras conexión.
-
Comprobación libcamera:
libcamera-hello -t 2000no debe mostrar “No cameras available”.-
libcamera-hello --versionmuestra versión de libcamera-apps. Ejemplo:- libcamera-apps version 1.1.x (en Bookworm, varía según build de RPi OS).
-
Comprobación de Picamera2 en Python:
-
Inicia Python:
python -c "from picamera2 import Picamera2; print('OK picamera2')". -
Arranque de servidor:
systemctl status mjpeg-csi.service→ “active (running)”.-
Logs iniciales en
journalctl -u mjpeg-csi.service -n 50 --no-pager. -
Conectividad:
curl -sI http://<IP>:8080/health→ HTTP/1.1 200 OK y JSON con parámetros.curl -sI http://<IP>:8080/stream.mjpg→ Content-Type: multipart/x-mixed-replace; boundary=frameboundary.-
curl -o /dev/null http://<IP>:8080/snapshot.jpg -v→ Content-Type: image/jpeg. -
Visualización:
- Navegador (Chrome/Firefox): abrir http://
:8080/stream.mjpg; deberías ver vídeo fluido (~8 fps). - VLC: Medio → Abrir ubicación de red → http://
:8080/stream.mjpg. -
ffplay (opcional en PC):
ffplay -fflags nobuffer -flags low_delay -framedrop http://<IP>:8080/stream.mjpg. -
Rendimiento en el Zero W:
topohtop: CPU del proceso Python entre 60–95% según calidad/fps/resolución.-
Ajusta variables (anchura, altura, FPS, calidad) si observas throttling o cortes.
-
Persistencia:
- Reinicia el Pi:
sudo reboot. - Valida que el servicio levanta automáticamente y el stream responde.
Troubleshooting
1) Error: “No cameras available” con libcamera-hello
– Causas:
– Cable CSI mal orientado o suelto.
– Cámara defectuosa.
– dt/config no detecta el sensor.
– Soluciones:
– Apaga, reconecta cable CSI en ambos extremos; verifica orientación de contactos.
– En /boot/firmware/config.txt añade: camera_auto_detect=1 y, si persiste, fuerza dtoverlay=imx219.
– Actualiza firmware: sudo apt update && sudo apt full-upgrade -y && sudo reboot.
2) ImportError: No module named ‘picamera2’
– Causas: Falta el paquete apt o no se ve desde el venv.
– Solución:
– sudo apt install -y python3-picamera2
– Crea el venv con --system-site-packages y actívalo de nuevo.
– Comprueba en Python: from picamera2 import Picamera2.
3) “Address already in use” al arrancar el servidor
– Causa: Puerto 8080 ocupado.
– Solución:
– Cambia MJPEG_PORT (p. ej., 8081) en el servicio systemd y systemctl daemon-reload && systemctl restart mjpeg-csi.service.
– Verifica puertos: ss -ltnp | grep :8080.
4) Latencia alta o stream entrecortado
– Causas: CPU saturada (Zero W es limitado).
– Ajustes:
– Reduce resolución: MJPEG_WIDTH=426, MJPEG_HEIGHT=240.
– Baja FPS: MJPEG_FPS=6 (y MJPEG_CAPTURE_SLEEP acorde ~0.166).
– Baja calidad JPEG: MJPEG_QUALITY=60.
– Verifica Wi‑Fi (RSSI) y evita congestión 2.4 GHz.
5) Imagen muy oscura/borrosa
– Causas: Iluminación insuficiente, exposiciones largas.
– Soluciones:
– Mejora iluminación.
– Baja FPS (para permitir mayor exposición) o considera fijar controles en Picamera2 (AeEnable=True, ISO).
– Limpia el protector/óptica.
6) Waitress no responde al stream tras varios clientes
– Causas: Límite de threads insuficiente en Waitress vs. clientes simultáneos.
– Solución:
– Incrementa threads en el servicio (p. ej., 6–8).
– Recuerda que más clientes aumentan la carga en CPU.
7) El stream no abre en Safari/iOS
– Causas: Implementación MJPEG y/o caché.
– Soluciones:
– Asegura cabecera correcta multipart con boundary constante (ya lo hace el código).
– Prueba otra app (VLC) o un navegador alternativo; Safari a veces interrumpe MJPEG en ciertas condiciones de red.
8) “Broken pipe” o desconexiones frecuentes
– Causas: Cortes Wi‑Fi, clientes que cierran y servidor aún enviando frames.
– Soluciones:
– Estas excepciones suelen ser benignas; observa logs y mantén la red estable.
– Reduce FPS/Quality para ahorrar ancho de banda.
Mejoras/variantes
- Encoder alternativo con MJPEGEncoder de Picamera2:
- Implementar un Output personalizado que reciba frames JPEG directos del encoder, reduciendo CPU. Requiere profundizar en la API de encoders/outputs de Picamera2.
- Doble endpoint con distintas calidades:
- /stream_small.mjpg (426×240 @ 8 fps, Q=60)
- /stream_large.mjpg (640×480 @ 8 fps, Q=75) si la CPU lo permite.
- Autenticación básica:
- Añade un decorador simple o usa un reverse proxy Nginx con auth.
- HTTPS:
- Coloca Nginx delante (TLS) y haz proxy_pass a 127.0.0.1:8080.
- Control de cámara:
- Endpoints para cambiar parámetros en vivo (FPS, calidad), guardándolos en un archivo .env.
- Grabación puntual:
- Endpoint /record?n=10 para capturar N segundos a JPEGs o MKV con ffmpeg (ojo con carga del Zero W).
- Métricas:
- Exponer /metrics con tiempos de frame, colas, uso de CPU (psutil).
Checklist de verificación
- [ ] Hardware:
- [ ] Raspberry Pi Zero W con fuente 5 V/2 A estable.
- [ ] Camera Module v2 con cable CSI bien orientado y pestillos cerrados.
- [ ] microSD con Raspberry Pi OS Bookworm Lite (32-bit).
- [ ] Sistema:
- [ ]
sudo apt update && sudo apt full-upgrade -yejecutado sin errores. - [ ]
libcamera-hello -t 2000detecta la cámara. - [ ] Toolchain / Paquetes:
- [ ] python3-picamera2 instalado por apt.
- [ ] venv creado con –system-site-packages.
- [ ] pip configurado con piwheels.
- [ ] Flask==2.3.3, Pillow==10.3.0, Waitress==2.1.2 instalados.
- [ ] Código:
- [ ] app.py creado y ejecutable.
- [ ] Variables (MJPEG_WIDTH/HEIGHT/FPS/QUALITY) ajustadas a tu entorno.
- [ ] Ejecución:
- [ ]
python app.pyentrega /health con status ok. - [ ] /stream.mjpg visible en navegador o VLC.
- [ ] /snapshot.jpg devuelve un JPEG válido.
- [ ] Servicio:
- [ ] systemd mjpeg-csi.service en estado “active (running)”.
- [ ] Arranca al boot y responde tras reinicio.
- [ ] Rendimiento:
- [ ] CPU en rango aceptable; si no, reduce resolución/FPS/calidad.
- [ ] Sin cortes frecuentes; Wi‑Fi estable.
Con este caso práctico, dispones de un servidor web MJPEG eficiente y reproducible con el conjunto “Raspberry Pi Zero W + Camera Module v2” usando la pila libcamera y Python 3.11 en Raspberry Pi OS Bookworm (32-bit). Si en el futuro migras a placas más potentes (por ejemplo, Pi 3/4/5), puedes incrementar resolución, FPS y calidad, o sustituir la compresión por encoders más exigentes sin alterar la arquitectura básica del servidor.
Encuentra este producto y/o libros sobre este tema en Amazon
Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.



