This commit is contained in:
2026-08-19 17:26:45 +02:00
parent 164005f845
commit b6fa3f4290
4 changed files with 2153 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
*
!.gitignore
!pipeline_360.py
!pipeline_360_gui.py
!requirements.txt
+1585
View File
File diff suppressed because it is too large Load Diff
+542
View File
@@ -0,0 +1,542 @@
#!/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("<Enter>", self.show_tip)
widget.bind("<Leave>", 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("<<ComboboxSelected>>", 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("<Configure>", _on_inner_configure)
canvas.bind("<Configure>", _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("<MouseWheel>", _on_mousewheel) # Windows / macOS
canvas.bind_all("<Button-4>", _on_mousewheel) # Linux X11 scroll up
canvas.bind_all("<Button-5>", _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()
+21
View File
@@ -0,0 +1,21 @@
# Core pipeline (extract, cubemap, rig stages) -- always needed
opencv-python>=4.8
numpy>=1.24
tqdm>=4.65
py360convert>=1.0
# Masks stage only (YOLO segmentation) -- skip these if you always run
# with --skip-masks
ultralytics>=8.3
# NOTE on ultralytics/torch + ROCm:
# `pip install ultralytics` will pull in a CUDA-only torch wheel as a
# dependency if torch isn't already installed. On your RX 9070 XT setup,
# install the ROCm build of torch FIRST (from https://pytorch.org, ROCm
# variant), THEN install ultralytics, so it detects torch is already
# satisfied and doesn't overwrite it with the CUDA build. Once ROCm torch
# is in place, --mask-device cuda keeps working as-is (ROCm torch keeps
# the "cuda" device string).
# tkinter (needed for pipeline_360_gui.py) is not pip-installable -- it's
# a system package. On CachyOS/Arch: sudo pacman -S tk