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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- You signed in with another tab or window. Reload to refresh your session.
- You signed out in another tab or window. Reload to refresh your session.
- You switched accounts on another tab or window. Reload to refresh your session.
-
- Dismiss alert
-
-
-
-
-
-
- You can’t perform that action at this time.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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))"