| 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""" |
| <div class="preview-placeholder"> |
| <div class="preview-inner"> |
| <div class="preview-title">{title}</div> |
| <div class="preview-desc">{description}</div> |
| </div> |
| </div> |
| """ |
|
|
|
|
| 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() |
|
|