Dense multi-view stereo after the sparse SfM: design
Status: accepted, 2026-10-20. The owner approved this design and the start of its
implementation. Step 1 (host view selection, src/gpu/mvs-views.ts) is implemented. Each step
in "Steps" below is its own pull request with its own evidence.
This document is written for an engineer or an agent who has not read the conversation that
led to it. It names files, symbols, buffer layouts in bytes, kernels, tests and the numbers
that accept each step. A name marked (new) does not exist yet, and so does every name in a
list of proposed changes. Every other path, symbol and option of this repository named here
exists on main at 64eafc0. The compiler's paths are those of its pin, e0bf150.
1. Problem and goal
The lab (/lab/, website/src/components/SfmLab.tsx) runs the incremental sparse SfM in a
worker (website/src/workers/sfm.worker.ts) and shows its landmarks in a three.js viewer
(website/src/components/SfmViewer.tsx). A sparse map holds a few hundred to a few thousand
points. It shows the cameras well and the surfaces poorly.
Measured on 2026-10-20, headless Chromium on SwiftShader, 640 px analysis frames, the lab's defaults, gerrard-hall (12 photographs), against COLMAP's cameras:
| Front end | Landmarks | Median rotation error |
|---|---|---|
GPU SIFT, first octave 0 (main) |
724 | 0.216° |
| GPU SIFT, first octave -1 (pull request 84) | 2,296 | 0.019° |
| The dataset's own SIFT features | 7,781 | 0.000° |
The octave-0 row is in the measurement comment on pull request #80 (724 landmarks, 2,107
observations, 0.216°). The octave -1 row and the dataset row are the measurements reported
for pull request #84, which is open. Its option firstOctave is not on main yet.
Goal: after the final bundle adjustment, compute one depth map and one normal map for each
registered view, fuse them into a colored point cloud with normals, and show that cloud in
the lab beside the sparse one. At 640 px with 12 views the dense cloud is expected to hold
in the order of 10^5 points (inference, section 6). The work runs on the same ShadeDevice
as the SfM, under the same time budget, and on the CPU tier with the same kernels.
Out of scope: meshing, texturing, learned depth, and a camera model other than pinhole with
k1, k2 (docs/engine-v2.md, "What this does not decide").
2. Method choice
Three families were considered.
Plane sweep (Collins 1996, Gallup 2007). For each reference pixel, test D depth planes and keep the plane of least cost. A cost volume of W × H × D values is the usual form. At 640 × 480 with D = 256 planes that volume is 315 MB in f32. A volume-free form keeps only the best plane per pixel, but then each pixel tests all D planes in each pass, and it gives no normal: fronto-parallel windows lose accuracy on slanted surfaces such as a facade seen at an angle.
Learned MVS (MVSNet 2018 and its successors). A 3D CNN regularizes a cost volume. The
volume and the feature maps need hundreds of MB at 640 px. The weights must be shipped. The
kernels need workgroup memory and tensor operations that TypeNN has and TypeCV does not use.
docs/engine-v2.md keeps the TypeNN path a direction, not a step.
PatchMatch stereo (Bleyer, Rhemann and Rother 2011). Each pixel holds one hypothesis, a
depth and a normal, that is a slanted plane. Hypotheses spread to neighbours (propagation) and
are perturbed at random (refinement). Schönberger, Zheng, Pollefeys and Frahm (ECCV 2016,
COLMAP's patch_match_stereo) add a photometric pass and a geometric pass. The geometric pass
adds a forward-backward reprojection term against the depth maps of the source views. It then
filters and fuses. Galliani, Lasinger and Schindler (Gipuma, ICCV 2015) replace COLMAP's row
and column sweeps by a red-black checkerboard: all pixels of one color update in parallel,
reading only pixels of the other color.
Decision: PatchMatch with a red-black checkerboard (Gipuma's propagation), COLMAP's photometric and geometric passes, and a per-pixel fusion. The reasons:
- Memory. The state is a few values per pixel: depth, normal and cost. No cost volume exists. Section 4 gives 57.4 MiB at 640 × 480 with 12 views and 229.7 MiB at 1280 × 960, against the lab's working budgets of 64 MiB and 256 MiB.
- Per-pixel independence. In a red-black pass a pixel reads the other color and writes
only its own slots. That is one invocation per element, with no workgroup memory, the form
of the SIFT kernels (
src/gpu/sift.shade.ts). The CPU tier runs the same kernels. COLMAP's sweep is sequential along each row or column, so W or H dependent steps per iteration. That form does not fit one invocation per element. - Chunking. A color pass splits at any pixel boundary with no change of result, as
triangulateTrackssplits at track boundaries (docs/gpu-sfm.md, "Triangulation"). Each pixel does a fixed amount of work: a fixed number of hypotheses, sources and window samples, with no loop to convergence. The time budget then sizes the chunks from a rate in ms per unit (ShadeDevice.chunkSize).
What is not taken from COLMAP: its per-pixel probabilistic source selection. That selection
keeps a probability per pixel and per source (K f32 per pixel) and runs it along the sweep
direction. This design aggregates the best bestSources of the maxSources source costs per
hypothesis instead, as Gipuma does. Section 9 lists the accuracy question that this raises.
3. Pipeline
The stages run in this order, after session.finish() in the worker returns the final model.
All of them run on the device except stage 3.1 and the small host packing.
3.1 Input and normalization (host)
The input is the final sparse model in the shape of Scene (website/src/lib/sfm-scene.ts):
views with world-to-camera pose.R (row-major) and pose.t, intrinsics
fx, fy, cx, cy, k1, k2, and landmarks with xyz and observations. The input also holds
the RGBA pixels of each registered view at the analysis size.
The host moves the world to the map's centroid and scales it by the map's radius, as decision
3 of docs/engine-v2.md does for f32 numerics. Every depth and point below is in these
normalized units. The host maps the fused points back to the model's frame before it posts
them.
3.2 View selection (host)
For each reference view r and each other registered view s, the score is:
score(r, s) = Σ over landmarks p seen by both r and s of g(α_p), g(α) = min(1, (α / α0)²) for α ≥ αmin, and 0 below αmin.
α_p is the angle at p between the rays to the two camera centers. α0 = 5° and αmin = 1°. A
pair with fewer than 16 shared landmarks scores 0. The maxSources (default 6) best sources
with a positive score form the source list of r. A view with no source gets no depth map and
is reported in the result. This uses only the sparse model, which the host holds.
Inference: the score prefers sources with many shared points and a useful baseline, which is the intent of COLMAP's automatic source selection. The exact formula is this design's choice.
3.3 Depth range and seeds (host)
For each reference view the host takes the optical-axis depths of the landmarks it observes
(depthPoints in website/src/lib/sfm-scene.ts computes the same depth). The range is
[0.75 × the 1st percentile, 1.25 × the 99th percentile]. A view with fewer than 8 observed
landmarks gets no depth map.
Each observation (pixel, depth) is a seed: the pixel at the rounded observation starts with the landmark's depth and a normal that faces the camera. Seeds give propagation correct starting points. Inference from the counts of section 1: with 2,296 landmarks and about 3 observations each, a view holds about 575 seeds, about one in every 23 × 23 pixels at 640 × 480. With 724 landmarks the spacing is about 41 pixels.
3.4 Undistortion (device, `mvsUndistort` _(new)_)
The existing rgbaToGray (src/gpu/features.shade.ts) converts each view's packed RGBA to
f32 gray in [0, 1]. mvsUndistort then writes the undistorted gray image with the same
fx, fy, cx, cy and zero distortion. For output pixel (u, v) it computes x = (u − cx) / fx,
y = (v − cy) / fy, r² = x² + y², and the distorted position
(fx · x · (1 + k1 r² + k2 r⁴) + cx, fy · y · (1 + k1 r² + k2 r⁴) + cy). That is the forward
model of project in src/gpu/numerics.shade.ts, so no iteration is needed. It samples the
input bilinearly. An output pixel whose source position falls outside the image, or whose r²
is at or past radialLimit, gets −1, the "no data" mark. The pinhole views of the pyramid and
TUM samples (k1 = k2 = 0) skip the kernel.
3.5 Photometric initialization (device, `mvsInitialize` and `mvsSeed` _(new)_)
mvsInitialize gives each pixel of the reference a random depth, uniform in inverse depth
over the range, and a random unit normal in the hemisphere that faces the camera. It then
computes the pixel's cost (3.7). mvsSeed writes the seeds over the random values and
computes their costs.
Randomness is a counter-based hash of (seed, view, pixel, iteration, draw), as hash in
src/gpu/features.shade.ts. No random state is stored. The CPU tier and WebGPU therefore draw
the same numbers, and a chunk boundary changes no draw.
3.6 Propagation and refinement (device, `mvsPropagate` _(new)_)
One iteration is two passes: all red pixels ((x + y) even), then all black pixels. A pixel evaluates 10 hypotheses and keeps the one of least cost:
- 8 propagation candidates: the hypotheses of the pixels at (±1, 0), (0, ±1), (±5, 0) and (0, ±5). Each offset has an odd |dx| + |dy|, so every candidate has the other color. A candidate plane is moved to the pixel: its depth is the depth where the pixel's ray meets the candidate's plane.
- 2 refinement candidates: a new random depth and normal, and the current hypothesis perturbed. The perturbation is ±(range × 2^−iteration) in inverse depth and up to 30° × 2^−iteration in normal.
The pass reads the other color and writes only the current color's depth, normal and cost. So the pass can run in place, with no second buffer, and any split of a pass into chunks gives the same result bit for bit.
A lane past its chunk does not return before the loops. It takes the chunk's last pixel, runs
the same control flow and writes nothing. This is the clamp-and-mask form that removed the
device hang of docs/hardware-observations.md, A1. The other kernels of this design follow
the same rule.
Default iterations: photometricIterations 8 and geometricIterations 4. Inference: the
red-black pass moves information at most 5 pixels per color pass, so 10 pixels per iteration.
Eight iterations reach 80 pixels, more than the seed spacing of section 3.3. Step 2 measures the
error after each iteration on the synthetic scene and confirms or changes the default.
3.7 Cost
For a hypothesis (depth d, normal n) of reference pixel p and a source s, the plane is nᵀX + δ = 0 in r's camera frame, with X_p = d · K_r⁻¹ p̃ and δ = −nᵀX_p. The homography is H = K_s (R_rs − t_rs nᵀ / δ) K_r⁻¹, with (R_rs, t_rs) the pose of s relative to r.
The photometric cost is 1 − NCC over a window of radius windowRadius (5) sampled at step
windowStep (2): offsets −5, −3, −1, 1, 3, 5 in each axis, 36 samples. Each sample reads the
reference at an integer pixel and the source bilinearly at H p̃. The cost lies in [0, 2]. A
window with a reference variance below 1e-6, or with a source sample marked "no data" or
outside the image, costs 2 for that source. A source whose triangulation angle at X_p is
below 1° costs 2 for that source.
The aggregated cost of the hypothesis is the mean of the bestSources (3) least source costs
out of the maxSources (6).
Bilateral weights, as COLMAP uses (a spatial and a color Gaussian per sample), are an option,
bilateral (new), off by default. Step 2 measures their effect on the synthetic scene.
COLMAP's own defaults are a window radius of 5 and a step of 1 (121 samples). The step of 2 here cuts the samples by 3.4 times. Step 2 compares the two steps on the synthetic scene.
Step 1 checked every COLMAP default that this document quotes against COLMAP's source, tag
4.2.1 (commit bd1fcf654d2dd8fefa1466999c190a246f83f4b9), on 2026-10-20: the member
initializers of PatchMatchOptions in src/colmap/mvs/patch_match_options.h and of
StereoFusionOptions in src/colmap/mvs/fusion.h. Every quoted value agrees:
| COLMAP option | COLMAP default | Used in this document |
|---|---|---|
window_radius |
5 | windowRadius 5 (3.7) |
window_step |
1 | windowStep 2, by choice |
min_triangulation_angle |
1° | 1° (3.7) |
sigma_color |
0.2 | bilateral, off (3.7) |
geom_consistency_regularizer |
0.3 | λ = 0.3 (3.8) |
geom_consistency_max_cost |
3 px | ψmax = 3 px (3.8) |
filter_min_ncc |
0.1 | 0.1 (3.9) |
filter_min_triangulation_angle |
3° | 3° (3.9) |
filter_min_num_consistent |
2 | 2 (3.9) |
filter_geom_consistency_max_cost |
1 px | 1 px (3.9) |
max_depth_error (fusion) |
0.01 | 0.01 (3.10) |
max_reproj_error (fusion) |
2 px | 2 px (3.10) |
max_normal_error (fusion) |
10° | 10° (3.10) |
num_iterations |
5 | not used, see below |
min_num_pixels (fusion) |
5 | not used, see below |
Two COLMAP defaults have no counterpart here, and this document does not quote them as
COLMAP's. COLMAP's num_iterations 5 counts iterations of four sweeps each. This design's
photometricIterations 8 counts red-black iterations of two passes, so the counts do not
compare. COLMAP's min_num_pixels 5 is the least number of fused pixels of a point. This
design's minConsistentViews 3 counts views (3.10). The source is the struct initializers,
not colmap patch_match_stereo --help, because no COLMAP binary ran for this check.
3.8 Geometric consistency (device, `mvsPropagate` with `geometric: 1`)
After every view has its photometric depth map, each reference runs geometricIterations
more iterations, starting from its photometric depth and normal. The cost of each source gains
a term (Schönberger 2016):
cost_s = (1 − NCC_s) + λ · min(ψ_s, ψmax), λ = 0.3, ψmax = 3 px.
ψ_s is the forward-backward reprojection error. Project X_p into s at q. Read the source's photometric depth at the nearest pixel of q. Back-project that point and project it into r at p′. Then ψ_s = |p − p′| in pixels. The pass reads the photometric depth maps of the sources and writes the geometric depth map of the reference. So the order of the references changes no result.
3.9 Filtering (device, `mvsFilter` _(new)_)
A pixel of the geometric depth map is kept when all of these hold:
- the photometric cost of its hypothesis, averaged over its best sources, is at most 0.9 (NCC at least 0.1)
- at least 2 sources have ψ_s ≤ 1 px and a triangulation angle at X_p of at least 3°
- its depth lies inside the view's range
A rejected pixel gets depth 0, which means "no depth". The values are those of COLMAP's
filter options (filter_min_ncc 0.1, filter_min_num_consistent 2,
filter_geom_consistency_max_cost 1, filter_min_triangulation_angle 3), checked in 3.7.
3.10 Fusion (device, `mvsFuseFlags` and `mvsScatterPoints` _(new)_)
COLMAP's stereo_fusion grows clusters of consistent pixels from a queue. That is sequential.
This design fuses per pixel instead. For each pixel p of reference r with depth d > 0:
- Back-project X = the point at depth d on p's ray, in world coordinates.
- For each other view j with a depth map, project X into j at q. Read d_j at the nearest
pixel of q. View j is consistent when all of these hold:
- the relative depth error |z_j(X) − d_j(q)| / d_j(q) is at most 0.01
- the point of j at q, projected back into r, lands within 2 px of p
- the angle between the two normals is at most 10°
- Let c be 1 plus the count of consistent views. Emit a point when c ≥
minConsistentViews(3) and no consistent view has an index lower than r. - The point is the mean of X and the consistent views' points. The normal is the normalized mean of their normals. The color is the reference pixel's RGBA.
The rule "no consistent view with a lower index" makes each surface element come from one
view in most cases. Inference: duplicates remain where consistency is not symmetric, for
example at depth edges. Step 5 counts them on the synthetic scene. The thresholds 0.01, 2 px
and 10° are COLMAP's max_depth_error, max_reproj_error and max_normal_error defaults,
checked in 3.7.
mvsFuseFlags writes one flag per pixel. The shared flag scan (planFlagScan,
scanBlockCount in src/gpu/scan.ts) gives each emitted pixel its slot.
mvsScatterPoints writes the points. The host reads one count and the points of that
reference.
3.11 Colors
The color of a point is the RGBA of its reference pixel at the analysis size, after
undistortion with the same map as the gray image. The host already holds the RGBA of each
view (5.3). The fusion stage uploads the reference's packed RGBA, and mvsUndistort in its
RGBA mode writes it to mvsRgba, sampling each channel bilinearly. The array is released
after the reference's pass. Averaging the colors of all consistent views needs the RGBA of every view
resident. That costs 4 B per pixel per view and is not part of this design.
4. Data layout and memory
4.1 Resident arrays
P = W × H pixels per view, N views with a depth map. Pixel index i = y × W + x. A per-view
array is view-major: element v × P + i. All arrays are TypeShade arrays allocated through
ShadeDevice (allocate, allocateU32), so reserveWorkingBytes admits them against
workingBytes.
| Array (new) | Element | Count | Bytes per pixel |
|---|---|---|---|
mvsGray |
f32, undistorted gray in [0, 1], −1 for "no data" | N × P | 4 per view |
mvsDepth |
f32, photometric depth (optical-axis z, normalized units), 0 for "no depth" | N × P | 4 per view |
mvsGeoDepth |
f32, geometric depth, then the filtered depth | N × P | 4 per view |
mvsNormal |
u32, octahedral unit normal in the view's camera frame, pack2x16snorm |
N × P | 4 per view |
mvsCost |
f32, the cost of the current hypothesis of the reference | P | 4 |
mvsCameras |
f32 × 24 per view: R (9), t (3), fx, fy, cx, cy, W, H, dmin, dmax, center C (3), 0 | N | 96 B per view |
mvsSources |
u32 × 7 per view: source count, then up to 6 view indices | N | 28 B per view |
mvsSeeds |
f32 × 3 per seed: x, y, depth | seeds | 12 B per seed |
mvsRgba |
u32, packed RGBA of the reference (fusion stage only) | P | 4 |
mvsFlags |
u32, the fusion flag of each pixel | P | 4 |
mvsFlagOffsets |
u32, the scan's block sums | 2 × ⌈P/64⌉ | about 0.13 |
mvsPoints |
f32 × 3 per point: position, world | P | 12 |
mvsPointData |
u32 × 2 per point: normal (octahedral, world), RGBA | P | 8 |
The normal uses 4 bytes in place of 12. The octahedral map with two 16-bit components has an
angular error below 0.01° (inference from the 2^−15 step). TypeShade's CPU runtime implements
pack2x16snorm and unpack2x16snorm (src/core/cpu-runtime.ts of the compiler), so both
tiers agree. Random state takes 0 bytes (3.5). A stored u32 state per pixel would add 4 B per
pixel.
4.2 Totals for N = 12
The depth stage holds mvsGray, mvsDepth, mvsGeoDepth, mvsNormal and mvsCost:
(16 N + 4) bytes per pixel. Before fusion, mvsGray, mvsDepth and mvsCost are recycled. The fusion
stage holds mvsGeoDepth, mvsNormal, mvsRgba, mvsFlags, mvsFlagOffsets, mvsPoints
and mvsPointData: about (8 N + 28) bytes per pixel. The small arrays (mvsCameras, mvsSources,
mvsSeeds) take less than 0.2 MiB.
| Quantity | 640 × 480 (P = 307,200) | 1280 × 960 (P = 1,228,800) |
|---|---|---|
| One per-view f32 or u32 image | 1.17 MiB | 4.69 MiB |
| One per-view array × 12 views | 14.06 MiB | 56.25 MiB |
| Depth stage, 196 B per pixel | 57.42 MiB | 229.69 MiB |
| Fusion stage, about 124 B per pixel | 36.36 MiB | 145.46 MiB |
Lab workingBytes (worker) |
64 MiB | 256 MiB |
| Headroom in the depth stage | 6.58 MiB | 26.31 MiB |
| Host RGBA of 12 views (5.3) | 14.7 MB | 59.0 MB |
gerrard-hall's previews are 640 × 427 (P = 273,280). Its depth stage takes 51.08 MiB.
The worker scales workingBytes and poolBytes with the square of the analysis edge
(start in website/src/workers/sfm.worker.ts: 64 and 32 MiB at 640 px, 256 and 128 MiB at
1280 px). The bytes per pixel of the depth stage are the same at both sizes, so the largest N
is the same: N ≤ ⌊(workingBytes / P − 4) / 16⌋ = 13 views at 640 × 480 and at 1280 × 960, and
15 views at 640 × 427.
Each array must also fit maxStorageBufferBindingSize, which ShadeDevice.bindingBytes
reads (128 MiB when the adapter reports none). The largest array at 1280 × 960 with 12 views
is 56.25 MiB. It passes 128 MiB at N = 28.
maxKernelElements is 65,535 × 64 = 4,194,240 elements per dispatch. A color pass at
1280 × 960 has 614,400 pixels, so it fits in one dispatch.
4.3 What stays resident, and when
| Stage | Resident | Released at the end |
|---|---|---|
| Undistortion | mvsGray, one view's packed RGBA as input |
the RGBA input |
| Photometric, per view | all of the depth stage | nothing |
| Geometric, per view | all of the depth stage | mvsGray, mvsDepth, mvsCost |
| Fusion, per view | mvsGeoDepth, mvsNormal, mvsRgba, mvsFlags, mvsPoints, mvsPointData |
everything, after the last reference |
Nothing is read back between kernels except the fused point count and points of each reference, and the optional depth previews (5.3).
The SfM's arrays must be released before the MVS starts. The worker's releaseFeatures
releases the descriptors after session.finish(). Inference: the mapper's other arrays are
released when finish() returns. Step 6 checks device.statistics.liveBytes before the
first MVS allocation and records it.
South-building-24 (24 views) does not fit the all-resident layout at either size. Section 9 lists the windowed layout that it needs.
5. Scheduling
5.1 Kernels as chunks of the time budget
Every kernel that loops over pixels runs as chunks through device.planSteps, as
triangulateTracksGpu does (src/gpu/triangulation.ts). A chunk is a contiguous range of
color indices, first..end, in a uniform range. For color c of a W × H image, color index
k maps to row y = ⌊k / ⌈W/2⌉⌋, column x = 2 (k mod ⌈W/2⌉) + ((y + c) mod 2). A lane with
x ≥ W, or past end, is idle and masked (3.6).
The host sizes each chunk with device.chunkSize(key, remaining, 64), where 64 is one
workgroup of pixels. Each chunk step carries warm, its entries over zero workgroups, so the
pipeline is created outside the first timed chunk (docs/gpu-sfm.md, "The time budget").
| Chunk key (new) | Kernel | One unit | Work per unit |
|---|---|---|---|
mvsInitialize |
mvsInitialize |
one pixel × one source | 1 hypothesis × 36 samples |
mvsPropagate |
mvsPropagate, photometric |
one pixel × one source | 10 hypotheses × 36 samples |
mvsPropagateGeometric |
mvsPropagate, geometric |
one pixel × one source | 10 × (36 samples + 2 projections) |
mvsFilter |
mvsFilter |
one pixel × one source | 2 projections |
mvsFuse |
mvsFuseFlags |
one pixel × one other view | 2 projections |
The unit includes the source count, so the rate a view with 6 sources teaches sizes the chunks
of a view with 4. The two mvsPropagate variants have their own keys because their cost per
unit differs, as bundleCg and bundleCachedCg do. mvsUndistort, mvsSeed, the scan and
mvsScatterPoints do a few operations per pixel. Each runs as one plan with no chunk.
The CPU tier runs each key as one chunk (chunkSize returns max there), so its results are
those of the whole pass.
5.2 Dispatch counts and relative cost
With K sources and T iterations, one reference view takes these units:
| Stage | Units per reference | 640 × 480, K = 6, T = 8 or 4 |
|---|---|---|
| Initialize | P × K | 1.84 M |
| Photometric | 2 passes × T × (P / 2) × K | 14.7 M |
| Geometric | 2 passes × T × (P / 2) × K | 7.4 M |
| Filter | P × K | 1.84 M |
| Fusion | P × (N − 1) | 3.38 M |
In window samples, the main measure of work, one reference at 640 × 480 takes about 0.07 G samples to initialize, 5.3 G in the photometric pass and 2.7 G in the geometric pass. For 12 views that is about 97 G samples. Inference from these counts: the photometric pass takes about 65 % of the time, the geometric pass about 33 %, and initialization, filtering and fusion together less than 3 %. At 1280 × 960 each count is four times larger.
A color pass is c chunks, with c = ⌈units of the pass × rate / (0.8 × timeBudgetMs)⌉ once
the rate has settled. At 640 × 480 with K = 6, one color pass is 921,600 units. One chunk per
pass needs a rate of at most 0.217 µs per unit, so about 1.7 G samples per second. Inference,
not measured: a physical desktop GPU reaches this, and SwiftShader does not. On SwiftShader a
full 640 px run is a long job, so CI runs the small synthetic scenes of section 7 and the full
samples run in the lab. Step 3 measures the SwiftShader rate of each key, and the lab's run
records give the physical rates.
Fences: each key is timed for its first 8 chunks (CHUNK_TIMING_SAMPLES in
src/gpu/device.ts), with two fences each. The later chunks run untimed. So the five keys add
at most 80 fences to a run, whatever its size.
5.3 The worker
Proposed changes to website/src/workers/sfm.worker.ts:
Requestof typestartgainsdense?: boolean. The page sets it from?dense=1first, and later from a switch.LAB_OPTIONSgains adenserecord with the defaults of section 3:maxSources6,bestSources3,windowRadius5,windowStep2,photometricIterations8,geometricIterations4 andminConsistentViews3. The result carriesLAB_OPTIONS, so a run record carries these too.- While
denseis set,processFrameandprocessPrecomputedFramekeep each frame'spixelsin a map before they clearframe.pixels. A frame that is not a keyframe, or a view that does not register, leaves the map. The map costs 4 B per pixel per view on the host (section 4.2). finishposts the sparseresultfirst, as now. It then keeps the job and runsreconstructDenseGpu(new) on the same device.- During the MVS the worker posts, with the arrays transferred, not copied:
{ type: 'denseProgress', stage, viewId, done, total }after each reference of each stage{ type: 'denseDepth', viewId, width, height, depth }after a view's filtered depth map, withdeptha Float32Array at a quarter of the size in each axis (19,200 values at 640 × 480){ type: 'densePoints', viewId, positions, colors, normals }after the fusion of each reference: Float32Array × 3, Uint8Array × 3 and Int16Array × 2 per point{ type: 'denseResult', points, views, timing }at the end
- A request
{ type: 'cancelDense', id }sets a flag that the MVS reads between chunks. A chunk in flight completes. The worker then releases the MVS arrays and postsdenseResultwithcancelled: true. - A failure in the MVS posts an error that names the stage and the kernel, as
failJobdoes. The sparse result has already been posted and stays on the page.
The page thread does no MVS work. It receives at most one message per reference and stage.
The run record (website/src/lib/run-record.ts, format typecv-run-record-v4) gains an
optional dense block: options, views with a depth map, points, elapsed time per stage, and
the chunk keys of 5.1 under timing.chunks. Adding a block is a format change, so the step
that adds it moves the format to v5 and keeps v4 in RUN_RECORD_FORMATS, as v4 kept v3.
5.4 The API
reconstructDenseGpu(device, scene, images, options) (new) in src/gpu/mvs.ts (new),
with the kernels in src/gpu/mvs.shade.ts (new) and the host view selection in
src/gpu/mvs-views.ts (new). src/gpu/index.ts exports it. images maps a view id to its
RGBA, width and height. options.onView receives the depth previews and points of 5.3. The
result holds the points, the per-view status (no source, too few landmarks, done) and the
plan report. A new export of typecv/gpu changes the public API, so its step waits for the
owner's approval.
6. Viewer and page
Proposed changes to website/src/components/SfmViewer.tsx and
website/src/components/SfmLab.tsx:
- A second point cloud. The viewer gets an optional prop
densewithpositions(Float32Array),colors(Uint8Array) andcount. It draws a secondTHREE.Pointswith its ownBufferGeometry. Colors use a normalizedUint8BufferAttribute, 3 B per point instead of the 12 B of the sparse cloud'sGrowingAttribute. The cloud grows asdensePointsmessages arrive. - A toggle. A switch "Dense" in
sfm-map-optionsbeside "Cameras" and "Photo". It is disabled until the firstdensePointsmessage arrives. When it is on, the sparse cloud is hidden and the dense cloud is shown. Picking (onSelectPoint) stays on the sparse landmarks, because a raycast over 10^6 points on each pointer move is too slow (inference). - A point budget.
densePointBudget, 1,000,000 by default, 300,000 whennavigator.maxTouchPoints > 0. Over the budget the viewer draws every k-th point, with k the smallest integer that fits. The budget limits drawing only, not the export. At 1,000,000 points the attributes take 15 MB. - Export PLY. The existing
download('ply')writes ASCII. For the dense cloud it writesbinary_little_endian 1.0with x, y, z (float), nx, ny, nz (float) and red, green, blue (uchar): 27 B per point, 27 MB for 10^6 points. ASCII would be about 40 MB for the same points. - Depth previews. The photo overlay can show the
denseDepthmap of the selected view withdepthColor(website/src/lib/sfm-scene.ts), in place of the sparse depth points.
Expected size, inference: with half the pixels kept by the filter and each surface element seen consistently in about 4 views, 12 views at 640 × 480 give about 12 × 307,200 × 0.5 / 4 ≈ 460,000 points, and four times that at 1280 × 960. Step 5 and the lab runs replace this estimate with measured counts.
7. Verification plan
7.1 Synthetic textured scene on the CPU tier (CI)
tests/typeshade/mvs.test.mjs (new), added to npm run test:shade:
- The scene: a textured ground plane, a box and a sphere, with a value-noise texture. A host ray caster renders 5 views at 64 × 48 px with known K, R and t, and the exact depth and normal maps. The size keeps the CPU-tier run under 60 s. The test records its time.
- Photometric pass, central view: median relative depth error ≤ 1 % on the pixels kept by the filter, at least 80 % of the pixels kept, median normal error ≤ 5°.
- Geometric pass and filter: the median relative error is not larger than after the photometric pass, and the 95th percentile is smaller.
- Fusion: every emitted point lies within 1 % of the scene radius of the true surface for at least 95 % of the points. Duplicates (two points within 0.5 px of each other in their reference view) are below 10 %.
- Chunk invariance: one chunk and forced chunks of 64 pixels give the same depth, normal and
cost arrays bit for bit, as
tests/typeshade/cpu.test.mjsdoes fortriangulateTracks. - Reference order: running the geometric pass in reverse view order gives the same arrays bit for bit (3.8).
7.2 The same scene on WebGPU (CI)
tests/typeshade/mvs-webgpu.test.mjs (new), added to npm run test:webgpu (the webgpu
job of .github/workflows/ci.yml, SwiftShader). Bit equality with the CPU tier is not
expected, because f32 rounding and fused multiply-add can differ. The test asserts that at
least 99 % of the pixels kept on both tiers agree within 1e-3 relative depth, and that the
accuracy bounds of 7.1 hold. It records the rate of each chunk key.
7.3 TUM against ground-truth depth (lab, needs a data change)
The TUM sample (website/public/sample/tum-fr3-structure-texture-far/) has 16 frames at
640 × 480 and motion-capture poses. Its README states that the depth maps are not included.
The data change: scripts/prepare-lab-tum-sample.py gains a --depth option. It copies the
depth image nearest in time to each frame (depth.txt of the sequence) as
depth-NN.png, 16-bit, 640 × 480, depth = value / 5000 m. Raw, a frame is 614,400 B and 16
frames are 9.8 MB. The PNG size is not measured. The TUM file-format page states that the
freiburg3 depth images are registered to the RGB images. This document has not checked that
statement against the sequence.
The comparison: align the reconstruction's cameras to the reference with alignPoses
(website/src/lib/pose-alignment.ts). Scale the depths by the alignment's scale. Compare
on the pixels where both depths are valid. Targets for the first run: median relative depth
error ≤ 3 %, valid on at least 50 % of the pixels with ground truth. These targets are this
design's proposal. The first measured run sets the gate.
7.4 COLMAP samples against COLMAP's dense output (offline)
COLMAP's dense stage needs CUDA. CI has no GPU, so this comparison runs offline on a machine with an NVIDIA GPU and its record is committed. Procedure:
- Build a COLMAP workspace from the sample's
sample.jsoncameras and the preview images. - Run
colmap image_undistorter --output_type COLMAP --max_image_size 640. - Run
colmap patch_match_stereo --PatchMatchStereo.geom_consistency true. - Run
colmap stereo_fusion --input_type geometric. - Run
scripts/compare-dense.py(new) on COLMAP'sstereo/depth_maps/*.geometric.binandfused.plyand on the lab's exported dense PLY.
The record goes to docs/experiments/dense-mvs/ (new) as JSON: the COLMAP version, the
GPU, the options, and per view the median relative depth difference, the shared valid
fraction, and the median point-to-cloud distance as a fraction of the scene radius. The depth
maps themselves stay out of the repository: 12 views at 640 × 427 in f32 are 13.1 MB per
sample. Targets for the first run: a median relative depth difference ≤ 2 % where both are
valid, and a dense point count at least 25 % of COLMAP's fused count.
7.5 Gates
| Gate | Number | Where |
|---|---|---|
| Synthetic scene, CPU tier | Median relative depth error ≤ 1 %, ≥ 80 % kept, chunk invariance bit for bit | npm run test:shade |
| Synthetic scene, WebGPU | ≥ 99 % of kept pixels within 1e-3 of the CPU tier | npm run test:webgpu |
| TUM, ground-truth depth | Median relative error ≤ 3 %, ≥ 50 % valid (first run sets the gate) | lab, record |
| gerrard-hall vs COLMAP dense | Median relative difference ≤ 2 % (first run sets the gate) | offline record |
| Physical devices (collector records) | gerrard-hall dense at 640 px completes on devices A and B, no device loss, longest chunk ≤ budget | lab, run records |
| Memory | liveBytes at the depth stage's peak equals the formula of 4.2 within 1 % |
WebGPU test, lab |
8. Steps
Each step is one pull request, in this order. Each names this document on a line of its own in its commits.
| Step | Pull request content | What it proves |
|---|---|---|
| 1 | src/gpu/mvs-views.ts: view selection, depth ranges, seeds, normalization. tests/mvs-views.test.ts. The COLMAP defaults of 3.7 checked. |
Source lists of gerrard-hall from the dataset's features are stable. The defaults in this document are right. |
| 2 | mvsUndistort, mvsInitialize, mvsSeed, mvsPropagate (photometric) on one reference, CPU tier. tests/typeshade/mvs.test.mjs. |
Photometric accuracy on the synthetic scene. Chunk invariance. The defaults of iterations, window step and bilateral weights. |
| 3 | The chunk keys and warm steps. tests/typeshade/mvs-webgpu.test.mjs. |
WebGPU agrees with the CPU tier. The SwiftShader rate of each key. |
| 4 | The geometric pass and mvsFilter. |
The error tail shrinks. Reference order changes no result. |
| 5 | Fusion, mvsFuseFlags, the scan, mvsScatterPoints, colors. reconstructDenseGpu and its export. |
Point accuracy and the duplicate count on the synthetic scene. The memory formula of 4.2. |
| 6 | The worker: dense, the kept RGBA, the messages of 5.3, cancel, the run record v5. |
A lab run on gerrard-hall posts depth maps and points without a block of the page. liveBytes before the MVS. |
| 7 | The viewer and the page: the dense cloud, the switch, the point budget, binary PLY. | The page shows 10^5 to 10^6 points. The PLY loads in a standard reader. |
| 8 | TUM depth maps in the sample and --depth in scripts/prepare-lab-tum-sample.py. The comparison of 7.3. |
Depth accuracy against independent ground truth. |
| 9 | The offline COLMAP comparison of 7.4 and its record. | Agreement with COLMAP's dense output on the COLMAP samples. |
| 10 | Physical-device runs and the gate rows of 7.5. | No device loss on devices A and B. The physical rates. |
Steps 1 to 5 need no change to the lab. Steps 8 and 9 can run in parallel with steps 6 and 7.
9. Risks and open questions
Pose accuracy. A rotation error θ moves the projection of a point by about f · θ pixels. For gerrard-hall's 640 px previews f = 437.4 px:
| Front end | θ | f · θ at 640 px | f · θ at 1280 px |
|---|---|---|---|
| GPU SIFT, octave 0 | 0.216° | 1.65 px | 3.30 px |
| GPU SIFT, octave -1 (#84) | 0.019° | 0.15 px | 0.29 px |
The depth error that follows from a pixel error e is Δz ≈ z² e / (f b) for a baseline b. With e = f · θ this gives Δz / z ≈ θ / (b / z), where b / z is about the triangulation angle α in radians. For α = 10°: 0.216° gives a relative depth error of about 2.2 %, and 0.019° about 0.19 %. Inference: at 0.216° the misalignment of 1.65 px is larger than the geometric filter's 1 px (3.9), so the filter would reject many correct pixels, and the photometric windows would blur depth edges. At 0.019° the misalignment is a fraction of a pixel. So the first octave of -1 (pull request #84) makes MVS viable at 640 px, and MVS at 1280 px needs it. The rotation error of section 1 is a median of absolute rotations after alignment. The relative rotation of a pair can be up to about twice that.
Tangential distortion. The gerrard-hall sample drops COLMAP's tangential terms
(droppedTangential in its sample.json, p1 = 0.000116 and p2 = 0.000295). Inference: at the
image corner they move a pixel by about 0.3 px at 640 px. That error enters the undistortion.
Device loss on mobile. Each chunk is bounded by the time budget, and each lane's work is
fixed. The kernels are f32 only, so the flavor of the emulated double
(docs/hardware-observations.md, B1) does not apply. Open: no MVS kernel has run on device A
or B. The early-return hang of A1 is avoided by the clamp-and-mask rule, but its cause is not
known, so another loop structure could hang.
Memory at 1280. The depth stage takes 229.7 MiB of buffers, and the pool can hold up to
128 MiB more. Open: no record gives the memory a phone's browser grants to WebGPU. Two
fallbacks reduce the peak: gray as 16-bit pairs (pack2x16unorm) saves 2 B per pixel per view,
and a windowed layout keeps only a reference and its sources resident.
More than 13 views. South-building-24 does not fit the all-resident layout. A windowed layout uploads each reference's sources and keeps the depth maps on the host or in a bounded device cache. That is a later step with its own design amendment.
Textureless regions. Sky, sand, bare walls and water give a reference variance near 0. Those windows cost 2 (3.7), so their pixels fail the filter and stay empty. Inference: the pyramid video, with its sand, gives sparse coverage on the ground. A multi-scale scheme (ACMM, Xu and Tao 2019) fills such regions better and is an open item.
Source selection. The best-3-of-6 aggregation replaces COLMAP's per-pixel source probabilities. Open: whether it loses accuracy at occlusions. The comparison of 7.4 measures it.
Order of the work. docs/engine-v2.md, "Order", item 5 says that dense reconstruction
waits on the scene engine. This design reads only the final sparse model and the frames, not
the resident scene. Decision for the owner: whether the MVS steps start before the scene
engine's steps 3 to 5 of docs/gpu-resident-scene.md.
Page responsiveness. The worker and the page's WebGL viewer share one GPU. Inference:
chunks of up to 200 ms can delay the viewer's frames during the MVS. The lab's ?budget=
sets a smaller timeBudgetMs for a whole run. A budget for the MVS alone would need a
per-call cap, for example a max units from device.chunkRates. That is open.