Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import csv | |
| import json | |
| import shutil | |
| import tempfile | |
| import uuid | |
| from pathlib import Path | |
| import gradio as gr | |
| import yaml | |
| from bat_tracker.pipeline import run_pipeline | |
| APP_VERSION = "v1.1.7" | |
| APP_TITLE = f"Bat Tracker {APP_VERSION}" | |
| APP_DESCRIPTION = ( | |
| "Sube un video IR monocromo para ejecutar el pipeline, revisar la region valida " | |
| "detectada y consultar `events.csv` en forma de tabla." | |
| ) | |
| DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent / "config.out3_clean.yaml" | |
| # Vídeos públicos del bucket HF: URL `resolve` sin rama `main` (con `main` devuelve 404). | |
| EXAMPLE_VIDEO_RABELLA = ( | |
| "https://huggingface.co/buckets/kerojohan/tests_videos/" | |
| "resolve/rabella_20211016_DSCF0005_ttdata/rabella_20211016_DSCF0005.mp4" | |
| ) | |
| EXAMPLE_VIDEO_PROVAGRAN2 = ( | |
| "https://huggingface.co/buckets/kerojohan/tests_videos/" | |
| "resolve/PROVAGRAN2/2023_0919_211201_006.MOV" | |
| ) | |
| def _format_duration_hms(seconds: float) -> str: | |
| """Duración en texto a partir de segundos (metadatos del vídeo original).""" | |
| total = int(round(seconds)) | |
| h, rest = divmod(total, 3600) | |
| m, s = divmod(rest, 60) | |
| if h > 0: | |
| return f"{h} h {m} min {s} s" | |
| if m > 0: | |
| return f"{m} min {s} s" | |
| return f"{s} s" | |
| # Pesos según la ficha del bucket HF; duraciones comprobadas con ffprobe sobre la URL resolve. | |
| EXAMPLE_VIDEO_ROWS: list[tuple[str, str, str, str]] = [ | |
| ( | |
| EXAMPLE_VIDEO_RABELLA, | |
| EXAMPLE_VIDEO_RABELLA, | |
| "39,3 MB", | |
| _format_duration_hms(60.095667), | |
| ), | |
| ( | |
| EXAMPLE_VIDEO_PROVAGRAN2, | |
| EXAMPLE_VIDEO_PROVAGRAN2, | |
| "862 MB", | |
| _format_duration_hms(600.0), | |
| ), | |
| ] | |
| def _prepare_config(config_file: str | None, work_dir: Path) -> Path: | |
| source_path = Path(config_file) if config_file else DEFAULT_CONFIG_PATH | |
| if not source_path.exists(): | |
| raise FileNotFoundError(f"No se encuentra el archivo de configuración: {source_path}") | |
| with source_path.open("r", encoding="utf-8") as handle: | |
| cfg = yaml.safe_load(handle) or {} | |
| if not isinstance(cfg, dict): | |
| raise ValueError("La configuración debe ser un YAML con estructura de diccionario") | |
| cfg.setdefault("output", {}) | |
| cfg["output"]["progress_enabled"] = True | |
| cfg["output"]["progress_step_percent"] = 2 | |
| config_path = work_dir / "space_config.yaml" | |
| with config_path.open("w", encoding="utf-8") as handle: | |
| yaml.safe_dump(cfg, handle, sort_keys=False, allow_unicode=True) | |
| return config_path | |
| def _read_csv_table(path: Path) -> tuple[list[str], list[list[str]]]: | |
| with path.open("r", encoding="utf-8", newline="") as handle: | |
| reader = csv.reader(handle) | |
| rows = list(reader) | |
| if not rows: | |
| return [], [] | |
| return rows[0], rows[1:] | |
| def _copy_input_file(src: str, dst_dir: Path) -> Path: | |
| src_path = Path(src) | |
| safe_name = src_path.name or "input_video.mp4" | |
| dst_path = dst_dir / safe_name | |
| shutil.copy2(src_path, dst_path) | |
| return dst_path | |
| def process_video(video_file: str | None, config_file: str | None, progress=gr.Progress()): | |
| if not video_file: | |
| raise gr.Error("Debes subir un video de entrada.") | |
| session_root = Path(tempfile.gettempdir()) / "bat_tracker_spaces" | |
| session_root.mkdir(parents=True, exist_ok=True) | |
| work_dir = session_root / uuid.uuid4().hex | |
| output_dir = work_dir / "output" | |
| work_dir.mkdir(parents=True, exist_ok=True) | |
| try: | |
| progress(0, desc="Preparando ejecución") | |
| input_path = _copy_input_file(video_file, work_dir) | |
| config_path = _prepare_config(config_file, work_dir) | |
| def on_pipeline_progress(pct: int, detail: str | None) -> None: | |
| description = detail or "Procesando" | |
| progress(min(max(pct / 100.0, 0.0), 1.0), desc=description) | |
| progress(0.05, desc="Ejecutando pipeline") | |
| meta = run_pipeline( | |
| input_video=str(input_path), | |
| output_dir=str(output_dir), | |
| config_path=str(config_path), | |
| progress_callback=on_pipeline_progress, | |
| ) | |
| progress(1.0, desc="Resultados listos") | |
| outputs = meta.get("outputs", {}) | |
| events_csv = Path(outputs["events_csv"]) | |
| headers, rows = _read_csv_table(events_csv) | |
| table_headers = headers or ["Sin resultados"] | |
| table_value = rows if headers else [] | |
| valid_region_overlay = outputs.get("valid_region_overlay_png") | |
| tracks_overlay = outputs.get("tracks_overlay_png") | |
| summary = { | |
| "video_id": meta.get("video", {}).get("video_id"), | |
| "fps": meta.get("video", {}).get("fps"), | |
| "frames_processed": meta.get("metrics", {}).get("frames_processed"), | |
| "tracks_total": meta.get("metrics", {}).get("tracks_total"), | |
| "detections_kept": meta.get("metrics", {}).get("detections_kept"), | |
| "selected_device": meta.get("execution", {}).get("selected_device"), | |
| "valid_region": { | |
| "enabled": meta.get("valid_region", {}).get("enabled"), | |
| "x_start": meta.get("valid_region", {}).get("x_start"), | |
| "x_end": meta.get("valid_region", {}).get("x_end"), | |
| "width": meta.get("valid_region", {}).get("width"), | |
| "method": meta.get("valid_region", {}).get("method"), | |
| }, | |
| } | |
| return ( | |
| json.dumps(summary, indent=2, ensure_ascii=False), | |
| valid_region_overlay if valid_region_overlay and Path(valid_region_overlay).exists() else None, | |
| tracks_overlay if tracks_overlay and Path(tracks_overlay).exists() else None, | |
| gr.update( | |
| headers=table_headers, | |
| value=table_value, | |
| column_count=(len(table_headers), "fixed"), | |
| ), | |
| str(events_csv), | |
| outputs.get("tracks_csv"), | |
| ) | |
| except Exception as exc: # pragma: no cover - UI surface | |
| raise gr.Error(f"Error ejecutando el pipeline: {exc}") from exc | |
| APP_THEME = gr.themes.Soft( | |
| primary_hue="yellow", | |
| secondary_hue="stone", | |
| neutral_hue="gray", | |
| ).set( | |
| # Fondo exterior oscuro: el texto del cuerpo debe ser claro para el contraste. | |
| body_background_fill="rgb(28, 24, 20)", | |
| body_text_color="#ede6db", | |
| body_text_color_subdued="#b0a394", | |
| background_fill_primary="#faf7ef", | |
| background_fill_secondary="#f2ebe0", | |
| block_background_fill="#faf7ef", | |
| block_border_color="#d7d0c3", | |
| block_label_text_color="#3d352b", | |
| block_title_text_color="#2a231c", | |
| input_background_fill="#ffffff", | |
| input_border_color="#c5bcab", | |
| input_border_color_focus="#6f5d3f", | |
| input_placeholder_color="#61584d", | |
| button_primary_background_fill="#c89216", | |
| button_primary_background_fill_hover="#ad7d12", | |
| button_primary_text_color="#14110d", | |
| ) | |
| APP_CSS = """ | |
| .gradio-container { | |
| background: rgb(28, 24, 20); | |
| font-family: "Source Serif 4", Georgia, serif; | |
| color: #ede6db; | |
| } | |
| .app-shell { | |
| max-width: 1180px; | |
| margin: 0 auto; | |
| padding: 24px 12px 40px; | |
| } | |
| .hero { | |
| background: #faf7ef; | |
| border: 1px solid #d7d0c3; | |
| border-radius: 24px; | |
| padding: 24px; | |
| box-shadow: 0 8px 24px rgba(38, 28, 12, 0.07); | |
| } | |
| .hero h1 { | |
| margin: 0; | |
| font-size: clamp(2.2rem, 4vw, 3.6rem); | |
| line-height: 0.95; | |
| letter-spacing: -0.04em; | |
| text-transform: uppercase; | |
| color: #1b1510 !important; | |
| } | |
| .hero p { | |
| margin: 12px 0 0; | |
| max-width: 760px; | |
| color: #3f382e !important; | |
| font-size: 1rem; | |
| } | |
| .panel { | |
| border-radius: 16px; | |
| } | |
| .section-kicker { | |
| margin: 4px 0 10px; | |
| color: #5c4a32; | |
| font-size: 0.82rem; | |
| font-weight: 700; | |
| letter-spacing: 0.1em; | |
| text-transform: uppercase; | |
| } | |
| .examples-footer { | |
| margin-top: 8px; | |
| width: 100%; | |
| } | |
| .examples-footer .gr-examples { | |
| background: #f7f2e8; | |
| border: 1px solid #d7d0c3; | |
| border-radius: 12px; | |
| padding: 12px; | |
| } | |
| /* Tabla de ejemplos (varias columnas): aspecto de tabla clara */ | |
| .examples-footer .gr-examples table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| font-size: 0.95rem; | |
| color: #2a231c; | |
| } | |
| .examples-footer .gr-examples thead th { | |
| text-align: left; | |
| padding: 10px 12px; | |
| background: #ebe3d4; | |
| border: 1px solid #c5bcab; | |
| font-weight: 700; | |
| color: #3d352b; | |
| } | |
| .examples-footer .gr-examples tbody td { | |
| vertical-align: middle; | |
| padding: 10px 12px; | |
| border: 1px solid #d7d0c3; | |
| background: #fffdf8; | |
| } | |
| .examples-footer .gr-examples tbody tr:hover td { | |
| background: #f3ece0; | |
| } | |
| .examples-footer .gr-examples tbody td:first-child { | |
| width: 136px; | |
| min-width: 136px; | |
| max-width: 136px; | |
| } | |
| .examples-footer .gr-examples tbody td:first-child video { | |
| width: 112px; | |
| height: 64px; | |
| object-fit: cover; | |
| display: block; | |
| border-radius: 10px; | |
| background: #1b1510; | |
| border: 1px solid #c5bcab; | |
| } | |
| .config-note, | |
| .config-note * { | |
| color: #4a4238 !important; | |
| opacity: 1 !important; | |
| } | |
| .config-note code { | |
| background: rgba(92, 74, 50, 0.12) !important; | |
| color: #2a231c !important; | |
| border: none !important; | |
| padding: 0.1em 0.35em !important; | |
| border-radius: 4px; | |
| } | |
| """ | |
| with gr.Blocks(title=APP_TITLE) as demo: | |
| with gr.Column(elem_classes=["app-shell"]): | |
| gr.HTML( | |
| f""" | |
| <section class="hero"> | |
| <h1>Bat Tracker Space {APP_VERSION}</h1> | |
| <p> | |
| Interfaz minima para ejecutar el tracker sobre un video, inspeccionar la | |
| region valida detectada y revisar los eventos agregados en forma de tabla. | |
| </p> | |
| </section> | |
| """ | |
| ) | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=1, elem_classes=["panel"]): | |
| gr.HTML('<div class="section-kicker">Entrada</div>') | |
| video_input = gr.File( | |
| label="Video de entrada", | |
| file_types=["video", ".mov", ".MOV", ".mp4", ".MP4", ".avi", ".AVI", ".mkv", ".MKV"], | |
| type="filepath", | |
| ) | |
| config_input = gr.File( | |
| label="Configuración YAML opcional", | |
| file_types=[".yaml", ".yml"], | |
| type="filepath", | |
| ) | |
| run_button = gr.Button("Procesar", variant="primary") | |
| gr.Markdown( | |
| "Si no subes configuración, se usará `config.out3_clean.yaml`.", | |
| elem_classes=["config-note"], | |
| ) | |
| example_thumbnail = gr.Video( | |
| label="Miniatura", | |
| interactive=False, | |
| visible=False, | |
| ) | |
| example_file_size = gr.Textbox( | |
| label="Peso (original)", | |
| interactive=False, | |
| visible=False, | |
| ) | |
| example_duration = gr.Textbox( | |
| label="Duración", | |
| interactive=False, | |
| visible=False, | |
| ) | |
| with gr.Column(scale=1, elem_classes=["panel"]): | |
| gr.HTML('<div class="section-kicker">Resumen</div>') | |
| summary_output = gr.Textbox( | |
| label="Resumen de ejecución", | |
| lines=14, | |
| max_lines=18, | |
| interactive=False, | |
| ) | |
| with gr.Row(): | |
| valid_region_image = gr.Image( | |
| label="Región válida", | |
| type="filepath", | |
| interactive=False, | |
| ) | |
| tracks_overlay_image = gr.Image( | |
| label="Tracks sobre fondo", | |
| type="filepath", | |
| interactive=False, | |
| ) | |
| events_table = gr.Dataframe( | |
| label="events.csv", | |
| headers=["Sin resultados"], | |
| value=[], | |
| datatype="str", | |
| interactive=False, | |
| wrap=True, | |
| row_count=(0, "dynamic"), | |
| column_count=(1, "fixed"), | |
| ) | |
| with gr.Row(): | |
| events_csv_download = gr.File(label="Descargar events.csv") | |
| tracks_csv_download = gr.File(label="Descargar tracks.csv") | |
| with gr.Column(elem_classes=["examples-footer"]): | |
| gr.Markdown( | |
| "### Ejemplos de vídeo\n" | |
| "Pulsa una fila de la tabla para cargar ese vídeo en **Video de entrada** (arriba). " | |
| "**Peso** y **duración** corresponden al archivo original en el bucket. " | |
| "El segundo ejemplo es muy grande y puede tardar en descargarse." | |
| ) | |
| gr.Examples( | |
| examples=[list(row) for row in EXAMPLE_VIDEO_ROWS], | |
| inputs=[example_thumbnail, video_input, example_file_size, example_duration], | |
| label="Vídeos de ejemplo", | |
| cache_examples=False, | |
| examples_per_page=6, | |
| ) | |
| run_button.click( | |
| fn=process_video, | |
| inputs=[video_input, config_input], | |
| outputs=[ | |
| summary_output, | |
| valid_region_image, | |
| tracks_overlay_image, | |
| events_table, | |
| events_csv_download, | |
| tracks_csv_download, | |
| ], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch(theme=APP_THEME, css=APP_CSS, ssr_mode=False) | |