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).
This commit is contained in:
abraham 2026-10-01 18:51:37 +02:00
parent 2fbace4109
commit 42ff966f5a
3 changed files with 200 additions and 39 deletions

View file

@ -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: 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_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_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_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_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). - `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. - 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`). - 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` 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). - 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. - 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 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. 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`: `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. > **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). 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): 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* Fase A: Integración de Audio (Ladridos y Sonidos) — *hecha*
Descartado el micrófono USB: se usa el de la propia cámara. 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/`. El modelo YAMNet ONNX y el diccionario de clases de AudioSet (`yamnet_classes.csv`) están en `models/`.

116
pet_motion.py Normal file
View file

@ -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))

View file

@ -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 - 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). 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 - 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. - 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), - 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. en un segundo hilo y con su propia conexión RTSP.
- Avisos por consola y a Telegram con captura anotada (ver pet_alerts.py). - 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_alerts import AlertDispatcher
from pet_audio import AudioListener, BarkDetector from pet_audio import AudioListener, BarkDetector
from pet_common import build_rtsp_url, load_env, mask_url from pet_common import build_rtsp_url, load_env, mask_url
from pet_motion import Deteccion, MotionTracker
from pet_telegram import TelegramNotifier from pet_telegram import TelegramNotifier
# --- Configuración ------------------------------------------------------- # --- Configuración -------------------------------------------------------
@ -45,6 +48,11 @@ from pet_telegram import TelegramNotifier
TARGET_CLASSES = {15: "Gato", 16: "Perro"} TARGET_CLASSES = {15: "Gato", 16: "Perro"}
CONF_THRESHOLD = 0.45 # Confianza mínima para considerar una detección 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_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 DEFAULT_STREAM = "stream2" # stream1 = 2304x1296@15, stream2 = 1280x720@15
STATS_INTERVAL = 10.0 # Segundos entre líneas de estadísticas 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.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, 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_seq = 0
last_centroid = None miniatura_previa = None
last_frame_time = 0.0 escenas_cambiadas = 0
last_stats = time.time() last_stats = time.time()
frames_processed = 0 frames_processed = 0
inference_time = 0.0 inference_time = 0.0
@ -344,9 +358,23 @@ def main():
frames_processed += 1 frames_processed += 1
src_h, src_w = frame.shape[:2] 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(): for class_id, label in TARGET_CLASSES.items():
if class_id >= len(detections_by_class): if class_id >= len(detections_by_class):
continue continue
@ -354,39 +382,36 @@ def main():
confidence = det[4] confidence = det[4]
if confidence <= CONF_THRESHOLD: if confidence <= CONF_THRESHOLD:
continue continue
pet_found = True
x1, y1, x2, y2 = box_to_source(det, scale, pad_x, pad_y, src_w, src_h) 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 resultado = tracker.update(detecciones, frame_time)
alerts.set_pet_boxes([(label, float(confidence), (x1, y1, x2, y2))]) if resultado is None:
alerts.set_pet_boxes([])
if last_centroid and dt > 0: else:
dist = math.hypot(centroid[0] - last_centroid[0], centroid[1] - last_centroid[1]) elegida, velocidad, corriendo = resultado
speed = dist / dt alerts.set_pet_boxes([(elegida.etiqueta, elegida.confianza, elegida.caja)])
if speed > SPEED_THRESHOLD: if corriendo:
alerts.alert( alerts.alert(
"carrera", "carrera",
f"⚠️ [ALERTA] ¡{label} corriendo! Vel: {speed:.2f} norm/s (Conf: {confidence:.2f})", f"⚠️ [ALERTA] ¡{elegida.etiqueta} corriendo! Vel: {velocidad:.2f} norm/s "
f"(Conf: {elegida.confianza:.2f})",
) )
else: else:
alerts.alert( alerts.alert(
"calmado", "calmado",
f"🐾 [OK] {label} detectado (calmado). Vel: {speed:.2f} norm/s (Conf: {confidence:.2f})", f"🐾 [OK] {elegida.etiqueta} detectado (calmado). Vel: {velocidad:.2f} norm/s "
f"(Conf: {elegida.confianza:.2f})",
) )
last_centroid = centroid
break # Evaluamos la mascota principal detectada
if pet_found:
break
if not pet_found:
last_centroid = None
alerts.set_pet_boxes([])
last_frame_time = frame_time
now = time.time() now = time.time()
if now - last_stats >= STATS_INTERVAL: if now - last_stats >= STATS_INTERVAL:
avg_inf = inference_time / frames_processed * 1000 if frames_processed else 0.0 avg_inf = inference_time / frames_processed * 1000 if frames_processed else 0.0
@ -400,6 +425,7 @@ def main():
f" || audio: Bark máx {audio.take_max_bark():.2f} (umbral {audio.threshold:.2f}) " f" || audio: Bark máx {audio.take_max_bark():.2f} (umbral {audio.threshold:.2f}) "
f"| ventanas {audio.windows} | ladridos {audio.alerts}" f"| ventanas {audio.windows} | ladridos {audio.alerts}"
) )
linea += f" || escenas {escenas_cambiadas} | saltos descartados {tracker.discarded}"
linea += f" || {alerts.stats()}" linea += f" || {alerts.stats()}"
print(linea) print(linea)
last_stats = now last_stats = now