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)
|
# Logs del runtime Hailo (se regeneran en cada ejecución)
|
||||||
hailort.log
|
hailort.log
|
||||||
hailort.1.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.
|
Placa: Raspberry Pi 5.
|
||||||
Coprocesador IA: Raspberry Pi AI HAT (Hailo-8, 26 TOPS).
|
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.
|
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`:
|
> **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:
|
Se instalaron las utilidades de cámara, controladores del kernel y librerías dinámicas de audio requeridas por el sistema operativo:
|
||||||
```bash
|
```bash
|
||||||
sudo apt update && sudo apt full-upgrade -y
|
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
|
Verificación de hardware
|
||||||
NPU Hailo: `lspci` muestra el coprocesador Hailo Technologies y `hailortcli fw-control identify` responde con el firmware cargado.
|
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.
|
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:
|
# Opcionales:
|
||||||
# CAMERA_STREAM=stream2 # stream1 | stream2
|
# CAMERA_STREAM=stream2 # stream1 | stream2
|
||||||
# CAMERA_RTSP_URL=rtsp://... # URL completa; tiene prioridad sobre lo anterior
|
# 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`)
|
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 ...`.
|
> **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
|
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:
|
`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.
|
- 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.
|
- 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
|
uv run pet_speed.py
|
||||||
```
|
```
|
||||||
---
|
---
|
||||||
6. Próximos Pasos (Hoja de Ruta)
|
6. Detección de Ladridos (audio de la cámara)
|
||||||
Fase A: Integración de Audio (Ladridos y Sonidos)
|
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.
|
||||||
Conectar el micrófono USB.
|
|
||||||
Identificar el índice del dispositivo con:
|
`pet_audio.py`:
|
||||||
```bash
|
- Remuestrea el audio a los 16 kHz que espera YAMNet y lo analiza en **ventanas de 0.975 s con medio solape**.
|
||||||
uv run python -m sounddevice
|
- 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.
|
||||||
Descargar el modelo YAMNet en formato ONNX (`yamnet.onnx`) y el diccionario de clases de AudioSet (`yamnet_classes.csv`).
|
- Avisa como máximo una vez cada `BARK_COOLDOWN` segundos, para no inundar la consola con un ladrido sostenido.
|
||||||
Desarrollar el listener en un hilo secundario (`threading.Thread`) analizando fragmentos de audio de 0.975 segundos en busca de la clase `Bark`.
|
- 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
|
Fase B: Sistema de Alertas y Notificaciones
|
||||||
Crear un bot en Telegram con `@BotFather` y obtener el `TELEGRAM_BOT_TOKEN` y `CHAT_ID`.
|
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.
|
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
|
#!/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 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).
|
||||||
- Ingesta RTSP en un hilo aparte, que descarta los frames viejos y expone
|
- Detección de gato/perro (clases COCO 15 y 16) con YOLOv8s sobre la NPU Hailo
|
||||||
siempre el más reciente (evita la latencia acumulada típica de RTSP).
|
y alerta de carrera según el desplazamiento del centroide entre fotogramas.
|
||||||
- Reconexión automática con backoff si la cámara se cae o cambia de IP.
|
- Escucha de ladridos sobre el audio de la propia cámara (ver pet_audio.py),
|
||||||
- Inferencia YOLOv8s sobre la NPU Hailo (HEF del sistema).
|
en un segundo hilo y con su propia conexión RTSP.
|
||||||
- Detección de gato/perro (clases COCO 15 y 16) y alerta de carrera según
|
- Reconexión automática con backoff en ambos canales.
|
||||||
el desplazamiento del centroide entre frames consecutivos.
|
|
||||||
|
|
||||||
Variables en .env (junto a este script):
|
Variables en .env (junto a este script):
|
||||||
CAMERA_IP, CAMERA_USER, CAMERA_PW (obligatorias: cuenta de cámara Tapo)
|
CAMERA_IP, CAMERA_USER, CAMERA_PW (obligatorias: cuenta de cámara Tapo)
|
||||||
CAMERA_STREAM (opcional: stream2 = 720p, stream1 = 2K)
|
CAMERA_STREAM (opcional: stream2 = 720p, stream1 = 2K)
|
||||||
CAMERA_RTSP_URL (opcional: URL RTSP completa, tiene prioridad)
|
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
|
Ejecución: uv run pet_speed.py
|
||||||
"""
|
"""
|
||||||
|
|
@ -24,12 +25,14 @@ import os
|
||||||
import signal
|
import signal
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from picamera2.devices.hailo import Hailo, hailo_architecture
|
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 -------------------------------------------------------
|
# --- Configuración -------------------------------------------------------
|
||||||
# Clases COCO: 15 = Cat (Gato), 16 = Dog (Perro)
|
# Clases COCO: 15 = Cat (Gato), 16 = Dog (Perro)
|
||||||
TARGET_CLASSES = {15: "Gato", 16: "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
|
DEFAULT_STREAM = "stream2" # stream1 = 2304x1296@15, stream2 = 1280x720@15
|
||||||
STATS_INTERVAL = 10.0 # Segundos entre líneas de estadísticas
|
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
|
# 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).
|
# para RGB; con PET_COLOR_ORDER=bgr se invierte (útil para comparar precisión).
|
||||||
COLOR_ORDER = os.environ.get("PET_COLOR_ORDER", "rgb").lower()
|
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)
|
LETTERBOX_FILL = (114, 114, 114)
|
||||||
|
|
||||||
# Latencia baja en RTSP: forzar TCP evita el jitter y las pérdidas de UDP.
|
# 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 ----------------------------------------------------------
|
# --- 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():
|
def get_hailo_model_path():
|
||||||
"""Elige el HEF de YOLOv8s que corresponde a la arquitectura detectada."""
|
"""Elige el HEF de YOLOv8s que corresponde a la arquitectura detectada."""
|
||||||
arch = hailo_architecture()
|
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
|
x2 = (xmax * model_w - pad_x) / scale
|
||||||
y1 = (ymin * model_h - pad_y) / scale
|
y1 = (ymin * model_h - pad_y) / scale
|
||||||
y2 = (ymax * 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
|
return (
|
||||||
clip_y = lambda v: float(min(max(v, 0.0), src_h)) # noqa: E731
|
float(min(max(x1, 0.0), src_w)),
|
||||||
return clip_x(x1), clip_y(y1), clip_x(x2), clip_y(y2)
|
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 ----------------------------------------------------
|
# --- Ingesta de vídeo ----------------------------------------------------
|
||||||
|
|
@ -253,6 +215,35 @@ class RtspVideoStream:
|
||||||
self._thread.join(timeout=5.0)
|
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 -----------------------------------------------------
|
# --- Bucle principal -----------------------------------------------------
|
||||||
def main():
|
def main():
|
||||||
env = load_env()
|
env = load_env()
|
||||||
|
|
@ -265,10 +256,10 @@ def main():
|
||||||
model_h, model_w, _ = hailo.get_input_shape()
|
model_h, model_w, _ = hailo.get_input_shape()
|
||||||
print(f"[INFO] Modelo cargado. Resolución de entrada: {model_w}x{model_h}")
|
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)}")
|
print(f"[INFO] Cámara Tapo: {mask_url(url)}")
|
||||||
|
|
||||||
stream = RtspVideoStream(url).start()
|
stream = RtspVideoStream(url).start()
|
||||||
|
audio = start_audio_listener(url, env)
|
||||||
print("[INFO] Monitorizando mascotas (Gatos y Perros)... Presiona Ctrl+C para salir.\n")
|
print("[INFO] Monitorizando mascotas (Gatos y Perros)... Presiona Ctrl+C para salir.\n")
|
||||||
|
|
||||||
running = threading.Event()
|
running = threading.Event()
|
||||||
|
|
@ -343,19 +334,27 @@ def main():
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_stats >= STATS_INTERVAL:
|
if now - last_stats >= STATS_INTERVAL:
|
||||||
avg_inf = inference_time / frames_processed * 1000 if frames_processed else 0.0
|
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"[STATS] captura {stream.capture_fps:.1f} fps | análisis "
|
||||||
f"{frames_processed / (now - last_stats):.1f} fps | inferencia {avg_inf:.1f} ms "
|
f"{frames_processed / (now - last_stats):.1f} fps | inferencia {avg_inf:.1f} ms "
|
||||||
f"| reconexiones {stream.reconnects}"
|
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
|
last_stats = now
|
||||||
frames_processed = 0
|
frames_processed = 0
|
||||||
inference_time = 0.0
|
inference_time = 0.0
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
stream.stop()
|
stream.stop()
|
||||||
|
if audio is not None:
|
||||||
|
audio.stop()
|
||||||
hailo.close()
|
hailo.close()
|
||||||
print("[INFO] Stream RTSP cerrado y acelerador Hailo liberado correctamente.")
|
print("[INFO] Streams cerrados y acelerador Hailo liberado correctamente.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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