Spaces:
Sleeping
Sleeping
Commit ·
0c44a5a
1
Parent(s): 78ff9fe
Mostrar a los intrusos en una tabla con su foto a medida que se analizan
Browse filesLa página es para jefes no técnicos: se explica sola, sin instrucciones de uso.
Los conceptos (qué es intruso, cuadros por segundo, límites, que el video va a
Hugging Face) quedan en "Sobre esta demo", plegado.
- Una sola tabla con la foto recortada de cada intruso en vez de tabla + galería
de cuadros enteros, que parecían todos la misma foto.
- analyze es un generador: las filas aparecen mientras se analiza; salida y
duración se completan al final.
- El video aparece abajo y recién al terminar: vacío parecía roto.
- Un solo indicador de avance, en el botón. Las barras de Gradio iban una por
resultado, y la de la tabla tapaba las filas nuevas.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- camguard/pipeline.py +42 -21
- camguard/ui.py +82 -47
- tests/test_pipeline.py +21 -9
- tests/test_ui.py +2 -2
camguard/pipeline.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
"""El análisis completo de un video: lee, detecta, decide quién es intruso, dibuja y resume.
|
| 2 |
|
| 3 |
-
No depende de la interfaz: informa errores con ``ValueError`` y
|
| 4 |
-
así se puede usar desde Gradio, desde un script o desde los tests
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
import math
|
| 8 |
import tempfile
|
| 9 |
-
from collections.abc import Callable
|
| 10 |
from dataclasses import dataclass
|
| 11 |
from pathlib import Path
|
| 12 |
from typing import Protocol
|
|
@@ -30,12 +31,16 @@ class Tracker(Protocol):
|
|
| 30 |
...
|
| 31 |
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
@dataclass(frozen=True)
|
| 34 |
class Snapshot:
|
| 35 |
-
"""
|
| 36 |
|
| 37 |
Attributes:
|
| 38 |
-
image:
|
| 39 |
track_id: Número de seguimiento de la persona.
|
| 40 |
time_s: Segundo del video.
|
| 41 |
"""
|
|
@@ -47,41 +52,54 @@ class Snapshot:
|
|
| 47 |
|
| 48 |
@dataclass(frozen=True)
|
| 49 |
class AnalysisResult:
|
| 50 |
-
"""Resultado de analizar un video.
|
| 51 |
|
| 52 |
Attributes:
|
| 53 |
-
|
| 54 |
-
events: Cada estadía de una persona en la zona, ordenadas por entrada.
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
"""
|
| 57 |
|
| 58 |
-
|
| 59 |
events: list[Event]
|
| 60 |
snapshots: list[Snapshot]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
def analyze(
|
| 64 |
video_path: str | Path,
|
| 65 |
zone_layers: list[np.ndarray],
|
| 66 |
make_tracker: Callable[[float], Tracker] = PersonTracker,
|
| 67 |
-
progress: Callable[[float], None] | None = None,
|
| 68 |
out_dir: Path | None = None,
|
| 69 |
-
) -> AnalysisResult:
|
| 70 |
"""Analiza un video y detecta quién entra a la zona restringida.
|
| 71 |
|
| 72 |
Se analizan ``FPS`` cuadros por segundo y hasta ``MAX_SECONDS`` segundos (ver ``config``).
|
|
|
|
|
|
|
| 73 |
|
| 74 |
Args:
|
| 75 |
video_path: Video a analizar.
|
| 76 |
zone_layers: Capas RGBA pintadas por el usuario (ver :func:`zone_from_layers`).
|
| 77 |
make_tracker: Crea el tracker a partir de los fps analizados. Por defecto el real
|
| 78 |
(RF-DETR + ByteTrack); los tests pasan uno falso.
|
| 79 |
-
progress: Se llama después de cada cuadro con el avance, entre 0 y 1.
|
| 80 |
out_dir: Dónde guardar el video anotado (en una subcarpeta propia). Por defecto, la
|
| 81 |
carpeta temporal del sistema. La app pasa una que vacía al arrancar.
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
|
| 86 |
Raises:
|
| 87 |
ValueError: Si el video no se puede leer o no hay zona pintada.
|
|
@@ -109,12 +127,15 @@ def analyze(
|
|
| 109 |
centers = detections.get_anchors_coordinates(sv.Position.CENTER)
|
| 110 |
inside = points_in_zone(centers, zone)
|
| 111 |
annotated = annotate(frame, detections, inside, zone)
|
| 112 |
-
for track_id in
|
|
|
|
|
|
|
| 113 |
if track_id not in frames_in_zone:
|
| 114 |
-
|
| 115 |
-
|
|
|
|
| 116 |
writer.write(annotated)
|
| 117 |
-
|
| 118 |
-
|
| 119 |
|
| 120 |
-
|
|
|
|
| 1 |
"""El análisis completo de un video: lee, detecta, decide quién es intruso, dibuja y resume.
|
| 2 |
|
| 3 |
+
No depende de la interfaz: informa errores con ``ValueError`` y va devolviendo resultados
|
| 4 |
+
parciales a medida que avanza, así se puede usar desde Gradio, desde un script o desde los tests
|
| 5 |
+
(con un tracker falso).
|
| 6 |
"""
|
| 7 |
|
| 8 |
import math
|
| 9 |
import tempfile
|
| 10 |
+
from collections.abc import Callable, Iterator
|
| 11 |
from dataclasses import dataclass
|
| 12 |
from pathlib import Path
|
| 13 |
from typing import Protocol
|
|
|
|
| 31 |
...
|
| 32 |
|
| 33 |
|
| 34 |
+
# Aire que se deja alrededor de una persona al recortarla, como fracción de su alto y ancho.
|
| 35 |
+
CROP_MARGIN = 0.1
|
| 36 |
+
|
| 37 |
+
|
| 38 |
@dataclass(frozen=True)
|
| 39 |
class Snapshot:
|
| 40 |
+
"""Foto de una persona en el momento en que aparece por primera vez dentro de la zona.
|
| 41 |
|
| 42 |
Attributes:
|
| 43 |
+
image: La persona recortada del cuadro original (sin dibujos encima), en RGB.
|
| 44 |
track_id: Número de seguimiento de la persona.
|
| 45 |
time_s: Segundo del video.
|
| 46 |
"""
|
|
|
|
| 52 |
|
| 53 |
@dataclass(frozen=True)
|
| 54 |
class AnalysisResult:
|
| 55 |
+
"""Resultado de analizar un video, parcial mientras el análisis avanza.
|
| 56 |
|
| 57 |
Attributes:
|
| 58 |
+
progress: Avance, entre 0 y 1.
|
| 59 |
+
events: Cada estadía de una persona en la zona, ordenadas por entrada. Mientras se
|
| 60 |
+
analiza, la salida de quien sigue adentro todavía no es la definitiva.
|
| 61 |
+
snapshots: Una foto por persona: la de su primera entrada.
|
| 62 |
+
video_path: mp4 anotado (H.264), en una carpeta temporal. ``None`` hasta terminar.
|
| 63 |
"""
|
| 64 |
|
| 65 |
+
progress: float
|
| 66 |
events: list[Event]
|
| 67 |
snapshots: list[Snapshot]
|
| 68 |
+
video_path: Path | None = None
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def crop_person(frame: np.ndarray, box: np.ndarray) -> np.ndarray:
|
| 72 |
+
"""Recorta a una persona del cuadro, con algo de aire alrededor."""
|
| 73 |
+
x1, y1, x2, y2 = box
|
| 74 |
+
mx, my = (x2 - x1) * CROP_MARGIN, (y2 - y1) * CROP_MARGIN
|
| 75 |
+
h, w = frame.shape[:2]
|
| 76 |
+
top, bottom = max(0, int(y1 - my)), min(h, int(y2 + my))
|
| 77 |
+
left, right = max(0, int(x1 - mx)), min(w, int(x2 + mx))
|
| 78 |
+
return frame[top:bottom, left:right]
|
| 79 |
|
| 80 |
|
| 81 |
def analyze(
|
| 82 |
video_path: str | Path,
|
| 83 |
zone_layers: list[np.ndarray],
|
| 84 |
make_tracker: Callable[[float], Tracker] = PersonTracker,
|
|
|
|
| 85 |
out_dir: Path | None = None,
|
| 86 |
+
) -> Iterator[AnalysisResult]:
|
| 87 |
"""Analiza un video y detecta quién entra a la zona restringida.
|
| 88 |
|
| 89 |
Se analizan ``FPS`` cuadros por segundo y hasta ``MAX_SECONDS`` segundos (ver ``config``).
|
| 90 |
+
Es un generador: devuelve el resultado parcial después de cada cuadro, para mostrar a los
|
| 91 |
+
intrusos a medida que aparecen, y al final el completo, con el video.
|
| 92 |
|
| 93 |
Args:
|
| 94 |
video_path: Video a analizar.
|
| 95 |
zone_layers: Capas RGBA pintadas por el usuario (ver :func:`zone_from_layers`).
|
| 96 |
make_tracker: Crea el tracker a partir de los fps analizados. Por defecto el real
|
| 97 |
(RF-DETR + ByteTrack); los tests pasan uno falso.
|
|
|
|
| 98 |
out_dir: Dónde guardar el video anotado (en una subcarpeta propia). Por defecto, la
|
| 99 |
carpeta temporal del sistema. La app pasa una que vacía al arrancar.
|
| 100 |
|
| 101 |
+
Yields:
|
| 102 |
+
Los resultados parciales y, último, el completo (con ``video_path``).
|
| 103 |
|
| 104 |
Raises:
|
| 105 |
ValueError: Si el video no se puede leer o no hay zona pintada.
|
|
|
|
| 127 |
centers = detections.get_anchors_coordinates(sv.Position.CENTER)
|
| 128 |
inside = points_in_zone(centers, zone)
|
| 129 |
annotated = annotate(frame, detections, inside, zone)
|
| 130 |
+
for box, track_id in zip(
|
| 131 |
+
detections.xyxy[inside], detections.tracker_id[inside], strict=True
|
| 132 |
+
):
|
| 133 |
if track_id not in frames_in_zone:
|
| 134 |
+
photo = crop_person(frame, box)[:, :, ::-1]
|
| 135 |
+
snapshots.append(Snapshot(photo, int(track_id), i / fps))
|
| 136 |
+
frames_in_zone.setdefault(int(track_id), []).append(i)
|
| 137 |
writer.write(annotated)
|
| 138 |
+
progress = min(1.0, (i + 1) / expected)
|
| 139 |
+
yield AnalysisResult(progress, build_events(frames_in_zone, fps), list(snapshots))
|
| 140 |
|
| 141 |
+
yield AnalysisResult(1.0, build_events(frames_in_zone, fps), snapshots, out_path)
|
camguard/ui.py
CHANGED
|
@@ -1,19 +1,21 @@
|
|
| 1 |
"""La página web (Gradio): subir un video, pintar la zona, analizar y ver el resultado.
|
| 2 |
|
| 3 |
Es la única parte que conoce Gradio: traduce los ``ValueError`` del pipeline en avisos para
|
| 4 |
-
el usuario y los resultados en tabla y
|
| 5 |
"""
|
| 6 |
|
|
|
|
| 7 |
import os
|
| 8 |
import shutil
|
| 9 |
|
|
|
|
| 10 |
import gradio as gr
|
| 11 |
import numpy as np
|
| 12 |
import spaces
|
| 13 |
|
| 14 |
from camguard.config import FPS, MAX_SECONDS, MAX_UPLOAD, TEMP_DIR
|
| 15 |
from camguard.detection import load_model
|
| 16 |
-
from camguard.pipeline import analyze
|
| 17 |
from camguard.video import first_frame
|
| 18 |
|
| 19 |
# Grosor del pincel, en píxeles del video (1920 de ancho en el ejemplo).
|
|
@@ -48,31 +50,22 @@ REFIT_EDITOR_JS = """() => {
|
|
| 48 |
# corre entera y el trazo cae fuera de la imagen. Recortar lo que sobresale lo evita.
|
| 49 |
EDITOR_CSS = ".pixi-target, .pixi-target-crop { overflow: hidden; }"
|
| 50 |
|
|
|
|
| 51 |
# Hugging Face define SPACE_ID en todo Space; en la PC no existe.
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
INSTRUCTIONS = f"""\
|
| 60 |
-
# CamGuard — Intruso en zona
|
| 61 |
-
1. Ya viene cargado un video de ejemplo. Si querés usar uno propio, tocá la ✕ del video y \
|
| 62 |
-
subí el tuyo.
|
| 63 |
-
2. Pintá encima de la imagen de la derecha el área restringida: el pincel ya está activo, \
|
| 64 |
-
arrastrá directamente sobre la imagen. Si te equivocás, tocá **Borrar zona** y volvé a pintar.
|
| 65 |
-
3. Tocá **Analizar**.
|
| 66 |
-
|
| 67 |
-
Se analizan {FPS} cuadros por segundo y hasta {MAX_SECONDS} s de video.
|
| 68 |
-
|
| 69 |
-
{WHERE}
|
| 70 |
-
|
| 71 |
-
> ⚠️ **Es una demo.** Puede no detectar a algunas personas (lejanas, tapadas, agachadas o con \
|
| 72 |
-
poca luz) y no reemplaza la vigilancia humana. Una entrada a los 0,2 s puede significar que la \
|
| 73 |
-
persona ya estaba en la zona cuando empezó el video.
|
| 74 |
"""
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
def editor_value(background: np.ndarray, layers: list[np.ndarray] | None = None) -> dict:
|
| 78 |
"""Arma el valor del editor de Gradio: imagen de fondo y capas pintadas."""
|
|
@@ -124,69 +117,111 @@ def blank_zone(video_path: str | None):
|
|
| 124 |
return gr.skip()
|
| 125 |
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
# En el Space de Hugging Face pide una GPU (ZeroGPU) mientras dura el análisis; en la PC no hace
|
| 128 |
# nada. Va sobre on_analyze y no sobre analyze porque ZeroGPU lo corre en otro proceso y convierte
|
| 129 |
# cualquier excepción que no sea gr.Error en un "ZeroGPU worker error": acá los ValueError ya
|
| 130 |
# se volvieron avisos.
|
| 131 |
@spaces.GPU
|
| 132 |
-
def on_analyze(video_path: str | None, editor: dict | None
|
| 133 |
-
"""Corre el análisis y
|
| 134 |
|
| 135 |
-
|
| 136 |
-
(
|
| 137 |
-
|
|
|
|
| 138 |
"""
|
| 139 |
if not video_path:
|
| 140 |
warn("Primero subí un video.")
|
| 141 |
-
|
|
|
|
| 142 |
layers = (editor or {}).get("layers") or []
|
|
|
|
| 143 |
try:
|
| 144 |
-
result =
|
| 145 |
-
video_path,
|
| 146 |
-
layers,
|
| 147 |
-
progress=lambda p: progress(p, desc="Analizando"),
|
| 148 |
-
out_dir=TEMP_DIR / "resultados",
|
| 149 |
-
)
|
| 150 |
except ValueError as e: # video ilegible o sin zona pintada
|
| 151 |
warn(str(e))
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
if not result.events:
|
| 155 |
gr.Info("No hubo intrusiones en la zona.")
|
| 156 |
-
|
| 157 |
-
gallery = [(s.image, f"Intruso #{s.track_id} — {s.time_s:.1f} s") for s in result.snapshots]
|
| 158 |
-
return str(result.video_path), rows, gallery
|
| 159 |
|
| 160 |
|
| 161 |
def build_demo() -> gr.Blocks:
|
| 162 |
-
"""Arma la página: video y editor lado a lado, el botón,
|
| 163 |
example_video, example_editor = load_example()
|
| 164 |
# analytics_enabled=False: Gradio manda telemetría de uso por defecto (no los videos, pero
|
| 165 |
# una app que maneja grabaciones de seguridad no debería hablar con nadie).
|
| 166 |
with gr.Blocks(title="CamGuard", analytics_enabled=False, delete_cache=CACHE_CLEANUP) as demo:
|
| 167 |
-
gr.Markdown(
|
|
|
|
|
|
|
| 168 |
with gr.Row():
|
| 169 |
video = gr.Video(value=example_video, label="Video", sources=["upload"])
|
| 170 |
with gr.Column():
|
| 171 |
editor = gr.ImageEditor(
|
| 172 |
value=example_editor,
|
| 173 |
-
label="Zona restringida
|
| 174 |
type="numpy",
|
| 175 |
sources=[], # la imagen siempre es el primer cuadro del video
|
| 176 |
brush=gr.Brush(default_size=BRUSH_SIZE, colors=["#ff0000"], color_mode="fixed"),
|
| 177 |
)
|
| 178 |
clear = gr.Button("Borrar zona", size="sm")
|
| 179 |
run = gr.Button("Analizar", variant="primary")
|
| 180 |
-
result = gr.Video(label="Resultado")
|
| 181 |
table = gr.Dataframe(
|
| 182 |
-
headers=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
)
|
| 184 |
-
|
|
|
|
| 185 |
|
| 186 |
demo.load(None, js=REFIT_EDITOR_JS)
|
| 187 |
video.upload(blank_zone, video, editor).then(None, js=REFIT_EDITOR_JS)
|
| 188 |
clear.click(blank_zone, video, editor).then(None, js=REFIT_EDITOR_JS)
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
return demo
|
| 191 |
|
| 192 |
|
|
|
|
| 1 |
"""La página web (Gradio): subir un video, pintar la zona, analizar y ver el resultado.
|
| 2 |
|
| 3 |
Es la única parte que conoce Gradio: traduce los ``ValueError`` del pipeline en avisos para
|
| 4 |
+
el usuario y los resultados en una tabla (con la foto de cada intruso) y el video anotado.
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
import base64
|
| 8 |
import os
|
| 9 |
import shutil
|
| 10 |
|
| 11 |
+
import cv2
|
| 12 |
import gradio as gr
|
| 13 |
import numpy as np
|
| 14 |
import spaces
|
| 15 |
|
| 16 |
from camguard.config import FPS, MAX_SECONDS, MAX_UPLOAD, TEMP_DIR
|
| 17 |
from camguard.detection import load_model
|
| 18 |
+
from camguard.pipeline import AnalysisResult, analyze
|
| 19 |
from camguard.video import first_frame
|
| 20 |
|
| 21 |
# Grosor del pincel, en píxeles del video (1920 de ancho en el ejemplo).
|
|
|
|
| 50 |
# corre entera y el trazo cae fuera de la imagen. Recortar lo que sobresale lo evita.
|
| 51 |
EDITOR_CSS = ".pixi-target, .pixi-target-crop { overflow: hidden; }"
|
| 52 |
|
| 53 |
+
# La página se explica sola: esto son conceptos, plegados para quien quiera saber más.
|
| 54 |
# Hugging Face define SPACE_ID en todo Space; en la PC no existe.
|
| 55 |
+
ABOUT = f"""\
|
| 56 |
+
- Es intruso quien tiene el centro del cuerpo sobre la zona.
|
| 57 |
+
- Se analizan {FPS} cuadros por segundo y hasta {MAX_SECONDS} s de video.
|
| 58 |
+
- Si alguien entró en el segundo 0, ya estaba en la zona cuando empezó el video.
|
| 59 |
+
- Puede no detectar a personas lejanas, tapadas, agachadas o con poca luz: no reemplaza la \
|
| 60 |
+
vigilancia humana.
|
| 61 |
+
{"- El video se procesa en servidores de Hugging Face." if os.getenv("SPACE_ID") else ""}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
"""
|
| 63 |
|
| 64 |
+
HEADERS = ["ID", "Entró (s)", "Salió (s)", "Duración (s)", "Foto"]
|
| 65 |
+
|
| 66 |
+
# Alto con que se ve la foto de cada intruso en la tabla.
|
| 67 |
+
PHOTO_PX = 120
|
| 68 |
+
|
| 69 |
|
| 70 |
def editor_value(background: np.ndarray, layers: list[np.ndarray] | None = None) -> dict:
|
| 71 |
"""Arma el valor del editor de Gradio: imagen de fondo y capas pintadas."""
|
|
|
|
| 117 |
return gr.skip()
|
| 118 |
|
| 119 |
|
| 120 |
+
def photo_html(image_rgb: np.ndarray) -> str:
|
| 121 |
+
"""Convierte la foto de un intruso en un <img> embebido, para la columna "Foto"."""
|
| 122 |
+
_, jpg = cv2.imencode(".jpg", image_rgb[:, :, ::-1], [cv2.IMWRITE_JPEG_QUALITY, 85])
|
| 123 |
+
b64 = base64.b64encode(jpg).decode()
|
| 124 |
+
return f'<img src="data:image/jpeg;base64,{b64}" style="height:{PHOTO_PX}px;width:auto">'
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def table_rows(result: AnalysisResult) -> list[list]:
|
| 128 |
+
"""Una fila por entrada a la zona. Salida y duración quedan vacías hasta terminar."""
|
| 129 |
+
photos = {s.track_id: photo_html(s.image) for s in result.snapshots}
|
| 130 |
+
done = result.video_path is not None
|
| 131 |
+
return [
|
| 132 |
+
[
|
| 133 |
+
e.track_id,
|
| 134 |
+
e.enter_s,
|
| 135 |
+
e.leave_s if done else "",
|
| 136 |
+
e.duration_s if done else "",
|
| 137 |
+
photos[e.track_id],
|
| 138 |
+
]
|
| 139 |
+
for e in result.events
|
| 140 |
+
]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
# En el Space de Hugging Face pide una GPU (ZeroGPU) mientras dura el análisis; en la PC no hace
|
| 144 |
# nada. Va sobre on_analyze y no sobre analyze porque ZeroGPU lo corre en otro proceso y convierte
|
| 145 |
# cualquier excepción que no sea gr.Error en un "ZeroGPU worker error": acá los ValueError ya
|
| 146 |
# se volvieron avisos.
|
| 147 |
@spaces.GPU
|
| 148 |
+
def on_analyze(video_path: str | None, editor: dict | None):
|
| 149 |
+
"""Corre el análisis y va mostrando sus resultados en la página.
|
| 150 |
|
| 151 |
+
Yields:
|
| 152 |
+
(filas de la tabla, video anotado, texto del botón): al empezar (y se oculta el video
|
| 153 |
+
anterior), cada vez que entra alguien o avanza un 1%, y al final con el video. Si falta
|
| 154 |
+
el video o la zona, una sola vez ``gr.skip()`` en los tres: deja todo como estaba.
|
| 155 |
"""
|
| 156 |
if not video_path:
|
| 157 |
warn("Primero subí un video.")
|
| 158 |
+
yield (gr.skip(),) * 3
|
| 159 |
+
return
|
| 160 |
layers = (editor or {}).get("layers") or []
|
| 161 |
+
results = analyze(video_path, layers, out_dir=TEMP_DIR / "resultados")
|
| 162 |
try:
|
| 163 |
+
result = next(results) # el primer paso valida el video y la zona
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
except ValueError as e: # video ilegible o sin zona pintada
|
| 165 |
warn(str(e))
|
| 166 |
+
yield (gr.skip(),) * 3
|
| 167 |
+
return
|
| 168 |
+
|
| 169 |
+
yield table_rows(result), gr.Video(value=None, visible=False), gr.skip()
|
| 170 |
+
shown, label = len(result.events), ""
|
| 171 |
+
for result in results:
|
| 172 |
+
if len(result.events) != shown:
|
| 173 |
+
shown = len(result.events)
|
| 174 |
+
yield table_rows(result), gr.skip(), gr.skip()
|
| 175 |
+
text = f"Analizando… {result.progress:.0%}"
|
| 176 |
+
if text != label:
|
| 177 |
+
label = text
|
| 178 |
+
yield gr.skip(), gr.skip(), label
|
| 179 |
|
| 180 |
if not result.events:
|
| 181 |
gr.Info("No hubo intrusiones en la zona.")
|
| 182 |
+
yield table_rows(result), gr.Video(value=str(result.video_path), visible=True), gr.skip()
|
|
|
|
|
|
|
| 183 |
|
| 184 |
|
| 185 |
def build_demo() -> gr.Blocks:
|
| 186 |
+
"""Arma la página: video y editor lado a lado, el botón, la tabla de intrusos y el video."""
|
| 187 |
example_video, example_editor = load_example()
|
| 188 |
# analytics_enabled=False: Gradio manda telemetría de uso por defecto (no los videos, pero
|
| 189 |
# una app que maneja grabaciones de seguridad no debería hablar con nadie).
|
| 190 |
with gr.Blocks(title="CamGuard", analytics_enabled=False, delete_cache=CACHE_CLEANUP) as demo:
|
| 191 |
+
gr.Markdown("# CamGuard — Intruso en zona")
|
| 192 |
+
with gr.Accordion("Sobre esta demo", open=False):
|
| 193 |
+
gr.Markdown(ABOUT)
|
| 194 |
with gr.Row():
|
| 195 |
video = gr.Video(value=example_video, label="Video", sources=["upload"])
|
| 196 |
with gr.Column():
|
| 197 |
editor = gr.ImageEditor(
|
| 198 |
value=example_editor,
|
| 199 |
+
label="Zona restringida",
|
| 200 |
type="numpy",
|
| 201 |
sources=[], # la imagen siempre es el primer cuadro del video
|
| 202 |
brush=gr.Brush(default_size=BRUSH_SIZE, colors=["#ff0000"], color_mode="fixed"),
|
| 203 |
)
|
| 204 |
clear = gr.Button("Borrar zona", size="sm")
|
| 205 |
run = gr.Button("Analizar", variant="primary")
|
|
|
|
| 206 |
table = gr.Dataframe(
|
| 207 |
+
headers=HEADERS,
|
| 208 |
+
datatype=["number", "number", "number", "number", "html"],
|
| 209 |
+
label="Intrusos",
|
| 210 |
+
interactive=False,
|
| 211 |
+
wrap=True,
|
| 212 |
+
max_height="none", # la página crece con la tabla, en vez de un scroll adentro
|
| 213 |
)
|
| 214 |
+
# Oculto hasta que termina el análisis: vacío parecía roto.
|
| 215 |
+
result = gr.Video(label="Video analizado", visible=False)
|
| 216 |
|
| 217 |
demo.load(None, js=REFIT_EDITOR_JS)
|
| 218 |
video.upload(blank_zone, video, editor).then(None, js=REFIT_EDITOR_JS)
|
| 219 |
clear.click(blank_zone, video, editor).then(None, js=REFIT_EDITOR_JS)
|
| 220 |
+
# El avance se ve en el botón, que queda deshabilitado mientras analiza. Las barras de
|
| 221 |
+
# progreso de Gradio iban una por resultado, y la de la tabla tapaba las filas nuevas.
|
| 222 |
+
run.click(lambda: gr.Button("Analizando…", interactive=False), None, run, queue=False).then(
|
| 223 |
+
on_analyze, [video, editor], [table, result, run], show_progress="hidden"
|
| 224 |
+
).then(lambda: gr.Button("Analizar", interactive=True), None, run, queue=False)
|
| 225 |
return demo
|
| 226 |
|
| 227 |
|
tests/test_pipeline.py
CHANGED
|
@@ -10,6 +10,12 @@ from camguard.video import H264Writer, probe
|
|
| 10 |
SIZE = 100 # video de 100x100; la zona es el cuarto inferior derecho
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
class FakeTracker:
|
| 14 |
"""Reemplaza al modelo: una persona (#1) camina afuera, entra en los cuadros 2 a 5 y sale."""
|
| 15 |
|
|
@@ -45,15 +51,21 @@ def zone_layers():
|
|
| 45 |
|
| 46 |
|
| 47 |
def test_analyze_detects_the_intrusion(video, zone_layers):
|
| 48 |
-
|
| 49 |
-
result = analyze(video, zone_layers, make_tracker=FakeTracker, progress=progress.append)
|
| 50 |
|
| 51 |
assert result.events == [Event(track_id=1, enter_s=0.4, leave_s=1.2)]
|
| 52 |
assert [(s.track_id, s.time_s) for s in result.snapshots] == [(1, 0.4)]
|
| 53 |
-
assert result.snapshots[0].image.shape == (SIZE, SIZE, 3)
|
| 54 |
assert probe(result.video_path).frame_count == 10
|
| 55 |
-
|
| 56 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
class StraddlingTracker:
|
|
@@ -72,23 +84,23 @@ class StraddlingTracker:
|
|
| 72 |
|
| 73 |
def test_person_counts_by_body_center_not_feet(video, zone_layers):
|
| 74 |
# En la imagen se la ve afuera de la zona, que es lo que importa a quien mira el video.
|
| 75 |
-
result = analyze(video, zone_layers, make_tracker=StraddlingTracker)
|
| 76 |
assert result.events == []
|
| 77 |
|
| 78 |
|
| 79 |
def test_analyze_writes_the_result_inside_out_dir(video, zone_layers, tmp_path):
|
| 80 |
# Así la app puede borrar todos los videos generados (son grabaciones reales).
|
| 81 |
out_dir = tmp_path / "resultados"
|
| 82 |
-
result = analyze(video, zone_layers, make_tracker=FakeTracker, out_dir=out_dir)
|
| 83 |
assert out_dir in result.video_path.parents
|
| 84 |
|
| 85 |
|
| 86 |
def test_analyze_respects_max_seconds(video, zone_layers, monkeypatch):
|
| 87 |
monkeypatch.setattr("camguard.pipeline.MAX_SECONDS", 1)
|
| 88 |
-
result = analyze(video, zone_layers, make_tracker=FakeTracker)
|
| 89 |
assert probe(result.video_path).frame_count == 5
|
| 90 |
|
| 91 |
|
| 92 |
def test_analyze_without_zone_raises(video):
|
| 93 |
with pytest.raises(ValueError, match="Pintá la zona"):
|
| 94 |
-
analyze(video, [], make_tracker=FakeTracker)
|
|
|
|
| 10 |
SIZE = 100 # video de 100x100; la zona es el cuarto inferior derecho
|
| 11 |
|
| 12 |
|
| 13 |
+
def last(results):
|
| 14 |
+
"""El resultado final de analyze, que va devolviendo parciales."""
|
| 15 |
+
*_, result = results
|
| 16 |
+
return result
|
| 17 |
+
|
| 18 |
+
|
| 19 |
class FakeTracker:
|
| 20 |
"""Reemplaza al modelo: una persona (#1) camina afuera, entra en los cuadros 2 a 5 y sale."""
|
| 21 |
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
def test_analyze_detects_the_intrusion(video, zone_layers):
|
| 54 |
+
*partials, result = analyze(video, zone_layers, make_tracker=FakeTracker)
|
|
|
|
| 55 |
|
| 56 |
assert result.events == [Event(track_id=1, enter_s=0.4, leave_s=1.2)]
|
| 57 |
assert [(s.track_id, s.time_s) for s in result.snapshots] == [(1, 0.4)]
|
|
|
|
| 58 |
assert probe(result.video_path).frame_count == 10
|
| 59 |
+
# Un parcial por cuadro, sin video; el intruso aparece desde el cuadro en que entra.
|
| 60 |
+
assert [p.progress for p in partials] == [(i + 1) / 10 for i in range(10)]
|
| 61 |
+
assert all(p.video_path is None for p in partials)
|
| 62 |
+
assert [len(p.events) for p in partials] == [0, 0] + [1] * 8
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_snapshot_is_the_person_cropped(video, zone_layers):
|
| 66 |
+
photo = last(analyze(video, zone_layers, make_tracker=FakeTracker)).snapshots[0].image
|
| 67 |
+
# Recuadro de 10x20 en (70, 55), con 10% de aire: 1 px a los costados, 2 arriba y abajo.
|
| 68 |
+
assert photo.shape == (24, 12, 3)
|
| 69 |
|
| 70 |
|
| 71 |
class StraddlingTracker:
|
|
|
|
| 84 |
|
| 85 |
def test_person_counts_by_body_center_not_feet(video, zone_layers):
|
| 86 |
# En la imagen se la ve afuera de la zona, que es lo que importa a quien mira el video.
|
| 87 |
+
result = last(analyze(video, zone_layers, make_tracker=StraddlingTracker))
|
| 88 |
assert result.events == []
|
| 89 |
|
| 90 |
|
| 91 |
def test_analyze_writes_the_result_inside_out_dir(video, zone_layers, tmp_path):
|
| 92 |
# Así la app puede borrar todos los videos generados (son grabaciones reales).
|
| 93 |
out_dir = tmp_path / "resultados"
|
| 94 |
+
result = last(analyze(video, zone_layers, make_tracker=FakeTracker, out_dir=out_dir))
|
| 95 |
assert out_dir in result.video_path.parents
|
| 96 |
|
| 97 |
|
| 98 |
def test_analyze_respects_max_seconds(video, zone_layers, monkeypatch):
|
| 99 |
monkeypatch.setattr("camguard.pipeline.MAX_SECONDS", 1)
|
| 100 |
+
result = last(analyze(video, zone_layers, make_tracker=FakeTracker))
|
| 101 |
assert probe(result.video_path).frame_count == 5
|
| 102 |
|
| 103 |
|
| 104 |
def test_analyze_without_zone_raises(video):
|
| 105 |
with pytest.raises(ValueError, match="Pintá la zona"):
|
| 106 |
+
next(analyze(video, [], make_tracker=FakeTracker))
|
tests/test_ui.py
CHANGED
|
@@ -22,12 +22,12 @@ def video(tmp_path):
|
|
| 22 |
|
| 23 |
def test_analyze_without_video_warns_and_skips_outputs():
|
| 24 |
with pytest.warns(UserWarning, match="Primero subí un video"):
|
| 25 |
-
assert on_analyze(None, None) == (gr.skip(),) * 3
|
| 26 |
|
| 27 |
|
| 28 |
def test_analyze_without_zone_warns_and_skips_outputs(video):
|
| 29 |
with pytest.warns(UserWarning, match="Pintá la zona"):
|
| 30 |
-
assert on_analyze(video, {"layers": []}) == (gr.skip(),) * 3
|
| 31 |
|
| 32 |
|
| 33 |
def test_clear_zone_without_video_warns_and_skips_editor():
|
|
|
|
| 22 |
|
| 23 |
def test_analyze_without_video_warns_and_skips_outputs():
|
| 24 |
with pytest.warns(UserWarning, match="Primero subí un video"):
|
| 25 |
+
assert list(on_analyze(None, None)) == [(gr.skip(),) * 3]
|
| 26 |
|
| 27 |
|
| 28 |
def test_analyze_without_zone_warns_and_skips_outputs(video):
|
| 29 |
with pytest.warns(UserWarning, match="Pintá la zona"):
|
| 30 |
+
assert list(on_analyze(video, {"layers": []})) == [(gr.skip(),) * 3]
|
| 31 |
|
| 32 |
|
| 33 |
def test_clear_zone_without_video_warns_and_skips_editor():
|