Pet-Monitor/pet_speed.py
abraham 42ff966f5a Evitar falsos positivos de carrera al mover la camara
La velocidad se medía con el salto del centroide entre dos fotogramas consecutivos. Con la Tapo a 15 fps eso daba falsos positivos por tres vías, todas comprobadas: mover la cámara (el aviso falso del 2026-10-01), el parpadeo de la caja del detector (31 alertas con la escena quieta) y el cambio de sujeto cuando hay dos animales (39 alertas con ambos inmóviles).

- pet_motion.py: seguimiento temporal. Velocidad medida sobre una ventana de 0.2 s (el temblor se cancela, el movimiento real no), asociación con la detección más cercana a la anterior en vez de con la primera de la lista, descarte de saltos imposibles para un animal y exigencia de 3 fotogramas seguidos por encima del umbral.

- pet_speed.py: detecta el cambio de escena comparando una miniatura del fotograma (calibrado con fotogramas reales: escena quieta 0.0001, mascota moviéndose hasta 0.062, encuadre desplazado un 5% ya 0.119) y reinicia el seguimiento en vez de avisar.

- Readme.md: sección de detección de carreras, con las tres causas de falso positivo y el límite conocido (paneo rápido y sostenido).
2026-10-01 18:51:37 +02:00

446 lines
18 KiB
Python

