SHA256
279 lines
14 KiB
Markdown
279 lines
14 KiB
Markdown
# 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.
|