Files

279 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) → `<frame>.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] <msg>` → 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.