SHA256
Start
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
!pipeline_360.py
|
||||||
|
!pipeline_360_gui.py
|
||||||
|
!requirements.txt
|
||||||
+1585
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user