Spaces:
Sleeping
Sleeping
Download camguard/ui.py from Pabloler21/CamGuard: direct link, hf CLI and curl.
- Browser
- Download file 15.4 kB
-
https://huggingface.co/spaces/Pabloler21/CamGuard/resolve/main/camguard/ui.py
- Command line
-
hf download hf://spaces/Pabloler21/CamGuard/camguard/ui.py
-
curl -L -o ui.py https://huggingface.co/spaces/Pabloler21/CamGuard/resolve/main/camguard/ui.py
15.4 kB
| """La página web (Gradio): subir un video, pintar la zona, analizar y ver el resultado. | |
| Es la única parte que conoce Gradio: traduce los ``ValueError`` del pipeline en avisos para | |
| el usuario y los resultados en una tabla (con la foto de cada intruso) y el video anotado. | |
| """ | |
| import base64 | |
| import shutil | |
| import cv2 | |
| import gradio as gr | |
| import numpy as np | |
| import spaces | |
| from camguard.config import MAX_UPLOAD, TEMP_DIR | |
| from camguard.detection import load_model | |
| from camguard.pipeline import AnalysisResult, analyze | |
| from camguard.video import first_frame | |
| # Ancho máximo de la imagen del editor. El editor (Gradio 6.30) crea cada capa del tamaño de la | |
| # imagen por la densidad de la pantalla al cuadrado: en un iPhone 15 Pro Max (densidad 3) un | |
| # cuadro de 1920 px daba ~75 MB por capa y Safari cerraba la pestaña ("A problem repeatedly | |
| # occurred"). Achicada, la zona pintada se agranda al tamaño del video en zone_from_layers. | |
| EDITOR_WIDTH = 960 | |
| # Grosor del pincel, en píxeles de la imagen del editor (EDITOR_WIDTH de ancho). | |
| BRUSH_SIZE = 30 | |
| # Gradio borra sus copias de videos subidos y resultados: cada media hora, las de más de 1 hora. | |
| CACHE_CLEANUP = (1800, 3600) | |
| # Arregla el editor de Gradio (6.30) cuando el zoom del navegador no es 100%: su lienzo queda | |
| # más ancho que el recuadro (1853 px en uno de 916 con zoom 90%), centra la imagen sobre ese | |
| # ancho y se ve corrida a la derecha, cortada, sin poder pintar encima. Ajusta el lienzo al | |
| # recuadro y vuelve a encuadrar la imagen; se repite unos segundos para cubrir la carga lenta de | |
| # la imagen, y al cambiar el zoom o el tamaño de la ventana. window.editor es interno de Gradio: | |
| # si deja de existir, no hace nada. Encuadra sin el margen de 30 px que el editor deja alrededor: | |
| # sin sus barras de herramientas no hace falta, y la imagen se veía chica. | |
| REFIT_EDITOR_JS = """() => { | |
| if (!window.camguardRefit) { | |
| window.camguardRefit = () => { | |
| try { | |
| const editor = window.editor; | |
| const box = editor.target_element.getBoundingClientRect(); | |
| editor.resize_canvas(box.width, box.height); | |
| const zoom = editor.tools.get("zoom"); | |
| zoom.border_padding = 0; | |
| zoom.set_zoom("fit"); | |
| } catch {} | |
| }; | |
| window.addEventListener("resize", () => setTimeout(window.camguardRefit, 100)); | |
| // El editor se queda con la rueda del mouse: con el puntero encima, la página no | |
| // scrolleaba. Se corta antes de que le llegue (el zoom no hace falta). | |
| document.getElementById("zona")?.addEventListener( | |
| "wheel", (e) => e.stopPropagation(), true | |
| ); | |
| } | |
| for (const ms of [300, 1000, 2500, 5000]) setTimeout(window.camguardRefit, ms); | |
| }""" | |
| # El lienzo de la herramienta de recorte del mismo editor mide el doble de su recuadro aunque no | |
| # se use: se sale y la página queda desplazable a los costados; al arrastrar cerca del borde se | |
| # corre entera y el trazo cae fuera de la imagen. Recortar lo que sobresale lo evita. | |
| # Las barras del editor (zoom, pincel, color, tamaño) se ocultan: solo se pinta, y deshacer es | |
| # un botón de abajo. El recuadro toma la proporción de un video común (16:9) para medir | |
| # lo mismo que el video de al lado; si el video es de otra proporción, quedan bordes. | |
| # La tabla de intrusos también toma 16:9 para medir lo mismo que el video analizado: con más | |
| # intrusos de los que entran, scrollea adentro (34 px son los de su título). | |
| EDITOR_CSS = """ | |
| .pixi-target, .pixi-target-crop { overflow: hidden; } | |
| #zona .top-panel, #zona .toolbar-wrap { display: none !important; } | |
| #zona .image-container { aspect-ratio: 16 / 9; } | |
| #camguard-hint { | |
| position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; | |
| pointer-events: none; z-index: 10; transition: opacity .3s; | |
| } | |
| #camguard-hint.hidden { opacity: 0; } | |
| #camguard-hint svg { width: 40%; max-width: 260px; filter: drop-shadow(0 1px 3px #0008); } | |
| #intrusos { aspect-ratio: 16 / 9; container-type: inline-size; } | |
| #intrusos .virtual-table-viewport { max-height: calc(56.25cqw - 34px) !important; } | |
| """ | |
| # Sin texto, la imagen de la zona no dice que hay que pintar encima: un pincel pinta en loop un | |
| # área sobre la imagen. Se va al primer trazo y vuelve con un video nuevo. Va encima del editor | |
| # y deja pasar los clics (pointer-events: none). | |
| ZONE_HINT_JS = """() => { | |
| const block = document.getElementById("zona"); | |
| if (!block) return; | |
| let hint = document.getElementById("camguard-hint"); | |
| if (!hint) { | |
| // Ida y vuelta, con trazos que se tocan: se ve un área pintada, no un contorno. | |
| const path = "M40,30 H180 V55 H40 V80 H180 V105 H40"; | |
| hint = document.createElement("div"); | |
| hint.id = "camguard-hint"; | |
| hint.innerHTML = `<svg viewBox="0 0 220 130"> | |
| <path d="${path}" pathLength="1" fill="none" stroke="#ff0000" stroke-opacity=".7" | |
| stroke-width="28" stroke-linecap="round" stroke-linejoin="round" | |
| stroke-dasharray="1"> | |
| <animate attributeName="stroke-dashoffset" values="1;0;0" keyTimes="0;.7;1" | |
| dur="3s" repeatCount="indefinite"/> | |
| </path> | |
| <circle r="9" fill="#ff0000" stroke="#fff" stroke-width="3"> | |
| <animateMotion path="${path}" keyPoints="0;1;1" keyTimes="0;.7;1" | |
| calcMode="linear" dur="3s" repeatCount="indefinite"/> | |
| </circle></svg>`; | |
| block.appendChild(hint); | |
| block.addEventListener("pointerdown", (e) => { | |
| if (e.target.closest(".pixi-target")) hint.classList.add("hidden"); | |
| }, true); | |
| } | |
| hint.classList.remove("hidden"); | |
| }""" | |
| # Al cargar la página y cada vez que la zona vuelve a quedar en blanco. | |
| ZONE_RESET_JS = f"() => {{ ({REFIT_EDITOR_JS})(); ({ZONE_HINT_JS})(); }}" | |
| HEADERS = ["ID", "Entró (s)", "Salió (s)", "Duración (s)", "Foto"] | |
| # Alto con que se ve la foto de cada intruso en la tabla. | |
| PHOTO_PX = 120 | |
| def editor_value(background: np.ndarray, layers: list[np.ndarray] | None = None) -> dict: | |
| """Arma el valor del editor de Gradio: imagen de fondo (achicada a ``EDITOR_WIDTH``) y capas.""" | |
| height, width = background.shape[:2] | |
| if width > EDITOR_WIDTH: | |
| size = (EDITOR_WIDTH, round(height * EDITOR_WIDTH / width)) | |
| background = cv2.resize(background, size, interpolation=cv2.INTER_AREA) | |
| return {"background": background, "layers": layers or [], "composite": None} | |
| def load_example() -> tuple[str | None, dict | None]: | |
| """Descarga el video de ejemplo (solo la primera vez), sin zona: la pinta quien prueba. | |
| El video es de Roboflow y no tiene una licencia clara, por eso se descarga en vez de | |
| incluirlo en el repo. | |
| Returns: | |
| (ruta del video, valor del editor), o (None, None) si no hay internet. | |
| """ | |
| from supervision.assets import VideoAssets, download_assets | |
| try: | |
| path = download_assets(VideoAssets.PEOPLE_WALKING, directory="examples") | |
| except Exception: # sin internet: la app arranca igual, sin ejemplo | |
| return None, None | |
| return path, editor_value(first_frame(path)) | |
| def warn(message: str) -> None: | |
| """Muestra un aviso amarillo para cuando al usuario le falta un paso. | |
| Se usa en vez de ``gr.Error`` porque no es una falla de la app: ``gr.Error`` muestra un | |
| cartel rojo "Error", marca el resultado como fallido e imprime un traceback en la consola. | |
| """ | |
| gr.Warning(message) | |
| def use_video(video_path: str): | |
| """Cambia al video subido con "Subir mi video": zona en blanco y sin los resultados del otro. | |
| Returns: | |
| (video, editor, tabla, video analizado), o ``gr.skip()`` en los cuatro (deja todo como | |
| estaba) si el video no se puede leer. | |
| """ | |
| try: | |
| frame = first_frame(video_path) | |
| except ValueError as e: | |
| warn(str(e)) | |
| return (gr.skip(),) * 4 | |
| return video_path, editor_value(frame), gr.Dataframe(visible=False), gr.Video(visible=False) | |
| def photo_html(image_rgb: np.ndarray) -> str: | |
| """Convierte la foto de un intruso en un <img> embebido, para la columna "Foto".""" | |
| _, jpg = cv2.imencode(".jpg", image_rgb[:, :, ::-1], [cv2.IMWRITE_JPEG_QUALITY, 85]) | |
| b64 = base64.b64encode(jpg).decode() | |
| return f'<img src="data:image/jpeg;base64,{b64}" style="height:{PHOTO_PX}px;width:auto">' | |
| def table_rows(result: AnalysisResult) -> list[list]: | |
| """Una fila por entrada a la zona. Salida y duración quedan vacías hasta terminar.""" | |
| photos = {s.track_id: photo_html(s.image) for s in result.snapshots} | |
| done = result.video_path is not None | |
| return [ | |
| [ | |
| e.track_id, | |
| e.enter_s, | |
| e.leave_s if done else "", | |
| e.duration_s if done else "", | |
| photos[e.track_id], | |
| ] | |
| for e in result.events | |
| ] | |
| # En el Space de Hugging Face pide una GPU (ZeroGPU) mientras dura el análisis; en la PC no hace | |
| # nada. Va sobre on_analyze y no sobre analyze porque ZeroGPU lo corre en otro proceso y convierte | |
| # cualquier excepción que no sea gr.Error en un "ZeroGPU worker error": acá los ValueError ya | |
| # se volvieron avisos. | |
| def on_analyze(video_path: str | None, editor: dict | None): | |
| """Corre el análisis y va mostrando sus resultados en la página. | |
| Yields: | |
| (filas de la tabla, video anotado, texto del botón): al empezar (y se oculta el video | |
| anterior), cada vez que entra alguien o avanza un 1%, y al final con el video. Si falta | |
| el video o la zona, una sola vez ``gr.skip()`` en los tres: deja todo como estaba. | |
| """ | |
| if not video_path: | |
| warn("Primero subí un video.") | |
| yield (gr.skip(),) * 3 | |
| return | |
| layers = (editor or {}).get("layers") or [] | |
| results = analyze(video_path, layers, out_dir=TEMP_DIR / "resultados") | |
| try: | |
| result = next(results) # el primer paso valida el video y la zona | |
| except ValueError as e: # video ilegible o sin zona pintada | |
| warn(str(e)) | |
| yield (gr.skip(),) * 3 | |
| return | |
| yield ( | |
| gr.Dataframe(table_rows(result), visible=True), | |
| gr.Video(value=None, visible=False), | |
| gr.skip(), | |
| ) | |
| shown, label = len(result.events), "" | |
| for result in results: | |
| if len(result.events) != shown: | |
| shown = len(result.events) | |
| yield table_rows(result), gr.skip(), gr.skip() | |
| text = f"Analizando… {result.progress:.0%}" | |
| if text != label: | |
| label = text | |
| yield gr.skip(), gr.skip(), label | |
| if not result.events: | |
| gr.Info("No hubo intrusiones en la zona.") | |
| yield table_rows(result), gr.Video(value=str(result.video_path), visible=True), gr.skip() | |
| def build_demo() -> gr.Blocks: | |
| """Arma la página: video y editor lado a lado, el botón, la tabla de intrusos y el video.""" | |
| example_video, example_editor = load_example() | |
| # analytics_enabled=False: Gradio manda telemetría de uso por defecto (no los videos, pero | |
| # una app que maneja grabaciones de seguridad no debería hablar con nadie). | |
| with gr.Blocks(title="CamGuard", analytics_enabled=False, delete_cache=CACHE_CLEANUP) as demo: | |
| gr.Markdown( | |
| "# CamGuard - Pablo Lerner · [GitHub](https://github.com/pabloler21/CamGuard)\n" | |
| "Detecta quién entra a una zona restringida de tus cámaras, cuándo y por cuánto tiempo." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| # Solo se reproduce: se cambia con el botón, más visible que la ✕ del componente. | |
| video = gr.Video(value=example_video, label="Input Video", interactive=False) | |
| upload = gr.UploadButton("Subir mi video", file_types=["video"], size="sm") | |
| with gr.Column(): | |
| editor = gr.ImageEditor( | |
| value=example_editor, | |
| label="Draw Restricted Area", | |
| elem_id="zona", # ahí se dibuja ZONE_HINT_JS | |
| type="numpy", | |
| sources=[], # la imagen siempre es el primer cuadro del video | |
| brush=gr.Brush(default_size=BRUSH_SIZE, colors=["#ff0000"], color_mode="fixed"), | |
| eraser=False, | |
| transforms=(), | |
| layers=False, | |
| height="auto", # lo da la proporción de EDITOR_CSS | |
| ) | |
| undo = gr.Button("Deshacer", size="sm") | |
| run = gr.Button("Analizar", variant="primary") | |
| # Lado a lado, como la fila de arriba: con la tabla arriba del video había que scrollear | |
| # para verlo. Si hay más intrusos de los que entran, la tabla scrollea adentro. | |
| with gr.Row(): | |
| with gr.Column(): | |
| table = gr.Dataframe( | |
| headers=HEADERS, | |
| datatype=["number", "number", "number", "number", "html"], | |
| label="Intruder Table", | |
| elem_id="intrusos", # EDITOR_CSS le da el alto del video de al lado | |
| visible=False, # hasta analizar: vacía era ruido | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| with gr.Column(): | |
| # Oculto hasta que termina el análisis: vacío parecía roto. | |
| result = gr.Video(label="Output Video", visible=False) | |
| demo.load(None, js=ZONE_RESET_JS) | |
| upload.upload(use_video, upload, [video, editor, table, result]).then( | |
| None, js=ZONE_RESET_JS | |
| ) | |
| undo.click(None, js="() => window.editor?.undo()") | |
| # El avance se ve en el botón, que queda deshabilitado mientras analiza. Las barras de | |
| # progreso de Gradio iban una por resultado, y la de la tabla tapaba las filas nuevas. | |
| analysis = run.click( | |
| lambda: gr.Button("Analizando…", interactive=False), None, run, queue=False | |
| ).then(on_analyze, [video, editor], [table, result, run], show_progress="hidden") | |
| # .then debería correr también si on_analyze falla, pero en Gradio 6.30 no (probado): el | |
| # botón quedaba trabado en "Analizando…". Por eso se restablece por éxito y por falla. | |
| for after in (analysis.success, analysis.failure): | |
| after(lambda: gr.Button("Analizar", interactive=True), None, run, queue=False) | |
| return demo | |
| def main() -> None: | |
| """Borra los videos de la sesión anterior, carga el modelo y abre la página. | |
| Los videos subidos y los resultados son grabaciones reales y quedan en ``TEMP_DIR``. Al | |
| cerrar la ventana el proceso termina de golpe y no alcanza a limpiar, por eso se vacía al | |
| arrancar; mientras la app está abierta, ``delete_cache`` borra lo que tiene más de una hora. | |
| En la PC solo escucha en 127.0.0.1: nadie de la red ni de internet puede entrar. En el Space | |
| de Hugging Face, Gradio escucha en 0.0.0.0 porque así lo indica ``GRADIO_SERVER_NAME``. | |
| """ | |
| # ponytail: los videos de la última sesión quedan en disco hasta el próximo arranque. | |
| shutil.rmtree(TEMP_DIR, ignore_errors=True) | |
| # Al arrancar, para que el primer análisis no espere la carga. En ZeroGPU además es | |
| # obligatorio: el modelo tiene que pasar a la GPU antes de entrar a una función @spaces.GPU. | |
| load_model() | |
| build_demo().launch(inbrowser=True, max_file_size=MAX_UPLOAD, css=EDITOR_CSS) | |