Pabloler21 Claude Opus 5.5 commited on
Commit
855e8cb
·
1 Parent(s): 0c69c36

Separar la demo en el paquete camguard por responsabilidad

Browse files

config (perillas), zone (máscara y regla de pies), events (Event y
armado de eventos), video (lectura submuestreada y escritura H.264),
detection (RF-DETR + ByteTrack), annotate (dibujo), pipeline (análisis
sin depender de Gradio) y ui (página). app.py queda como punto de
entrada. Mismo comportamiento: el video de ejemplo da las mismas 18
capturas que antes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

app.py CHANGED
@@ -1,177 +1,6 @@
1
- """CamGuard: detecta personas que entran a una zona restringida de un video.
2
-
3
- Todo corre local, en CPU. El video nunca sale de la máquina.
4
- """
5
-
6
- import tempfile
7
- from fractions import Fraction
8
-
9
- import av
10
- import cv2
11
- import gradio as gr
12
- import numpy as np
13
- import supervision as sv
14
-
15
- FPS = 5 # cuadros analizados por segundo
16
- MAX_SECONDS = 60 # tope de video analizado
17
- CONF = 0.4 # confianza mínima para contar una detección
18
- MAX_GAP_S = 1.0 # si alguien "desaparece" menos que esto, sigue siendo el mismo evento
19
- PERSON_ID = 1 # id COCO de "person" en RF-DETR
20
- # ponytail: CPU-only (~0.5 s/cuadro en 1080p); si es lento, exportar RF-DETR a ONNX + onnxruntime-directml
21
-
22
- model = None # se carga en main(), así los tests no bajan el modelo
23
-
24
-
25
- def feet_in_zone(feet_xy, zone):
26
- """True para cada punto (x, y) que cae dentro de la máscara de zona."""
27
- h, w = zone.shape
28
- x = np.clip(feet_xy[:, 0].astype(int), 0, w - 1)
29
- y = np.clip(feet_xy[:, 1].astype(int), 0, h - 1)
30
- return zone[y, x]
31
-
32
-
33
- def build_events(frames_in_zone, fps, max_gap_s=MAX_GAP_S):
34
- """{id: [índices de cuadro dentro de la zona]} -> filas [id, entró, salió, duración] en segundos."""
35
- max_gap = max(1, round(max_gap_s * fps))
36
- rows = []
37
- for tid, frames in frames_in_zone.items():
38
- start = prev = frames[0]
39
- for f in frames[1:] + [None]:
40
- if f is not None and f - prev <= max_gap:
41
- prev = f
42
- continue
43
- enter, leave = start / fps, (prev + 1) / fps
44
- rows.append([tid, round(enter, 1), round(leave, 1), round(leave - enter, 1)])
45
- start = prev = f
46
- return sorted(rows, key=lambda r: r[1])
47
-
48
-
49
- def zone_from_editor(editor, shape):
50
- layers = (editor or {}).get("layers") or []
51
- mask = np.any([layer[..., 3] > 0 for layer in layers], axis=0) if layers else None
52
- if mask is None or not mask.any():
53
- raise gr.Error("Pintá la zona restringida sobre la imagen antes de analizar.")
54
- if mask.shape != shape:
55
- mask = cv2.resize(mask.astype(np.uint8), (shape[1], shape[0]), interpolation=cv2.INTER_NEAREST) > 0
56
- return mask
57
-
58
-
59
- def first_frame(video_path):
60
- ok, frame = cv2.VideoCapture(video_path).read()
61
- if not ok:
62
- raise gr.Error("No pude leer el video.")
63
- return {"background": frame[:, :, ::-1], "layers": [], "composite": None}
64
-
65
-
66
- def example():
67
- """Video de ejemplo (se baja la primera vez) con una zona ya pintada abajo a la derecha."""
68
- from supervision.assets import VideoAssets, download_assets
69
-
70
- try:
71
- path = download_assets(VideoAssets.PEOPLE_WALKING, directory="examples")
72
- except Exception: # sin internet: arranca sin ejemplo
73
- return None, None
74
- editor = first_frame(path)
75
- h, w = editor["background"].shape[:2]
76
- layer = np.zeros((h, w, 4), np.uint8)
77
- layer[int(h * 0.55) :, w // 2 :] = (255, 0, 0, 255)
78
- editor["layers"] = [layer]
79
- return path, editor
80
-
81
-
82
- def draw(frame, det, inside, zone):
83
- out = frame.copy()
84
- out[zone] = (0.6 * out[zone] + 0.4 * np.array([0, 0, 255])).astype(np.uint8) # rojo (BGR)
85
- for mask, color, text in ((~inside, sv.Color.GREEN, "persona #{}"), (inside, sv.Color.RED, "INTRUSO #{}")):
86
- d = det[mask]
87
- out = sv.BoxAnnotator(color=color, thickness=3).annotate(out, d)
88
- out = sv.LabelAnnotator(color=color).annotate(out, d, labels=[text.format(t) for t in d.tracker_id])
89
- return out
90
-
91
-
92
- def analyze(video_path, editor, progress=gr.Progress()):
93
- from trackers import ByteTrackTracker
94
-
95
- if not video_path:
96
- raise gr.Error("Primero subí un video.")
97
- cap = cv2.VideoCapture(video_path)
98
- src_fps = cap.get(cv2.CAP_PROP_FPS) or 30
99
- step = max(1, round(src_fps / FPS))
100
- fps = src_fps / step
101
- total = int(min(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 1e9, MAX_SECONDS * src_fps))
102
- w, h = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) // 2 * 2, int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) // 2 * 2
103
- zone = zone_from_editor(editor, (int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))))
104
-
105
- tracker = ByteTrackTracker(frame_rate=fps, track_activation_threshold=CONF, minimum_consecutive_frames=1)
106
- out_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
107
- container = av.open(out_path, "w")
108
- stream = container.add_stream("libx264", rate=Fraction(fps).limit_denominator(1000))
109
- stream.width, stream.height, stream.pix_fmt = w, h, "yuv420p"
110
-
111
- in_zone, snapshots, i = {}, [], 0
112
- for idx in progress.tqdm(range(total), desc="Analizando"):
113
- if idx % step:
114
- if not cap.grab():
115
- break
116
- continue
117
- ok, frame = cap.read()
118
- if not ok:
119
- break
120
- det = model.predict(frame[:, :, ::-1].copy(), threshold=CONF)
121
- det = tracker.update(det[det.class_id == PERSON_ID])
122
- det = det[det.tracker_id != -1]
123
- inside = feet_in_zone(det.get_anchors_coordinates(sv.Position.BOTTOM_CENTER), zone)
124
- out = draw(frame, det, inside, zone)
125
- for tid in det.tracker_id[inside].tolist():
126
- if tid not in in_zone:
127
- snapshots.append((out[:, :, ::-1], f"Intruso #{tid} — {i / fps:.1f} s"))
128
- in_zone.setdefault(tid, []).append(i)
129
- for packet in stream.encode(av.VideoFrame.from_ndarray(out[:h, :w], format="bgr24")):
130
- container.mux(packet)
131
- i += 1
132
- for packet in stream.encode():
133
- container.mux(packet)
134
- container.close()
135
- cap.release()
136
-
137
- rows = build_events(in_zone, fps)
138
- if not rows:
139
- gr.Info("No hubo intrusiones en la zona.")
140
- return out_path, rows, snapshots
141
-
142
-
143
- def main():
144
- global model
145
- from rfdetr import RFDETRSmall
146
-
147
- model = RFDETRSmall()
148
- example_video, example_zone = example()
149
- with gr.Blocks(title="CamGuard") as demo:
150
- gr.Markdown(
151
- "# CamGuard — Intruso en zona\n"
152
- "**Para probar rápido:** ya viene cargado un video de ejemplo con la zona marcada. Tocá **Analizar**.\n\n"
153
- "**Con tu propio video:** tocá la ✕ del video, subí el tuyo, pintá encima de la imagen el área "
154
- "restringida y tocá **Analizar**.\n\n"
155
- f"Se analizan {FPS} cuadros por segundo y hasta {MAX_SECONDS} s de video. Todo corre en esta PC."
156
- )
157
- with gr.Row():
158
- video = gr.Video(value=example_video, label="Video", sources=["upload"])
159
- editor = gr.ImageEditor(
160
- value=example_zone,
161
- label="Zona restringida (pintá encima)",
162
- type="numpy",
163
- sources=[],
164
- brush=gr.Brush(default_size=120, colors=["#ff0000"], color_mode="fixed"),
165
- )
166
- run = gr.Button("Analizar", variant="primary")
167
- result = gr.Video(label="Resultado")
168
- table = gr.Dataframe(headers=["ID", "Entró (s)", "Salió (s)", "Duración (s)"], label="Eventos")
169
- gallery = gr.Gallery(label="Momento de entrada de cada intruso", columns=4)
170
-
171
- video.upload(first_frame, video, editor)
172
- run.click(analyze, [video, editor], [result, table, gallery])
173
- demo.launch(inbrowser=True)
174
 
 
175
 
