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"""
{title}
{description}
""" 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()