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).
116 lines
4.6 KiB
Python
116 lines
4.6 KiB
Python
"""
|
|
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))
|