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:
parent
2fbace4109
commit
42ff966f5a
3 changed files with 200 additions and 39 deletions
27
Readme.md
27
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/`.
|
||||
|
|
|
|||
116
pet_motion.py
Normal file
116
pet_motion.py
Normal 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))
|
||||
84
pet_speed.py
84
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,39 +382,36 @@ 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:
|
||||
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] ¡{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:
|
||||
alerts.alert(
|
||||
"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()
|
||||
if now - last_stats >= STATS_INTERVAL:
|
||||
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"| ventanas {audio.windows} | ladridos {audio.alerts}"
|
||||
)
|
||||
linea += f" || escenas {escenas_cambiadas} | saltos descartados {tracker.discarded}"
|
||||
linea += f" || {alerts.stats()}"
|
||||
print(linea)
|
||||
last_stats = now
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue