#!/usr/bin/env python3 """ Pet Monitor - Visión por computador con cámara IP Tapo (RTSP) + NPU Hailo. - Ingesta RTSP de vídeo en un hilo aparte, que descarta los fotogramas viejos y expone siempre el más reciente (evita la latencia acumulada típica de RTSP). - Detección de gato/perro (clases COCO 15 y 16) con YOLOv8s sobre la NPU Hailo y alerta de carrera según el desplazamiento del centroide entre fotogramas. - Escucha de ladridos sobre el audio de la propia cámara (ver pet_audio.py), en un segundo hilo y con su propia conexión RTSP. - Reconexión automática con backoff en ambos canales. 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) PET_AUDIO (opcional: 0 desactiva la escucha de ladridos) BARK_THRESHOLD, BARK_COOLDOWN (opcional: umbral y anti-repetición del ladrido) Ejecución: uv run pet_speed.py """ import math import os import signal import threading import time import cv2 import numpy as np from picamera2.devices.hailo import Hailo, hailo_architecture from pet_audio import AudioListener, BarkDetector from pet_common import build_rtsp_url, load_env, mask_url # --- 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 BARK_THRESHOLD = 0.15 # Puntuación de Bark a partir de la cual se avisa BARK_COOLDOWN = 15.0 # Segundos mínimos entre dos avisos de ladrido # 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() 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 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 return ( float(min(max(x1, 0.0), src_w)), float(min(max(y1, 0.0), src_h)), float(min(max(x2, 0.0), src_w)), float(min(max(y2, 0.0), src_h)), ) # --- 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) # --- Escucha de ladridos ------------------------------------------------- def start_audio_listener(url, env): """ Arranca el hilo de audio, o explica por qué no se puede. Devolver None es un caso normal (no un error fatal): el monitor sigue funcionando solo con vídeo si falta el modelo o se desactiva a propósito. """ if env.get("PET_AUDIO", "1") == "0": print("[INFO] Escucha de ladridos desactivada (PET_AUDIO=0).") return None try: detector = BarkDetector() except (FileNotFoundError, Exception) as exc: # noqa: BLE001 print(f"[WARN] Audio desactivado: {exc}") return None threshold = float(env.get("BARK_THRESHOLD", BARK_THRESHOLD)) cooldown = float(env.get("BARK_COOLDOWN", BARK_COOLDOWN)) def avisa(ladrido, grupo, _cuando): print(f"🔊 [ALERTA] ¡Ladrido detectado! Bark {ladrido:.2f} (grupo canino {grupo:.2f})", flush=True) listener = AudioListener(url, detector, on_bark=avisa, threshold=threshold, cooldown=cooldown).start() print(f"[INFO] Escucha de ladridos activa (umbral Bark {threshold:.2f}, aviso cada {cooldown:.0f}s como mínimo).") return listener # --- 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}") print(f"[INFO] Cámara Tapo: {mask_url(url)}") stream = RtspVideoStream(url).start() audio = start_audio_listener(url, env) 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 linea = ( 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}" ) if audio is not None: linea += ( f" || audio: Bark máx {audio.take_max_bark():.2f} (umbral {audio.threshold:.2f}) " f"| ventanas {audio.windows} | ladridos {audio.alerts}" ) print(linea) last_stats = now frames_processed = 0 inference_time = 0.0 finally: stream.stop() if audio is not None: audio.stop() hailo.close() print("[INFO] Streams cerrados y acelerador Hailo liberado correctamente.") if __name__ == "__main__": main()