Spaces:
Sleeping
Sleeping
Commit ·
855e8cb
1
Parent(s): 0c69c36
Separar la demo en el paquete camguard por responsabilidad
Browse filesconfig (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 +2 -173
- camguard/__init__.py +14 -0
- camguard/annotate.py +36 -0
- camguard/config.py +21 -0
- camguard/detection.py +63 -0
- camguard/events.py +57 -0
- camguard/pipeline.py +112 -0
- camguard/ui.py +120 -0
- camguard/video.py +146 -0
- camguard/zone.py +56 -0
- test_zone.py +3 -2
app.py
CHANGED
|
@@ -1,177 +1,6 @@
|
|
| 1 |
-
"""CamGuard:
|
| 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
|
|
|
|
| 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 == [
|
| 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")
|