feat: camera_server v2 — encodeur hw v4l2h264enc + fallback sw

- Suppression du bloc MJPEG (double pipeline impossible sur Pi Zero 2W,
  interdit par CLAUDE.md : Flask = health + settings uniquement)
- Encodeur par défaut : v4l2h264enc hardware (/dev/video11, bcm2835-codec)
  attend ~40% CPU vs 80-90% avec x264enc
- Fallback automatique vers x264enc si v4l2h264enc échoue au démarrage
- Correction pipeline split : shlex.split() remplace .split() (bug caps GStreamer)
- Lecture stderr gst-launch en temps réel (thread daemon)
- GstManager : restart propre via SIGTERM sur le process GStreamer
- Signal SIGTERM/SIGINT : arrêt propre du process GStreamer
- /settings POST : changement à chaud de jetson_host, bitrate, encoder, etc.
- CLAUDE.md : correction "pas de GPU" → VideoCore IV + pipelines hw/sw documentés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:52:14 +02:00
parent 9221a1a2b9
commit e2ceeded8a
2 changed files with 226 additions and 149 deletions

View File

@ -1,25 +1,50 @@
# WireClaw — Pi Zero 2W # WireClaw — Pi Zero 2W
## Contexte ## Hardware
Composant vidéo du drone d'inspection WireClaw. - Modèle : Raspberry Pi Zero 2W
Caméra OV5647 (CSI) → GStreamer RTP H264 → Jetson - CPU : 4× ARM Cortex-A53 @1GHz
- GPU : VideoCore IV — encodeur H264 hardware disponible via v4l2h264enc (/dev/video11)
- RAM : 512MB
- OS : Raspberry Pi OS Bookworm Lite (64-bit)
- Caméra : OV5647 (module CSI standard Raspberry Pi)
- Boîtier : dissipateur thermique
- Charge CPU encodage : ~40% CPU (hw) vs ~80-90% (sw x264)
- Prérequis : gpu_mem=160 dans /boot/firmware/config.txt
## Stack ## Stack
- OS : Raspberry Pi OS Bookworm Lite - Caméra : libcamerasrc OBLIGATOIRE (Bookworm, pas v4l2src)
- Caméra : OV5647, libcamerasrc (pas v4l2src) - Encodage : v4l2h264enc hardware (défaut) → x264enc software (fallback auto)
- Encodage : x264enc tune=zerolatency bitrate=1000 - omxh264enc : non disponible sur Bookworm 64-bit (OpenMAX supprimé)
- Transport : RTP H264 pt=96 UDP port 5600 - Transport : RTP H264 pt=96 UDP
- Flask : API légère port 5000 (health + settings uniquement, pas de MJPEG local)
## Réseau ## Réseau
- Dev Freebox : Pi Zero 192.168.1.132 → Jetson 192.168.1.84 - Dev Freebox : 192.168.1.132 → Jetson 192.168.1.84:5600
- Hotspot WireClaw : Pi Zero DHCP → Jetson 10.42.0.1 - Hotspot WireClaw : DHCP → Jetson 10.42.0.1:5600
## Pipeline émetteur validé ## Pipelines validés
### Hardware (v4l2h264enc) — défaut
gst-launch-1.0 libcamerasrc ! \
video/x-raw,width=640,height=480,format=NV12,framerate=15/1 ! \
v4l2convert ! \
v4l2h264enc video-bitrate=1000000 extra-controls="controls,repeat_sequence_header=1" ! \
video/x-h264,level=(string)3.2 ! \
h264parse ! \
rtph264pay config-interval=1 pt=96 ! \
udpsink host=192.168.1.84 port=5600
### Software (x264enc) — fallback
gst-launch-1.0 libcamerasrc ! \ gst-launch-1.0 libcamerasrc ! \
video/x-raw,width=640,height=480,framerate=15/1 ! \ video/x-raw,width=640,height=480,framerate=15/1 ! \
videoconvert ! x264enc tune=zerolatency bitrate=1000 ! \ videoconvert ! x264enc tune=zerolatency bitrate=1000 ! \
rtph264pay config-interval=1 pt=96 ! \ rtph264pay config-interval=1 pt=96 ! \
udpsink host=192.168.1.84 port=5600 udpsink host=192.168.1.84 port=5600
## Rôle dans WireClaw
- Émet uniquement le flux RTP vers le Jetson
- Pas de traitement local (YOLOv8 tourne sur le Jetson CUDA)
- Réglages caméra via POST /settings (proxyfié depuis FastAPI Jetson)
## Repo ## Repo
git.syoul.fr/nicoboy/wireclaw-pizero git.syoul.fr/nicoboy/wireclaw-pizero

