Files
3sixty2cubemap/src/gui.rs
T

601 lines
25 KiB
Rust

//! Native GUI for the 360° -> COLMAP cubemap rig pipeline.
//!
//! Built with **egui** (rendered through **wgpu** via eframe) so it is
//! cross-vendor / cross-platform (Vulkan/Metal/DX12) just like the pipeline.
//! The GUI drives the pipeline by shelling out to the `pipeline_360` CLI binary
//! with `--gui-log-output`, streaming its `[progress] ` / log line protocol —
//! the same protocol the Python GUI uses — so the heavy GPU work runs in the
//! (optionally release-built) CLI process and the GUI stays responsive.
//!
//! Build: cargo build --features gui
//! Run: ./target/debug/pipeline_360_gui
//!
//! The GUI looks for the `pipeline_360` binary beside itself, then on PATH.
//! Native GUI (egui + wgpu). Enabled by the `gui` feature.
#![cfg(feature = "gui")]
use std::collections::VecDeque;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::Instant;
use eframe::egui;
const PROGRESS_PREFIX: &str = "[progress] ";
const LOG_CAP: usize = 4000;
/// Locate the `pipeline_360` CLI binary: beside the GUI exe, else on PATH.
fn resolve_cli_binary() -> Option<PathBuf> {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
for name in ["pipeline_360", "pipeline_360.exe"] {
let p = dir.join(name);
if p.exists() {
return Some(p);
}
}
}
}
which("pipeline_360")
}
/// Minimal `which` (no extra dep): search PATH for an executable.
fn which(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let full = dir.join(name);
if full.is_file() {
return Some(full);
}
}
None
}
enum Line {
Log(String),
Progress(String),
Done(i32),
}
struct RunHandle {
child: std::process::Child,
rx: Receiver<Line>,
started: Instant,
code: Option<i32>,
}
impl RunHandle {
fn spawn(mut cmd: std::process::Command) -> std::io::Result<RunHandle> {
let mut child = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let (tx, rx) = mpsc::channel::<Line>();
spawn_line_reader(stdout, tx.clone(), false);
spawn_line_reader(stderr, tx, true);
Ok(RunHandle { child, rx, started: Instant::now(), code: None })
}
fn poll(&mut self, log: &mut VecDeque<String>) {
while let Ok(line) = self.rx.try_recv() {
match line {
Line::Log(s) | Line::Progress(s) => {
log.push_back(s);
while log.len() > LOG_CAP {
log.pop_front();
}
}
Line::Done(c) => self.code = Some(c),
}
}
if self.code.is_none() {
if let Ok(Some(status)) = self.child.try_wait() {
self.code = Some(status.code().unwrap_or(-1));
}
}
}
fn kill(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
self.code = Some(-1);
}
fn elapsed(&self) -> std::time::Duration {
self.started.elapsed()
}
}
fn spawn_line_reader<R: std::io::Read + Send + 'static>(mut r: R, tx: Sender<Line>, is_stderr: bool) {
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
let mut leftover = Vec::new();
loop {
let n = match r.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
leftover.extend_from_slice(&buf[..n]);
while let Some(pos) = leftover.iter().position(|&b| b == b'\n') {
let line_bytes = leftover.drain(..=pos).collect::<Vec<u8>>();
let raw = String::from_utf8_lossy(&line_bytes);
let raw = raw.trim_end_matches(['\n', '\r']);
if let Some(rest) = raw.strip_prefix(PROGRESS_PREFIX) {
let _ = tx.send(Line::Progress(format!("[…] {rest}")));
} else if !raw.is_empty() {
let _ = tx.send(Line::Log(if is_stderr {
format!("[stderr] {raw}")
} else {
raw.to_string()
}));
}
}
}
let _ = tx.send(Line::Done(0));
});
}
// ---------------------------------------------------------------------------
// Form state — mirrors the CLI flags + defaults.
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct ExtractForm {
chunk_size: u32,
scale_width: u32,
output_format: String,
jpeg_quality: u32,
min_sharpness: f64,
min_exposure: f64,
max_exposure: f64,
min_contrast: f64,
min_motion: f64,
max_motion: f64,
selection: String,
target_count: u32,
min_gap: u32,
bad_frame_action: String,
motion_mode: String,
batch: u32,
}
impl Default for ExtractForm {
fn default() -> Self {
Self {
chunk_size: 30, scale_width: 1920, output_format: "png".into(), jpeg_quality: 95,
min_sharpness: 0.0, min_exposure: 0.0, max_exposure: 255.0, min_contrast: 0.0,
min_motion: 0.0, max_motion: 1.0e12, selection: "chunk".into(), target_count: 0,
min_gap: 0, bad_frame_action: "keep".into(), motion_mode: "frame_diff".into(), batch: 8,
}
}
}
#[derive(Clone)]
struct MasksForm {
classes: String,
model: PathBuf,
device: String,
conf: f64,
imgsz: u32,
dilate: u32,
projection: String,
face_size: u32,
skip_faces: String,
tile_batch: u32,
empty_if_none: bool,
iou: f64,
}
impl Default for MasksForm {
fn default() -> Self {
Self {
classes: "person,car,bus,truck,bicycle,motorcycle".into(),
model: PathBuf::from("yolo11x-seg.onnx"), device: "cpu".into(), conf: 0.35,
imgsz: 1280, dilate: 6, projection: "cubemap".into(), face_size: 1024,
skip_faces: String::new(), tile_batch: 8, empty_if_none: false, iou: 0.45,
}
}
}
#[derive(Clone)]
struct CubemapForm { face_size: u32, skip_faces: String, jpeg_quality: u32, batch: u32 }
impl Default for CubemapForm {
fn default() -> Self { Self { face_size: 1600, skip_faces: "U,D".into(), jpeg_quality: 95, batch: 4 } }
}
#[derive(Clone)]
struct RigForm { ref_face: String, intrinsics_convention: String, link_mode: String }
impl Default for RigForm {
fn default() -> Self { Self { ref_face: "F".into(), intrinsics_convention: "linspace".into(), link_mode: "symlink".into() } }
}
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
struct App {
project_dir: PathBuf,
videos: Vec<PathBuf>,
start_stage: String,
skip_masks: bool,
extract: ExtractForm,
masks: MasksForm,
cubemap: CubemapForm,
rig: RigForm,
cli_binary: Option<PathBuf>,
cli_binary_str: String,
run: Option<RunHandle>,
log: VecDeque<String>,
status: String,
last_progress: String,
}
impl Default for App {
fn default() -> Self {
let cli = resolve_cli_binary();
Self {
project_dir: PathBuf::new(), videos: Vec::new(), start_stage: "extract".into(),
skip_masks: false, extract: ExtractForm::default(), masks: MasksForm::default(),
cubemap: CubemapForm::default(), rig: RigForm::default(),
cli_binary_str: cli.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "pipeline_360 (NOT FOUND — build it first)".into()),
cli_binary: cli, log: VecDeque::new(), run: None, status: "Idle".into(), last_progress: String::new(),
}
}
}
impl App {
fn is_running(&self) -> bool {
self.run.as_ref().is_some_and(|h| h.code.is_none())
}
fn build_command(&self) -> Option<std::process::Command> {
let bin = self.cli_binary.clone()?;
let mut cmd = std::process::Command::new(bin);
cmd.arg("all");
cmd.arg("--project-dir").arg(&self.project_dir);
for v in &self.videos {
cmd.arg("--video").arg(v);
}
cmd.arg("--start-stage").arg(&self.start_stage);
if self.skip_masks {
cmd.arg("--skip-masks");
}
cmd.arg("--gui-log-output");
let e = &self.extract;
cmd.args([
"--extract-chunk-size", &e.chunk_size.to_string(),
"--extract-scale-width", &e.scale_width.to_string(),
"--extract-output-format", &e.output_format,
"--extract-jpeg-quality", &e.jpeg_quality.to_string(),
"--extract-min-sharpness", &fmt_f(e.min_sharpness),
"--extract-min-exposure", &fmt_f(e.min_exposure),
"--extract-max-exposure", &fmt_f(e.max_exposure),
"--extract-min-contrast", &fmt_f(e.min_contrast),
"--extract-min-motion", &fmt_f(e.min_motion),
"--extract-max-motion", &fmt_f(e.max_motion),
"--extract-selection", &e.selection,
"--extract-target-count", &e.target_count.to_string(),
"--extract-min-gap", &e.min_gap.to_string(),
"--extract-bad-frame-action", &e.bad_frame_action,
"--extract-motion-mode", &e.motion_mode,
"--extract-batch", &e.batch.to_string(),
]);
let m = &self.masks;
cmd.args([
"--mask-classes", &m.classes,
"--mask-model", &m.model.display().to_string(),
"--mask-device", &m.device,
"--mask-conf", &fmt_f(m.conf),
"--mask-imgsz", &m.imgsz.to_string(),
"--mask-dilate", &m.dilate.to_string(),
"--mask-projection", &m.projection,
"--mask-face-size", &m.face_size.to_string(),
"--mask-skip-faces", &m.skip_faces,
"--mask-tile-batch", &m.tile_batch.to_string(),
"--mask-iou", &fmt_f(m.iou),
]);
if m.empty_if_none {
cmd.arg("--mask-empty-if-none");
}
let c = &self.cubemap;
cmd.args([
"--cubemap-face-size", &c.face_size.to_string(),
"--cubemap-skip-faces", &c.skip_faces,
"--cubemap-jpeg-quality", &c.jpeg_quality.to_string(),
"--cubemap-batch", &c.batch.to_string(),
]);
let r = &self.rig;
cmd.args([
"--rig-ref-face", &r.ref_face,
"--rig-intrinsics-convention", &r.intrinsics_convention,
"--rig-link-mode", &r.link_mode,
]);
Some(cmd)
}
fn start(&mut self) {
if self.is_running() {
return;
}
if self.cli_binary.is_none() {
self.status = "Error: pipeline_360 binary not found (build it first).".into();
return;
}
if self.project_dir.as_os_str().is_empty() || self.videos.is_empty() {
self.status = "Error: choose a project dir and at least one video.".into();
return;
}
let Some(cmd) = self.build_command() else { return };
self.log.clear();
self.last_progress.clear();
self.status = "Running…".into();
self.log.push_back(format!("$ {}", cmd_to_string(&cmd)));
match RunHandle::spawn(cmd) {
Ok(h) => self.run = Some(h),
Err(e) => self.status = format!("Error: failed to start pipeline: {e}"),
}
}
fn cancel(&mut self) {
if let Some(h) = self.run.as_mut() {
h.kill();
self.status = "Cancelled.".into();
}
}
}
fn fmt_f(v: impl ToString) -> String {
let s = v.to_string();
if s.eq_ignore_ascii_case("inf") { "1e12".into() } else { s }
}
fn cmd_to_string(cmd: &std::process::Command) -> String {
let prog = cmd.get_program().to_string_lossy().to_string();
let args: Vec<String> = cmd.get_args().map(|a| {
let a = a.to_string_lossy().to_string();
if a.contains(' ') { format!("\"{a}\"") } else { a }
}).collect();
format!("{prog} {}", args.join(" "))
}
fn combo(ui: &mut egui::Ui, id: &str, options: &[&str], val: &mut String) {
egui::ComboBox::from_id_salt(id).selected_text(val.clone()).show_ui(ui, |ui| {
for o in options {
ui.selectable_value(val, (*o).to_string(), *o);
}
});
}
impl eframe::App for App {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// Poll subprocess each frame.
if let Some(h) = self.run.as_mut() {
h.poll(&mut self.log);
if let Some(code) = h.code {
if !self.status.starts_with("Cancelled") {
self.status = if code == 0 { "Pipeline complete.".into() } else { format!("Pipeline exited with code {code}. See log.") };
}
}
}
// Bottom: log panel + run controls.
egui::Panel::bottom("log_panel")
.resizable(true)
.min_size(150.0)
.show(ui, |ui| {
ui.horizontal(|ui| {
let status = if self.is_running() {
if let Some(h) = self.run.as_ref() {
format!("{} ({}s)", self.status, h.elapsed().as_secs())
} else { self.status.clone() }
} else { self.status.clone() };
ui.label(egui::RichText::new(status).strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let running = self.is_running();
if running {
ui.spinner();
}
if ui.add_enabled(running, egui::Button::new("⏹ Stop")).clicked() {
self.cancel();
}
if ui.add_enabled(!running, egui::Button::new("▶ Run pipeline")).clicked() {
self.start();
}
});
});
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.stick_to_bottom(true)
.show(ui, |ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
for line in &self.log {
if line.starts_with("[…] ") {
continue;
}
ui.label(egui::RichText::new(line).monospace().size(12.0));
}
if !self.last_progress.is_empty() {
ui.label(
egui::RichText::new(&self.last_progress)
.monospace()
.color(egui::Color32::LIGHT_BLUE)
.size(12.0),
);
}
});
});
// Left: inputs.
egui::Panel::left("inputs")
.resizable(true)
.min_size(320.0)
.show(ui, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
ui.heading("360° -> COLMAP cubemap rig");
ui.add_space(2.0);
ui.label(egui::RichText::new(format!("CLI: {}", self.cli_binary_str)).small().color(egui::Color32::GRAY));
ui.add_space(8.0);
ui.label("Project directory");
ui.horizontal(|ui| {
let mut s = self.project_dir.display().to_string();
let r = ui.text_edit_singleline(&mut s);
if r.changed() {
self.project_dir = PathBuf::from(s);
}
if ui.button("Browse…").clicked() {
if let Some(p) = rfd::FileDialog::new().pick_folder() {
self.project_dir = p;
}
}
});
ui.add_space(8.0);
ui.label("Source videos");
let vids = self.videos.clone();
for (i, v) in vids.iter().enumerate() {
ui.horizontal(|ui| {
ui.label(format!("{}. {}", i + 1, v.display()));
if ui.small_button("✕").clicked() {
self.videos.remove(i);
}
});
}
if ui.button("+ Add videos…").clicked() {
if let Some(files) = rfd::FileDialog::new()
.add_filter("Videos", &["mp4", "mov", "mkv", "avi", "webm", "m4v"])
.pick_files()
{
self.videos.extend(files);
}
}
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("Start stage:");
egui::ComboBox::from_id_salt("start_stage")
.selected_text(self.start_stage.clone())
.show_ui(ui, |ui| {
for s in ["extract", "masks", "cubemap", "rig"] {
ui.selectable_value(&mut self.start_stage, s.into(), s);
}
});
});
ui.checkbox(&mut self.skip_masks, "Skip masks stage");
});
});
// Centre: stage option sections.
egui::CentralPanel::default().show(ui, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 6.0;
egui::CollapsingHeader::new("Stage 1 — Extract (sharpest/best frames)")
.default_open(true)
.show(ui, |ui| {
let e = &mut self.extract;
egui::Grid::new("extract_grid").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| {
ui.label("Chunk size"); ui.add(egui::DragValue::new(&mut e.chunk_size).range(1..=10000)); ui.end_row();
ui.label("Scale width"); ui.add(egui::DragValue::new(&mut e.scale_width).range(0..=8000)); ui.end_row();
ui.label("Output format"); combo(ui, "extract_fmt", &["png", "jpg"], &mut e.output_format); ui.end_row();
ui.label("JPEG quality"); ui.add(egui::DragValue::new(&mut e.jpeg_quality).range(1..=100)); ui.end_row();
ui.label("Batch (frames/GPU)");ui.add(egui::DragValue::new(&mut e.batch).range(1..=64)); ui.end_row();
ui.label("Selection"); combo(ui, "extract_sel", &["chunk", "spread"], &mut e.selection); ui.end_row();
ui.label("Target count"); ui.add(egui::DragValue::new(&mut e.target_count).range(0..=100000)); ui.end_row();
ui.label("Min gap (frames)"); ui.add(egui::DragValue::new(&mut e.min_gap).range(0..=10000)); ui.end_row();
ui.label("Bad-frame action"); combo(ui, "extract_bfa", &["keep", "skip", "warn"], &mut e.bad_frame_action); ui.end_row();
ui.label("Motion mode"); combo(ui, "extract_mm", &["frame_diff", "off"], &mut e.motion_mode); ui.end_row();
});
ui.label(egui::RichText::new("Quality thresholds (0/255 bounds = no filter):").small());
let e = &mut self.extract;
egui::Grid::new("extract_q").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| {
ui.label("Min sharpness"); ui.add(egui::DragValue::new(&mut e.min_sharpness).speed(0.05).range(0.0..=1.0e9)); ui.end_row();
ui.label("Min exposure"); ui.add(egui::DragValue::new(&mut e.min_exposure).speed(1.0).range(0.0..=255.0)); ui.end_row();
ui.label("Max exposure"); ui.add(egui::DragValue::new(&mut e.max_exposure).speed(1.0).range(0.0..=255.0)); ui.end_row();
ui.label("Min contrast"); ui.add(egui::DragValue::new(&mut e.min_contrast).speed(1.0).range(0.0..=255.0)); ui.end_row();
ui.label("Min motion"); ui.add(egui::DragValue::new(&mut e.min_motion).speed(1.0).range(0.0..=255.0)); ui.end_row();
ui.label("Max motion"); ui.add(egui::DragValue::new(&mut e.max_motion).speed(1.0).range(0.0..=1.0e9)); ui.end_row();
});
});
egui::CollapsingHeader::new("Stage 2 — Masks (YOLO segmentation; needs --features masks)")
.show(ui, |ui| {
let m = &mut self.masks;
egui::Grid::new("masks_grid").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| {
ui.label("Classes"); ui.text_edit_singleline(&mut m.classes); ui.end_row();
ui.label("Model (ONNX)"); ui.horizontal(|ui| {
let mut s = m.model.display().to_string();
let r = ui.text_edit_singleline(&mut s);
if r.changed() { m.model = PathBuf::from(s); }
if ui.button("…").clicked() {
if let Some(p) = rfd::FileDialog::new().add_filter("ONNX", &["onnx"]).pick_file() { m.model = p; }
}
});
ui.end_row();
ui.label("Device"); combo(ui, "mask_dev", &["cpu", "cuda", "rocm"], &mut m.device); ui.end_row();
ui.label("Conf"); ui.add(egui::DragValue::new(&mut m.conf).speed(0.01).range(0.0..=1.0).fixed_decimals(2)); ui.end_row();
ui.label("Imgsz"); ui.add(egui::DragValue::new(&mut m.imgsz).range(64..=4096)); ui.end_row();
ui.label("Dilate (px)"); ui.add(egui::DragValue::new(&mut m.dilate).range(0..=100)); ui.end_row();
ui.label("Projection"); combo(ui, "mask_proj", &["cubemap", "equirect"], &mut m.projection); ui.end_row();
ui.label("Face size"); ui.add(egui::DragValue::new(&mut m.face_size).range(64..=4096)); ui.end_row();
ui.label("Skip faces"); ui.text_edit_singleline(&mut m.skip_faces); ui.end_row();
ui.label("Tile batch"); ui.add(egui::DragValue::new(&mut m.tile_batch).range(1..=64)); ui.end_row();
ui.label("NMS IoU"); ui.add(egui::DragValue::new(&mut m.iou).speed(0.01).range(0.0..=1.0).fixed_decimals(2)); ui.end_row();
});
ui.checkbox(&mut m.empty_if_none, "Write empty (all-keep) mask when no objects detected");
});
egui::CollapsingHeader::new("Stage 3 — Cubemap (equirect -> 6 faces)")
.show(ui, |ui| {
let c = &mut self.cubemap;
egui::Grid::new("cubemap_grid").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| {
ui.label("Face size"); ui.add(egui::DragValue::new(&mut c.face_size).range(64..=4096)); ui.end_row();
ui.label("Skip faces"); ui.text_edit_singleline(&mut c.skip_faces); ui.end_row();
ui.label("JPEG quality"); ui.add(egui::DragValue::new(&mut c.jpeg_quality).range(1..=100)); ui.end_row();
ui.label("Batch (frames/GPU)"); ui.add(egui::DragValue::new(&mut c.batch).range(1..=32)); ui.end_row();
});
});
egui::CollapsingHeader::new("Stage 4 — Rig config")
.show(ui, |ui| {
let r = &mut self.rig;
egui::Grid::new("rig_grid").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| {
ui.label("Reference face"); combo(ui, "rig_ref", &["F", "R", "B", "L", "U", "D"], &mut r.ref_face); ui.end_row();
ui.label("Intrinsics"); combo(ui, "rig_int", &["linspace", "half"], &mut r.intrinsics_convention); ui.end_row();
ui.label("Link mode"); combo(ui, "rig_link", &["symlink", "hardlink", "copy"], &mut r.link_mode); ui.end_row();
});
});
});
});
// Keep repainting while a run is in progress so the log streams live.
if self.is_running() {
ui.ctx().request_repaint();
}
}
}
pub fn launch() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1100.0, 760.0])
.with_min_inner_size([700.0, 500.0]),
..Default::default()
};
eframe::run_native(
"pipeline_360 — 360° -> COLMAP cubemap rig",
options,
Box::new(|_cc| Ok(Box::<App>::default())),
)
}