diff --git a/.gitignore b/.gitignore index da1e85c..7136d0c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ wheels/ # Logs del runtime Hailo (se regeneran en cada ejecución) hailort.log hailort.1.log + +# Modelos grandes: se descargan con scripts/fetch_models.sh +models/*.onnx diff --git a/Readme.md b/Readme.md index d03cdde..a5cedb8 100644 --- a/Readme.md +++ b/Readme.md @@ -5,7 +5,7 @@ Sistema de monitorización inteligente para mascotas utilizando aceleración por Placa: Raspberry Pi 5. Coprocesador IA: Raspberry Pi AI HAT (Hailo-8, 26 TOPS). Cámara: cámara IP TP-Link Tapo accesible por RTSP en la red local. -Entrada de audio: Micrófono USB (pendiente de conectar). +Entrada de audio: micrófono integrado de la propia cámara Tapo (viaja en el stream RTSP). No hace falta micrófono USB. > **Corrección respecto a la primera versión de este documento:** el módulo instalado identifica como Hailo-8, no Hailo-8L. Verificado con `hailortcli fw-control identify`: > ``` @@ -26,8 +26,10 @@ Paquetes del Sistema Instalados Se instalaron las utilidades de cámara, controladores del kernel y librerías dinámicas de audio requeridas por el sistema operativo: ```bash sudo apt update && sudo apt full-upgrade -y -sudo apt install -y hailo-all rpicam-apps libportaudio2 portaudio19-dev libasound2-dev python3-picamera2 +sudo apt install -y hailo-all rpicam-apps libportaudio2 portaudio19-dev libasound2-dev python3-picamera2 python3-av ``` + +> `python3-av` (PyAV) es imprescindible para el audio: OpenCV no sabe decodificar la pista de sonido del RTSP. Se instala a nivel de sistema y el venv lo ve gracias a `--system-site-packages`. Verificación de hardware NPU Hailo: `lspci` muestra el coprocesador Hailo Technologies y `hailortcli fw-control identify` responde con el firmware cargado. Cámara: `ffprobe -rtsp_transport tcp rtsp://usuario:clave@IP:554/stream2` debe describir un stream H.264. @@ -50,6 +52,9 @@ CAMERA_PW=... # Opcionales: # CAMERA_STREAM=stream2 # stream1 | stream2 # CAMERA_RTSP_URL=rtsp://... # URL completa; tiene prioridad sobre lo anterior +# PET_AUDIO=0 # desactiva la escucha de ladridos +# BARK_THRESHOLD=0.15 # umbral de la puntuación de Bark +# BARK_COOLDOWN=15 # segundos mínimos entre dos avisos de ladrido ``` --- 4. Entorno de Desarrollo Python (`uv`) @@ -67,6 +72,12 @@ uv pip install sounddevice numpy scipy requests onnxruntime > **Nota:** `uv` vive en `~/.local/bin/uv` y no está en el PATH de sesiones SSH no interactivas; usa `bash -lc 'uv run ...'` o `~/.local/bin/uv run ...`. --- 5. Estado Actual del Software +El proyecto se reparte en tres módulos, más el script de descarga del modelo: +- `pet_common.py`: carga del `.env`, construcción de la URL RTSP y enmascarado de credenciales. +- `pet_speed.py`: visión por computador (vídeo + NPU Hailo). Es el punto de entrada. +- `pet_audio.py`: detección de ladridos sobre el audio de la cámara. +- `scripts/fetch_models.sh`: descarga y verifica (SHA-256) el modelo YAMNet. + `pet_speed.py`: Script de visión artificial optimizado con aceleración por hardware que: - Lee el vídeo de la cámara Tapo por **RTSP en un hilo aparte** (`RtspVideoStream`), que se queda siempre con el fotograma más reciente y descarta los antiguos. Así no se acumula latencia si el análisis va más lento que la cámara. - Se **reconecta automáticamente** con retroceso exponencial (2 s → 30 s) si la cámara se cae, se reinicia o cambia de IP. @@ -89,15 +100,35 @@ Ejecución: uv run pet_speed.py ``` --- -6. Próximos Pasos (Hoja de Ruta) -Fase A: Integración de Audio (Ladridos y Sonidos) -Conectar el micrófono USB. -Identificar el índice del dispositivo con: -```bash - uv run python -m sounddevice - ``` -Descargar el modelo YAMNet en formato ONNX (`yamnet.onnx`) y el diccionario de clases de AudioSet (`yamnet_classes.csv`). -Desarrollar el listener en un hilo secundario (`threading.Thread`) analizando fragmentos de audio de 0.975 segundos en busca de la clase `Bark`. +6. Detección de Ladridos (audio de la cámara) +La cámara Tapo publica su micrófono dentro del mismo stream RTSP que el vídeo, así que **no hace falta micrófono USB**. El audio va en G.711 A-law, 8 kHz mono, pero el vídeo y el audio se piden en dos conexiones RTSP distintas (la cámara admite varias en paralelo), porque OpenCV no sabe decodificar sonido y hay que usar PyAV. + +`pet_audio.py`: +- Remuestrea el audio a los 16 kHz que espera YAMNet y lo analiza en **ventanas de 0.975 s con medio solape**. +- Carga YAMNet en ONNX (`models/yamnet.onnx`, 16 MB, *no versionado: se descarga con `bash scripts/fetch_models.sh`*). +- Dispara por la clase **`Bark`** de AudioSet, no por el grupo canino: con el mismo material, `Bark` separa perros de gatos con 6 veces más margen, porque las clases genéricas (`Animal`, `Whimper`) también se activan con maullidos. El grupo canino se calcula solo como contexto en el aviso. +- Avisa como máximo una vez cada `BARK_COOLDOWN` segundos, para no inundar la consola con un ladrido sostenido. +- Se reconecta solo si la cámara se cae, y **nunca imprime la contraseña**: las excepciones de PyAV incluyen la URL completa con credenciales, así que todo el texto de error pasa por un enmascarador. + +Calidad del audio, medida con [BarkMeowDB](https://zenodo.org/records/3563990) (CC-BY-4.0, 46 wavs de perro y 50 de gato) pasando el audio por el códec real de la cámara con `ffmpeg`: + +| ruta | Bark en perros | Bark en gatos | disparan (umbral 0.15) | +|---|---|---|---| +| 16 kHz (referencia) | 0.715 | 0.020 | — | +| 8 kHz A-law (cámara) | 0.616 | 0.007 (máx 0.049) | 20/20 perros, 0/20 gatos | + +El recorte de banda a 4 kHz cuesta un 14% de puntuación, pero la separación entre perro y gato sigue siendo enorme, así que el audio de la cámara sirve de sobra. Coste: **4.6 ms por ventana** en CPU, unas 200 veces más rápido que el tiempo real. + +> **Cómo ajustar el umbral en tu casa:** la línea `[STATS]` muestra `Bark máx` de los últimos 10 s junto al umbral. Si en una semana de uso ves que se acerca al umbral sin que ladre nadie, súbelo con `BARK_THRESHOLD` en el `.env`. Si se le escapan ladridos, bájalo. +> +> **Pendiente de comprobar in situ:** el experimento aísla la pérdida de códec y banda, pero no la calidad del micrófono de la cámara, la distancia al animal ni la acústica de la sala. Conviene validarlo con un ladrido real delante de la cámara. + +--- +7. Próximos Pasos (Hoja de Ruta) +Fase A: Integración de Audio (Ladridos y Sonidos) — *hecha* +Descartado el micrófono USB: se usa el de la propia cámara. +El modelo YAMNet ONNX y el diccionario de clases de AudioSet (`yamnet_classes.csv`) están en `models/`. +Listener en hilo secundario analizando ventanas de 0.975 s en busca de la clase `Bark`: implementado en `pet_audio.py`. Fase B: Sistema de Alertas y Notificaciones Crear un bot en Telegram con `@BotFather` y obtener el `TELEGRAM_BOT_TOKEN` y `CHAT_ID`. Programar un despachador asíncrono con control de enfriamiento (cooldown de 30-60 s) para evitar spam. diff --git a/models/yamnet.onnx b/models/yamnet.onnx deleted file mode 100644 index 222f0ef..0000000 --- a/models/yamnet.onnx +++ /dev/null @@ -1,529 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Page not found · GitHub · GitHub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- Skip to content - - - - - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - -
-
- - - - -
- -
- - - - - - - - -
- - - - - - -
- - - - - - - - - -
-
- - - -
-
- -
-
- 404 “This is not the web page you are looking for” -
-
- -
-
- -
- - -
-
- -
- -
- -
- - - - - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - - - -
-
-
- - - diff --git a/pet_audio.py b/pet_audio.py new file mode 100644 index 0000000..b49c8bf --- /dev/null +++ b/pet_audio.py @@ -0,0 +1,179 @@ +""" +Detección de ladridos sobre el audio RTSP de la cámara Tapo. + +La cámara publica su micrófono dentro del mismo stream RTSP que el vídeo, en +formato G.711 A-law a 8 kHz mono. OpenCV no sabe decodificar audio, así que +aquí se abre una segunda conexión RTSP con PyAV (la cámara admite varias en +paralelo, verificado con el vídeo funcionando a la vez). + +El audio se remuestrea a los 16 kHz que espera YAMNet y se analiza en ventanas +de 0.975 s. Medido en la Pi 5: ~4.6 ms por ventana, unas 200 veces más rápido +que el tiempo real, así que no compite de forma apreciable con la NPU. +""" + +import os +import threading +import time + +import av +import numpy as np +import onnxruntime as ort + +from pet_common import mask_secrets, secrets_of + +SAMPLE_RATE = 16000 +WINDOW_SAMPLES = 15600 # 0.975 s, la ventana que espera YAMNet +HOP_SAMPLES = 7800 # medio solape entre ventanas consecutivas + +# Índices en AudioSet (verificados contra models/yamnet_classes.csv) +BARK_CLASS = 70 +CANINE_GROUP = (69, 70, 71, 72, 73, 74, 75, 117) # Dog, Bark, Yip, Howl, Bow-wow, Growling, Whimper (dog), Canidae + +MODEL_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models", "yamnet.onnx") + + +class BarkDetector: + """ + YAMNet en ONNX: puntúa una ventana de audio como ladrido. + + Se dispara por la clase `Bark` sola, no por el grupo canino: con el mismo + material, `Bark` separa perros de gatos con un margen 6 veces mayor que la + suma del grupo, porque las clases genéricas (`Animal`, `Whimper`) también + se activan con maullidos. El grupo se calcula solo como contexto al avisar. + """ + + def __init__(self, model_path=MODEL_PATH): + if not os.path.exists(model_path): + raise FileNotFoundError( + f"No se encontró {model_path}.\n" + "Descárgalo con: bash scripts/fetch_models.sh" + ) + self.session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"]) + self.input_name = self.session.get_inputs()[0].name + + def analyze(self, samples): + """ + samples: array float32 mono a 16 kHz de exactamente 0.975 s. + Devuelve (puntuación de ladrido, suma del grupo canino), ambas en [0, 1+] . + """ + scores = np.asarray(self.session.run(None, {self.input_name: samples})[0]).reshape(-1) + return float(scores[BARK_CLASS]), float(scores[list(CANINE_GROUP)].sum()) + + +class AudioListener: + """ + Hilo que escucha el audio RTSP y avisa cuando detecta un ladrido. + + Llama a `on_bark(puntuacion, grupo, cuando)` como máximo una vez cada + `cooldown` segundos, para no inundar de avisos un ladrido sostenido. + """ + + def __init__( + self, + url, + detector, + on_bark=None, + threshold=0.15, + cooldown=15.0, + reconnect_delay=2.0, + max_reconnect_delay=30.0, + socket_timeout_us=5_000_000, + ): + self._url = url + self._detector = detector + self._on_bark = on_bark + self.threshold = threshold + self.cooldown = cooldown + self._reconnect_delay = reconnect_delay + self._max_reconnect_delay = max_reconnect_delay + self._socket_timeout_us = socket_timeout_us + self._secrets = secrets_of(url) + self._stop = threading.Event() + self._thread = None + self._last_alert = 0.0 + + # Estadísticas consultables desde el bucle principal + self.windows = 0 + self.alerts = 0 + self.reconnects = 0 + self.connected = False + self.max_bark = 0.0 + self.last_error = None + + def start(self): + self._thread = threading.Thread(target=self._loop, name="audio-listener", daemon=True) + self._thread.start() + return self + + def stop(self): + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=5.0) + + def take_max_bark(self): + """Puntuación máxima de ladrido desde la última consulta (para las estadísticas).""" + valor, self.max_bark = self.max_bark, 0.0 + return valor + + # --- interno --------------------------------------------------------- + def _loop(self): + delay = self._reconnect_delay + while not self._stop.is_set(): + try: + self._captura() + except Exception as exc: # noqa: BLE001 - el hilo nunca debe morir + mensaje = mask_secrets(exc, self._secrets) + self.last_error = mensaje + self.connected = False + if self._stop.is_set(): + break + print(f"[WARN] Audio RTSP interrumpido ({mensaje}); reintentando en {delay:.0f}s", flush=True) + self._stop.wait(delay) + delay = min(delay * 2, self._max_reconnect_delay) + self.reconnects += 1 + else: + delay = self._reconnect_delay + + def _captura(self): + """Una sesión de escucha completa; termina cuando se para o falla la conexión.""" + container = av.open( + self._url, + options={"rtsp_transport": "tcp", "timeout": str(self._socket_timeout_us)}, + ) + try: + audio = next((s for s in container.streams if s.type == "audio"), None) + if audio is None: + raise RuntimeError("la cámara no publica pista de audio") + + self.connected = True + print("[INFO] Audio RTSP conectado.", flush=True) + + resampler = av.AudioResampler(format="s16", layout="mono", rate=SAMPLE_RATE) + buffer = np.empty(0, dtype=np.int16) + + for packet in container.demux(audio): + if self._stop.is_set(): + return + for frame in packet.decode(): + for remuestreado in resampler.resample(frame): + buffer = np.concatenate([buffer, remuestreado.to_ndarray().reshape(-1)]) + while len(buffer) >= WINDOW_SAMPLES: + self._procesa(buffer[:WINDOW_SAMPLES]) + buffer = buffer[HOP_SAMPLES:] + finally: + self.connected = False + container.close() + + def _procesa(self, ventana): + samples = ventana.astype(np.float32) / 32768.0 + ladrido, grupo = self._detector.analyze(samples) + self.windows += 1 + if ladrido > self.max_bark: + self.max_bark = ladrido + + ahora = time.time() + if ladrido >= self.threshold and ahora - self._last_alert >= self.cooldown: + self._last_alert = ahora + self.alerts += 1 + if self._on_bark is not None: + self._on_bark(ladrido, grupo, ahora) diff --git a/pet_common.py b/pet_common.py new file mode 100644 index 0000000..33e4ff5 --- /dev/null +++ b/pet_common.py @@ -0,0 +1,87 @@ +"""Utilidades compartidas por los módulos del Pet Monitor.""" + +import os +import urllib.parse + +ENV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") + +# Claves que se pueden sobreescribir desde el entorno real (systemd, pruebas +# puntuales). Las variables de entorno tienen prioridad sobre el fichero .env. +ENV_KEYS = ( + "CAMERA_IP", + "CAMERA_USER", + "CAMERA_PW", + "CAMERA_STREAM", + "CAMERA_RTSP_URL", + "PET_AUDIO", + "BARK_THRESHOLD", + "BARK_COOLDOWN", +) + + +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("'") + for key in ENV_KEYS: + if os.environ.get(key): + env[key] = os.environ[key] + return env + + +def build_rtsp_url(env, stream): + """Construye la URL RTSP de la cámara, 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 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 secrets_of(url): + """Devuelve los trozos de una URL que nunca deben aparecer en un log.""" + parts = urllib.parse.urlsplit(url) + valores = [parts.password, parts.username] + if parts.password: + valores.append(urllib.parse.quote(parts.password, safe="")) + return [v for v in valores if v] + + +def mask_secrets(text, secrets): + """ + Tapa las credenciales dentro de un texto. + + PyAV incluye la URL completa (con contraseña) en sus excepciones, así que + todo lo que se imprima desde el hilo de audio tiene que pasar por aquí: si + no, un fallo de red dejaría la contraseña escrita en journalctl. + """ + text = str(text) + for secret in secrets: + if secret: + text = text.replace(secret, "***") + return text diff --git a/pet_speed.py b/pet_speed.py index 8ec87c5..b8b21fb 100644 --- a/pet_speed.py +++ b/pet_speed.py @@ -1,20 +1,21 @@ #!/usr/bin/env python3 """ -Pet Monitor - Visión por computador con cámara IP Tapo (RTSP) + NPU Hailo-8. +Pet Monitor - Visión por computador con cámara IP Tapo (RTSP) + NPU Hailo. -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. + - 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 """ @@ -24,12 +25,14 @@ 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 +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"} @@ -38,11 +41,13 @@ SPEED_THRESHOLD = 0.30 # Velocidad normalizada por segundo (fracción del fra 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() -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. @@ -51,52 +56,6 @@ 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() @@ -139,9 +98,12 @@ def box_to_source(box, scale, pad_x, pad_y, src_w, src_h): 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) + 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 ---------------------------------------------------- @@ -253,6 +215,35 @@ class RtspVideoStream: 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() @@ -265,10 +256,10 @@ def main(): 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() + audio = start_audio_listener(url, env) print("[INFO] Monitorizando mascotas (Gatos y Perros)... Presiona Ctrl+C para salir.\n") running = threading.Event() @@ -343,19 +334,27 @@ def main(): now = time.time() if now - last_stats >= STATS_INTERVAL: avg_inf = inference_time / frames_processed * 1000 if frames_processed else 0.0 - print( + 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] Stream RTSP cerrado y acelerador Hailo liberado correctamente.") + print("[INFO] Streams cerrados y acelerador Hailo liberado correctamente.") if __name__ == "__main__": diff --git a/scripts/fetch_models.sh b/scripts/fetch_models.sh new file mode 100755 index 0000000..2ed24bb --- /dev/null +++ b/scripts/fetch_models.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# +# Descarga el modelo YAMNet en ONNX que necesita la detección de ladridos. +# +# El binario (16 MB) no se versiona en git: se descarga aquí y se verifica su +# SHA-256. YAMNet es Apache-2.0 (TF Hub de Google); esta es una conversión a +# ONNX ya validada con audio real: +# - entrada `waveform` de rango 1, (15600,) float32 -> 0.975 s a 16 kHz +# - salidas: 521 clases de AudioSet + 1024 embeddings + 64 espectrograma +# - los índices cuadran con models/yamnet_classes.csv (Bark = 70) +# +# Uso: bash scripts/fetch_models.sh + +set -euo pipefail + +URL="https://huggingface.co/zeropointnine/yamnet-onnx/resolve/main/yamnet.onnx" +SHA256="1510041dce24a2e9e84ec546807ac408ae496da6d1ed41bc3ccba649623f8e19" + +RAIZ="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DESTINO="$RAIZ/models/yamnet.onnx" + +if [[ -f "$DESTINO" ]] && echo "$SHA256 $DESTINO" | sha256sum -c --status 2>/dev/null; then + echo "Ya está descargado y verificado: $DESTINO" + exit 0 +fi + +echo "Descargando YAMNet ONNX (16 MB)..." +curl -fL --progress-bar -o "$DESTINO.tmp" "$URL" + +echo "Verificando SHA-256..." +if ! echo "$SHA256 $DESTINO.tmp" | sha256sum -c --status; then + rm -f "$DESTINO.tmp" + echo "ERROR: el fichero descargado no coincide con el SHA-256 esperado." >&2 + echo "Puede ser un fallo de red o que el fichero remoto haya cambiado." >&2 + exit 1 +fi + +mv "$DESTINO.tmp" "$DESTINO" +echo "Listo: $DESTINO ($(du -h "$DESTINO" | cut -f1))"