452 lines
15 KiB
Python
452 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Réception flux H.264 UDP depuis Pi Zero (port 5600) via GStreamer.
|
|
Inférence YOLOv8n sur chaque frame, exposition MJPEG annoté sur port 5601.
|
|
Thread sideband UDP 5602 : reçoit JSON Pi Zero (résolution, fps, temp, CPU...)
|
|
et affiche un overlay semi-transparent sur chaque frame.
|
|
|
|
Fallback automatique vers webcam locale (cv2.VideoCapture(0)) si le flux
|
|
UDP n'est pas disponible, pour tester sans le Pi Zero.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import cv2
|
|
from ultralytics import YOLO
|
|
|
|
MJPEG_PORT = 5601
|
|
SIDEBAND_PORT = 5602
|
|
MJPEG_QUALITY = 70
|
|
MODEL_PATH = Path(__file__).parent / "yolov8n.pt"
|
|
|
|
# Pipeline GStreamer : RTP H.264 UDP → BGR frames
|
|
# Deux udpsrc (IPv4 0.0.0.0 + IPv6 ::) fusionnés par funnel : un socket IPv6 "::"
|
|
# seul ne reçoit pas les paquets IPv4 sur ce Jetson (IPV6_V6ONLY actif, pas de
|
|
# dual-stack), donc on écoute les deux familles en parallèle sur le même port.
|
|
# IPv4 : réseau local Freebox (dev) — IPv6 : Mycelium (prod, 4G).
|
|
GSTREAMER_PIPELINE = (
|
|
"funnel name=f "
|
|
"! application/x-rtp,payload=96 "
|
|
"! rtph264depay "
|
|
"! avdec_h264 "
|
|
"! videoconvert "
|
|
"! video/x-raw,format=BGR "
|
|
"! appsink drop=true max-buffers=1 "
|
|
"udpsrc address=0.0.0.0 port=5600 ! f. "
|
|
"udpsrc address=:: port=5600 ! f."
|
|
)
|
|
|
|
# Frame annotée partagée entre le thread de capture et le serveur MJPEG
|
|
_frame_lock = threading.Lock()
|
|
_current_frame = None
|
|
_running = True
|
|
|
|
# Source active partagée entre le watcher GStreamer et la boucle de capture :
|
|
# "synthetic" par défaut, bascule vers "gstreamer" dès qu'une frame réelle arrive.
|
|
_source_lock = threading.Lock()
|
|
_active_cap: cv2.VideoCapture | None = None
|
|
_active_source = "synthetic"
|
|
|
|
# Dernières stats Pi Zero reçues via UDP 5602 (thread-safe)
|
|
_stats_lock = threading.Lock()
|
|
_pi_stats: dict | None = None
|
|
|
|
# RSSI du flux vidéo dédié WFB-NG reçu via API JSON 127.0.0.1:8103
|
|
_rssi_lock = threading.Lock()
|
|
_rssi_data: dict | None = None # {"rssi_avg": -63, "snr_avg": 17}
|
|
_band_label: str = "?" # "2.4GHz" ou "5GHz", déterminé au démarrage
|
|
RSSI_API_PORT = 8103
|
|
|
|
|
|
def sideband_loop() -> None:
|
|
"""Écoute UDP 5602, stocke le dernier JSON Pi Zero dans _pi_stats."""
|
|
global _pi_stats
|
|
# Liaison point à point exclusivement IPv4 (tunnel WFB-NG) — plus de
|
|
# dual-stack IPv6/Mycelium, qui ne recevait pas fiablement le trafic
|
|
# provenant de l'interface tunnel point-à-point.
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
sock.settimeout(1.0)
|
|
sock.bind(("0.0.0.0", SIDEBAND_PORT))
|
|
print(f"[SIDEBAND] Écoute UDP sur port {SIDEBAND_PORT}")
|
|
while _running:
|
|
try:
|
|
data, _ = sock.recvfrom(4096)
|
|
payload = json.loads(data.decode("utf-8"))
|
|
with _stats_lock:
|
|
_pi_stats = payload
|
|
except socket.timeout:
|
|
continue
|
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
print(f"[SIDEBAND] JSON invalide : {e}")
|
|
sock.close()
|
|
print("[SIDEBAND] Arrêt.")
|
|
|
|
|
|
def read_band_label() -> str:
|
|
"""Lit wifi_channel depuis /etc/wifibroadcast.cfg (local, fichier
|
|
système) pour déterminer la bande. Lu une seule fois au démarrage
|
|
— la bande ne change qu'via switch_band.sh, qui redémarre ce
|
|
service de toute façon."""
|
|
try:
|
|
with open("/etc/wifibroadcast.cfg") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line.startswith("wifi_channel"):
|
|
# ex: "wifi_channel = 6 # commentaire"
|
|
val = line.split("=", 1)[1].split("#")[0].strip()
|
|
ch = int(val)
|
|
return "2.4GHz" if ch <= 14 else "5GHz"
|
|
except (FileNotFoundError, ValueError, IndexError) as e:
|
|
print(f"[BAND] Impossible de lire wifi_channel : {e}")
|
|
return "?"
|
|
|
|
|
|
def rssi_loop() -> None:
|
|
"""Écoute l'API JSON wfb-ng locale (127.0.0.1:8103), extrait le RSSI
|
|
du flux vidéo dédié (id="video rx") et le stocke pour l'overlay.
|
|
Reconnexion automatique en cas de coupure (wfb-server-gs redémarré)."""
|
|
global _rssi_data
|
|
buf = ""
|
|
while _running:
|
|
try:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.settimeout(2.0)
|
|
sock.connect(("127.0.0.1", RSSI_API_PORT))
|
|
print(f"[RSSI] Connecté à l'API wfb-ng (port {RSSI_API_PORT})")
|
|
while _running:
|
|
chunk = sock.recv(8192).decode("utf-8", errors="ignore")
|
|
if not chunk:
|
|
break
|
|
buf += chunk
|
|
while "\n" in buf:
|
|
line, buf = buf.split("\n", 1)
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
d = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if d.get("id") == "video rx":
|
|
ants = d.get("rx_ant_stats") or []
|
|
if ants:
|
|
rssi_avg = sum(a.get("rssi_avg", -80) for a in ants) / len(ants)
|
|
snr_avg = sum(a.get("snr_avg", 0) for a in ants) / len(ants)
|
|
with _rssi_lock:
|
|
_rssi_data = {"rssi_avg": rssi_avg, "snr_avg": snr_avg}
|
|
sock.close()
|
|
except (ConnectionRefusedError, OSError, socket.timeout):
|
|
with _rssi_lock:
|
|
_rssi_data = None
|
|
time.sleep(2)
|
|
|
|
|
|
def draw_rssi_pyramid(frame: np.ndarray) -> np.ndarray:
|
|
"""Pyramide inversée (base large en haut, pointe en bas), 10 segments,
|
|
remplissage proportionnel au RSSI, code couleur vert/jaune/rouge.
|
|
Positionnée en haut à droite du frame."""
|
|
with _rssi_lock:
|
|
rssi = _rssi_data
|
|
|
|
h, w = frame.shape[:2]
|
|
N = 6
|
|
pyr_w = 49
|
|
pyr_h = 54
|
|
gap = 2
|
|
margin_top = 6
|
|
margin_right = 10
|
|
min_bottom_ratio = 0.35
|
|
x_center = w - margin_right - pyr_w // 2
|
|
y_top = margin_top
|
|
|
|
out = frame
|
|
|
|
if rssi is None:
|
|
fill_count = 0
|
|
color = (128, 128, 128)
|
|
else:
|
|
rssi_val = rssi["rssi_avg"]
|
|
fill_ratio = max(0.0, min(1.0, (rssi_val + 75) / 25))
|
|
fill_count = round(fill_ratio * N)
|
|
if rssi_val >= -60:
|
|
color = (0, 200, 0)
|
|
elif rssi_val >= -70:
|
|
color = (0, 220, 220)
|
|
else:
|
|
color = (0, 0, 220)
|
|
|
|
row_h = (pyr_h - gap * (N - 1)) / N
|
|
|
|
for i in range(N):
|
|
y0 = y_top + i * (row_h + gap)
|
|
y1 = y0 + row_h
|
|
top_frac = 1 - i / N
|
|
bot_frac = max(min_bottom_ratio, 1 - (i + 1) / N)
|
|
if i == N - 1:
|
|
top_frac = max(min_bottom_ratio, top_frac)
|
|
top_half_w = (pyr_w / 2) * top_frac
|
|
bot_half_w = (pyr_w / 2) * bot_frac
|
|
|
|
pts = np.array([
|
|
[x_center - top_half_w, y0],
|
|
[x_center + top_half_w, y0],
|
|
[x_center + bot_half_w, y1],
|
|
[x_center - bot_half_w, y1],
|
|
], dtype=np.int32)
|
|
|
|
filled = i >= (N - fill_count)
|
|
seg_color = color if filled else (60, 60, 60)
|
|
cv2.fillConvexPoly(out, pts, seg_color, cv2.LINE_AA)
|
|
cv2.polylines(out, [pts], True, (20, 20, 20), 1, cv2.LINE_AA)
|
|
|
|
if rssi is not None:
|
|
label = f"{rssi['rssi_avg']:.0f}dBm"
|
|
font = cv2.FONT_HERSHEY_SIMPLEX
|
|
(tw, th), _ = cv2.getTextSize(label, font, 0.45, 1)
|
|
tx = x_center - tw // 2
|
|
ty = y_top + pyr_h + th + 4
|
|
cv2.putText(out, label, (tx, ty), font, 0.45, (255, 255, 255), 1, cv2.LINE_AA)
|
|
|
|
return out
|
|
|
|
|
|
def draw_overlay(frame: np.ndarray) -> np.ndarray:
|
|
"""Dessine l'overlay stats Pi Zero (fond noir semi-transparent) sur le frame."""
|
|
global _band_label
|
|
with _stats_lock:
|
|
stats = _pi_stats
|
|
if stats is None:
|
|
return frame
|
|
|
|
w_res = stats.get("width", "?")
|
|
h_res = stats.get("height", "?")
|
|
fps = stats.get("fps", "?")
|
|
enc = stats.get("encoder", "?")
|
|
temp = stats.get("temp_c", "?")
|
|
cpu = stats.get("cpu_pct", "?")
|
|
throttled = stats.get("throttled", False)
|
|
|
|
line1 = f"{w_res}x{h_res} | {fps}fps | {enc} | {temp}C"
|
|
line2 = f"CPU {cpu}% | {_band_label}"
|
|
if throttled:
|
|
line2 += " | THROTTLED"
|
|
|
|
font = cv2.FONT_HERSHEY_SIMPLEX
|
|
scale = 0.55
|
|
thickness = 1
|
|
margin = 6
|
|
|
|
(tw1, th1), _ = cv2.getTextSize(line1, font, scale, thickness)
|
|
(tw2, th2), _ = cv2.getTextSize(line2, font, scale, thickness)
|
|
box_w = max(tw1, tw2) + margin * 2
|
|
box_h = th1 + th2 + margin * 3
|
|
|
|
# Copie pour ne pas altérer le frame original (réutilisé en mode synthétique)
|
|
out = frame.copy()
|
|
bg = out.copy()
|
|
cv2.rectangle(bg, (0, 0), (box_w, box_h), (0, 0, 0), -1)
|
|
cv2.addWeighted(bg, 0.55, out, 0.45, 0, out)
|
|
|
|
cv2.putText(out, line1, (margin, margin + th1),
|
|
font, scale, (255, 255, 255), thickness, cv2.LINE_AA)
|
|
cv2.putText(out, line2, (margin, margin * 2 + th1 + th2),
|
|
font, scale, (255, 255, 255), thickness, cv2.LINE_AA)
|
|
|
|
return out
|
|
|
|
|
|
def gstreamer_watcher() -> None:
|
|
"""
|
|
Thread de fond permanent : tant que la source active n'est pas "gstreamer",
|
|
tente d'ouvrir le pipeline et de lire une première frame (le read() bloque
|
|
naturellement jusqu'à ce que des paquets valides arrivent, ce qui remplace
|
|
l'ancien timeout fixe). Bascule la source active dès qu'une frame arrive,
|
|
y compris après un démarrage en mode synthétique ou une perte de flux.
|
|
"""
|
|
global _active_cap, _active_source
|
|
|
|
while _running:
|
|
with _source_lock:
|
|
already_connected = _active_source == "gstreamer"
|
|
if already_connected:
|
|
time.sleep(1)
|
|
continue
|
|
|
|
cap = cv2.VideoCapture(GSTREAMER_PIPELINE, cv2.CAP_GSTREAMER)
|
|
if not cap.isOpened():
|
|
cap.release()
|
|
time.sleep(2)
|
|
continue
|
|
|
|
ret, _ = cap.read() # bloque jusqu'à la première frame valide
|
|
if not ret:
|
|
cap.release()
|
|
time.sleep(2)
|
|
continue
|
|
|
|
print("[CAM] Flux GStreamer UDP reçu.")
|
|
with _source_lock:
|
|
_active_cap = cap
|
|
_active_source = "gstreamer"
|
|
|
|
|
|
def _make_waiting_frame() -> np.ndarray:
|
|
"""Génère un frame noir 640x480 avec message d'attente."""
|
|
frame = np.zeros((480, 640, 3), dtype=np.uint8)
|
|
cv2.putText(frame, "En attente flux Pi Zero...", (50, 220),
|
|
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2)
|
|
cv2.putText(frame, "UDP 5600 non disponible", (50, 260),
|
|
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (100, 100, 255), 2)
|
|
return frame
|
|
|
|
|
|
def capture_loop(model: YOLO) -> None:
|
|
"""
|
|
Lit les frames de la source active (gstreamer ou synthétique), passe dans
|
|
YOLOv8, stocke le résultat annoté. La source est mise à jour en tâche de
|
|
fond par gstreamer_watcher() : ce thread n'a pas besoin d'attendre le flux
|
|
réel au démarrage, il bascule dessus dès qu'il devient disponible.
|
|
"""
|
|
global _current_frame, _running, _active_cap, _active_source
|
|
|
|
print("[CAM] Démarrage en mode synthétique — connexion GStreamer en tâche de fond.")
|
|
synthetic_frame = _make_waiting_frame()
|
|
|
|
while _running:
|
|
with _source_lock:
|
|
cap, source = _active_cap, _active_source
|
|
|
|
if source == "synthetic":
|
|
frame = synthetic_frame
|
|
time.sleep(1 / 10) # 10 fps synthétiques
|
|
else:
|
|
ret, frame = cap.read()
|
|
if not ret:
|
|
print("[CAM] Flux GStreamer perdu, retour en mode synthétique.")
|
|
cap.release()
|
|
with _source_lock:
|
|
_active_cap = None
|
|
_active_source = "synthetic"
|
|
continue
|
|
|
|
# Inférence YOLOv8 — skip en mode synthétique (frame vide sans objet)
|
|
if model is not None and source != "synthetic":
|
|
results = model(frame, verbose=False)
|
|
annotated = results[0].plot()
|
|
else:
|
|
annotated = frame
|
|
|
|
# Overlay stats Pi Zero (ne modifie pas annotated en place)
|
|
annotated = draw_overlay(annotated)
|
|
|
|
# Overlay RSSI pyramide inversée
|
|
annotated = draw_rssi_pyramid(annotated)
|
|
|
|
with _frame_lock:
|
|
_current_frame = annotated
|
|
|
|
with _source_lock:
|
|
if _active_cap is not None:
|
|
_active_cap.release()
|
|
print("[CAM] Capture terminée.")
|
|
|
|
|
|
class MJPEGHandler(BaseHTTPRequestHandler):
|
|
def log_message(self, format, *args):
|
|
pass # Silencieux pour ne pas polluer les logs WireClaw
|
|
|
|
def do_GET(self):
|
|
if self.path != "/":
|
|
self.send_error(404)
|
|
return
|
|
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
|
self.end_headers()
|
|
|
|
while _running:
|
|
with _frame_lock:
|
|
frame = _current_frame
|
|
|
|
if frame is None:
|
|
time.sleep(0.05)
|
|
continue
|
|
|
|
ok, jpeg = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, MJPEG_QUALITY])
|
|
if not ok:
|
|
continue
|
|
data = jpeg.tobytes()
|
|
|
|
try:
|
|
self.wfile.write(
|
|
f"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: {len(data)}\r\n\r\n".encode()
|
|
+ data
|
|
+ b"\r\n"
|
|
)
|
|
self.wfile.flush()
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
break
|
|
|
|
time.sleep(1 / 30)
|
|
|
|
|
|
def main():
|
|
global _running, _band_label
|
|
|
|
# Détecte la bande radio au démarrage (depuis le fichier système)
|
|
_band_label = read_band_label()
|
|
print(f"[BAND] Bande détectée : {_band_label}")
|
|
|
|
parser = argparse.ArgumentParser(description="WireClaw — réception caméra + YOLOv8")
|
|
parser.add_argument("--no-yolo", action="store_true", help="Désactiver l'inférence YOLOv8 (flux brut)")
|
|
args = parser.parse_args()
|
|
|
|
print(f"[CAM] Chargement modèle YOLOv8 : {MODEL_PATH}")
|
|
model = YOLO(str(MODEL_PATH))
|
|
|
|
if args.no_yolo:
|
|
# Mode flux brut : remplace l'inférence par une copie directe
|
|
model = None
|
|
print("[CAM] Mode flux brut (YOLOv8 désactivé)")
|
|
|
|
# Thread sideband : stats Pi Zero via UDP 5602
|
|
ts = threading.Thread(target=sideband_loop, daemon=True)
|
|
ts.start()
|
|
|
|
# Thread RSSI : lecture API JSON wfb-ng pour l'overlay pyramide inversée
|
|
tr = threading.Thread(target=rssi_loop, daemon=True)
|
|
tr.start()
|
|
|
|
# Thread de fond : connexion/reconnexion continue au flux GStreamer réel
|
|
tg = threading.Thread(target=gstreamer_watcher, daemon=True)
|
|
tg.start()
|
|
|
|
# Thread de capture + inférence (démarre immédiatement en mode synthétique)
|
|
t = threading.Thread(target=capture_loop, args=(model,), daemon=True)
|
|
t.start()
|
|
|
|
server = ThreadingHTTPServer(("0.0.0.0", MJPEG_PORT), MJPEGHandler)
|
|
print(f"[CAM] MJPEG stream sur http://0.0.0.0:{MJPEG_PORT}/")
|
|
print(f"[CAM] Ouvrir dans un navigateur ou VLC : http://192.168.1.84:{MJPEG_PORT}/")
|
|
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
_running = False
|
|
server.shutdown()
|
|
print("[CAM] Arrêt.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|