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

Bat Tracker Space {APP_VERSION}

Interfaz minima para ejecutar el tracker sobre un video, inspeccionar la region valida detectada y revisar los eventos agregados en forma de tabla.

""" ) with gr.Row(equal_height=False): with gr.Column(scale=1, elem_classes=["panel"]): gr.HTML('
Entrada
') 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('
Resumen
') 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)