#!/usr/bin/env python3
"""
Pet Monitor - Visión por computador con cámara IP Tapo (RTSP) + NPU Hailo.
- 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.
- Alerta de carrera con seguimiento temporal (ver pet_motion.py): velocidad
medida sobre una ventana, descarte de saltos imposibles y reinicio del
seguimiento cuando la escena cambia entera porque han movido la cámara.
- 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.
- Avisos por consola y a Telegram con captura anotada (ver pet_alerts.py).
- Reconexión automática con backoff en todos los 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)
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID (opcional: avisos a Telegram; el chat se
deduce solo si no se indica)
ALERT_COOLDOWN (opcional: segundos entre capturas de un aviso)
SNAPSHOTS, SNAPSHOT_DIR (opcional: guardar las capturas en disco)
Ejecución: uv run pet_speed.py
"""
import math
import os
import signal
import threading
import time
import cv2
import numpy as np
from picamera2.devices.hailo import Hailo, hailo_architecture
from pet_alerts import AlertDispatcher
from pet_audio import AudioListener, BarkDetector
from pet_common import build_rtsp_url, load_env, mask_url
from pet_motion import Deteccion, MotionTracker
from pet_telegram import TelegramNotifier
# --- Configuración -------------------------------------------------------
# Clases COCO: 15 = Cat (Gato), 16 = Dog (Perro)
TARGET_CLASSES = {15: "Gato", 16: "Perro"}
CONF_THRESHOLD = 0.45 # Confianza mínima para considerar una detección
SPEED_THRESHOLD = 0.30 # Velocidad normalizada por segundo (fracción del frame)
SPEED_FRAMES_REQUIRED = 3 # Fotogramas seguidos por encima del umbral para avisar
SPEED_WINDOW = 0.2 # Ventana (s) sobre la que se mide la velocidad
MAX_PLAUSIBLE_SPEED = 3.0 # Salto imposible para un animal: cámara movida o cambio de sujeto
SCENE_CHANGE_THRESHOLD = 0.12 # Cambio global de la miniatura que delata que se movió la cámara
SCENE_THUMBNAIL = (64, 36) # Tamaño de la miniatura para detectar el cambio de escena
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
ALERT_COOLDOWN = 60.0 # Segundos mínimos entre capturas del mismo tipo de aviso
SNAPSHOT_DIR = "capturas"
# 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()
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 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
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 ----------------------------------------------------
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 latest_frame(self):
"""
Copia del último fotograma recibido, para las capturas de alerta.
Se copia fuera del cerrojo porque el hilo lector reemplaza el array en
lugar de modificarlo, así que la referencia no cambia bajo los pies.
"""
with self._lock:
frame = self._frame
return None if frame is None else frame.copy()
def stop(self):
self._stop.set()
self._new_frame.set()
if self._thread is not None:
self._thread.join(timeout=5.0)
# --- Arranque de los canales de aviso ------------------------------------
def start_telegram(env):
"""Arranca el envío a Telegram, o explica por qué no se puede."""
token = env.get("TELEGRAM_BOT_TOKEN")
if not token:
print("[INFO] Telegram no configurado (falta TELEGRAM_BOT_TOKEN); los avisos solo saldrán por consola.")
return None
try:
notifier = TelegramNotifier(token, env.get("TELEGRAM_CHAT_ID") or None).start()
except Exception as exc: # noqa: BLE001
print(f"[WARN] Telegram desactivado: {exc}")
return None
print("[INFO] Telegram activo.")
return notifier
def start_audio_listener(url, env, alerts):
"""
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 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):
alerts.alert("ladrido", f"🔊 [ALERTA] ¡Ladrido detectado! Bark {ladrido:.2f} (grupo canino {grupo:.2f})")
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()
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}")
print(f"[INFO] Cámara Tapo: {mask_url(url)}")
stream = RtspVideoStream(url).start()
notifier = start_telegram(env)
alerts = AlertDispatcher(
stream,
notifier=notifier,
snapshot_dir=env.get("SNAPSHOT_DIR", SNAPSHOT_DIR),
save_snapshots=env.get("SNAPSHOTS", "1") != "0",
capture_cooldown=float(env.get("ALERT_COOLDOWN", ALERT_COOLDOWN)),
)
audio = start_audio_listener(url, env, alerts)
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)
tracker = MotionTracker(
speed_threshold=SPEED_THRESHOLD,
frames_required=SPEED_FRAMES_REQUIRED,
window=SPEED_WINDOW,
max_plausible_speed=MAX_PLAUSIBLE_SPEED,
)
last_seq = 0
miniatura_previa = None
escenas_cambiadas = 0
last_stats = time.time()
frames_processed = 0
inference_time = 0.0
try:
while running.is_set():
seq, frame, frame_time = stream.wait_for_frame(last_seq)
if frame is None:
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]
# ¿Ha movido alguien la cámara? Si la escena cambia entera, no es la
# mascota moviéndose: reiniciamos el seguimiento en vez de avisar.
miniatura = cv2.cvtColor(
cv2.resize(frame, SCENE_THUMBNAIL, interpolation=cv2.INTER_AREA),
cv2.COLOR_BGR2GRAY,
)
if miniatura_previa is not None:
cambio = float(np.mean(cv2.absdiff(miniatura, miniatura_previa))) / 255.0
if cambio > SCENE_CHANGE_THRESHOLD:
tracker.reset()
escenas_cambiadas += 1
miniatura_previa = miniatura
# Todas las detecciones de interés del fotograma, en píxeles y en
# coordenadas normalizadas. El tracker decide con cuál se queda.
detecciones = []
for class_id, label in TARGET_CLASSES.items():
if class_id >= len(detections_by_class):
continue
for det in detections_by_class[class_id]:
confidence = det[4]
if confidence <= CONF_THRESHOLD:
continue
x1, y1, x2, y2 = box_to_source(det, scale, pad_x, pad_y, src_w, src_h)
detecciones.append(
Deteccion(
label,
float(confidence),
(x1, y1, x2, y2),
(x1 + x2) / 2.0 / src_w,
(y1 + y2) / 2.0 / src_h,
)
)
resultado = tracker.update(detecciones, frame_time)
if resultado is None:
alerts.set_pet_boxes([])
else:
elegida, velocidad, corriendo = resultado
alerts.set_pet_boxes([(elegida.etiqueta, elegida.confianza, elegida.caja)])
if corriendo:
alerts.alert(
"carrera",
f"⚠️ [ALERTA] ¡{elegida.etiqueta} corriendo! Vel: {velocidad:.2f} norm/s "
f"(Conf: {elegida.confianza:.2f})",
)
else:
alerts.alert(
"calmado",
f"🐾 [OK] {elegida.etiqueta} detectado (calmado). Vel: {velocidad:.2f} norm/s "
f"(Conf: {elegida.confianza:.2f})",
)
now = time.time()
if now - last_stats >= STATS_INTERVAL:
avg_inf = inference_time / frames_processed * 1000 if frames_processed else 0.0
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}"
)
linea += f" || escenas {escenas_cambiadas} | saltos descartados {tracker.discarded}"
linea += f" || {alerts.stats()}"
print(linea)
last_stats = now
frames_processed = 0
inference_time = 0.0
finally:
stream.stop()
if audio is not None:
audio.stop()
if notifier is not None:
notifier.stop()
hailo.close()
print("[INFO] Streams cerrados y acelerador Hailo liberado correctamente.")
if __name__ == "__main__":
main()