176
  if __name__ == "__main__":
177
  main()
 
1
+ """Punto de entrada de CamGuard: ``uv run python app.py`` (es lo que ejecutan run.bat y run.sh)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ from camguard.ui import main
4
 
5
  if __name__ == "__main__":
6
  main()
camguard/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CamGuard: detecta personas que entran a una zona restringida de un video.
2
+
3
+ Todo corre local, en CPU: el video nunca sale de la máquina.
4
+
5
+ Módulos:
6
+ config: perillas ajustables (fps analizados, tope de duración, confianza).
7
+ zone: de lo que pinta el usuario a una máscara, y quién la pisa.
8
+ events: de cuadros sueltos a eventos de entrada y salida.
9
+ video: lectura submuestreada y escritura H.264.
10
+ detection: detección (RF-DETR) y seguimiento (ByteTrack) de personas.
11
+ annotate: dibujo de la zona y los recuadros sobre cada cuadro.
12
+ pipeline: orquesta todo el análisis; no depende de la interfaz.
13
+ ui: la página web (Gradio).
14
+ """
camguard/annotate.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dibujo sobre cada cuadro: la zona en rojo translúcido y un recuadro por persona."""
2
+
3
+ import numpy as np
4
+ import supervision as sv
5
+
6
+ ZONE_COLOR_BGR = np.array([0, 0, 255]) # rojo (OpenCV usa BGR)
7
+ ZONE_OPACITY = 0.4
8
+
9
+
10
+ def annotate(
11
+ frame_bgr: np.ndarray, detections: sv.Detections, inside: np.ndarray, zone: np.ndarray
12
+ ) -> np.ndarray:
13
+ """Dibuja la zona, las personas de afuera en verde y los intrusos en rojo.
14
+
15
+ Args:
16
+ frame_bgr: Cuadro original en BGR. No se modifica.
17
+ detections: Personas detectadas, con ``tracker_id``.
18
+ inside: Array booleano: True para las personas con los pies en la zona.
19
+ zone: Máscara booleana de la zona.
20
+
21
+ Returns:
22
+ Una copia del cuadro, anotada, en BGR.
23
+ """
24
+ out = frame_bgr.copy()
25
+ # Mezcla en vez de pintar sólido, para que se vea lo que hay debajo de la zona.
26
+ out[zone] = ((1 - ZONE_OPACITY) * out[zone] + ZONE_OPACITY * ZONE_COLOR_BGR).astype(np.uint8)
27
+ groups = (
28
+ (~inside, sv.Color.GREEN, "persona #{}"),
29
+ (inside, sv.Color.RED, "INTRUSO #{}"),
30
+ )
31
+ for mask, color, label in groups:
32
+ group = detections[mask]
33
+ out = sv.BoxAnnotator(color=color, thickness=3).annotate(out, group)
34
+ labels = [label.format(track_id) for track_id in group.tracker_id]
35
+ out = sv.LabelAnnotator(color=color).annotate(out, group, labels=labels)
36
+ return out
camguard/config.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Perillas de la demo: valores que se pueden ajustar sin tocar la lógica."""
2
+
3
+ # Cuadros analizados por segundo. Una persona caminando no cruza una zona en menos de 0,2 s,
4
+ # así que alcanza y es 5-6 veces más rápido que analizar los 25-30 cuadros del video.
5
+ # ponytail: CPU-only (~0.5 s/cuadro en 1080p); si es lento, exportar RF-DETR a ONNX +
6
+ # onnxruntime-directml para usar la GPU AMD.
7
+ FPS = 5
8
+
9
+ # Segundos de video que se analizan como máximo, para que en CPU no tarde una eternidad.
10
+ MAX_SECONDS = 60
11
+
12
+ # Confianza mínima para contar una detección. Más bajo aparecen "personas" falsas;
13
+ # más alto se pierden las que están lejos.
14
+ CONF = 0.4
15
+
16
+ # Si alguien "desaparece" de la zona menos que esto (el detector lo pierde un cuadro, alguien
17
+ # le pasa por delante), sigue siendo el mismo evento en vez de partirse en varios.
18
+ MAX_GAP_S = 1.0
19
+
20
+ # Id de "person" entre las 80 clases COCO que reconoce RF-DETR.
21
+ PERSON_ID = 1
camguard/detection.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Detección y seguimiento de personas.
2
+
3
+ RF-DETR (Roboflow, Apache-2.0) encuentra personas en cada cuadro; ByteTrack les asigna un número
4
+ que se mantiene entre cuadros, para poder decir "el #3 entró a los 12 s y salió a los 18 s".
5
+ Este es el único módulo que conoce el modelo: cambiarlo (por ejemplo a una versión ONNX) no toca
6
+ el resto del código.
7
+ """
8
+
9
+ from functools import cache
10
+
11
+ import numpy as np
12
+ import supervision as sv
13
+ from trackers import ByteTrackTracker
14
+
15
+ from camguard.config import CONF, PERSON_ID
16
+
17
+
18
+ @cache
19
+ def load_model():
20
+ """Carga RF-DETR Small una sola vez por proceso.
21
+
22
+ La primera vez descarga los pesos (~368 MB) a ``~/.roboflow/models``.
23
+ """
24
+ # Import diferido: rfdetr arrastra PyTorch (~14 s de import) y los tests no lo necesitan.
25
+ from rfdetr import RFDETRSmall
26
+
27
+ return RFDETRSmall()
28
+
29
+
30
+ class PersonTracker:
31
+ """Detecta personas en cada cuadro y les asigna un número de seguimiento estable.
32
+
33
+ Se crea uno nuevo por cada video analizado, para que la numeración arranque de cero.
34
+
35
+ Args:
36
+ fps: Cuadros por segundo que va a recibir (los analizados, no los del archivo). ByteTrack
37
+ lo usa para saber cuánto tiempo "recordar" a una persona que dejó de verse.
38
+ conf: Confianza mínima de detección.
39
+ """
40
+
41
+ def __init__(self, fps: float, conf: float = CONF):
42
+ self._model = load_model()
43
+ self._conf = conf
44
+ # El umbral de activación por defecto (0.7) tardaría en "confirmar" a las personas,
45
+ # y minimum_consecutive_frames=1 les da número desde el primer cuadro en que aparecen.
46
+ self._tracker = ByteTrackTracker(
47
+ frame_rate=fps, track_activation_threshold=conf, minimum_consecutive_frames=1
48
+ )
49
+
50
+ def update(self, frame_bgr: np.ndarray) -> sv.Detections:
51
+ """Procesa el siguiente cuadro del video.
52
+
53
+ Args:
54
+ frame_bgr: Cuadro en BGR, como lo entrega OpenCV.
55
+
56
+ Returns:
57
+ Las personas detectadas, con ``tracker_id`` asignado. Las que el tracker todavía
58
+ no confirmó (``tracker_id == -1``) se descartan.
59
+ """
60
+ # El modelo espera RGB; .copy() porque la vista invertida tiene strides negativos.
61
+ detections = self._model.predict(frame_bgr[:, :, ::-1].copy(), threshold=self._conf)
62
+ detections = self._tracker.update(detections[detections.class_id == PERSON_ID])
63
+ return detections[detections.tracker_id != -1]
camguard/events.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Eventos de intrusión: de "en qué cuadros estuvo cada persona en la zona" a entradas y salidas."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from camguard.config import MAX_GAP_S
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Event:
10
+ """Una estadía continua de una persona dentro de la zona.
11
+
12
+ Attributes:
13
+ track_id: Número de seguimiento de la persona (el "#N" del video).
14
+ enter_s: Segundo del video en que entró (o en que se la vio adentro por primera vez).
15
+ leave_s: Segundo del video en que salió.
16
+ """
17
+
18
+ track_id: int
19
+ enter_s: float
20
+ leave_s: float
21
+
22
+ @property
23
+ def duration_s(self) -> float:
24
+ """Segundos que estuvo dentro de la zona."""
25
+ return round(self.leave_s - self.enter_s, 1)
26
+
27
+
28
+ def build_events(
29
+ frames_in_zone: dict[int, list[int]], fps: float, max_gap_s: float = MAX_GAP_S
30
+ ) -> list[Event]:
31
+ """Agrupa los cuadros en que cada persona estuvo en la zona en eventos de entrada y salida.
32
+
33
+ Mientras el salto entre dos cuadros consecutivos sea de ``max_gap_s`` o menos, es el mismo
34
+ evento: así un cuadro en que el detector "pierde" a la persona no parte una entrada en varias.
35
+ Si el salto es mayor, la persona salió y volvió: son dos eventos.
36
+
37
+ Args:
38
+ frames_in_zone: Para cada persona, los índices (crecientes) de los cuadros analizados
39
+ en que tuvo los pies en la zona.
40
+ fps: Cuadros analizados por segundo, para pasar de índices a segundos.
41
+ max_gap_s: Tolerancia en segundos antes de considerar que la persona salió.
42
+
43
+ Returns:
44
+ Los eventos, ordenados por segundo de entrada.
45
+ """
46
+ max_gap = max(1, round(max_gap_s * fps))
47
+ events = []
48
+ for track_id, frames in frames_in_zone.items():
49
+ start = prev = frames[0]
50
+ for frame in [*frames[1:], None]: # el None final cierra el último evento
51
+ if frame is not None and frame - prev <= max_gap:
52
+ prev = frame
53
+ continue
54
+ # prev + 1: si estuvo un solo cuadro, igual estuvo 1/fps segundos, no 0.
55
+ events.append(Event(track_id, round(start / fps, 1), round((prev + 1) / fps, 1)))
56
+ start = prev = frame
57
+ return sorted(events, key=lambda e: e.enter_s)
camguard/pipeline.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
13
+
14
+ import numpy as np
15
+ import supervision as sv
16
+
17
+ from camguard.annotate import annotate
18
+ from camguard.config import FPS, MAX_SECONDS
19
+ from camguard.detection import PersonTracker
20
+ from camguard.events import Event, build_events
21
+ from camguard.video import H264Writer, frame_limit, probe, sample_frames, sampling_step
22
+ from camguard.zone import feet_in_zone, zone_from_layers
23
+
24
+
25
+ class Tracker(Protocol):
26
+ """Lo que el pipeline necesita de un tracker: personas con número, cuadro a cuadro."""
27
+
28
+ def update(self, frame_bgr: np.ndarray) -> sv.Detections:
29
+ """Devuelve las personas del cuadro, con ``tracker_id``."""
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
+ """
42
+
43
+ image: np.ndarray
44
+ track_id: int
45
+ time_s: float
46
+
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
+ ) -> AnalysisResult:
69
+ """Analiza un video y detecta quién entra a la zona restringida.
70
+
71
+ Se analizan ``FPS`` cuadros por segundo y hasta ``MAX_SECONDS`` segundos (ver ``config``).
72
+
73
+ Args:
74
+ video_path: Video a analizar.
75
+ zone_layers: Capas RGBA pintadas por el usuario (ver :func:`zone_from_layers`).
76
+ make_tracker: Crea el tracker a partir de los fps analizados. Por defecto el real
77
+ (RF-DETR + ByteTrack); los tests pasan uno falso.
78
+ progress: Se llama después de cada cuadro con el avance, entre 0 y 1.
79
+
80
+ Returns:
81
+ El video anotado, los eventos y las capturas.
82
+
83
+ Raises:
84
+ ValueError: Si el video no se puede leer o no hay zona pintada.
85
+ """
86
+ info = probe(video_path)
87
+ zone = zone_from_layers(zone_layers, (info.height, info.width))
88
+ step = sampling_step(info.fps, FPS)
89
+ fps = info.fps / step # cuadros analizados por segundo
90
+ max_frames = frame_limit(info, MAX_SECONDS)
91
+ expected = max(1, math.ceil(max_frames / step))
92
+
93
+ tracker = make_tracker(fps)
94
+ out_path = Path(tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name)
95
+ frames_in_zone: dict[int, list[int]] = {}
96
+ snapshots: list[Snapshot] = []
97
+
98
+ with H264Writer(out_path, fps, info.width, info.height) as writer:
99
+ for i, frame in enumerate(sample_frames(video_path, step, max_frames)):
100
+ detections = tracker.update(frame)
101
+ feet = detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
102
+ inside = feet_in_zone(feet, zone)
103
+ annotated = annotate(frame, detections, inside, zone)
104
+ for track_id in detections.tracker_id[inside].tolist():
105
+ if track_id not in frames_in_zone:
106
+ snapshots.append(Snapshot(annotated[:, :, ::-1], track_id, i / fps))
107
+ frames_in_zone.setdefault(track_id, []).append(i)
108
+ writer.write(annotated)
109
+ if progress:
110
+ progress(min(1.0, (i + 1) / expected))
111
+
112
+ return AnalysisResult(out_path, build_events(frames_in_zone, fps), snapshots)
camguard/ui.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 gradio as gr
8
+ import numpy as np
9
+
10
+ from camguard.config import FPS, MAX_SECONDS
11
+ from camguard.detection import load_model
12
+ from camguard.pipeline import analyze
13
+ from camguard.video import first_frame
14
+
15
+ # Zona del video de ejemplo, en fracción de la imagen: de la mitad hacia la derecha
16
+ # y del 55 % de la altura hacia abajo.
17
+ EXAMPLE_ZONE_LEFT = 0.5
18
+ EXAMPLE_ZONE_TOP = 0.55
19
+
20
+ INSTRUCTIONS = f"""\
21
+ # CamGuard — Intruso en zona
22
+ **Para probar rápido:** ya viene cargado un video de ejemplo con la zona marcada. \
23
+ Tocá **Analizar**.
24
+
25
+ **Con tu propio video:** tocá la ✕ del video, subí el tuyo, pintá encima de la imagen \
26
+ el área restringida y tocá **Analizar**.
27
+
28
+ Se analizan {FPS} cuadros por segundo y hasta {MAX_SECONDS} s de video. Todo corre en esta PC.
29
+ """
30
+
31
+
32
+ def editor_value(background: np.ndarray, layers: list[np.ndarray] | None = None) -> dict:
33
+ """Arma el valor del editor de Gradio: imagen de fondo y capas pintadas."""
34
+ return {"background": background, "layers": layers or [], "composite": None}
35
+
36
+
37
+ def load_example() -> tuple[str | None, dict | None]:
38
+ """Descarga el video de ejemplo (solo la primera vez) y le pinta una zona por defecto.
39
+
40
+ El video es de Roboflow y no tiene una licencia clara, por eso se descarga en vez de
41
+ incluirlo en el repo.
42
+
43
+ Returns:
44
+ (ruta del video, valor del editor), o (None, None) si no hay internet.
45
+ """
46
+ from supervision.assets import VideoAssets, download_assets
47
+
48
+ try:
49
+ path = download_assets(VideoAssets.PEOPLE_WALKING, directory="examples")
50
+ except Exception: # sin internet: la app arranca igual, sin ejemplo
51
+ return None, None
52
+ background = first_frame(path)
53
+ h, w = background.shape[:2]
54
+ layer = np.zeros((h, w, 4), np.uint8)
55
+ layer[int(h * EXAMPLE_ZONE_TOP) :, int(w * EXAMPLE_ZONE_LEFT) :] = (255, 0, 0, 255)
56
+ return path, editor_value(background, [layer])
57
+
58
+
59
+ def on_upload(video_path: str) -> dict:
60
+ """Al subir un video, pone su primer cuadro en el editor para pintar la zona."""
61
+ try:
62
+ return editor_value(first_frame(video_path))
63
+ except ValueError as e:
64
+ raise gr.Error(str(e)) from e
65
+
66
+
67
+ def on_analyze(video_path: str | None, editor: dict | None, progress=gr.Progress()): # noqa: B008
68
+ """Corre el análisis y lo convierte en lo que muestran los componentes de la página.
69
+
70
+ Returns:
71
+ (video anotado, filas de la tabla de eventos, imágenes de la galería con su leyenda).
72
+ """
73
+ if not video_path:
74
+ raise gr.Error("Primero subí un video.")
75
+ layers = (editor or {}).get("layers") or []
76
+ try:
77
+ result = analyze(video_path, layers, progress=lambda p: progress(p, desc="Analizando"))
78
+ except ValueError as e:
79
+ raise gr.Error(str(e)) from e
80
+
81
+ if not result.events:
82
+ gr.Info("No hubo intrusiones en la zona.")
83
+ rows = [[e.track_id, e.enter_s, e.leave_s, e.duration_s] for e in result.events]
84
+ gallery = [(s.image, f"Intruso #{s.track_id} — {s.time_s:.1f} s") for s in result.snapshots]
85
+ return str(result.video_path), rows, gallery
86
+
87
+
88
+ def build_demo() -> gr.Blocks:
89
+ """Arma la página: video y editor lado a lado, el botón, y los tres resultados."""
90
+ example_video, example_editor = load_example()
91
+ with gr.Blocks(title="CamGuard") as demo:
92
+ gr.Markdown(INSTRUCTIONS)
93
+ with gr.Row():
94
+ video = gr.Video(value=example_video, label="Video", sources=["upload"])
95
+ editor = gr.ImageEditor(
96
+ value=example_editor,
97
+ label="Zona restringida (pintá encima)",
98
+ type="numpy",
99
+ sources=[], # la imagen siempre es el primer cuadro del video
100
+ brush=gr.Brush(default_size=120, colors=["#ff0000"], color_mode="fixed"),
101
+ )
102
+ run = gr.Button("Analizar", variant="primary")
103
+ result = gr.Video(label="Resultado")
104
+ table = gr.Dataframe(
105
+ headers=["ID", "Entró (s)", "Salió (s)", "Duración (s)"], label="Eventos"
106
+ )
107
+ gallery = gr.Gallery(label="Momento de entrada de cada intruso", columns=4)
108
+
109
+ video.upload(on_upload, video, editor)
110
+ run.click(on_analyze, [video, editor], [result, table, gallery])
111
+ return demo
112
+
113
+
114
+ def main() -> None:
115
+ """Carga el modelo, arma la página y la abre en el navegador.
116
+
117
+ Solo escucha en 127.0.0.1: nadie de la red ni de internet puede entrar (son videos reales).
118
+ """
119
+ load_model() # al arrancar, para que el primer análisis no espere la carga
120
+ build_demo().launch(inbrowser=True)
camguard/video.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lectura y escritura de video.
2
+
3
+ Junta los detalles de bajo nivel en un solo lugar: OpenCV entrega los cuadros en BGR (no RGB),
4
+ H.264 exige ancho y alto pares, y saltear cuadros con ``grab()`` es más rápido que decodificarlos.
5
+ Si mañana se lee de una cámara en vivo (RTSP), este es el único módulo que cambia.
6
+ """
7
+
8
+ import math
9
+ from collections.abc import Iterator
10
+ from dataclasses import dataclass
11
+ from fractions import Fraction
12
+ from pathlib import Path
13
+
14
+ import av
15
+ import cv2
16
+ import numpy as np
17
+
18
+ UNREADABLE_MSG = "No pude leer el video."
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class VideoInfo:
23
+ """Datos básicos de un archivo de video.
24
+
25
+ Attributes:
26
+ fps: Cuadros por segundo del archivo.
27
+ width: Ancho en píxeles.
28
+ height: Alto en píxeles.
29
+ frame_count: Cantidad de cuadros (0 si el archivo no lo informa).
30
+ """
31
+
32
+ fps: float
33
+ width: int
34
+ height: int
35
+ frame_count: int
36
+
37
+
38
+ def probe(path: str | Path) -> VideoInfo:
39
+ """Lee los datos básicos de un video.
40
+
41
+ Raises:
42
+ ValueError: Si el archivo no se puede abrir como video.
43
+ """
44
+ cap = cv2.VideoCapture(str(path))
45
+ try:
46
+ if not cap.isOpened():
47
+ raise ValueError(UNREADABLE_MSG)
48
+ return VideoInfo(
49
+ fps=cap.get(cv2.CAP_PROP_FPS) or 30.0,
50
+ width=int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
51
+ height=int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
52
+ frame_count=int(cap.get(cv2.CAP_PROP_FRAME_COUNT)),
53
+ )
54
+ finally:
55
+ cap.release()
56
+
57
+
58
+ def first_frame(path: str | Path) -> np.ndarray:
59
+ """Devuelve el primer cuadro del video en RGB (el orden que espera el navegador).
60
+
61
+ Raises:
62
+ ValueError: Si no se puede leer ningún cuadro.
63
+ """
64
+ cap = cv2.VideoCapture(str(path))
65
+ ok, frame = cap.read()
66
+ cap.release()
67
+ if not ok:
68
+ raise ValueError(UNREADABLE_MSG)
69
+ return frame[:, :, ::-1]
70
+
71
+
72
+ def sampling_step(source_fps: float, target_fps: float) -> int:
73
+ """Cada cuántos cuadros del archivo hay que analizar uno para acercarse a ``target_fps``."""
74
+ return max(1, round(source_fps / target_fps))
75
+
76
+
77
+ def frame_limit(info: VideoInfo, max_seconds: float) -> int:
78
+ """Cuántos cuadros del archivo recorrer como máximo para no pasar de ``max_seconds``."""
79
+ limit = math.floor(max_seconds * info.fps)
80
+ return min(info.frame_count, limit) if info.frame_count > 0 else limit
81
+
82
+
83
+ def sample_frames(path: str | Path, step: int, max_frames: int) -> Iterator[np.ndarray]:
84
+ """Recorre el video y devuelve uno de cada ``step`` cuadros, en BGR.
85
+
86
+ Args:
87
+ path: Archivo de video.
88
+ step: Se devuelve el cuadro 0, el ``step``, el ``2*step``... (ver :func:`sampling_step`).
89
+ max_frames: Cuadros del archivo a recorrer como máximo (ver :func:`frame_limit`).
90
+
91
+ Yields:
92
+ Cuadros BGR (alto, ancho, 3).
93
+ """
94
+ cap = cv2.VideoCapture(str(path))
95
+ try:
96
+ for idx in range(max_frames):
97
+ if idx % step:
98
+ # grab() avanza sin decodificar la imagen completa: mucho más rápido que read().
99
+ if not cap.grab():
100
+ return
101
+ continue
102
+ ok, frame = cap.read()
103
+ if not ok:
104
+ return
105
+ yield frame
106
+ finally:
107
+ cap.release()
108
+
109
+
110
+ class H264Writer:
111
+ """Escribe cuadros BGR en un mp4 H.264, el formato que reproducen todos los navegadores.
112
+
113
+ El codec por defecto de OpenCV muchas veces no se reproduce en el navegador; PyAV trae
114
+ libx264 incluido, así que no hace falta instalar ffmpeg. Se usa con ``with``::
115
+
116
+ with H264Writer("salida.mp4", fps=5, width=1920, height=1080) as writer:
117
+ writer.write(frame)
118
+ """
119
+
120
+ def __init__(self, path: str | Path, fps: float, width: int, height: int):
121
+ # El formato de color yuv420p exige ancho y alto pares: si son impares se recorta 1 px.
122
+ self._width, self._height = width // 2 * 2, height // 2 * 2
123
+ self._container = av.open(str(path), "w")
124
+ self._stream = self._container.add_stream(
125
+ "libx264", rate=Fraction(fps).limit_denominator(1000)
126
+ )
127
+ self._stream.width, self._stream.height = self._width, self._height
128
+ self._stream.pix_fmt = "yuv420p"
129
+
130
+ def write(self, frame_bgr: np.ndarray) -> None:
131
+ """Agrega un cuadro BGR al video."""
132
+ frame = av.VideoFrame.from_ndarray(
133
+ frame_bgr[: self._height, : self._width], format="bgr24"
134
+ )
135
+ self._container.mux(self._stream.encode(frame))
136
+
137
+ def close(self) -> None:
138
+ """Vacía los cuadros que el encoder tiene en buffer y cierra el archivo."""
139
+ self._container.mux(self._stream.encode())
140
+ self._container.close()
141
+
142
+ def __enter__(self) -> "H264Writer":
143
+ return self
144
+
145
+ def __exit__(self, *exc_info: object) -> None:
146
+ self.close()
camguard/zone.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Zona restringida: de lo que pinta el usuario a una máscara, y quién la pisa.
2
+
3
+ La zona es una máscara booleana del tamaño del video (True = restringido) en vez de un
4
+ polígono, porque el usuario la pinta a mano: puede tener cualquier forma o ser varias zonas.
5
+ """
6
+
7
+ import cv2
8
+ import numpy as np
9
+
10
+ NO_ZONE_MSG = "Pintá la zona restringida sobre la imagen antes de analizar."
11
+
12
+
13
+ def zone_from_layers(layers: list[np.ndarray], shape: tuple[int, int]) -> np.ndarray:
14
+ """Combina las capas pintadas en el editor en una sola máscara del tamaño del video.
15
+
16
+ Args:
17
+ layers: Capas RGBA del editor. Un píxel es zona si su canal alfa es mayor que 0.
18
+ shape: (alto, ancho) del video.
19
+
20
+ Returns:
21
+ Máscara booleana de forma ``shape``.
22
+
23
+ Raises:
24
+ ValueError: Si no hay nada pintado.
25
+ """
26
+ if not layers:
27
+ raise ValueError(NO_ZONE_MSG)
28
+ mask = np.any([layer[..., 3] > 0 for layer in layers], axis=0)
29
+ if not mask.any():
30
+ raise ValueError(NO_ZONE_MSG)
31
+ if mask.shape != shape:
32
+ # INTER_NEAREST mantiene los bordes nítidos (sí/no) en vez de difuminarlos.
33
+ size = (shape[1], shape[0]) # OpenCV pide (ancho, alto)
34
+ mask = cv2.resize(mask.astype(np.uint8), size, interpolation=cv2.INTER_NEAREST) > 0
35
+ return mask
36
+
37
+
38
+ def feet_in_zone(feet_xy: np.ndarray, zone: np.ndarray) -> np.ndarray:
39
+ """Indica qué personas tienen los pies dentro de la zona.
40
+
41
+ Se usan los pies (centro del borde inferior del recuadro) y no el recuadro entero porque,
42
+ con la cámara en perspectiva, alguien que pasa delante de una puerta la tapa con el cuerpo
43
+ sin estar en ella: los pies dicen dónde está parado de verdad.
44
+
45
+ Args:
46
+ feet_xy: Array (N, 2) con las coordenadas (x, y) de los pies de cada persona.
47
+ zone: Máscara booleana (alto, ancho).
48
+
49
+ Returns:
50
+ Array booleano (N,): True si los pies de esa persona caen en la zona.
51
+ """
52
+ h, w = zone.shape
53
+ # clip: un punto justo en el borde (x == ancho) se saldría del array al indexar.
54
+ x = np.clip(feet_xy[:, 0].astype(int), 0, w - 1)
55
+ y = np.clip(feet_xy[:, 1].astype(int), 0, h - 1)
56
+ return zone[y, x]
test_zone.py CHANGED
@@ -1,6 +1,7 @@
1
  import numpy as np
