from __future__ import annotations
import math
from pathlib import Path
from typing import Final
import gradio as gr
from video_to_colmap import ConversionOutputs, convert_video_to_colmap_archive
APP_DIR: Final[Path] = Path(__file__).resolve().parent
OUTPUTS_DIR: Final[Path] = APP_DIR / "outputs"
OUTPUTS_DIR.mkdir(parents=True, exist_ok=True)
gr.set_static_paths(paths=[str(OUTPUTS_DIR)])
CSS: Final[str] = """
html { scrollbar-gutter: stable; }
body { overflow: auto; }
.gradio-container {
max-width: none;
width: 100%;
margin: 0;
padding: 0.75rem 1rem 1rem;
}
#main-row {
gap: 1rem;
align-items: stretch;
}
#controls-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
#preview-panel {
min-height: 540px;
}
.preview-placeholder {
width: 100%;
min-height: 540px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: linear-gradient(135deg, #111827 0%, #1f2937 100%);
border: 1px solid rgba(148, 163, 184, 0.2);
color: #e5e7eb;
}
.preview-inner {
max-width: 460px;
padding: 32px;
text-align: center;
}
.preview-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
.preview-desc {
font-size: 14px;
line-height: 1.5;
opacity: 0.82;
}
#status-text {
font-size: 13px;
opacity: 0.92;
}
@media (max-width: 900px) {
#main-row {
flex-direction: column;
}
#preview-panel,
.preview-placeholder {
min-height: 420px;
}
}
"""
def preview_placeholder_html(title: str, description: str) -> str:
return f"""
"""
def start_generation() -> tuple[object, object, str]:
return (
gr.update(interactive=False, value="Converting..."),
gr.update(interactive=False),
preview_placeholder_html(
"Preparing Video for COLMAP",
"Normalizing the clip, selecting sharp overlapping keyframes, and running sparse reconstruction.",
),
)
def _status_text(outputs: ConversionOutputs) -> str:
coverage = 0.0
if outputs.selected_frames:
coverage = outputs.registered_frames / outputs.selected_frames
return (
f"Prepared **{outputs.scene_name}** from a **{outputs.duration_seconds:.1f}s** clip. "
f"Selected **{outputs.selected_frames}** keyframes, COLMAP registered **{outputs.registered_frames}**, "
f"and the reconstruction quality is **{outputs.quality_label}** "
f"({math.floor(coverage * 100)}% registration)."
)
def run_conversion(
video_path: str | None,
target_frames: str,
sampling_profile: str,
max_edge: str,
) -> tuple[object, object, object, str]:
if not video_path:
raise gr.Error("Upload a video first.")
try:
outputs = convert_video_to_colmap_archive(
video_path=video_path,
target_frames=int(target_frames),
profile_key=sampling_profile,
max_image_edge=int(max_edge),
)
return (
gr.update(value=str(outputs.archive_path), visible=True, interactive=True),
gr.update(value=str(outputs.report_path), visible=True, interactive=True),
gr.update(value=str(outputs.contact_sheet_path), visible=True),
_status_text(outputs),
)
except gr.Error:
raise
except Exception as exc:
raise gr.Error(f"Conversion failed: {type(exc).__name__}: {exc}") from exc
def clear_all() -> tuple[None, object, object, object, str]:
return (
None,
gr.update(value=None, visible=False),
gr.update(value=None, visible=False),
gr.update(value=None, visible=False),
"",
)
def on_video_change(video_path: str | None) -> tuple[object, object]:
has_video = bool(video_path)
return (
gr.update(interactive=has_video, value="Build COLMAP Archive"),
gr.update(interactive=has_video),
)
def build_demo() -> gr.Blocks:
with gr.Blocks(
css=CSS,
title="Video to COLMAP for tttLRM",
theme=gr.themes.Origin(),
) as demo:
gr.Markdown("## Video to COLMAP for tttLRM")
gr.Markdown(
"Upload a single video. The Space will pick sharp overlapping keyframes, run COLMAP, and export a raw scene archive ready for the `tttLRM` Space."
)
with gr.Row(elem_id="main-row", equal_height=True):
with gr.Column(scale=3, min_width=320, elem_id="controls-panel"):
video_in = gr.File(
label="Input Video",
type="filepath",
file_types=[".mp4", ".mov", ".webm", ".mkv", ".avi"],
)
target_frames = gr.Dropdown(
label="Target Keyframes",
choices=["16", "24", "32", "48"],
value="24",
)
sampling_profile = gr.Dropdown(
label="Sampling Profile",
choices=["balanced", "dense", "sparse"],
value="balanced",
)
max_edge = gr.Dropdown(
label="Max Frame Edge",
choices=["960", "1280", "1600"],
value="1280",
)
with gr.Row():
generate_btn = gr.Button("Build COLMAP Archive", variant="primary", interactive=False)
clear_btn = gr.Button("Clear", interactive=False)
archive_download = gr.File(label="Download Raw COLMAP Archive", visible=False)
report_download = gr.File(label="Download Reconstruction Report", visible=False)
status_text = gr.Markdown(elem_id="status-text")
with gr.Column(scale=7, min_width=520):
preview_html = gr.HTML(
value=preview_placeholder_html(
"Keyframe Selection Preview",
"After conversion, the selected frames contact sheet will appear here so you can check overlap and viewpoint coverage.",
),
elem_id="preview-panel",
)
contact_sheet = gr.Image(label="Selected Keyframes", visible=False, type="filepath")
video_in.change(
on_video_change,
inputs=[video_in],
outputs=[generate_btn, clear_btn],
)
generate_btn.click(
start_generation,
outputs=[generate_btn, clear_btn, preview_html],
queue=False,
).then(
run_conversion,
inputs=[video_in, target_frames, sampling_profile, max_edge],
outputs=[archive_download, report_download, contact_sheet, status_text],
).then(
lambda: (
gr.update(interactive=True, value="Build COLMAP Archive"),
gr.update(interactive=True),
preview_placeholder_html(
"Keyframe Selection Complete",
"Review the contact sheet below and download the raw COLMAP archive for the `tttLRM` Space.",
),
),
outputs=[generate_btn, clear_btn, preview_html],
queue=False,
)
clear_btn.click(
clear_all,
outputs=[video_in, archive_download, report_download, contact_sheet, status_text],
queue=False,
).then(
lambda: (
gr.update(interactive=False),
gr.update(interactive=False),
preview_placeholder_html(
"Keyframe Selection Preview",
"After conversion, the selected frames contact sheet will appear here so you can check overlap and viewpoint coverage.",
),
),
outputs=[generate_btn, clear_btn, preview_html],
queue=False,
)
demo.queue(max_size=4)
return demo
if __name__ == "__main__":
build_demo().launch()