diff --git a/CLAUDE.md b/CLAUDE.md index 48fafaf..f07d79c 100644 --- a/CLAUDE.md +++ b/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 \ No newline at end of file diff --git a/camera_server.py b/camera_server.py index 3124ed1..1f1819a 100644 --- a/camera_server.py +++ b/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 ( - "
" - "