View File

@ -1,46 +1,36 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
WireClaw — Pi Zero 2W camera server WireClaw — Pi Zero 2W camera server
- MJPEG local stream on port 5000 (Flask) Flask API port 5000 : /health /settings
- RTP H264 stream to Jetson 192.168.1.84:5600 (GStreamer thread) RTP H264 → Jetson via GStreamer subprocess
Encoder strategy: v4l2h264enc hardware (default) → x264enc software (auto-fallback)
""" """
import os
import shlex
import signal
import subprocess import subprocess
import threading import threading
import time import time
import logging import logging
import cv2 from flask import Flask, jsonify, request
from flask import Flask, Response
# --- Configuration ----------------------------------------------------------- # --- Configuration -----------------------------------------------------------
JETSON_HOST = "192.168.1.84" DEFAULTS: dict = {
JETSON_PORT = 5600 "jetson_host": "192.168.1.84",
"jetson_port": 5600,
"width": 640,
"height": 480,
"fps": 15,
"bitrate_kbps": 1000,
# "auto" tries hw then sw; "hw" forces v4l2h264enc; "sw" forces x264enc
"encoder": "auto",
}
MJPEG_PORT = 5000 FLASK_PORT = 5000
MJPEG_WIDTH = 640
MJPEG_HEIGHT = 480
MJPEG_FPS = 15
MJPEG_QUALITY = 80 # JPEG quality (0-100)
GST_PIPELINE = (
"libcamerasrc ! "
"video/x-raw,width=640,height=480,framerate=15/1 ! "
"videoconvert ! "
"x264enc tune=zerolatency bitrate=1000 ! "
"rtph264pay config-interval=1 pt=96 ! "
f"udpsink host={JETSON_HOST} port={JETSON_PORT}"
)
# OpenCV capture pipeline for MJPEG (reads raw frames from libcamera)
CV_PIPELINE = (
"libcamerasrc ! "
"video/x-raw,width=640,height=480,framerate=15/1 ! "
"videoconvert ! "
"video/x-raw,format=BGR ! "
"appsink drop=true max-buffers=2"
)
# --- Logging ----------------------------------------------------------------- # --- Logging -----------------------------------------------------------------
@ -50,134 +40,196 @@ logging.basicConfig(
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# --- GStreamer RTP thread ----------------------------------------------------- # --- Pipeline builder --------------------------------------------------------
_gst_proc: subprocess.Popen | None = None def _build_pipeline(cfg: dict, encoder: str) -> str:
_gst_lock = threading.Lock() w, h, fps = cfg["width"], cfg["height"], cfg["fps"]
host, port = cfg["jetson_host"], cfg["jetson_port"]
sink = f"rtph264pay config-interval=1 pt=96 ! udpsink host={host} port={port}"
def _gst_worker() -> None: if encoder == "hw":
"""Run GStreamer RTP emitter; restart on failure.""" bitrate_bps = cfg["bitrate_kbps"] * 1000
global _gst_proc # NV12 format avoids a CPU color-conversion step before v4l2convert
cmd = ["gst-launch-1.0", "-e"] + GST_PIPELINE.split() # level=(string)3.2 is required — default level 1.0 caps at 176x144
while True: return (
log.info("GStreamer RTP: starting → %s:%s", JETSON_HOST, JETSON_PORT) f"libcamerasrc ! "
try: f"video/x-raw,width={w},height={h},format=NV12,framerate={fps}/1 ! "
proc = subprocess.Popen( f"v4l2convert ! "
cmd, f"v4l2h264enc video-bitrate={bitrate_bps} "
stdout=subprocess.DEVNULL, f'extra-controls="controls,repeat_sequence_header=1" ! '
stderr=subprocess.PIPE, f"video/x-h264,level=(string)3.2 ! "
) f"h264parse ! "
with _gst_lock: f"{sink}"
_gst_proc = proc
_, stderr = proc.communicate()
rc = proc.returncode
if stderr:
log.warning("GStreamer stderr: %s", stderr.decode(errors="replace").strip())
log.warning("GStreamer exited with code %d — restarting in 3 s", rc)
except Exception as exc:
log.error("GStreamer launch failed: %s", exc)
time.sleep(3)
def start_rtp_thread() -> threading.Thread:
t = threading.Thread(target=_gst_worker, name="gst-rtp", daemon=True)
t.start()
return t
# --- MJPEG capture & streaming -----------------------------------------------
_cap: cv2.VideoCapture | None = None
_frame_lock = threading.Lock()
_latest_frame: bytes | None = None
def _capture_worker() -> None:
"""Continuously grab frames from libcamera via GStreamer appsink."""
global _cap, _latest_frame
log.info("MJPEG capture: opening libcamerasrc pipeline")
cap = cv2.VideoCapture(CV_PIPELINE, cv2.CAP_GSTREAMER)
if not cap.isOpened():
log.error("Failed to open capture pipeline — MJPEG unavailable")
return
_cap = cap
log.info("MJPEG capture: pipeline open")
while True:
ok, frame = cap.read()
if not ok:
log.warning("MJPEG capture: frame grab failed, retrying…")
time.sleep(0.1)
continue
_, jpg = cv2.imencode(
".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, MJPEG_QUALITY]
) )
with _frame_lock: # sw
_latest_frame = jpg.tobytes() return (
f"libcamerasrc ! "
f"video/x-raw,width={w},height={h},framerate={fps}/1 ! "
f"videoconvert ! "
f"x264enc tune=zerolatency bitrate={cfg['bitrate_kbps']} ! "
f"{sink}"
)
# --- GStreamer process manager -----------------------------------------------
class GstManager:
def __init__(self) -> None:
self._cfg: dict = dict(DEFAULTS)
self._lock = threading.Lock()
self._proc: subprocess.Popen | None = None
self._restart_event = threading.Event()
self._shutdown = threading.Event()
self._thread: threading.Thread | None = None
self.encoder_active: str | None = None
# Public API
def start(self) -> None:
self._thread = threading.Thread(target=self._worker, name="gst-rtp", daemon=True)
self._thread.start()
def stop(self) -> None:
self._shutdown.set()
self._terminate()
def restart_with(self, patch: dict) -> None:
with self._lock:
self._cfg.update(patch)
self._terminate()
def is_alive(self) -> bool:
with self._lock:
return self._proc is not None and self._proc.poll() is None
def get_cfg(self) -> dict:
with self._lock:
return dict(self._cfg)
# Internal
def _terminate(self) -> None:
with self._lock:
proc = self._proc
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
def _launch(self, encoder: str) -> "subprocess.Popen | None":
with self._lock:
cfg = dict(self._cfg)
pipeline = _build_pipeline(cfg, encoder)
cmd = ["gst-launch-1.0", "-e"] + shlex.split(pipeline)
log.info("GStreamer [%s] pipeline: %s", encoder, pipeline)
try:
return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
except FileNotFoundError:
log.error("gst-launch-1.0 not found in PATH")
return None
@staticmethod
def _drain_stderr(proc: "subprocess.Popen") -> None:
for line in proc.stderr:
stripped = line.rstrip()
if stripped:
log.debug("gst: %s", stripped)
def _worker(self) -> None:
with self._lock:
pref = self._cfg.get("encoder", "auto")
if pref == "hw":
queue = ["hw"]
elif pref == "sw":
queue = ["sw"]
else:
queue = ["hw", "sw"]
while not self._shutdown.is_set():
for enc in queue:
proc = self._launch(enc)
if proc is None:
continue
with self._lock:
self._proc = proc
self.encoder_active = enc
log.info("RTP stream running (encoder=%s)", enc)
stderr_t = threading.Thread(target=self._drain_stderr, args=(proc,), daemon=True)
stderr_t.start()
proc.wait()
rc = proc.returncode
if self._shutdown.is_set():
return
log.warning("GStreamer [%s] exited rc=%d", enc, rc)
if enc == "hw" and pref == "auto":
log.warning("Hardware encoder failed — permanent fallback to x264enc")
queue = ["sw"]
break # restart outer while-loop with updated queue
if not self._shutdown.is_set():
log.info("Restarting in 3 s…")
time.sleep(3)
def start_capture_thread() -> threading.Thread: _gst = GstManager()
t = threading.Thread(target=_capture_worker, name="mjpeg-cap", daemon=True)
t.start()
return t
def _mjpeg_generator():
boundary = b"--frame\r\nContent-Type: image/jpeg\r\n\r\n"
while True:
with _frame_lock:
frame = _latest_frame
if frame is None:
time.sleep(0.05)
continue
yield boundary + frame + b"\r\n"
time.sleep(1.0 / MJPEG_FPS)
# --- Flask app --------------------------------------------------------------- # --- Flask app ---------------------------------------------------------------
app = Flask(__name__) app = Flask(__name__)
@app.route("/")
def index():
return (
"<html><body>"
"<h2>WireClaw Pi Zero — camera feed</h2>"
'<img src="/stream" />'
"</body></html>"
)
@app.route("/stream")
def stream():
return Response(
_mjpeg_generator(),
mimetype="multipart/x-mixed-replace; boundary=frame",
)
@app.route("/health") @app.route("/health")
def health(): def health():
with _frame_lock: alive = _gst.is_alive()
has_frame = _latest_frame is not None return jsonify({
with _gst_lock: "status": "ok" if alive else "degraded",
gst_alive = _gst_proc is not None and _gst_proc.poll() is None "rtp": alive,
status = "ok" if (has_frame and gst_alive) else "degraded" "encoder": _gst.encoder_active,
return {"status": status, "mjpeg": has_frame, "rtp": gst_alive} })
@app.route("/settings", methods=["GET"])
def settings_get():
return jsonify(_gst.get_cfg())
@app.route("/settings", methods=["POST"])
def settings_post():
data = request.get_json(force=True, silent=True) or {}
allowed = {"jetson_host", "jetson_port", "width", "height", "fps", "bitrate_kbps", "encoder"}
patch = {k: v for k, v in data.items() if k in allowed}
if not patch:
return jsonify({"error": "no valid fields", "allowed": sorted(allowed)}), 400
_gst.restart_with(patch)
return jsonify({"status": "restarting", "applied": patch})
# --- Shutdown ----------------------------------------------------------------
def _on_signal(sig, _frame) -> None:
log.info("Signal %d — shutting down", sig)
_gst.stop()
os._exit(0)
# --- Entrypoint -------------------------------------------------------------- # --- Entrypoint --------------------------------------------------------------
if __name__ == "__main__": if __name__ == "__main__":
start_rtp_thread() signal.signal(signal.SIGTERM, _on_signal)
start_capture_thread() signal.signal(signal.SIGINT, _on_signal)
# Give the pipelines a moment to initialise before accepting connections _gst.start()
time.sleep(2) time.sleep(1)
log.info("Flask MJPEG server starting on port %d", MJPEG_PORT) log.info("Flask API on 0.0.0.0:%d", FLASK_PORT)
app.run(host="0.0.0.0", port=MJPEG_PORT, threaded=True) app.run(host="0.0.0.0", port=FLASK_PORT, threaded=True)