From a44539b6db88b1bde2dcbed7bf85c2c9ef5c078e35c6f141fc09838eb886d906 Mon Sep 17 00:00:00 2001 From: Ben_Kosytorz Date: Sat, 22 Aug 2026 16:40:11 +0200 Subject: [PATCH] Funktioning rewrite, that is able to use gpu on a more variety of systems --- .gitignore | 3 + Cargo.toml | 100 +++ PLAN.md | 278 +++++++ README.md | 226 ++++++ offline_check/main.rs | 42 + src/error.rs | 60 ++ src/gpu/mod.rs | 468 +++++++++++ src/gpu/shaders/c2e.wgsl | 112 +++ src/gpu/shaders/equirect_to_cubemap.wgsl | 145 ++++ src/gpu/shaders/sharpness.wgsl | 102 +++ src/gui.rs | 600 ++++++++++++++ src/json.rs | 318 ++++++++ src/lib.rs | 24 + src/logging.rs | 33 + src/main.rs | 777 ++++++++++++++++++ src/math.rs | 172 ++++ src/project.rs | 74 ++ src/selftest.rs | 326 ++++++++ src/stages/cubemap.rs | 553 +++++++++++++ src/stages/extract.rs | 786 ++++++++++++++++++ src/stages/masks.rs | 991 +++++++++++++++++++++++ src/stages/mod.rs | 6 + src/stages/rig.rs | 728 +++++++++++++++++ src/video/mod.rs | 154 ++++ 24 files changed, 7078 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 offline_check/main.rs create mode 100644 src/error.rs create mode 100644 src/gpu/mod.rs create mode 100644 src/gpu/shaders/c2e.wgsl create mode 100644 src/gpu/shaders/equirect_to_cubemap.wgsl create mode 100644 src/gpu/shaders/sharpness.wgsl create mode 100644 src/gui.rs create mode 100644 src/json.rs create mode 100644 src/lib.rs create mode 100644 src/logging.rs create mode 100644 src/main.rs create mode 100644 src/math.rs create mode 100644 src/project.rs create mode 100644 src/selftest.rs create mode 100644 src/stages/cubemap.rs create mode 100644 src/stages/extract.rs create mode 100644 src/stages/masks.rs create mode 100644 src/stages/mod.rs create mode 100644 src/stages/rig.rs create mode 100644 src/video/mod.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a3abf0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +Cargo.lock +offline_check/selftest_bin diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9f8ddaa --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,100 @@ +[package] +name = "pipeline-360" +version = "0.1.0" +edition = "2021" +description = "360° equirect -> COLMAP cubemap rig pipeline (Rust/WebGPU rewrite)" +license = "MIT" + +[lib] +name = "pipeline_360" +path = "src/lib.rs" + +[[bin]] +name = "pipeline_360" +path = "src/main.rs" + +[dependencies] +# GPU compute (the WebGPU layer) — Vulkan/Metal/DX12 on native, WebGPU on wasm. +wgpu = "25" + +# Image I/O (PNG/JPEG) for extracted frames and cube faces. +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff", "webp"] } + +# CPU-side parallel orchestration (per-frame / per-face work). +rayon = "1.10" + +# CLI parsing. +clap = { version = "4.5", features = ["derive"] } + +# Serde for any structured config beyond the minimal `json` module. +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Block on the async wgpu device/adapter setup from a sync CLI. +pollster = "0.4" + +# Errors / logging. +anyhow = "1" +thiserror = "2" + +# Optional YOLO masks (ONNX Runtime). Gated behind a feature so the +# extract/cubemap/rig core builds without system deps. Video decode uses the +# `ffmpeg`/`ffprobe` CLI (piped rawvideo) instead of native bindings, so there +# is no `video` feature/dependency. +# +# `ort` is used with `load-dynamic` so it loads the *system* libonnxruntime at +# runtime (via dlopen, path overridable with `ORT_DYLIB_PATH`) instead of +# downloading a CPU-only build. On this box the system lib is 1.28 built with +# the MIGraphX execution provider (AMD ROCm GPU), so `--mask-device rocm` runs +# YOLO inference on the Radeon GPU. `migraphx` gates the `ep::MIGraphX` binding; +# `api-28` matches the system lib's API version. +[features] +# `masks` is on by default so a plain `cargo build` produces a fully-featured +# binary (YOLO segmentation via ONNX Runtime). Use `--no-default-features` for a +# lighter build without ONNX Runtime. +default = ["masks"] +masks = ["dep:ort", "dep:ndarray", "ort/load-dynamic", "ort/migraphx", "ort/api-28"] +# Native GUI (egui + wgpu). When enabled, the single `pipeline_360` binary also +# launches the GUI when invoked with no subcommand (or `--gui`). +gui = [ + "dep:eframe", + "dep:egui", + "dep:egui-wgpu", + "dep:rfd", +] + +# ort has no stable release yet; declare the rc so `cargo fetch` resolves it. +# `default-features = false` + `load-dynamic` avoids downloading/linking a +# CPU-only ONNX Runtime and instead dlopens the system lib (which has the +# MIGraphX GPU EP on AMD/ROCm systems). `ndarray` is needed for +# `TensorRef::from_array_view` / `try_extract_tensor`. +[dependencies.ort] +version = "2.0.0-rc" +optional = true +default-features = false +features = ["ndarray", "load-dynamic", "migraphx", "api-28"] + +# NCHW tensor construction for ort inputs (only needed by the masks stage). +[dependencies.ndarray] +version = "0.17" +optional = true + +# Native GUI deps (egui + wgpu + native file dialogs). +[dependencies.egui] +version = "0.36" +optional = true +[dependencies.eframe] +version = "0.36" +optional = true +default-features = false +features = ["wgpu", "wayland", "x11"] +[dependencies.egui-wgpu] +version = "0.36" +optional = true +[dependencies.rfd] +version = "0.15" +optional = true + +[profile.release] +lto = true +codegen-units = 1 diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..ce21ddc --- /dev/null +++ b/PLAN.md @@ -0,0 +1,278 @@ +# Rust + WebGPU rewrite plan — `pipeline_360` + +This document is the detailed design for rewriting the Python +`pipeline_360.py` / `pipeline_360_gui.py` pipeline in Rust, with all heavy +image math running on the GPU through **WebGPU** (the `wgpu` crate) for +maximum cross-vendor / cross-platform compatibility. + +Status legend at the top of each stage: +- ✅ **done** — implemented and (for pure logic) verified +- 🚧 **scaffold** — module/API in place, kernels/logic not complete +- ⬜ **planned** — design decided, not started + +--- + +## 1. Why Rust + WebGPU + +| Concern | Rust + `wgpu` | +| --- | --- | +| Parallelism | Rust's ownership model + `rayon` for CPU, and first-class GPU compute | +| Cross-vendor GPU | `wgpu` = portable WebGPU: Vulkan (NVIDIA/AMD/Intel), Metal (Apple), DX12 (Windows), WebGPU (wasm). One codebase, one shader language (WGSL) | +| Safety | No GC pauses, no segfaults/use-after-free, explicit resource lifetimes | +| Distribution | Single static-ish binary; trivially parallel per-frame work | +| Fit | Equirect→cubemap and sharpness are embarrassingly-parallel *per pixel* — ideal for a compute shader | + +Video **decode** stays on the CPU via FFmpeg (codecs are not something you +hand-roll on a GPU bindless layer); we push the decoded RGBA frames to the GPU +texture for all subsequent math. Hardware decode (Vulkan/VAAPI/NVDEC) is a +later optimization. + +--- + +## 2. Repository layout + +``` +rust_rewrite/ + Cargo.toml binary + lib, features: [video(fmpeg), masks(ort)] + PLAN.md this file + src/ + main.rs entry point, dispatch, --gui-log-output + gui_main.rs native egui+wgpu GUI (feature `gui`), drives the CLI + lib.rs crate root, re-exports + cli.rs clap parser mirroring the Python CLI + error.rs Error/Cancelled types + logging.rs emit / emit_progress ([progress] line protocol) + project.rs project layout (01_frames…04_colmap), gather/scan + json.rs minimal JSON value/writer (pure std) + math.rs face rotations, quaternions, intrinsics (pure std) ✅ + selftest.rs mirrors the Python rig self-test (pure std) ✅ + gpu/ + mod.rs GpuContext (device/queue/instance), texture helpers ✅ + shaders/ + equirect_to_cubemap.wgsl ✅ verified vs py360convert + sharpness.wgsl 🚧 used by stage 1 + stages/ + mod.rs stage registry + orchestration + extract.rs stage 1: GPU sharpness + frame writes (scaffold) + masks.rs stage 2: YOLO segmentation (ONNX/wgpu) (scaffold) + cubemap.rs stage 3: GPU equirect→cubemap projection ✅ + rig.rs stage 4: rig config + nested tree (pure std) ✅ + video/ + mod.rs FFmpeg decode interface (frames → GPU) +``` + +--- + +## 3. Stage-by-stage plan + +### Stage 0 — Media input (✅ done, via ffmpeg CLI) +- **Decision change:** instead of fragile native `ffmpeg-next` bindings (the + system ships FFmpeg 9 / libavcodec 63, which `ffmpeg-next 7` would not + match), `video/mod.rs` shells out to the **`ffmpeg` / `ffprobe` CLI** and + pipes **`rawvideo` RGBA**. No native bindings, no `video` feature, no system + dev headers — just `ffmpeg` on PATH. Robust across FFmpeg versions. +- `probe(path)` -> `{width, height, nb_frames, fps}` via `ffprobe -of json`. +- `FramePipe::open(path, scale_width, probe)` decodes sequentially; analysis + decodes downscaled (`-vf scale=W:-1`), extraction decodes native res. Two + sequential decode passes mirror the Python analyze/extract split. +- Multi-video = iterate several paths; per-video stem prefix + per-video + metadata CSV, exactly like Python. + +### Stage 1 — Extract best frames (✅ done, superset of Python; verified end-to-end) +- **GPU kernel (`sharpness.wgsl`)**: one dispatch/frame computes, per 16×16 + workgroup, the 4-neighbour **Laplacian** (matches `cv2.Laplacian` ksize=1) + and luma, reduces in shared memory to five partial sums + (`sum_g, sum_g2, sum_L, sum_L2, n`), and writes 5 floats/workgroup to a + storage buffer. The CPU sums the tiny partial buffer and derives: + - **sharpness** = var(L) (variance-of-Laplacian, the original metric), + - **exposure** = mean(luma), + - **contrast** = std(luma). + Readback is ~5 floats/workgroup instead of the full Laplacian plane. +- **CPU motion proxy (`frame_diff`)**: a 64×32 luma thumbnail per frame; + mean-abs-diff vs the previous frame → the **parallax/baseline** metric. +- **Quality filtering** ("throw away bad frames"): `--extract-min-sharpness`, + `--extract-min-exposure`/`--max-exposure`, `--extract-min-contrast`, + `--extract-min-motion`/`--max-motion`. +- **Selection**: + - `chunk` (default, original behaviour): sharpest surviving frame per + `--extract-chunk-size` window; `--extract-bad-frame-action + skip|keep|warn` decides chunks with no survivors. + - `spread`: up to `--extract-target-count` sharp, well-distributed frames + (greedy by sharpness, accepting only if `--extract-min-gap` away from all + accepted). + - `--extract-min-gap` enforces a minimum frame spacing in both modes. +- Defaults reproduce the original Python behaviour exactly (sharpest per + chunk, no filtering). Enriched CSV: `frame,sharpness,exposure,contrast,motion` + (reused unless `--extract-regenerate-metadata`). +- Multi-video prefixes/metadata identical to Python. + +### Stage 2 — Masks / YOLO segmentation (✅ done, `ort` backend; verified vs Python) +- **Backend:** ONNX Runtime via the `ort` crate (`--features masks`). The + YOLO11x-seg model must be an **ONNX** export (the `.pt` is not loadable in + Rust): `yolo export model=yolo11x-seg.pt format=onnx imgsz=1280 dynamic=True` + (dynamic batch + spatial dims so `--mask-imgsz` and `--mask-tile-batch` work). +- **Decode (pure Rust):** output0 `[B,4+80+32,A]` (post-sigmoid class scores, + box cxcywh in imgsz space, 32 mask coeffs) + output1 protos `[B,32,mh,mw]`. + Per-class conf filter + greedy NMS (IoU `--mask-iou`, default 0.45), then per + detection `mask = sigmoid(coeffs ⊗ protos)` cropped to the box and scaled + back to the original/face resolution. +- **Cubemap path (default):** e2c (GPU, the stage-3 kernel) → YOLO per face → + per-face binary masks (dilated) → **c2e** (new GPU nearest kernel, + `gpu/shaders/c2e.wgsl`, the gnomonic inverse of `xyzcube`) → equirect mask → + threshold(127) → invert (COLMAP black = excluded) → `.png`. +- **Equirect path:** YOLO directly on the frame (letterboxed to `--mask-imgsz`). +- All Python `--mask-*` flags retained; `--mask-empty-if-none` controls whether + frames with no targets get an all-keep mask or are left unmasked. +- **Validation:** vs the Python stage on a real image with people — equirect + projection reproduces Python masks at **~0.89 IoU** (ONNX vs PyTorch + nearest + letterbox differences); cubemap path runs end-to-end (e2c→YOLO→c2e) and covers + the right regions. The `burn`/wgpu backend (true “everything on WebGPU”) is + left as a future optimization; `ort` is the pragmatic first backend. +- **c2e note:** the shader uses a dominant-axis face selection + gnomonic + (tangent) UV — the geometrically exact inverse of our e2c `xyzcube`. It is + close to `py360convert.c2e` but not pixel-identical at curved pole boundaries + (py360convert uses a facetype LUT); for binary masks this is immaterial. + +### Stage 3 — Equirect → cubemap (✅ done, verified vs `py360convert.e2c`) +- **`equirect_to_cubemap.wgsl`** is a faithful GPU port of `py360convert.e2c`: + - `xyzcube(face_w)` endpoint-**inclusive** `linspace(-0.5, 0.5, N)` grid — + NOT `(j+0.5)/N*2-1` (the earlier scaffold grid was a parity bug). + - per-face direction from the exact `py360convert` cube geometry, + - `xyz2uv` (`u=atan2(x,z)`, `v=atan2(y,hypot(x,z))`), + - `uv2coor` with the `-0.5` offsets, + - a 1-px-**padded** sampler replicating `EquirecSampler._pad` (top/bottom + rows rolled by `w/2`, left/right columns clamped) so the ±180° seam is + continuous; bilinear for images, nearest for masks. +- **Resolved parity detail:** U/D faces use the *reversed-row* coordinate + (`rng_i = fliplr(y)`), which caused the earlier U/D mismatch. +- **`stages/cubemap.rs`**: builds one wgpu pipeline once, uploads each equirect + as RGBA, dispatches per face to reusable storage textures, reads back + (256-byte `bytes_per_row` alignment handled in `gpu::read_texture_rgba`), + and writes `{stem}_{FACE}.{suffix}` (+ binary `.png` masks) via `image`. + Implements `--cubemap-face-size`, `--cubemap-skip-faces`, `--cubemap-resume`, + and `--cubemap-jpeg-quality` for jpg sources. +- **Validation:** with a real GPU present, output was diffed against the + installed `py360convert`: bilinear faces match to ≤6/255 (float32 vs float64 + rounding); nearest masks match **byte-for-byte**; skip/resume/naming all + match the Python stage. + +### Stage 4 — Rig config + nested tree (✅ done — pure std, verified) +- Port of the Python `math.rs` primitives + `rig.rs` logic: + - `scan_cubemap_images/masks`, `detect_face_size` (PNG header read), + - `build_colmap_rig_config` (serde/minimal JSON), + - `compute_intrinsics` (linspace / half), + - materialise nested `{FACE}/{stem}.ext` tree (symlink/hardlink/copy). +- Verified offline by compiling `math.rs` + `json.rs` + `rig.rs` + + `selftest.rs` with plain `rustc` (no network) and re-running the *same six + check groups* as `pipeline_360.py rig --self-test`. + +--- + +## 4. CLI parity + +The Rust binary must accept the same commands/flags as the Python one so the +existing Python GUI (`pipeline_360_gui.py`) can drive it unmodified, and so +drops build up a `--gui-log-output` flag (SUPPRESS help) that toggles the +`[progress] ` line protocol on stdout. + +- `all --project-dir … [--video v1 v2 …] --start-stage … [--skip-masks]` +- `extract --video … …`, `masks …`, `cubemap …`, `rig …`, `rig --self-test` +- Per-stage flags match Python defaults (chunk size 30, scale 1920, workers 4, + masks conf 0.35 / model yolo11x-seg.pt, cubemap face 1600 / skip U,D, + rig ref F / linspace / symlink, …). + +Exit codes: `0` ok, `1` error, `130` cancelled. + +> Note: the Python GUI currently passes `--gui-log-output` *after* `all`. +> The Rust parser can accept it in both positions (add it to the top parser +> and the subparsers) to avoid the argparse ordering bug we hit in Python. + +--- + +## 5. Progress / logging line protocol + +Keep byte-for-byte compatibility with the Python GUI: +- plain lines → appended log lines, +- `[progress] ` → replace-the-last-line in-place updates (tqdm-style). + +--- + +## 6. Project layout compatibility (identical directories) + +Outputs must be byte-identical in structure to drop into the existing COLMAP +workflow: + +``` +01_frames/ 02_masks/ 03_cubemap/{images,masks}/ 04_colmap/{images,masks,rig_config.json} +``` + +Filenames, mask `.png` convention (black = excluded), and the nested +`{FACE}/{stem}` tree all unchanged. + +--- + +## 7. Dependencies (Cargo.toml) + +- `wgpu` — WebGPU implementation (the whole point). +- `image` — PNG/JPEG/BMP/TIFF/WEBP pixel I/O. +- `rayon` — CPU parallel orchestration (per-frame / per-face). +- `clap` — CLI derivation. +- `serde`/`serde_json` — config; fallback `json.rs` keeps core pure-std. +- `anyhow`/`thiserror` — error handling. +- `pollster` — block on async wgpu adapter/device setup from the sync CLI. +- **Video decode uses the `ffmpeg`/`ffprobe` CLI** (no native binding, no + feature) — see stage 0. +- `ort` (feature `masks`) or `burn`+wgpu — YOLO segmentation. + +--- + +## 8. Milestones + +1. **Core skeleton** — Cargo project, CLI, error, logging, project layout. ✅ done, `cargo build` clean +2. **Stage 4 rig** (pure) — full parity incl. self-test. ✅ done, `cargo test` + `cargo run -- rig --self-test` pass +3. **wgpu scaffolding** — `GpuContext`, texture readback; smoke kernel. ✅ done; used by extract + cubemap +4. **Stage 0 video** — ffmpeg CLI decode → RGBA pipe → GPU textures. ✅ done (no native binding) +5. **Stage 1 extract** — GPU metrics + quality filtering + chunk/spread selection. ✅ done, superset of Python, verified end-to-end +6. **Stage 3 cubemap** — projection kernel + mask reprojection. ✅ done, verified vs `py360convert.e2c` +7. **`all` orchestration** — extract → cubemap → masks → rig on one GPU device. ✅ done (order changed: cubemap before masks so masks run on the cube faces, no equirect round-trip; masks skipped with `--skip-masks`) +8. **Stage masks** — `ort`/ONNX backend, YOLO on the cube-face images the cubemap stage produced (no e2c/c2e round-trip); GPU via MIGraphX with auto compiled-model cache. ✅ done (`--features masks`), verified IoU 0.96 vs ultralytics +9. **Parity polish** — `--gui-log-output` line protocol, multi-video CSVs, edge + flags. 🚧 core done; GUI smoke-test pending +10. **GUI** — native **egui+wgpu** GUI (`gui_main.rs`, `--features gui`) that + drives the CLI subprocess via the `--gui-log-output` protocol. ✅ done, + builds & runs cross-vendor; the Python GUI remains usable too + +### Batching (added) +`--extract-batch` (8) and `--cubemap-batch` (4) submit a batch of frames' GPU +work in one encoder and read it all back with one `device.poll`, amortising +readback latency and overlapping the GPU pipeline across frames. Implemented in +`gpu::readback_many` / `gpu::read_textures_rgba`; output is byte-identical to +the non-batched path (validated for both stages). + +--- + +## 9. Risks / open decisions + +- **wgpu readback latency** — extract keeps readback tiny (5 floats/workgroup); + cubemap reads back one face at a time. Both reuse persistent buffers/textures + across frames. +- **Dependency/build weight** — `wgpu` pulls a large tree; the rig/selftest + core stays pure-std and buildable offline via `offline_check/`. +- **YOLO backend maturity** — `burn`+wgpu is the least mature; `ort` is the + pragmatic first backend. +- **System deps** — only `ffmpeg`/`ffprobe` on PATH (no dev headers); ONNX + Runtime (`--features masks`) needs its native library. +- **Float parity** — GPU kernels use f32; py360convert is float64 numpy. + Cubemap output matches within float32 rounding (≤6/255 bilinear; nearest + masks byte-exact). The rig intrinsics are exact and deterministic. + +--- + +## 10. Verification strategy + +- Offline (no network / no GPU): `math.rs + json.rs + rig.rs + selftest.rs` + compiled with plain `rustc`; the 6 rig-check groups must pass — mirrors + `pipeline_360.py rig --self-test` (✅ done in this first pass). +- Online (user machine): `cargo test`, `cargo run -- rig --self-test`, then + stage parity checks against a sample video + each stage's output diffed + with the Python run. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f29661b --- /dev/null +++ b/README.md @@ -0,0 +1,226 @@ +# pipeline_360 — Rust/WebGPU rewrite + +An in-progress rewrite of the Python `pipeline_360.py` equirect→COLMAP-cubemap +rig pipeline in **Rust**, with all heavy image math on the **GPU through +WebGPU** (`wgpu`) for cross-vendor compatibility (Vulkan / Metal / DX12, and +WebGPU/wasm). + +> **Status: complete — single binary, masks default, native GUI, GPU masks.** +> One `pipeline_360` binary runs the full GPU pipeline and, with +> `--features gui`, also launches a native egui+wgpu GUI when invoked with no +> subcommand. The YOLO masks stage is on by default. Validated end-to-end on +> real 8K equirect video. `cargo test` passes; 0 clippy warnings in pipeline +> code. YOLO11x-seg via ONNX Runtime, and masks now run on the **GPU** (AMD +> MIGraphX/ROCm) and on the **cube-face images** directly (no equirect +> round-trip). All GPU stages are **batched** for throughput. +> See [PLAN.md](PLAN.md) for the full design and milestone list. + +## Current state + +| Piece | Status | +| --- | --- | +| `math.rs` (face rotations, quaternions, intrinsics) | ✅ pure std, verified | +| `stages/rig.rs` (rig_config.json + nested tree + linking) | ✅ pure std, verified | +| `selftest.rs` (mirrors Python `rig --self-test`, 6 groups) | ✅ passes (`cargo test`) | +| CLI / `main.rs` (derive-based, mirrors Python commands) | ✅ `extract` + `cubemap` + `all` + `rig` work | +| `video/mod.rs` (ffmpeg/ffprobe CLI decode → rawvideo RGBA) | ✅ verified end-to-end | +| `gpu/mod.rs` (adapter prefers **discrete** GPU; batched readback) | ✅ picks the dGPU (was defaulting to LowPower/iGPU) | +| `gpu/shaders/equirect_to_cubemap.wgsl` | ✅ projection verified vs `py360convert.e2c` | +| `gpu/shaders/sharpness.wgsl` (workgroup-reduced metrics) | ✅ verified end-to-end | +| `gpu/shaders/c2e.wgsl` (cubemap→equirect nearest) | ✅ round-trip validated (legacy mask reprojection) | +| `stages/extract.rs` (GPU sharpness + quality + selection, **batched**) | ✅ superset of Python; verified | +| `stages/cubemap.rs` (GPU equirect→6 faces, skip/resume, **batched**) | ✅ verified vs `py360convert.e2c` | +| `stages/masks.rs` (YOLO11x-seg on cube faces; **MIGraphX GPU**; cache) | ✅ IoU 0.96 vs ultralytics | +| `gui.rs` (native **egui+wgpu** GUI; in the same binary via `--features gui`) | ✅ builds & runs | + +## Build + +There is **one binary**, `pipeline_360`. The YOLO masks stage is **on by +default** (via ONNX Runtime). Use `--no-default-features` for a lighter build +without masks. + +```bash +cargo build # default: full pipeline incl. YOLO masks +./target/debug/pipeline_360 all --project-dir --video --skip-masks +cargo test # runs the rig self-test (6 groups) +cargo run -- rig --self-test + +# Lighter build without ONNX Runtime (no masks stage): +cargo build --no-default-features + +# Run the GPU extract stage (needs ffmpeg/ffprobe on PATH + a GPU): +cargo run -- extract --video