Anadir deteccion de ladridos con el audio de la camara (Fase A)

- 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.
This commit is contained in:
abraham 2026-10-01 18:26:55 +02:00
parent 60002a90a7
commit 937bbd91ac
7 changed files with 412 additions and 603 deletions

3
.gitignore vendored
View file

@ -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

View file

@ -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.

File diff suppressed because one or more lines are too long

179
pet_audio.py Normal file
View file

@ -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)

87
pet_common.py Normal file
View file

@ -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

View file

@ -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__":

39
scripts/fetch_models.sh Executable file
View file

@ -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))"