Compare commits
2 commits
7128413c7f
...
60002a90a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 60002a90a7 | |||
| 44849611a3 |
5 changed files with 404 additions and 69 deletions
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
.lock
|
||||||
|
|
||||||
|
# Logs del runtime Hailo (se regeneran en cada ejecución)
|
||||||
|
hailort.log
|
||||||
|
hailort.1.log
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
3.13
|
||||||
78
Readme.md
78
Readme.md
|
|
@ -1,15 +1,23 @@
|
||||||
Pet Monitor con Raspberry Pi 5 & Hailo-8L (AI HAT)
|
Pet Monitor con Raspberry Pi 5 & Hailo-8 (AI HAT+)
|
||||||
Sistema de monitorización inteligente para mascotas utilizando aceleración por hardware NPU (chip Hailo-8L) para visión por computador a 30 FPS y detección de audio local en CPU para ladridos/sonidos anómalos.
|
Sistema de monitorización inteligente para mascotas utilizando aceleración por hardware NPU (chip Hailo-8) para visión por computador y detección de audio local en CPU para ladridos/sonidos anómalos.
|
||||||
---
|
---
|
||||||
1. Hardware Utilizado
|
1. Hardware Utilizado
|
||||||
Placa: Raspberry Pi 5.
|
Placa: Raspberry Pi 5.
|
||||||
Coprocesador IA: Raspberry Pi AI HAT (Hailo-8L, 13 TOPS).
|
Coprocesador IA: Raspberry Pi AI HAT (Hailo-8, 26 TOPS).
|
||||||
Cámara: Módulo de cámara Raspberry Pi (CSI).
|
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 USB (pendiente de conectar).
|
||||||
|
|
||||||
|
> **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`:
|
||||||
|
> ```
|
||||||
|
> Board Name: Hailo-8
|
||||||
|
> Device Architecture: HAILO8
|
||||||
|
> Firmware Version: 4.23.0
|
||||||
|
> ```
|
||||||
|
> Por eso el modelo cargado es `yolov8s_h8.hef` (el de 8 carriles de cómputo de la NPU), no la variante `_h8l`.
|
||||||
---
|
---
|
||||||
2. Configuración del Sistema y Hardware
|
2. Configuración del Sistema y Hardware
|
||||||
Habilitar PCIe Gen 3
|
Habilitar PCIe Gen 3
|
||||||
Para maximizar el ancho de banda con el chip Hailo-8L, se configuró el bus PCIe en `/boot/firmware/config.txt`:
|
Para maximizar el ancho de banda con el chip Hailo, se configuró el bus PCIe en `/boot/firmware/config.txt`:
|
||||||
```ini
|
```ini
|
||||||
dtparam=pciex1
|
dtparam=pciex1
|
||||||
dtparam=pciex1_gen=3
|
dtparam=pciex1_gen=3
|
||||||
|
|
@ -22,10 +30,30 @@ sudo apt install -y hailo-all rpicam-apps libportaudio2 portaudio19-dev libasoun
|
||||||
```
|
```
|
||||||
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: `rpicam-hello --list-cameras` detecta correctamente el sensor CSI.
|
Cámara: `ffprobe -rtsp_transport tcp rtsp://usuario:clave@IP:554/stream2` debe describir un stream H.264.
|
||||||
---
|
---
|
||||||
3. Entorno de Desarrollo Python (`uv`)
|
3. Cámara IP Tapo (RTSP)
|
||||||
Se configuró el entorno virtual utilizando `uv` con acceso a los paquetes del sistema (`--system-site-packages`) para reutilizar los bindings compilados de `picamera2` y `hailo_platform` instalados por Debian/Raspberry Pi OS:
|
La cámara Tapo **no expone RTSP con la cuenta de TP-Link**: hay que crear una cuenta local de cámara en la app Tapo (Ajustes de la cámara → Configuración avanzada → Cuenta de cámara). El usuario y la contraseña de esa cuenta son los que van en el `.env`.
|
||||||
|
|
||||||
|
Streams disponibles (verificados con `ffprobe`):
|
||||||
|
- `stream1`: 2304x1296 @ 15 fps (máxima calidad)
|
||||||
|
- `stream2`: 1280x720 @ 15 fps (el que usa el monitor: menos CPU de decodificación)
|
||||||
|
- `stream3`: no existe en este modelo (devuelve 404)
|
||||||
|
|
||||||
|
El vídeo va por H.264, así que la decodificación es por software (la Pi 5 solo acelera HEVC por hardware). A 720p el coste es asumible; con `stream1` sube bastante sin mejorar la detección, porque el modelo trabaja a 640x640 en cualquier caso.
|
||||||
|
|
||||||
|
Credenciales en `~/pet_monitor/.env` (no se versiona):
|
||||||
|
```
|
||||||
|
CAMERA_IP=192.168.178.234
|
||||||
|
CAMERA_USER=camera_dog
|
||||||
|
CAMERA_PW=...
|
||||||
|
# Opcionales:
|
||||||
|
# CAMERA_STREAM=stream2 # stream1 | stream2
|
||||||
|
# CAMERA_RTSP_URL=rtsp://... # URL completa; tiene prioridad sobre lo anterior
|
||||||
|
```
|
||||||
|
---
|
||||||
|
4. Entorno de Desarrollo Python (`uv`)
|
||||||
|
Se configuró el entorno virtual utilizando `uv` con acceso a los paquetes del sistema (`--system-site-packages`) para reutilizar los bindings compilados de `picamera2`, `hailo_platform` y `OpenCV` instalados por Debian/Raspberry Pi OS:
|
||||||
```bash
|
```bash
|
||||||
cd ~/pet_monitor
|
cd ~/pet_monitor
|
||||||
# Creación del entorno con acceso a librerías nativas
|
# Creación del entorno con acceso a librerías nativas
|
||||||
|
|
@ -35,19 +63,33 @@ uv venv --system-site-packages
|
||||||
uv pip install sounddevice numpy scipy requests onnxruntime
|
uv pip install sounddevice numpy scipy requests onnxruntime
|
||||||
```
|
```
|
||||||
> **Nota técnica:** Se seleccionó `onnxruntime` en lugar de `tflite-runtime` para garantizar compatibilidad con versiones modernas de Python en ARM64 sin necesidad de compilar desde fuente.
|
> **Nota técnica:** Se seleccionó `onnxruntime` en lugar de `tflite-runtime` para garantizar compatibilidad con versiones modernas de Python en ARM64 sin necesidad de compilar desde fuente.
|
||||||
|
|
||||||
|
> **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 ...`.
|
||||||
---
|
---
|
||||||
4. Estado Actual del Software
|
5. Estado Actual del Software
|
||||||
`pet_speed.py`: Script de visión artificial que:
|
`pet_speed.py`: Script de visión artificial optimizado con aceleración por hardware que:
|
||||||
Inicializa `Picamera2` a 1280x720 a 30 FPS.
|
- 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.
|
||||||
Carga el pipeline oficial de Hailo (`/usr/share/rpi-camera-assets/hailo_yolov8_inference.json`).
|
- Se **reconecta automáticamente** con retroceso exponencial (2 s → 30 s) si la cámara se cae, se reinicia o cambia de IP.
|
||||||
Filtra detecciones de COCO para gato (id: 15) y perro (id: 16).
|
- Escala el fotograma con **letterbox** (mantiene la relación de aspecto, relleno gris) hasta los 640x640 que espera el modelo, y deshace esa transformación para dar las cajas y el centroide en píxeles del fotograma original.
|
||||||
Calcula el centroide del animal y evalúa el desplazamiento entre frames consecutivos para alertar si la velocidad supera el umbral de carrera (`SPEED_THRESHOLD = 0.30`).
|
- Utiliza la API nativa de aceleración `picamera2.devices.hailo.Hailo` (parte del paquete del sistema, no requiere la cámara CSI) cargando el modelo optimizado (`/usr/share/hailo-models/yolov8s_h8.hef`).
|
||||||
Ejecución actual:
|
- Filtra detecciones del conjunto COCO para gato (id: 15) y perro (id: 16).
|
||||||
|
- Calcula el centroide del animal y evalúa el desplazamiento entre fotogramas consecutivos para alertar si la velocidad supera el umbral de carrera (`SPEED_THRESHOLD = 0.30` por segundo).
|
||||||
|
- Imprime estadísticas cada 10 s (fps de captura, fps de análisis, latencia de inferencia, reconexiones).
|
||||||
|
- Libera el stream RTSP y el acelerador Hailo al recibir `SIGINT` (`Ctrl+C`) o `SIGTERM`, de cara a ejecutarlo como servicio systemd.
|
||||||
|
|
||||||
|
Rendimiento medido en la Pi 5 con `stream2`:
|
||||||
|
```
|
||||||
|
[STATS] captura 14.7 fps | análisis 12-15 fps | inferencia 11.8 ms | reconexiones 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Nota sobre el orden de color: los HEF de YOLOv8 esperan **RGB**, y así se alimenta al modelo. Comprobado con imágenes de prueba: con un perro la confianza pasa de 0.42 (BGR) a 0.85 (RGB), y con un gato de 0.79 a 0.87. Si algún día hiciera falta comparar, `PET_COLOR_ORDER=bgr uv run pet_speed.py`.
|
||||||
|
|
||||||
|
Ejecución:
|
||||||
```bash
|
```bash
|
||||||
uv run python pet_speed.py
|
uv run pet_speed.py
|
||||||
```
|
```
|
||||||
---
|
---
|
||||||
5. Próximos Pasos (Hoja de Ruta)
|
6. Próximos Pasos (Hoja de Ruta)
|
||||||
Fase A: Integración de Audio (Ladridos y Sonidos)
|
Fase A: Integración de Audio (Ladridos y Sonidos)
|
||||||
Conectar el micrófono USB.
|
Conectar el micrófono USB.
|
||||||
Identificar el índice del dispositivo con:
|
Identificar el índice del dispositivo con:
|
||||||
|
|
@ -59,7 +101,7 @@ Desarrollar el listener en un hilo secundario (`threading.Thread`) analizando fr
|
||||||
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.
|
||||||
Guardar una captura JPEG con la caja delimitadora en el momento de la carrera o ladrido y enviarla a Telegram.
|
Guardar una captura JPEG con la caja delimitadora en el momento de la carrera o ladrido y enviarla a Telegram (las cajas ya se calculan en píxeles del fotograma original, solo falta dibujarlas y guardarlas).
|
||||||
Fase C: Despliegue Autónomo (Systemd)
|
Fase C: Despliegue Autónomo (Systemd)
|
||||||
Crear una unidad de servicio `/etc/systemd/system/pet_monitor.service`.
|
Crear una unidad de servicio `/etc/systemd/system/pet_monitor.service`.
|
||||||
Habilitar arranque automático al encender la Raspberry Pi:
|
Habilitar arranque automático al encender la Raspberry Pi:
|
||||||
|
|
|
||||||
379
pet_speed.py
379
pet_speed.py
|
|
@ -1,85 +1,362 @@
|
||||||
import time
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pet Monitor - Visión por computador con cámara IP Tapo (RTSP) + NPU Hailo-8.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
Ejecución: uv run pet_speed.py
|
||||||
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from picamera2 import Picamera2
|
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
|
||||||
|
|
||||||
|
# --- Configuración -------------------------------------------------------
|
||||||
|
# Clases COCO: 15 = Cat (Gato), 16 = Dog (Perro)
|
||||||
TARGET_CLASSES = {15: "Gato", 16: "Perro"}
|
TARGET_CLASSES = {15: "Gato", 16: "Perro"}
|
||||||
SPEED_THRESHOLD = 0.30
|
CONF_THRESHOLD = 0.45 # Confianza mínima para considerar una detección
|
||||||
|
SPEED_THRESHOLD = 0.30 # Velocidad normalizada por segundo (fracción del frame)
|
||||||
|
DEFAULT_STREAM = "stream2" # stream1 = 2304x1296@15, stream2 = 1280x720@15
|
||||||
|
STATS_INTERVAL = 10.0 # Segundos entre líneas de estadísticas
|
||||||
|
|
||||||
def calculate_centroid(box):
|
# Orden de color que espera el HEF. Los modelos de Hailo Model Zoo se compilan
|
||||||
if isinstance(box, dict):
|
# para RGB; con PET_COLOR_ORDER=bgr se invierte (útil para comparar precisión).
|
||||||
x = box.get("x", 0.0) + (box.get("width", 0.0) / 2.0)
|
COLOR_ORDER = os.environ.get("PET_COLOR_ORDER", "rgb").lower()
|
||||||
y = box.get("y", 0.0) + (box.get("height", 0.0) / 2.0)
|
|
||||||
elif isinstance(box, (list, tuple)) and len(box) >= 4:
|
|
||||||
x = (box[0] + box[2]) / 2.0
|
|
||||||
y = (box[1] + box[3]) / 2.0
|
|
||||||
else:
|
|
||||||
x, y = 0.5, 0.5
|
|
||||||
return x, y
|
|
||||||
|
|
||||||
def main():
|
ENV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
|
||||||
picam2 = Picamera2()
|
LETTERBOX_FILL = (114, 114, 114)
|
||||||
|
|
||||||
post_process_file = "/usr/share/rpi-camera-assets/hailo_yolov8_inference.json"
|
|
||||||
|
|
||||||
# Se inyecta la etapa Hailo directamente en la configuración de vídeo
|
# Latencia baja en RTSP: forzar TCP evita el jitter y las pérdidas de UDP.
|
||||||
config = picam2.create_video_configuration(
|
# (No se usa 'stimeout': fue eliminado en FFmpeg 6, que es el que trae OpenCV 4.10)
|
||||||
main={"size": (1280, 720), "format": "RGB888"},
|
os.environ.setdefault("OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp")
|
||||||
controls={"FrameRate": 30},
|
|
||||||
post_process_file=post_process_file
|
|
||||||
|
# --- 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
picam2.configure(config)
|
|
||||||
picam2.start()
|
|
||||||
|
|
||||||
print("[INFO] Pipeline Picamera2 + Hailo-8L activo.")
|
|
||||||
|
|
||||||
|
def get_hailo_model_path():
|
||||||
|
"""Elige el HEF de YOLOv8s que corresponde a la arquitectura detectada."""
|
||||||
|
arch = hailo_architecture()
|
||||||
|
if arch == "HAILO8":
|
||||||
|
candidates = ["/usr/share/hailo-models/yolov8s_h8.hef", "/usr/share/hailo-models/yolov8s_h8l.hef"]
|
||||||
|
else:
|
||||||
|
candidates = ["/usr/share/hailo-models/yolov8s_h8l.hef", "/usr/share/hailo-models/yolov8s_h8.hef"]
|
||||||
|
for path in candidates:
|
||||||
|
if os.path.exists(path):
|
||||||
|
return path
|
||||||
|
raise FileNotFoundError("No se encontró ningún modelo YOLOv8 HEF en /usr/share/hailo-models/")
|
||||||
|
|
||||||
|
|
||||||
|
def letterbox(frame, width, height):
|
||||||
|
"""
|
||||||
|
Reescala manteniendo la relación de aspecto y rellena hasta width x height.
|
||||||
|
Devuelve (imagen, escala, pad_x, pad_y) para poder deshacer la transformación.
|
||||||
|
"""
|
||||||
|
src_h, src_w = frame.shape[:2]
|
||||||
|
scale = min(width / src_w, height / src_h)
|
||||||
|
new_w, new_h = int(round(src_w * scale)), int(round(src_h * scale))
|
||||||
|
resized = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
|
||||||
|
pad_x, pad_y = (width - new_w) // 2, (height - new_h) // 2
|
||||||
|
padded = cv2.copyMakeBorder(
|
||||||
|
resized, pad_y, height - new_h - pad_y, pad_x, width - new_w - pad_x,
|
||||||
|
cv2.BORDER_CONSTANT, value=LETTERBOX_FILL,
|
||||||
|
)
|
||||||
|
return padded, scale, pad_x, pad_y
|
||||||
|
|
||||||
|
|
||||||
|
def box_to_source(box, scale, pad_x, pad_y, src_w, src_h):
|
||||||
|
"""
|
||||||
|
Convierte una caja normalizada del modelo (letterbox) a píxeles del frame
|
||||||
|
original. Hailo NMS devuelve [ymin, xmin, ymax, xmax, confidence].
|
||||||
|
"""
|
||||||
|
ymin, xmin, ymax, xmax = box[:4]
|
||||||
|
model_w = src_w * scale + 2 * pad_x
|
||||||
|
model_h = src_h * scale + 2 * pad_y
|
||||||
|
x1 = (xmin * model_w - pad_x) / scale
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Ingesta de vídeo ----------------------------------------------------
|
||||||
|
class RtspVideoStream:
|
||||||
|
"""
|
||||||
|
Hilo lector de RTSP: mantiene siempre el último frame disponible.
|
||||||
|
|
||||||
|
Si el bucle principal va más lento que la cámara, los frames intermedios se
|
||||||
|
descartan solos (no se encolan), así la detección trabaja sobre imagen en
|
||||||
|
vivo y no sobre un buffer con retardo creciente.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, url, reconnect_delay=2.0, max_reconnect_delay=30.0):
|
||||||
|
self._url = url
|
||||||
|
self._reconnect_delay = reconnect_delay
|
||||||
|
self._max_reconnect_delay = max_reconnect_delay
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._new_frame = threading.Event()
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread = None
|
||||||
|
self._seq = 0
|
||||||
|
self._frame = None
|
||||||
|
self._timestamp = 0.0
|
||||||
|
self.frames_read = 0
|
||||||
|
self.reconnects = 0
|
||||||
|
self.connected = False
|
||||||
|
self.capture_fps = 0.0
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._thread = threading.Thread(target=self._loop, name="rtsp-reader", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _open_capture(self):
|
||||||
|
cap = cv2.VideoCapture(self._url, cv2.CAP_FFMPEG)
|
||||||
|
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||||
|
if not cap.isOpened():
|
||||||
|
cap.release()
|
||||||
|
return None
|
||||||
|
return cap
|
||||||
|
|
||||||
|
def _loop(self):
|
||||||
|
cap = None
|
||||||
|
delay = self._reconnect_delay
|
||||||
|
window_start = time.time()
|
||||||
|
window_frames = 0
|
||||||
|
|
||||||
|
while not self._stop.is_set():
|
||||||
|
if cap is None:
|
||||||
|
cap = self._open_capture()
|
||||||
|
if cap is None:
|
||||||
|
self.connected = False
|
||||||
|
print(f"[WARN] No hay stream RTSP; reintentando en {delay:.0f}s", flush=True)
|
||||||
|
self._stop.wait(delay)
|
||||||
|
delay = min(delay * 2, self._max_reconnect_delay)
|
||||||
|
self.reconnects += 1
|
||||||
|
continue
|
||||||
|
self.connected = True
|
||||||
|
delay = self._reconnect_delay
|
||||||
|
window_start, window_frames = time.time(), 0
|
||||||
|
print("[INFO] Stream RTSP conectado.", flush=True)
|
||||||
|
|
||||||
|
ok, frame = cap.read()
|
||||||
|
if not ok or frame is None:
|
||||||
|
print("[WARN] Lectura RTSP interrumpida; reconectando...", flush=True)
|
||||||
|
cap.release()
|
||||||
|
cap = None
|
||||||
|
self.connected = False
|
||||||
|
self.reconnects += 1
|
||||||
|
self._stop.wait(delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
window_frames += 1
|
||||||
|
elapsed = now - window_start
|
||||||
|
if elapsed >= 2.0:
|
||||||
|
self.capture_fps = window_frames / elapsed
|
||||||
|
window_start, window_frames = now, 0
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._seq += 1
|
||||||
|
self._frame = frame
|
||||||
|
self._timestamp = now
|
||||||
|
self.frames_read += 1
|
||||||
|
self._new_frame.set()
|
||||||
|
|
||||||
|
if cap is not None:
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
def wait_for_frame(self, last_seq, timeout=1.0):
|
||||||
|
"""
|
||||||
|
Bloquea hasta que haya un frame más nuevo que last_seq.
|
||||||
|
Devuelve (seq, frame, timestamp) o (last_seq, None, None) si expira.
|
||||||
|
"""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while not self._stop.is_set() and time.time() < deadline:
|
||||||
|
with self._lock:
|
||||||
|
seq, frame, ts = self._seq, self._frame, self._timestamp
|
||||||
|
if seq > last_seq and frame is not None:
|
||||||
|
return seq, frame, ts
|
||||||
|
self._new_frame.wait(0.05)
|
||||||
|
self._new_frame.clear()
|
||||||
|
return last_seq, None, None
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop.set()
|
||||||
|
self._new_frame.set()
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join(timeout=5.0)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bucle principal -----------------------------------------------------
|
||||||
|
def main():
|
||||||
|
env = load_env()
|
||||||
|
stream_name = env.get("CAMERA_STREAM", DEFAULT_STREAM)
|
||||||
|
url = build_rtsp_url(env, stream_name)
|
||||||
|
|
||||||
|
model_path = get_hailo_model_path()
|
||||||
|
print(f"[INFO] Cargando modelo Hailo: {model_path} ...")
|
||||||
|
hailo = Hailo(model_path)
|
||||||
|
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()
|
||||||
|
print("[INFO] Monitorizando mascotas (Gatos y Perros)... Presiona Ctrl+C para salir.\n")
|
||||||
|
|
||||||
|
running = threading.Event()
|
||||||
|
running.set()
|
||||||
|
|
||||||
|
def handle_signal(signum, _frame):
|
||||||
|
print(f"\n[INFO] Señal {signum} recibida; deteniendo...", flush=True)
|
||||||
|
running.clear()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, handle_signal)
|
||||||
|
signal.signal(signal.SIGTERM, handle_signal)
|
||||||
|
|
||||||
|
last_seq = 0
|
||||||
last_centroid = None
|
last_centroid = None
|
||||||
last_time = time.time()
|
last_frame_time = 0.0
|
||||||
|
last_stats = time.time()
|
||||||
|
frames_processed = 0
|
||||||
|
inference_time = 0.0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while running.is_set():
|
||||||
metadata = picam2.capture_metadata()
|
seq, frame, frame_time = stream.wait_for_frame(last_seq)
|
||||||
current_time = time.time()
|
if frame is None:
|
||||||
dt = current_time - last_time
|
continue
|
||||||
|
last_seq = seq
|
||||||
|
|
||||||
|
letterboxed, scale, pad_x, pad_y = letterbox(frame, model_w, model_h)
|
||||||
|
if COLOR_ORDER == "bgr":
|
||||||
|
model_input = np.ascontiguousarray(letterboxed)
|
||||||
|
else:
|
||||||
|
model_input = np.ascontiguousarray(cv2.cvtColor(letterboxed, cv2.COLOR_BGR2RGB))
|
||||||
|
|
||||||
|
started = time.time()
|
||||||
|
detections_by_class = hailo.run(model_input)
|
||||||
|
inference_time += time.time() - started
|
||||||
|
frames_processed += 1
|
||||||
|
|
||||||
|
src_h, src_w = frame.shape[:2]
|
||||||
|
dt = frame_time - last_frame_time if last_frame_time else 0.0
|
||||||
|
|
||||||
detections = metadata.get("hailo_detections", [])
|
|
||||||
|
|
||||||
pet_found = False
|
pet_found = False
|
||||||
for det in detections:
|
for class_id, label in TARGET_CLASSES.items():
|
||||||
class_id = det.get("class_id")
|
if class_id >= len(detections_by_class):
|
||||||
confidence = det.get("confidence", 0.0)
|
continue
|
||||||
|
for det in detections_by_class[class_id]:
|
||||||
|
confidence = det[4]
|
||||||
|
if confidence <= CONF_THRESHOLD:
|
||||||
|
continue
|
||||||
|
|
||||||
if class_id in TARGET_CLASSES and confidence > 0.45:
|
|
||||||
pet_found = True
|
pet_found = True
|
||||||
label = TARGET_CLASSES[class_id]
|
x1, y1, x2, y2 = box_to_source(det, scale, pad_x, pad_y, src_w, src_h)
|
||||||
box = det.get("box", [0, 0, 0, 0])
|
centroid = ((x1 + x2) / 2.0 / src_w, (y1 + y2) / 2.0 / src_h)
|
||||||
|
|
||||||
centroid = calculate_centroid(box)
|
|
||||||
|
|
||||||
if last_centroid and dt > 0:
|
if last_centroid and dt > 0:
|
||||||
dx = centroid[0] - last_centroid[0]
|
dist = math.hypot(centroid[0] - last_centroid[0], centroid[1] - last_centroid[1])
|
||||||
dy = centroid[1] - last_centroid[1]
|
|
||||||
dist = math.hypot(dx, dy)
|
|
||||||
speed = dist / dt
|
speed = dist / dt
|
||||||
|
|
||||||
if speed > SPEED_THRESHOLD:
|
if speed > SPEED_THRESHOLD:
|
||||||
print(f"⚠️ [ALERTA] ¡{label} corriendo! Vel: {speed:.2f} norm/s (Conf: {confidence:.2f})")
|
print(f"⚠️ [ALERTA] ¡{label} corriendo! Vel: {speed:.2f} norm/s (Conf: {confidence:.2f})")
|
||||||
else:
|
else:
|
||||||
print(f"[OK] {label} detectado (calmado). Vel: {speed:.2f} norm/s")
|
print(f"🐾 [OK] {label} detectado (calmado). Vel: {speed:.2f} norm/s (Conf: {confidence:.2f})")
|
||||||
|
|
||||||
last_centroid = centroid
|
last_centroid = centroid
|
||||||
|
break # Evaluamos la mascota principal detectada
|
||||||
|
if pet_found:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not pet_found:
|
if not pet_found:
|
||||||
last_centroid = None
|
last_centroid = None
|
||||||
|
|
||||||
last_time = current_time
|
last_frame_time = frame_time
|
||||||
time.sleep(0.03)
|
|
||||||
|
now = time.time()
|
||||||
|
if now - last_stats >= STATS_INTERVAL:
|
||||||
|
avg_inf = inference_time / frames_processed * 1000 if frames_processed else 0.0
|
||||||
|
print(
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
last_stats = now
|
||||||
|
frames_processed = 0
|
||||||
|
inference_time = 0.0
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n[INFO] Deteniendo monitor...")
|
|
||||||
finally:
|
finally:
|
||||||
picam2.stop()
|
stream.stop()
|
||||||
|
hailo.close()
|
||||||
|
print("[INFO] Stream RTSP cerrado y acelerador Hailo liberado correctamente.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue