#!/usr/bin/env python3 """ pipeline_360_gui.py -- single-form GUI for pipeline_360.py. One form, all settings, one Run button that runs the whole pipeline (extract -> masks -> cubemap -> rig) in sequence, streaming progress into a log pane. Mirrors extract_sharpest_frame_gui.py's approach of shelling out to the CLI script and parsing "[progress] "-prefixed lines for in-place progress updates -- except this wraps all four stages instead of one, via `pipeline_360.py all --gui-log-output ...`. Differences from the original GUI this is modeled on: - English-only. The original's EN/JA translation table added a fair amount of boilerplate for a tool this much larger; happy to add localisation back if it's wanted. - Cross-platform Stop button. The original only force-killed via Windows' `taskkill`, which silently wouldn't have worked on Linux. This one kills the whole process group via SIGTERM/SIGKILL on POSIX (and still uses taskkill on Windows), so Stop actually stops the ProcessPoolExecutor workers the extract/cubemap stages spawn, not just the parent CLI process. - No PyInstaller-frozen-exe branch -- always invokes `sys.executable pipeline_360.py all ...`. Add that branch back if you package this as a standalone binary later. Requires: tkinter (usually stdlib; on some Linux distros it's a separate package, e.g. `sudo pacman -S tk` on Arch/CachyOS). """ import os import queue import signal import subprocess import sys import threading import tkinter as tk from pathlib import Path from tkinter import filedialog, messagebox, ttk from typing import Optional from pipeline_360 import PROGRESS_PREFIX, COCO_MOVABLE_DEFAULTS, FACES APP_TITLE = "360 Photogrammetry Pipeline" class ToolTip: def __init__(self, widget, text: str = "") -> None: self.widget = widget self.text = text self.tip_window = None widget.bind("", self.show_tip) widget.bind("", self.hide_tip) def show_tip(self, _event=None) -> None: if self.tip_window or not self.text: return x = self.widget.winfo_rootx() + 20 y = self.widget.winfo_rooty() + self.widget.winfo_height() + 8 self.tip_window = tw = tk.Toplevel(self.widget) tw.wm_overrideredirect(True) tw.wm_geometry(f"+{x}+{y}") tk.Label(tw, text=self.text, justify=tk.LEFT, background="#ffffe0", relief=tk.SOLID, borderwidth=1, font=("Segoe UI", 9), wraplength=360).pack(ipadx=4) def hide_tip(self, _event=None) -> None: if self.tip_window: self.tip_window.destroy() self.tip_window = None # ------------------------------------------------------------------------ # Field spec: one entry per pipeline_360.py 'all' flag exposed in the form. # kind: "int" | "float" | "str" | "choice" | "bool" # ------------------------------------------------------------------------ class Field: def __init__(self, flag, label, kind, default, choices=None, tip=""): self.flag = flag # CLI flag, e.g. "--extract-chunk-size" self.label = label self.kind = kind self.default = default self.choices = choices or [] self.tip = tip SECTIONS = [ ("Extract Sharpest Frames", [ Field("--extract-chunk-size", "Chunk size", "int", 30, tip="Pick one sharpest frame out of every N video frames."), Field("--extract-scale-width", "Scale width (px)", "int", 1920, tip="Resize width used only for sharpness scoring. 0 = full resolution."), Field("--extract-workers", "Workers", "int", 4, tip="Parallel worker processes for the sharpness pass."), Field("--extract-output-format", "Output format", "choice", "png", choices=["png", "jpg"]), Field("--extract-jpeg-quality", "JPEG quality", "int", 95, tip="Only used when output format is jpg."), Field("--extract-regenerate-metadata", "Regenerate sharpness cache", "bool", False, tip="Ignore any existing _sharpness_metadata.csv in 01_frames and recompute it."), ]), ("Generate SfM Masks (YOLO)", [ Field("--mask-classes", "Classes to mask", "str", ",".join(COCO_MOVABLE_DEFAULTS), tip="Comma-separated COCO class names to exclude from reconstruction."), Field("--mask-model", "Model", "str", "yolo11x-seg.pt", tip="Ultralytics seg model name or path."), Field("--mask-device", "Device", "str", "cuda", tip="cuda, cuda:0, cpu, or mps. ROCm PyTorch keeps the 'cuda' device string."), Field("--mask-conf", "Confidence threshold", "float", 0.35), Field("--mask-imgsz", "Inference size", "int", 1280), Field("--mask-dilate", "Dilate (px)", "int", 6, tip="Grows mask edges to cover segmentation-boundary error / motion blur."), Field("--mask-projection", "Projection", "choice", "cubemap", choices=["cubemap", "equirect"], tip="cubemap fixes YOLO missing people near the poles; equirect runs on the raw frame."), Field("--mask-face-size", "Detection face size (px)", "int", 1024), Field("--mask-skip-faces", "Skip faces", "str", "", tip="Comma list of F/R/B/L/U/D to skip detection on, e.g. U,D."), Field("--mask-batch", "Frame batch", "int", 8), Field("--mask-tile-batch", "GPU tile batch", "int", 8, tip="Bounds VRAM use directly for cubemap projection. Lower this first on OOM."), Field("--mask-empty-if-none", "Write empty masks for clean frames", "bool", False, tip="Off by default to save disk space; a missing mask already means 'unmasked'."), ]), ("Project to Cubemap", [ Field("--cubemap-face-size", "Face size (px)", "int", 1600, tip="Final training image resolution per cube face."), Field("--cubemap-skip-faces", "Skip faces", "str", "", tip="Comma list of F/R/B/L/U/D to exclude from the exported dataset, e.g. U,D."), Field("--cubemap-jpeg-quality", "JPEG quality", "int", 95), Field("--cubemap-workers", "Workers", "int", 0, tip="0 = use all CPU cores."), Field("--cubemap-resume", "Resume (skip already-written frames)", "bool", False), ]), ("Generate Rig Config", [ Field("--rig-ref-face", "Reference face", "choice", "F", choices=list(FACES), tip="Defines the rig's coordinate frame. F (front) is the sane default."), Field("--rig-intrinsics-convention", "Intrinsics convention", "choice", "linspace", choices=["linspace", "half"], tip="linspace is exact for py360convert output; only change this if COLMAP rejects the schema."), Field("--rig-link-mode", "Link mode", "choice", "symlink", choices=["symlink", "hardlink", "copy"], tip="How the flat cubemap output is relinked into COLMAP's required nested tree."), ]), ] class PipelineGui(tk.Tk): def __init__(self) -> None: super().__init__() self.title(APP_TITLE) self.geometry("920x760") self.minsize(760, 560) self.project_dir = tk.StringVar() self.video_path = tk.StringVar() self.start_stage = tk.StringVar(value="extract") self.skip_masks = tk.BooleanVar(value=False) self.status_text = tk.StringVar(value="Ready") self.field_vars: dict[str, tk.Variable] = {} self.field_widgets: dict[str, tk.Widget] = {} self.worker_process: Optional[subprocess.Popen] = None self.log_thread: Optional[threading.Thread] = None self.log_queue: "queue.Queue" = queue.Queue() self.stop_requested = False self.is_closing = False self.progress_line_active = False self._build_widgets() self.protocol("WM_DELETE_WINDOW", self._on_close_requested) self.after(100, self._drain_log_queue) # -- layout ----------------------------------------------------------- def _build_widgets(self) -> None: outer = ttk.Frame(self, padding=12) outer.pack(fill="both", expand=True) outer.rowconfigure(1, weight=3) outer.rowconfigure(3, weight=2) outer.columnconfigure(0, weight=1) top = ttk.Frame(outer) top.grid(row=0, column=0, sticky="ew", pady=(0, 8)) top.columnconfigure(1, weight=1) ttk.Label(top, text="Project directory").grid(row=0, column=0, sticky="w", padx=(0, 8), pady=4) entry = ttk.Entry(top, textvariable=self.project_dir) entry.grid(row=0, column=1, sticky="ew", pady=4) ttk.Button(top, text="Browse...", command=self._browse_project_dir).grid(row=0, column=2, padx=(8, 0), pady=4) ttk.Label(top, text="Video file").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=4) self.video_entry = ttk.Entry(top, textvariable=self.video_path) self.video_entry.grid(row=1, column=1, sticky="ew", pady=4) self.video_button = ttk.Button(top, text="Browse...", command=self._browse_video) self.video_button.grid(row=1, column=2, padx=(8, 0), pady=4) ttk.Label(top, text="Start from stage").grid(row=2, column=0, sticky="w", padx=(0, 8), pady=4) stage_combo = ttk.Combobox(top, state="readonly", values=["extract", "masks", "cubemap", "rig"], textvariable=self.start_stage, width=14) stage_combo.grid(row=2, column=1, sticky="w", pady=4) stage_combo.bind("<>", lambda _e: self._sync_video_requirement()) ToolTip(stage_combo, "Resuming from 'masks' or later assumes 01_frames (and 02_masks) already exist " "in the project directory from a prior run.") self.skip_masks_check = ttk.Checkbutton(top, text="Skip masking entirely", variable=self.skip_masks) self.skip_masks_check.grid(row=2, column=2, sticky="w", padx=(8, 0), pady=4) # Scrollable settings area ----------------------------------------- settings_outer = ttk.Frame(outer) settings_outer.grid(row=1, column=0, sticky="nsew") settings_outer.rowconfigure(0, weight=1) settings_outer.columnconfigure(0, weight=1) canvas = tk.Canvas(settings_outer, highlightthickness=0) vscroll = ttk.Scrollbar(settings_outer, orient="vertical", command=canvas.yview) canvas.configure(yscrollcommand=vscroll.set) canvas.grid(row=0, column=0, sticky="nsew") vscroll.grid(row=0, column=1, sticky="ns") inner = ttk.Frame(canvas) inner_id = canvas.create_window((0, 0), window=inner, anchor="nw") def _on_inner_configure(_event): canvas.configure(scrollregion=canvas.bbox("all")) def _on_canvas_configure(event): canvas.itemconfigure(inner_id, width=event.width) inner.bind("", _on_inner_configure) canvas.bind("", _on_canvas_configure) def _on_mousewheel(event): delta = 1 if getattr(event, "num", None) == 5 or event.delta < 0 else -1 canvas.yview_scroll(delta, "units") canvas.bind_all("", _on_mousewheel) # Windows / macOS canvas.bind_all("", _on_mousewheel) # Linux X11 scroll up canvas.bind_all("", _on_mousewheel) # Linux X11 scroll down for title, fields in SECTIONS: self._build_section(inner, title, fields) # Buttons ------------------------------------------------------- actions = ttk.Frame(outer) actions.grid(row=2, column=0, sticky="ew", pady=(8, 4)) self.run_button = ttk.Button(actions, text="Run pipeline", command=self._start_run) self.run_button.pack(side="right") self.stop_button = ttk.Button(actions, text="Stop", command=self._stop_run, state="disabled") self.stop_button.pack(side="right", padx=(0, 8)) ttk.Button(actions, text="Clear log", command=self._clear_log).pack(side="right", padx=(0, 8)) ttk.Label(actions, textvariable=self.status_text).pack(side="left") # Log ------------------------------------------------------------- log_frame = ttk.LabelFrame(outer, text="Log") log_frame.grid(row=3, column=0, sticky="nsew") log_frame.rowconfigure(0, weight=1) log_frame.columnconfigure(0, weight=1) self.log_text = tk.Text(log_frame, wrap="word", state="disabled", height=14) self.log_text.grid(row=0, column=0, sticky="nsew") log_scroll = ttk.Scrollbar(log_frame, orient="vertical", command=self.log_text.yview) log_scroll.grid(row=0, column=1, sticky="ns") self.log_text.configure(yscrollcommand=log_scroll.set) self._sync_video_requirement() def _build_section(self, parent, title: str, fields) -> None: frame = ttk.LabelFrame(parent, text=title, padding=8) frame.pack(fill="x", expand=True, padx=4, pady=6) frame.columnconfigure(1, weight=1) frame.columnconfigure(3, weight=1) for i, field in enumerate(fields): row, col_base = divmod(i, 2) col = col_base * 2 if field.kind == "bool": var = tk.BooleanVar(value=field.default) widget = ttk.Checkbutton(frame, text=field.label, variable=var) widget.grid(row=row, column=col, columnspan=2, sticky="w", padx=(0, 16), pady=3) else: ttk.Label(frame, text=field.label).grid(row=row, column=col, sticky="w", padx=(0, 6), pady=3) if field.kind == "choice": var = tk.StringVar(value=str(field.default)) widget = ttk.Combobox(frame, state="readonly", values=field.choices, textvariable=var, width=14) else: var = tk.StringVar(value=str(field.default)) widget = ttk.Entry(frame, textvariable=var, width=18) widget.grid(row=row, column=col + 1, sticky="w", padx=(0, 16), pady=3) if field.tip: ToolTip(widget, field.tip) self.field_vars[field.flag] = var self.field_widgets[field.flag] = widget # -- state sync --------------------------------------------------------- def _sync_video_requirement(self) -> None: needs_video = self.start_stage.get() == "extract" state = "normal" if needs_video else "disabled" self.video_entry.configure(state=state) self.video_button.configure(state=state) # -- browse dialogs ------------------------------------------------- def _browse_project_dir(self) -> None: selected = filedialog.askdirectory(title="Select project directory") if selected: self.project_dir.set(selected) def _browse_video(self) -> None: selected = filedialog.askopenfilename(title="Select video file") if selected: self.video_path.set(selected) # -- log helpers (mirrors extract_sharpest_frame_gui.py's in-place # progress-line replacement so tqdm-style updates don't spam the log) -- def _clear_log(self) -> None: self.log_text.configure(state="normal") self.log_text.delete("1.0", tk.END) if self.progress_line_active: self.log_text.mark_unset("progress_start") self.log_text.mark_unset("progress_end") self.log_text.configure(state="disabled") self.progress_line_active = False def _append_log(self, message: str, replace: bool = False) -> None: self.log_text.configure(state="normal") if replace: if self.progress_line_active: self.log_text.delete("progress_start", "progress_end") else: self.log_text.mark_set("progress_start", "end-1c") self.log_text.mark_gravity("progress_start", tk.LEFT) self.log_text.insert("progress_start", message + "\n") self.log_text.mark_set("progress_end", "progress_start +1 lines") self.progress_line_active = True else: if self.progress_line_active: self.log_text.mark_unset("progress_start") self.log_text.mark_unset("progress_end") self.progress_line_active = False self.log_text.insert(tk.END, message + "\n") self.log_text.see(tk.END) self.log_text.configure(state="disabled") # -- command building / validation ----------------------------------- def _validate_and_build_command(self) -> Optional[list]: project_dir = self.project_dir.get().strip() if not project_dir: messagebox.showerror(APP_TITLE, "Select a project directory.") return None start_stage = self.start_stage.get() video = self.video_path.get().strip() if start_stage == "extract" and not video: messagebox.showerror(APP_TITLE, "Select a video file, or choose a later start stage.") return None base_dir = Path(__file__).parent command = [sys.executable, str(base_dir / "pipeline_360.py"), "all", "--gui-log-output", "--project-dir", project_dir, "--start-stage", start_stage] if start_stage == "extract": command += ["--video", video] if self.skip_masks.get(): command += ["--skip-masks"] for _title, fields in SECTIONS: for field in fields: var = self.field_vars[field.flag] if field.kind == "bool": if var.get(): command.append(field.flag) continue raw = var.get().strip() if isinstance(var, tk.StringVar) else str(var.get()) if field.kind == "int": try: int(raw) except ValueError: messagebox.showerror(APP_TITLE, f"'{field.label}' must be a whole number (got {raw!r}).") return None elif field.kind == "float": try: float(raw) except ValueError: messagebox.showerror(APP_TITLE, f"'{field.label}' must be a number (got {raw!r}).") return None command += [field.flag, raw] return command # -- process lifecycle ------------------------------------------------ def _is_worker_running(self) -> bool: return self.worker_process is not None and self.worker_process.poll() is None def _spawn(self, command: list) -> subprocess.Popen: kwargs = {} if sys.platform == "win32": kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) else: kwargs["start_new_session"] = True # own process group, so we can kill it (and its children) together return subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1, **kwargs, ) def _force_kill_process_tree(self, process: subprocess.Popen) -> None: if sys.platform == "win32": subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], capture_output=True, text=True, check=False) try: process.wait(timeout=2.0) except subprocess.TimeoutExpired: process.kill() else: try: pgid = os.getpgid(process.pid) os.killpg(pgid, signal.SIGTERM) process.wait(timeout=2.0) except (ProcessLookupError, subprocess.TimeoutExpired): try: os.killpg(os.getpgid(process.pid), signal.SIGKILL) except (ProcessLookupError, PermissionError): pass except PermissionError: pass try: process.wait(timeout=1.0) except subprocess.TimeoutExpired: pass def _stream_process_output(self, process: subprocess.Popen) -> None: assert process.stdout is not None for line in process.stdout: self.log_queue.put(("log", line.rstrip("\r\n"))) process.stdout.close() return_code = process.wait() if self.stop_requested: self.log_queue.put(("cancelled", None)) elif return_code == 0: self.log_queue.put(("done", None)) else: self.log_queue.put(("error", f"Pipeline exited with code {return_code}. See log above for details.")) def _set_running_state(self, running: bool) -> None: state = "disabled" if running else "normal" self.run_button.configure(state=state) for widget in self.field_widgets.values(): widget.configure(state=("disabled" if running else ("readonly" if isinstance(widget, ttk.Combobox) else "normal"))) self.skip_masks_check.configure(state=state) self.stop_button.configure(state="normal" if running else "disabled") self.status_text.set("Running..." if running else "Ready") if not running: self._sync_video_requirement() def _start_run(self) -> None: if self._is_worker_running(): return command = self._validate_and_build_command() if command is None: return self.stop_requested = False self._set_running_state(True) self._append_log(f"Running: {' '.join(command)}") try: self.worker_process = self._spawn(command) except OSError as exc: self._set_running_state(False) messagebox.showerror(APP_TITLE, f"Could not start pipeline_360.py: {exc}") return self.log_thread = threading.Thread(target=self._stream_process_output, args=(self.worker_process,), daemon=True) self.log_thread.start() def _stop_run(self) -> None: if not self._is_worker_running(): return self.status_text.set("Stopping...") self._append_log("Stopping...") self.stop_button.configure(state="disabled") self.stop_requested = True self._force_kill_process_tree(self.worker_process) self.worker_process = None self.log_thread = None self._set_running_state(False) def _on_close_requested(self) -> None: if self._is_worker_running(): if not messagebox.askyesno(APP_TITLE, "The pipeline is still running. Force stop it and close the window?"): return self.status_text.set("Stopping...") self._append_log("Stopping...") self._force_kill_process_tree(self.worker_process) self.is_closing = True self.destroy() def _drain_log_queue(self) -> None: if self.is_closing: return while True: try: kind, payload = self.log_queue.get_nowait() except queue.Empty: break if kind == "log": if payload.startswith(PROGRESS_PREFIX): self._append_log(payload[len(PROGRESS_PREFIX):], replace=True) else: self._append_log(payload) elif kind == "done": self._append_log("Done: pipeline completed successfully.") self.worker_process = None self.log_thread = None self._set_running_state(False) messagebox.showinfo(APP_TITLE, "Pipeline completed successfully.") elif kind == "cancelled": self._append_log("Cancelled.") self.worker_process = None self.log_thread = None self._set_running_state(False) elif kind == "error": self._append_log(f"Error: {payload}") self.worker_process = None self.log_thread = None self._set_running_state(False) messagebox.showerror(APP_TITLE, payload) self.after(100, self._drain_log_queue) def main() -> None: app = PipelineGui() app.mainloop() if __name__ == "__main__": main()