- 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.
179 lines
6.5 KiB
Python
179 lines
6.5 KiB
Python
"""
|
|
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)
|