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:
45
CLAUDE.md
45
CLAUDE.md
@ -1,25 +1,50 @@
|
||||
# WireClaw — Pi Zero 2W
|
||||
|
||||
## Contexte
|
||||
Composant vidéo du drone d'inspection WireClaw.
|
||||
Caméra OV5647 (CSI) → GStreamer RTP H264 → Jetson
|
||||
## Hardware
|
||||
- Modèle : Raspberry Pi Zero 2W
|
||||
- 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
|
||||
- OS : Raspberry Pi OS Bookworm Lite
|
||||
- Caméra : OV5647, libcamerasrc (pas v4l2src)
|
||||
- Encodage : x264enc tune=zerolatency bitrate=1000
|
||||
- Transport : RTP H264 pt=96 UDP port 5600
|
||||
- Caméra : libcamerasrc OBLIGATOIRE (Bookworm, pas v4l2src)
|
||||
- Encodage : v4l2h264enc hardware (défaut) → x264enc software (fallback auto)
|
||||
- omxh264enc : non disponible sur Bookworm 64-bit (OpenMAX supprimé)
|
||||
- Transport : RTP H264 pt=96 UDP
|
||||
- Flask : API légère port 5000 (health + settings uniquement, pas de MJPEG local)
|
||||
|
||||
## Réseau
|
||||
- Dev Freebox : Pi Zero 192.168.1.132 → Jetson 192.168.1.84
|
||||
- Hotspot WireClaw : Pi Zero DHCP → Jetson 10.42.0.1
|
||||
- Dev Freebox : 192.168.1.132 → Jetson 192.168.1.84:5600
|
||||
- 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 ! \
|
||||
video/x-raw,width=640,height=480,framerate=15/1 ! \
|
||||
videoconvert ! x264enc tune=zerolatency bitrate=1000 ! \
|
||||
rtph264pay config-interval=1 pt=96 ! \
|
||||
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
|
||||
git.syoul.fr/nicoboy/wireclaw-pizero
|
||||
330
camera_server.py
330
camera_server.py
@ -1,46 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WireClaw — Pi Zero 2W camera server
|
||||
- MJPEG local stream on port 5000 (Flask)
|
||||
- RTP H264 stream to Jetson 192.168.1.84:5600 (GStreamer thread)
|
||||
Flask API port 5000 : /health /settings
|
||||
RTP H264 → Jetson via GStreamer subprocess
|
||||
|
||||
Encoder strategy: v4l2h264enc hardware (default) → x264enc software (auto-fallback)
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
|
||||
import cv2
|
||||
from flask import Flask, Response
|
||||
from flask import Flask, jsonify, request
|
||||
|
||||
# --- Configuration -----------------------------------------------------------
|
||||
|
||||
JETSON_HOST = "192.168.1.84"
|
||||
JETSON_PORT = 5600
|
||||
DEFAULTS: dict = {
|
||||
"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
|
||||
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"
|
||||
)
|
||||
FLASK_PORT = 5000
|
||||
|
||||
# --- Logging -----------------------------------------------------------------
|
||||
|
||||
@ -50,134 +40,196 @@ logging.basicConfig(
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# --- GStreamer RTP thread -----------------------------------------------------
|
||||
# --- Pipeline builder --------------------------------------------------------
|
||||
|
||||
_gst_proc: subprocess.Popen | None = None
|
||||
_gst_lock = threading.Lock()
|
||||
def _build_pipeline(cfg: dict, encoder: str) -> str:
|
||||
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:
|
||||
"""Run GStreamer RTP emitter; restart on failure."""
|
||||
global _gst_proc
|
||||
cmd = ["gst-launch-1.0", "-e"] + GST_PIPELINE.split()
|
||||
while True:
|
||||
log.info("GStreamer RTP: starting → %s:%s", JETSON_HOST, JETSON_PORT)
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
with _gst_lock:
|
||||
_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]
|
||||
if encoder == "hw":
|
||||
bitrate_bps = cfg["bitrate_kbps"] * 1000
|
||||
# NV12 format avoids a CPU color-conversion step before v4l2convert
|
||||
# level=(string)3.2 is required — default level 1.0 caps at 176x144
|
||||
return (
|
||||
f"libcamerasrc ! "
|
||||
f"video/x-raw,width={w},height={h},format=NV12,framerate={fps}/1 ! "
|
||||
f"v4l2convert ! "
|
||||
f"v4l2h264enc video-bitrate={bitrate_bps} "
|
||||
f'extra-controls="controls,repeat_sequence_header=1" ! '
|
||||
f"video/x-h264,level=(string)3.2 ! "
|
||||
f"h264parse ! "
|
||||
f"{sink}"
|
||||
)
|
||||
with _frame_lock:
|
||||
_latest_frame = jpg.tobytes()
|
||||
# sw
|
||||
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:
|
||||
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)
|
||||
|
||||
_gst = GstManager()
|
||||
|
||||
# --- Flask app ---------------------------------------------------------------
|
||||
|
||||
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")
|
||||
def health():
|
||||
with _frame_lock:
|
||||
has_frame = _latest_frame is not None
|
||||
with _gst_lock:
|
||||
gst_alive = _gst_proc is not None and _gst_proc.poll() is None
|
||||
status = "ok" if (has_frame and gst_alive) else "degraded"
|
||||
return {"status": status, "mjpeg": has_frame, "rtp": gst_alive}
|
||||
alive = _gst.is_alive()
|
||||
return jsonify({
|
||||
"status": "ok" if alive else "degraded",
|
||||
"rtp": alive,
|
||||
"encoder": _gst.encoder_active,
|
||||
})
|
||||
|
||||
|
||||
@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 --------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
start_rtp_thread()
|
||||
start_capture_thread()
|
||||
signal.signal(signal.SIGTERM, _on_signal)
|
||||
signal.signal(signal.SIGINT, _on_signal)
|
||||
|
||||
# Give the pipelines a moment to initialise before accepting connections
|
||||
time.sleep(2)
|
||||
_gst.start()
|
||||
time.sleep(1)
|
||||
|
||||
log.info("Flask MJPEG server starting on port %d", MJPEG_PORT)
|
||||
app.run(host="0.0.0.0", port=MJPEG_PORT, threaded=True)
|
||||
log.info("Flask API on 0.0.0.0:%d", FLASK_PORT)
|
||||
app.run(host="0.0.0.0", port=FLASK_PORT, threaded=True)
|
||||
|
||||
Reference in New Issue
Block a user