Pabloler21 Claude Opus 5.5 commited on
Commit
0c44a5a
·
1 Parent(s): 78ff9fe

Mostrar a los intrusos en una tabla con su foto a medida que se analizan

Browse files

La 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>

Files changed (4) hide show
  1. camguard/pipeline.py +42 -21
  2. camguard/ui.py +82 -47
  3. tests/test_pipeline.py +21 -9
  4. 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 el progreso con un callback,
4
- así se puede usar desde Gradio, desde un script o desde los tests (con un tracker falso).
 
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
- """Captura del momento en que una persona aparece por primera vez dentro de la zona.
36
 
37
  Attributes:
38
- image: Cuadro anotado, en RGB (listo para mostrar).
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
- video_path: mp4 anotado (H.264), en una carpeta temporal.
54
- events: Cada estadía de una persona en la zona, ordenadas por entrada.
55
- snapshots: Una captura por persona: la de su primera entrada.
 
 
56
  """
57
 
58
- video_path: Path
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
- Returns:
84
- El video anotado, los eventos y las capturas.
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 detections.tracker_id[inside].tolist():
 
 
113
  if track_id not in frames_in_zone:
114
- snapshots.append(Snapshot(annotated[:, :, ::-1], track_id, i / fps))
115
- frames_in_zone.setdefault(track_id, []).append(i)
 
116
  writer.write(annotated)
117
- if progress:
118
- progress(min(1.0, (i + 1) / expected))
119
 
120
- return AnalysisResult(out_path, build_events(frames_in_zone, fps), snapshots)
 
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 galería.
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
- WHERE = (
53
- "> 🔒 **El video se procesa en servidores de Hugging Face.** No subas grabaciones reales o "
54
- "sensibles."
55
- if os.getenv("SPACE_ID")
56
- else "Todo corre en esta PC."
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, progress=gr.Progress()): # noqa: B008
133
- """Corre el análisis y lo convierte en lo que muestran los componentes de la página.
134
 
135
- Returns:
136
- (video anotado, filas de la tabla de eventos, imágenes de la galería con su leyenda), o
137
- ``gr.skip()`` en las tres (deja los resultados como estaban) si falta el video o la zona.
 
138
  """
139
  if not video_path:
140
  warn("Primero subí un video.")
141
- return (gr.skip(),) * 3
 
142
  layers = (editor or {}).get("layers") or []
 
143
  try:
144
- result = analyze(
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
- return (gr.skip(),) * 3
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
  if not result.events:
155
  gr.Info("No hubo intrusiones en la zona.")
156
- rows = [[e.track_id, e.enter_s, e.leave_s, e.duration_s] for e in result.events]
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, y los tres resultados."""
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(INSTRUCTIONS)
 
 
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 (pintá encima)",
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=["ID", "Entró (s)", "Salió (s)", "Duración (s)"], label="Eventos"
 
 
 
 
 
183
  )
184
- gallery = gr.Gallery(label="Momento de entrada de cada intruso", columns=4)
 
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
- run.click(on_analyze, [video, editor], [result, table, gallery])
 
 
 
 
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
- progress = []
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
- assert len(progress) == 10
56
- assert progress[-1] == 1.0
 
 
 
 
 
 
 
 
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():