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).
446 lines
18 KiB
Python
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()
|