bat_tracker / app.py
kerojohan
Sync logic with bat_tracker v1.1.7
81a2d8e
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)