2
 
3
- from app import build_events, feet_in_zone
 
4
 
5
  zone = np.zeros((10, 10), bool)
6
  zone[5:, 5:] = True
@@ -9,6 +10,6 @@ assert feet_in_zone(feet, zone).tolist() == [True, False, True, True]
9
 
10
  # fps=5: #1 entra en 0 s y sale en 1 s; #2 entra, sale 2 s y vuelve (dos eventos); un hueco de 1 cuadro no corta
11
  events = build_events({1: [0, 1, 2, 3, 4], 2: [10, 12, 30]}, fps=5)
12
- assert events == [[1, 0.0, 1.0, 1.0], [2, 2.0, 2.6, 0.6], [2, 6.0, 6.2, 0.2]], events
13
  assert build_events({}, fps=5) == []
14
  print("ok")
 
1
  import numpy as np
2
 
3
+ from camguard.events import Event, build_events
4
+ from camguard.zone import feet_in_zone
5
 
6
  zone = np.zeros((10, 10), bool)
7
  zone[5:, 5:] = True
 
10
 
11
  # fps=5: #1 entra en 0 s y sale en 1 s; #2 entra, sale 2 s y vuelve (dos eventos); un hueco de 1 cuadro no corta
12
  events = build_events({1: [0, 1, 2, 3, 4], 2: [10, 12, 30]}, fps=5)
13
+ assert events == [Event(1, 0.0, 1.0), Event(2, 2.0, 2.6), Event(2, 6.0, 6.2)], events
14
  assert build_events({}, fps=5) == []
15
  print("ok")