diff --git a/Readme.md b/Readme.md index 7e8bab3..8adf17b 100644 --- a/Readme.md +++ b/Readme.md @@ -75,6 +75,7 @@ uv pip install sounddevice numpy scipy requests onnxruntime El proyecto se reparte en módulos, más los scripts de apoyo: - `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_motion.py`: seguimiento del movimiento y decisión de "está corriendo". - `pet_audio.py`: detección de ladridos sobre el audio de la cámara. - `pet_alerts.py`: decide qué sale por consola, qué se guarda como captura y qué va a Telegram. - `pet_telegram.py`: cliente de Telegram (cola + hilo de envío, reintentos). @@ -87,7 +88,7 @@ El proyecto se reparte en módulos, más los scripts de apoyo: - 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` (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). -- 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). +- Calcula la velocidad de la mascota con un **seguimiento temporal** (`pet_motion.py`) y avisa si 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. @@ -103,7 +104,25 @@ Ejecución: uv run pet_speed.py ``` --- -6. Detección de Ladridos (audio de la cámara) +6. Detección de Carreras (seguimiento temporal) +La primera versión medía el salto del centroide **entre dos fotogramas consecutivos**, sin más. Eso genera falsos positivos por tres vías, y las tres se comprobaron: + +1. **Mover la cámara.** El detector encuentra "algo" antes y después de recolocar la cámara, y el salto entre ambas posiciones parece una carrera. Es el aviso falso que saltó el 2026-10-01 al mover la cámara de sitio. +2. **Parpadeo del detector.** Con la caja temblando ±2% del encuadre, la velocidad instantánea llega a 0.8 norm/s sin que nadie se mueva (medido: la lógica antigua daba 31 alertas en una escena completamente quieta). +3. **Cambio de sujeto.** Si hay dos animales y la lista de detecciones cambia de orden, el centroide salta de uno a otro (la lógica antigua daba 39 alertas con dos animales inmóviles). + +`pet_motion.py` lo resuelve con cuatro medidas: +- **Velocidad sobre una ventana de 0.2 s**, no entre dos fotogramas: el temblor se cancela, el movimiento real no. +- **Asociación por cercanía**: cada fotograma se empareja con la detección más próxima a la anterior, en vez de con la primera de la lista. +- **Descarte de saltos imposibles** (`MAX_PLAUSIBLE_SPEED = 3.0` norm/s): cámara movida o cambio de sujeto. +- **Reinicio por cambio de escena**: el bucle compara una miniatura en gris (64x36) del fotograma con la anterior; si el cambio supera `SCENE_CHANGE_THRESHOLD = 0.12`, la escena entera se ha movido y el seguimiento se reinicia en vez de avisar. Calibrado con fotogramas reales: escena quieta 0.0001, un objeto grande moviéndose 0.019-0.062, y un desplazamiento del encuadre del 5% ya da 0.119. + +Además se exige que la velocidad supere el umbral durante **3 fotogramas seguidos** (`SPEED_FRAMES_REQUIRED`), no en uno suelto. + +> **Límite conocido:** un paneo **rápido y sostenido** de la cámara (arrastrarla mientras se mueve, sin parar) sigue pareciendo una carrera: el movimiento es continuo y coherente, así que ni el descarte de saltos ni la puerta de escena lo distinguen. La solución completa sería compensar el movimiento global con correlación de fase; no se ha implementado porque recolocar la cámara son movimientos bruscos, que sí se detectan. + +--- +7. Detección de Ladridos (audio de la cámara) 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. `pet_audio.py`: @@ -127,7 +146,7 @@ El recorte de banda a 4 kHz cuesta un 14% de puntuación, pero la separación en > **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. Avisos por Telegram +8. Avisos por Telegram Cuando salta una alerta (carrera o ladrido), el monitor manda un mensaje **con la foto de la cámara anotada**: la caja de la mascota detectada, su confianza y la fecha y hora. Las capturas se guardan además en `capturas/` (ignorado por git). Puesta en marcha (una sola vez): @@ -159,7 +178,7 @@ Variables opcionales en el `.env`: ``` --- -8. Próximos Pasos (Hoja de Ruta) +9. 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/`. diff --git a/pet_motion.py b/pet_motion.py new file mode 100644 index 0000000..8aff029 --- /dev/null +++ b/pet_motion.py @@ -0,0 +1,116 @@ +""" +Seguimiento del movimiento de la mascota principal. + +El cálculo original comparaba el centroide entre dos fotogramas consecutivos, y +nada más. Con la Tapo a 15 fps eso da falsos positivos por tres vías distintas: + + 1. Mover la cámara: el detector encuentra "algo" antes y después del cambio de + encuadre, y el salto entre ambas posiciones parece una carrera. Es el caso + real que se dio el 2026-10-01. + 2. Parpadeo del detector: la caja se desplaza unos píxeles de un fotograma al + siguiente sin que el animal se mueva. + 3. Cambio de sujeto: si hay dos animales, o el detector duda, el centroide + salta de uno a otro. + +Medir la velocidad fotograma a fotograma es muy ruidoso: con la caja temblando ++-2% del encuadre, la velocidad instantánea llega a 0.8 norm/s sin que nadie se +mueva. Por eso aquí la velocidad se mide como el **desplazamiento neto sobre una +ventana de tiempo** (0.2 s por defecto): el temblor se cancela, el movimiento +real no. + +Salvaguardas: + - Asociación con la detección más cercana a la anterior, no con la primera. + - Descarte de saltos imposibles para un animal (cámara movida, cambio de sujeto). + - Velocidad medida sobre una ventana, no entre dos fotogramas. + - Exigencia de varios fotogramas seguidos por encima del umbral. + - La escena puede reiniciar el seguimiento cuando cambia entera (lo detecta + `pet_speed.py` comparando miniaturas de fotograma). +""" + +import math +from collections import namedtuple + +# Detección ya convertida a píxeles (caja) y a coordenadas normalizadas (centro) +Deteccion = namedtuple("Deteccion", "etiqueta confianza caja cx cy") + + +class MotionTracker: + """Decide si la mascota seguida está corriendo.""" + + def __init__( + self, + speed_threshold=0.30, + frames_required=3, + window=0.2, + max_plausible_speed=3.0, + max_history=10, + ): + self.speed_threshold = speed_threshold + self.frames_required = frames_required + self.window = window + self.max_plausible_speed = max_plausible_speed + self.max_history = max_history + self.discarded = 0 # saltos imposibles descartados + self.reset() + + def reset(self): + """Olvida el seguimiento actual (cambio de escena, o pérdida del sujeto).""" + self._historial = [] + self._racha = 0 + self.speed = 0.0 + + def update(self, detecciones, timestamp): + """ + detecciones: lista de Deteccion del fotograma actual (puede ir vacía). + Devuelve (deteccion_elegida, velocidad, corriendo) o None si no hay nada. + """ + elegida = self._asocia(detecciones) + if elegida is None: + self.reset() + return None + + # ¿Salto imposible desde el fotograma anterior? + if self._historial: + t_prev, x_prev, y_prev = self._historial[-1] + dt_prev = timestamp - t_prev + if dt_prev > 0 and math.hypot(elegida.cx - x_prev, elegida.cy - y_prev) / dt_prev > self.max_plausible_speed: + # Ningún animal cruza el encuadre varias veces por segundo: esto + # es la cámara moviéndose o el detector saltando a otro objeto. + self.discarded += 1 + self._historial = [(timestamp, elegida.cx, elegida.cy)] + self._racha = 0 + self.speed = 0.0 + return elegida, 0.0, False + + self._historial.append((timestamp, elegida.cx, elegida.cy)) + while len(self._historial) > 2 and timestamp - self._historial[0][0] > self.window: + self._historial.pop(0) + if len(self._historial) > self.max_history: + self._historial.pop(0) + + # Velocidad = desplazamiento neto sobre la ventana disponible + t0, x0, y0 = self._historial[0] + lapso = timestamp - t0 + if len(self._historial) < 2 or lapso < self.window * 0.5: + # Todavía no hay ventana suficiente para juzgar + self.speed = 0.0 + return elegida, 0.0, False + + self.speed = math.hypot(elegida.cx - x0, elegida.cy - y0) / lapso + + if self.speed > self.speed_threshold: + self._racha += 1 + else: + self._racha = 0 + + return elegida, self.speed, self._racha >= self.frames_required + + def _asocia(self, detecciones): + """Se queda con la detección más parecida a la que venía siguiendo.""" + if not detecciones: + return None + if not self._historial: + # Sin historial, la más fiable + return max(detecciones, key=lambda d: d.confianza) + _, cx, cy = self._historial[-1] + return min(detecciones, key=lambda d: math.hypot(d.cx - cx, d.cy - cy)) diff --git a/pet_speed.py b/pet_speed.py index 4de4c12..87cde48 100644 --- a/pet_speed.py +++ b/pet_speed.py @@ -4,8 +4,10 @@ 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 - y alerta de carrera según el desplazamiento del centroide entre fotogramas. + - 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). @@ -38,6 +40,7 @@ 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 ------------------------------------------------------- @@ -45,6 +48,11 @@ from pet_telegram import TelegramNotifier 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 @@ -318,9 +326,15 @@ def main(): 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 - last_centroid = None - last_frame_time = 0.0 + miniatura_previa = None + escenas_cambiadas = 0 last_stats = time.time() frames_processed = 0 inference_time = 0.0 @@ -344,9 +358,23 @@ def main(): 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 + # ¿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 @@ -354,38 +382,35 @@ def main(): confidence = det[4] if confidence <= CONF_THRESHOLD: continue - - pet_found = True 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) + detecciones.append( + Deteccion( + label, + float(confidence), + (x1, y1, x2, y2), + (x1 + x2) / 2.0 / src_w, + (y1 + y2) / 2.0 / src_h, + ) + ) - # Cajas del fotograma actual, para anotar la captura del aviso - alerts.set_pet_boxes([(label, float(confidence), (x1, y1, x2, y2))]) - - if last_centroid and dt > 0: - dist = math.hypot(centroid[0] - last_centroid[0], centroid[1] - last_centroid[1]) - speed = dist / dt - if speed > SPEED_THRESHOLD: - alerts.alert( - "carrera", - f"⚠️ [ALERTA] ¡{label} corriendo! Vel: {speed:.2f} norm/s (Conf: {confidence:.2f})", - ) - else: - alerts.alert( - "calmado", - f"🐾 [OK] {label} detectado (calmado). Vel: {speed:.2f} norm/s (Conf: {confidence:.2f})", - ) - - last_centroid = centroid - break # Evaluamos la mascota principal detectada - if pet_found: - break - - if not pet_found: - last_centroid = None + resultado = tracker.update(detecciones, frame_time) + if resultado is None: alerts.set_pet_boxes([]) - - last_frame_time = frame_time + 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: @@ -400,6 +425,7 @@ def main(): 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