- pet_audio.py: escucha del audio RTSP en un hilo propio con PyAV, remuestreo de 8 kHz a 16 kHz y YAMNet en ONNX sobre ventanas de 0.975 s con medio solape. Dispara por la clase Bark (mejor margen perro/gato que el grupo canino), con anti-repeticion y reconexion automatica. - pet_common.py: utilidades compartidas de .env, construccion de la URL RTSP y enmascarado de credenciales, porque PyAV incluye la contrasena en el texto de sus excepciones. - pet_speed.py: arranca la escucha de audio y la refleja en la linea de estadisticas. - scripts/fetch_models.sh: descarga y verifica por SHA-256 el modelo YAMNet ONNX (16 MB). - models/yamnet.onnx: el fichero versionado no era un modelo, sino una pagina HTML de GitHub. Se saca de git y pasa a descargarse con el script. - Readme.md: seccion de audio, estado de la Fase A y ajuste de umbral.
361 lines
14 KiB
Python
361 lines
14 KiB
Python
#!/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()
|