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:
parent
60002a90a7
commit
937bbd91ac
7 changed files with 412 additions and 603 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
53
Readme.md
53
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.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
179
pet_audio.py
Normal file
179
pet_audio.py
Normal 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
87
pet_common.py
Normal 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
|
||||
125
pet_speed.py
125
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__":
|
||||
|
|
|
|||
39
scripts/fetch_models.sh
Executable file
39
scripts/fetch_models.sh
Executable 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))"
|
||||
Loading…
Add table
Reference in a new issue