Pet-Monitor/pet_speed.py
abraham 60002a90a7 Usar la camara IP Tapo por RTSP en lugar de la camara CSI
- pet_speed.py: ingesta RTSP en hilo aparte, que descarta fotogramas antiguos y se reconecta con backoff exponencial; escalado letterbox a 640x640 con desmapeo de cajas y centroide al fotograma original; configuracion por .env (con CAMERA_STREAM y CAMERA_RTSP_URL opcionales); cierre limpio con SIGINT y SIGTERM.

- Readme.md: seccion de la camara Tapo (cuenta de camara, streams, rendimiento medido); se corrige Hailo-8L por Hailo-8, que es lo que reporta hailortcli.

- .gitignore: excluir los logs del runtime Hailo.
2026-10-01 16:17:18 +02:00

362 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Pet Monitor - Visión por computador con cámara IP Tapo (RTSP) + NPU Hailo-8.
Sustituye la cámara CSI interna de la Raspberry Pi por la cámara Tapo de red:
- Ingesta RTSP en un hilo aparte, que descarta los frames viejos y expone
siempre el más reciente (evita la latencia acumulada típica de RTSP).
- Reconexión automática con backoff si la cámara se cae o cambia de IP.
- Inferencia YOLOv8s sobre la NPU Hailo (HEF del sistema).
- Detección de gato/perro (clases COCO 15 y 16) y alerta de carrera según
el desplazamiento del centroide entre frames consecutivos.
Variables en .env (junto a este script):
CAMERA_IP, CAMERA_USER, CAMERA_PW (obligatorias: cuenta de cámara Tapo)
CAMERA_STREAM (opcional: stream2 = 720p, stream1 = 2K)
CAMERA_RTSP_URL (opcional: URL RTSP completa, tiene prioridad)
Ejecución: uv run pet_speed.py
"""
import math
import os
import signal
import threading
import time
import urllib.parse
import cv2
import numpy as np
from picamera2.devices.hailo import Hailo, hailo_architecture
# --- Configuración -------------------------------------------------------
# Clases COCO: 15 = Cat (Gato), 16 = Dog (Perro)
TARGET_CLASSES = {15: "Gato", 16: "Perro"}
CONF_THRESHOLD = 0.45 # Confianza mínima para considerar una detección
SPEED_THRESHOLD = 0.30 # Velocidad normalizada por segundo (fracción del frame)
DEFAULT_STREAM = "stream2" # stream1 = 2304x1296@15, stream2 = 1280x720@15
STATS_INTERVAL = 10.0 # Segundos entre líneas de estadísticas
# Orden de color que espera el HEF. Los modelos de Hailo Model Zoo se compilan
# para RGB; con PET_COLOR_ORDER=bgr se invierte (útil para comparar precisión).
COLOR_ORDER = os.environ.get("PET_COLOR_ORDER", "rgb").lower()
ENV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
LETTERBOX_FILL = (114, 114, 114)
# Latencia baja en RTSP: forzar TCP evita el jitter y las pérdidas de UDP.
# (No se usa 'stimeout': fue eliminado en FFmpeg 6, que es el que trae OpenCV 4.10)
os.environ.setdefault("OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp")
# --- Utilidades ----------------------------------------------------------
def load_env(path=ENV_PATH):
"""Lee un .env sencillo (CLAVE=valor) sin dependencias externas."""
env = {}
if os.path.exists(path):
with open(path) as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
# Las variables de entorno reales tienen prioridad sobre el fichero, para
# poder sobreescribirlas desde systemd o desde una prueba puntual.
for key in ("CAMERA_IP", "CAMERA_USER", "CAMERA_PW", "CAMERA_STREAM", "CAMERA_RTSP_URL"):
if os.environ.get(key):
env[key] = os.environ[key]
return env
def mask_url(url):
"""Oculta las credenciales de la URL para poder mostrarla por consola."""
parts = urllib.parse.urlsplit(url)
if not parts.hostname:
return "***"
port = f":{parts.port}" if parts.port else ""
return f"{parts.scheme}://***@{parts.hostname}{port}{parts.path}"
def build_rtsp_url(env, stream):
"""Construye la URL RTSP de la Tapo, escapando usuario y contraseña."""
if env.get("CAMERA_RTSP_URL"):
return env["CAMERA_RTSP_URL"]
ip = env.get("CAMERA_IP")
user = env.get("CAMERA_USER")
password = env.get("CAMERA_PW")
missing = [k for k, v in (("CAMERA_IP", ip), ("CAMERA_USER", user), ("CAMERA_PW", password)) if not v]
if missing:
raise SystemExit(f"[ERROR] Faltan variables en {ENV_PATH}: {', '.join(missing)}")
return "rtsp://%s:%s@%s:554/%s" % (
urllib.parse.quote(user, safe=""),
urllib.parse.quote(password, safe=""),
ip,
stream,
)
def get_hailo_model_path():
"""Elige el HEF de YOLOv8s que corresponde a la arquitectura detectada."""
arch = hailo_architecture()
if arch == "HAILO8":
candidates = ["/usr/share/hailo-models/yolov8s_h8.hef", "/usr/share/hailo-models/yolov8s_h8l.hef"]
else:
candidates = ["/usr/share/hailo-models/yolov8s_h8l.hef", "/usr/share/hailo-models/yolov8s_h8.hef"]
for path in candidates:
if os.path.exists(path):
return path
raise FileNotFoundError("No se encontró ningún modelo YOLOv8 HEF en /usr/share/hailo-models/")
def letterbox(frame, width, height):
"""
Reescala manteniendo la relación de aspecto y rellena hasta width x height.
Devuelve (imagen, escala, pad_x, pad_y) para poder deshacer la transformación.
"""
src_h, src_w = frame.shape[:2]
scale = min(width / src_w, height / src_h)
new_w, new_h = int(round(src_w * scale)), int(round(src_h * scale))
resized = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
pad_x, pad_y = (width - new_w) // 2, (height - new_h) // 2
padded = cv2.copyMakeBorder(
resized, pad_y, height - new_h - pad_y, pad_x, width - new_w - pad_x,
cv2.BORDER_CONSTANT, value=LETTERBOX_FILL,
)
return padded, scale, pad_x, pad_y
def box_to_source(box, scale, pad_x, pad_y, src_w, src_h):
"""
Convierte una caja normalizada del modelo (letterbox) a píxeles del frame
original. Hailo NMS devuelve [ymin, xmin, ymax, xmax, confidence].
"""
ymin, xmin, ymax, xmax = box[:4]
model_w = src_w * scale + 2 * pad_x
model_h = src_h * scale + 2 * pad_y
x1 = (xmin * model_w - pad_x) / scale
x2 = (xmax * model_w - pad_x) / scale
y1 = (ymin * model_h - pad_y) / scale
y2 = (ymax * model_h - pad_y) / scale
clip_x = lambda v: float(min(max(v, 0.0), src_w)) # noqa: E731
clip_y = lambda v: float(min(max(v, 0.0), src_h)) # noqa: E731
return clip_x(x1), clip_y(y1), clip_x(x2), clip_y(y2)
# --- Ingesta de vídeo ----------------------------------------------------
class RtspVideoStream:
"""
Hilo lector de RTSP: mantiene siempre el último frame disponible.
Si el bucle principal va más lento que la cámara, los frames intermedios se
descartan solos (no se encolan), así la detección trabaja sobre imagen en
vivo y no sobre un buffer con retardo creciente.
"""
def __init__(self, url, reconnect_delay=2.0, max_reconnect_delay=30.0):
self._url = url
self._reconnect_delay = reconnect_delay
self._max_reconnect_delay = max_reconnect_delay
self._lock = threading.Lock()
self._new_frame = threading.Event()
self._stop = threading.Event()
self._thread = None
self._seq = 0
self._frame = None
self._timestamp = 0.0
self.frames_read = 0
self.reconnects = 0
self.connected = False
self.capture_fps = 0.0
def start(self):
self._thread = threading.Thread(target=self._loop, name="rtsp-reader", daemon=True)
self._thread.start()
return self
def _open_capture(self):
cap = cv2.VideoCapture(self._url, cv2.CAP_FFMPEG)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
if not cap.isOpened():
cap.release()
return None
return cap
def _loop(self):
cap = None
delay = self._reconnect_delay
window_start = time.time()
window_frames = 0
while not self._stop.is_set():
if cap is None:
cap = self._open_capture()
if cap is None:
self.connected = False
print(f"[WARN] No hay stream RTSP; reintentando en {delay:.0f}s", flush=True)
self._stop.wait(delay)
delay = min(delay * 2, self._max_reconnect_delay)
self.reconnects += 1
continue
self.connected = True
delay = self._reconnect_delay
window_start, window_frames = time.time(), 0
print("[INFO] Stream RTSP conectado.", flush=True)
ok, frame = cap.read()
if not ok or frame is None:
print("[WARN] Lectura RTSP interrumpida; reconectando...", flush=True)
cap.release()
cap = None
self.connected = False
self.reconnects += 1
self._stop.wait(delay)
continue
now = time.time()
window_frames += 1
elapsed = now - window_start
if elapsed >= 2.0:
self.capture_fps = window_frames / elapsed
window_start, window_frames = now, 0
with self._lock:
self._seq += 1
self._frame = frame
self._timestamp = now
self.frames_read += 1
self._new_frame.set()
if cap is not None:
cap.release()
def wait_for_frame(self, last_seq, timeout=1.0):
"""
Bloquea hasta que haya un frame más nuevo que last_seq.
Devuelve (seq, frame, timestamp) o (last_seq, None, None) si expira.
"""
deadline = time.time() + timeout
while not self._stop.is_set() and time.time() < deadline:
with self._lock:
seq, frame, ts = self._seq, self._frame, self._timestamp
if seq > last_seq and frame is not None:
return seq, frame, ts
self._new_frame.wait(0.05)
self._new_frame.clear()
return last_seq, None, None
def stop(self):
self._stop.set()
self._new_frame.set()
if self._thread is not None:
self._thread.join(timeout=5.0)
# --- Bucle principal -----------------------------------------------------
def main():
env = load_env()
stream_name = env.get("CAMERA_STREAM", DEFAULT_STREAM)
url = build_rtsp_url(env, stream_name)
model_path = get_hailo_model_path()
print(f"[INFO] Cargando modelo Hailo: {model_path} ...")
hailo = Hailo(model_path)
model_h, model_w, _ = hailo.get_input_shape()
print(f"[INFO] Modelo cargado. Resolución de entrada: {model_w}x{model_h}")
# Ocultamos la contraseña al mostrarla por consola
print(f"[INFO] Cámara Tapo: {mask_url(url)}")
stream = RtspVideoStream(url).start()
print("[INFO] Monitorizando mascotas (Gatos y Perros)... Presiona Ctrl+C para salir.\n")
running = threading.Event()
running.set()
def handle_signal(signum, _frame):
print(f"\n[INFO] Señal {signum} recibida; deteniendo...", flush=True)
running.clear()
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
last_seq = 0
last_centroid = None
last_frame_time = 0.0
last_stats = time.time()
frames_processed = 0
inference_time = 0.0
try:
while running.is_set():
seq, frame, frame_time = stream.wait_for_frame(last_seq)
if frame is None:
continue
last_seq = seq
letterboxed, scale, pad_x, pad_y = letterbox(frame, model_w, model_h)
if COLOR_ORDER == "bgr":
model_input = np.ascontiguousarray(letterboxed)
else:
model_input = np.ascontiguousarray(cv2.cvtColor(letterboxed, cv2.COLOR_BGR2RGB))
started = time.time()
detections_by_class = hailo.run(model_input)
inference_time += time.time() - started
frames_processed += 1
src_h, src_w = frame.shape[:2]
dt = frame_time - last_frame_time if last_frame_time else 0.0
pet_found = False
for class_id, label in TARGET_CLASSES.items():
if class_id >= len(detections_by_class):
continue
for det in detections_by_class[class_id]:
confidence = det[4]
if confidence <= CONF_THRESHOLD:
continue
pet_found = True
x1, y1, x2, y2 = box_to_source(det, scale, pad_x, pad_y, src_w, src_h)
centroid = ((x1 + x2) / 2.0 / src_w, (y1 + y2) / 2.0 / src_h)
if last_centroid and dt > 0:
dist = math.hypot(centroid[0] - last_centroid[0], centroid[1] - last_centroid[1])
speed = dist / dt
if speed > SPEED_THRESHOLD:
print(f"⚠️ [ALERTA] ¡{label} corriendo! Vel: {speed:.2f} norm/s (Conf: {confidence:.2f})")
else:
print(f"🐾 [OK] {label} detectado (calmado). Vel: {speed:.2f} norm/s (Conf: {confidence:.2f})")
last_centroid = centroid
break # Evaluamos la mascota principal detectada
if pet_found:
break
if not pet_found:
last_centroid = None
last_frame_time = frame_time
now = time.time()
if now - last_stats >= STATS_INTERVAL:
avg_inf = inference_time / frames_processed * 1000 if frames_processed else 0.0
print(
f"[STATS] captura {stream.capture_fps:.1f} fps | análisis "
f"{frames_processed / (now - last_stats):.1f} fps | inferencia {avg_inf:.1f} ms "
f"| reconexiones {stream.reconnects}"
)
last_stats = now
frames_processed = 0
inference_time = 0.0
finally:
stream.stop()
hailo.close()
print("[INFO] Stream RTSP cerrado y acelerador Hailo liberado correctamente.")
if __name__ == "__main__":
main()