Usar la camara IP Tapo por RTSP en lugar de la camara CSI

- pet_speed.py: ingesta RTSP en hilo aparte, que descarta fotogramas antiguos y se reconecta con backoff exponencial; escalado letterbox a 640x640 con desmapeo de cajas y centroide al fotograma original; configuracion por .env (con CAMERA_STREAM y CAMERA_RTSP_URL opcionales); cierre limpio con SIGINT y SIGTERM.

- Readme.md: seccion de la camara Tapo (cuenta de camara, streams, rendimiento medido); se corrige Hailo-8L por Hailo-8, que es lo que reporta hailortcli.

- .gitignore: excluir los logs del runtime Hailo.
This commit is contained in:
abraham 2026-10-01 16:17:18 +02:00
parent 44849611a3
commit 60002a90a7
5 changed files with 368 additions and 6951 deletions

4
.gitignore vendored
View file

@ -9,3 +9,7 @@ wheels/
# Virtual environments # Virtual environments
.venv .venv
.lock .lock
# Logs del runtime Hailo (se regeneran en cada ejecución)
hailort.log
hailort.1.log

View file

@ -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,23 +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 optimizado con aceleración por hardware que: `pet_speed.py`: Script de visión artificial optimizado con aceleración por hardware que:
- Inicializa `Picamera2` con flujo dual sincronizado por hardware (PiSP): - 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.
- Stream `main` (1280x720) para capturas de alta definición y alertas. - Se **reconecta automáticamente** con retroceso exponencial (2 s → 30 s) si la cámara se cae, se reinicia o cambia de IP.
- Stream `lores` (640x640) escalado por hardware por el ISP directamente al formato esperado por el modelo YOLOv8 (0% sobrecarga en CPU). - 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.
- Utiliza la API nativa de aceleración `picamera2.devices.hailo.Hailo` cargando el modelo optimizado (`/usr/share/hailo-models/yolov8s_h8.hef`). - 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`).
- Filtra detecciones del conjunto COCO para gato (id: 15) y perro (id: 16). - 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`). - 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).
- Libera limpiamente la cámara y el acelerador Hailo al recibir señal de interrupción (`SIGINT`/`Ctrl+C`). - 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: Ejecución:
```bash ```bash
uv run 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:
@ -63,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:

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,95 +1,330 @@
#!/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
import os import os
import sys import signal
import threading
import time import time
from picamera2 import Picamera2 import urllib.parse
import cv2
import numpy as np
from picamera2.devices.hailo import Hailo, hailo_architecture from picamera2.devices.hailo import Hailo, hailo_architecture
# --- 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"}
SPEED_THRESHOLD = 0.30 # Umbral de velocidad normalizada por segundo 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
# 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.
# (No se usa 'stimeout': fue eliminado en FFmpeg 6, que es el que trae OpenCV 4.10)
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(): def get_hailo_model_path():
"""Elige el HEF de YOLOv8s que corresponde a la arquitectura detectada."""
arch = hailo_architecture() arch = hailo_architecture()
candidates = []
if arch == "HAILO8": if arch == "HAILO8":
candidates = [ candidates = ["/usr/share/hailo-models/yolov8s_h8.hef", "/usr/share/hailo-models/yolov8s_h8l.hef"]
"/usr/share/hailo-models/yolov8s_h8.hef",
"/usr/share/hailo-models/yolov8s_h8l.hef"
]
else: else:
candidates = [ candidates = ["/usr/share/hailo-models/yolov8s_h8l.hef", "/usr/share/hailo-models/yolov8s_h8.hef"]
"/usr/share/hailo-models/yolov8s_h8l.hef",
"/usr/share/hailo-models/yolov8s_h8.hef"
]
for path in candidates: for path in candidates:
if os.path.exists(path): if os.path.exists(path):
return path return path
raise FileNotFoundError("No se encontró ningún modelo YOLOv8 HEF en /usr/share/hailo-models/") raise FileNotFoundError("No se encontró ningún modelo YOLOv8 HEF en /usr/share/hailo-models/")
def calculate_centroid(box):
def letterbox(frame, width, height):
""" """
Hailo YOLOv8 NMS devuelve [ymin, xmin, ymax, xmax, confidence]. Reescala manteniendo la relación de aspecto y rellena hasta width x height.
Calcula el centroide normalizado (x, y). 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] ymin, xmin, ymax, xmax = box[:4]
cx = (xmin + xmax) / 2.0 model_w = src_w * scale + 2 * pad_x
cy = (ymin + ymax) / 2.0 model_h = src_h * scale + 2 * pad_y
return cx, cy 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(): 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() model_path = get_hailo_model_path()
print(f"[INFO] Cargando modelo Hailo: {model_path} ...") print(f"[INFO] Cargando modelo Hailo: {model_path} ...")
hailo = Hailo(model_path) hailo = Hailo(model_path)
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}")
picam2 = Picamera2() # Ocultamos la contraseña al mostrarla por consola
# Usamos configuración dual: print(f"[INFO] Cámara Tapo: {mask_url(url)}")
# - main: 1280x720 para visualización / grabación / alertas nítidas
# - lores: 640x640 directo al tamaño del modelo Hailo sin sobrecarga de CPU (escalado por hardware PiSP)
config = picam2.create_video_configuration(
main={"size": (1280, 720), "format": "RGB888"},
lores={"size": (model_w, model_h), "format": "RGB888"},
controls={"FrameRate": 30}
)
picam2.configure(config)
picam2.start()
print("[INFO] Pipeline Picamera2 + Hailo activo a 30 FPS.") stream = RtspVideoStream(url).start()
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.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():
# Capturamos el frame 'lores' escalado por hardware por el ISP seq, frame, frame_time = stream.wait_for_frame(last_seq)
lores_frame = picam2.capture_array("lores") if frame is None:
current_time = time.time() continue
dt = current_time - last_time last_seq = seq
# Inferencia por hardware en la NPU Hailo letterboxed, scale, pad_x, pad_y = letterbox(frame, model_w, model_h)
detections_by_class = hailo.run(lores_frame) 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
pet_found = False pet_found = False
for class_id, label in TARGET_CLASSES.items(): for class_id, label in TARGET_CLASSES.items():
if class_id < len(detections_by_class): if class_id >= len(detections_by_class):
class_detections = detections_by_class[class_id] continue
for det in class_detections: for det in detections_by_class[class_id]:
# det: [ymin, xmin, ymax, xmax, confidence]
confidence = det[4] confidence = det[4]
if confidence <= CONF_THRESHOLD:
continue
if confidence > 0.45:
pet_found = True pet_found = True
centroid = calculate_centroid(det) x1, y1, x2, y2 = box_to_source(det, scale, pad_x, pad_y, src_w, src_h)
centroid = ((x1 + x2) / 2.0 / src_w, (y1 + y2) / 2.0 / src_h)
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:
@ -103,14 +338,25 @@ def main():
if not pet_found: if not pet_found:
last_centroid = None last_centroid = None
last_time = current_time last_frame_time = frame_time
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 de mascotas...")
finally: finally:
picam2.stop() stream.stop()
hailo.close() hailo.close()
print("[INFO] Cámara y acelerador Hailo liberados correctamente.") print("[INFO] Stream RTSP cerrado y acelerador Hailo liberado correctamente.")
if __name__ == "__main__": if __name__ == "__main__":
main() main()