Reconstructing one scene from several videos: design
Status: accepted (2026-10-20, under the owner's approval of the work in the conversation).
This document is written for an engineer or an agent who has not read the conversation that led
to it. It names files, symbols, defaults, gates and the numbers that accept each step. A name
marked (new) does not exist yet. Every other path and symbol named here exists on main at
ebc39b6. The owner approved the work on 2026-10-20 and asked how a randomly ordered set of
videos is handled. This record is the answer. It changes no code.
Labels used below. Fact: read in the code or in a record on main. Inference: reasoned,
not measured. Decision: a default this record chooses. Open: a question a step answers.
1. Problem and goal
1.1 What the video path does today
The lab (/lab/) takes one video. The page decodes it at 4 frames per second
(VIDEO_SAMPLE_FPS, website/src/lib/video-sampling.ts). The worker
(website/src/workers/sfm.worker.ts, processFrame) then handles each sampled frame:
- The phase gate (
PhaseGate,website/src/lib/keyframe-gate.ts) keeps or skips the frame before any SIFT work. It compares the frame with the last keyframe of the same video. - A keyframe gets GPU SIFT features (
extractSiftGpuResident). - The keyframe is matched with the earlier keyframes that the session still holds
(
matchFloatDescriptorsGpu, ratio 0.8, mutual). The last keyframe is matched first. - The pairs go to one
IncrementalSfmSession(src/gpu/mapper.ts) throughsession.add.
Fact: the worker keeps at most a few keyframes resident (registerFrame keeps the reference
frames and the last three). The session keeps a window of 32 views (windowSize 32). It drops
older views and the edges of dropped views.
1.2 Why this fails for several videos
Fact: a new frame is matched only with recent frames. Those frames are the frames of the same video, because the worker holds one job with one clock. Three consequences follow.
- Frames of video 2 are never matched with frames of video 1. No cross-video edge exists, so the mapper cannot join the two videos in one map.
- The result depends on the order. The first frame of the first video is the first view. The session evicts old views as new ones arrive. The seed pair and the order of registration follow the arrival order.
- An unrelated video cannot be told from a related one. Its frames build their own map or fail to initialize, and the page shows the failure as an error of the run.
The photo path is the opposite. A photo is matched with every active earlier photo, whatever the order. That is exhaustive matching, N(N−1)/2 pairs, and it holds only for small N (the window of 32 bounds it). It does not scale to hundreds of keyframes from tens of videos.
1.3 Goal
Given n videos of one object (an orbit, a vertical ascent, a lateral pass, in any order and possibly mixed with unrelated videos), produce one sparse map for every set of videos that see the same scene. Report every video that joins no map as "not connected".
The goal has four parts:
- G1. Order independence. The result depends on the content of the videos. The order inside a video counts. The order of the videos does not.
- G2. Scale. Matching cost grows with N·k, not with N². N is the number of keyframes. k is about 10.
- G3. Separation. Unrelated videos become separate components. They do not break the map of the related videos.
- G4. Honest failure. When two videos see the same object from views too far apart, the lab says that they are not connected. It does not guess.
Non-goals: dense MVS over several components (the dense path keeps its present limits and runs on one component at a time), a learned retrieval network, loop closure inside one video as a new feature (the global descriptor gives it for free, section 3.3), rolling-shutter modeling, and video synchronization (the videos have no shared clock).
2. Input
2.1 Videos and samples
The lab's upload control accepts several files (section 7.1). A lab sample set (section 7.2) adds several remote samples at once. Each input is one video with:
videoId: the content hash of the file (2.4). It is the name of the video in the run record. Two uploads with the same hash are one video. The lab shows one "duplicate" tag and drops the second.- a label: the file name or the sample id. Labels never decide anything.
- a camera: the intrinsics of this video (2.3).
- its sampled frames: decoded at
VIDEO_SAMPLE_FPS(4), as today.
2.2 Per-video sampling and keyframe gate
Decision: each video has its own PhaseGate. The gate sees only the frames of its own video, in
time order. A keyframe decision therefore does not depend on the other videos or on their order.
Fact: the phase gate is deterministic for a given decoded frame sequence. So the keyframe set of a video is the same in any input order of the videos. The shuffle gate (7.1) tests this.
The gate keeps its present options (PHASE_GATE_OPTIONS: minShift 0.04, minGapSeconds 0.2,
maxGapSeconds 1). Open: a slow video can yield many near-duplicate keyframes. A cap
maxKeyframesPerVideo (default 60, Decision) bounds N. The cap keeps the first and last frame and
thins the others evenly in time. The cap is an option, and the run record names it.
2.3 Per-video intrinsics
Different videos can come from different cameras. Fact: the lab today takes one assumed focal
ratio for the whole run (DEFAULT_VIDEO_FOCAL, focalRatio) and zero distortion. The worker builds
each frame's camera from it (frameCamera).
Decision: the focal ratio is a property of the video, not of the run.
- The default of each video is the run's focal ratio. The sample catalog may give a calibrated
camera for a sample (
frame.camera), as it does today. - The lab shows the focal ratio of each video as one editable field in the video's row.
- A pair is verified with the two cameras of its two videos (
verifyPairtakes both cameras). Pairs inside a video and across videos use the same call. - The final bundle adjustment refines one focal scale for each video (a camera group), not one
for the whole run. Fact:
refineSharedFocalrefines one scale that all views share (src/reconstruction/bundle-adjustment.ts). A per-group form is new work (step 5).
Inference: the Pexels clips of the test set (section 9) come from a drone, possibly from one camera. If the cameras are equal, the per-video focal groups add parameters and no accuracy. Step 7 measures both the per-video and the shared form on the test set.
2.4 Content identity
Decision: every identifier that decides anything comes from content. Input order, file names and the arrival order of decoded frames decide nothing.
| Identifier | Definition |
|---|---|
videoId |
SHA-256 of the file's size (8 bytes), its first 1 MiB and its last 1 MiB, cut to 16 hex digits. Cheap for files of hundreds of megabytes. |
frameKey |
SHA-256 of the analysis frame's RGBA bytes (the pixels the worker receives), cut to 16 hex digits. Equal frames in two videos get equal keys. |
viewId |
The rank of (frameKey, videoId, time) in the sorted list of all keyframes of the run, from 0. The sort is by the three fields, in that order. |
The mapper's integer ids (ImageFeatures.id, PairMatches.first and second) are the viewIds.
Fact: the mapper breaks ties by id. verifyPairs sorts pairs by id and seeds each pair with
seed + pi, where pi is the index in the sorted list. The next view is chosen by the number of
2D–3D correspondences, then by the lower id (incrementalSteps). With canonical ids, every one of
those ties breaks by content.
Two changes in the mapper follow (step 2):
- The RANSAC seed of a pair comes from the two ids (a fixed mix of
seed,firstandsecond), not from the pair's index in the list. The index changes when a pair is added or dropped. - A test shuffles the ids of a synthetic input and expects the same registered set and the same cameras, after the shuffle is undone (7.1).
Fact: frameKey depends on the decoder. Two browsers can decode a frame to bytes that differ in
the last bit. So viewId is canonical inside one run on one decoder, not across devices. The shuffle
gate runs on one device and one build. Open: whether to hash a 32 × 32 gray thumbnail with 4-bit
quantization instead, so that two decoders agree. Step 2 measures how often two decodes of the
same video give different keys.
3. Order independence: the view graph
3.1 Overview
The view graph is a graph whose nodes are keyframes. An edge joins two keyframes that a geometric check has accepted. The weight of an edge is its number of inliers. The graph is built from content only, in four stages:
- Features. SIFT features of every keyframe (as today), kept for the matching stage (3.5).
- Global descriptor. One compact vector for each keyframe (3.2).
- Candidates. For each keyframe, the k most similar keyframes (3.3), and its neighbours in time inside its own video.
- Verification. SIFT matching and geometric verification on the candidate pairs only (3.4).
The cost is O(N·k) matchings. Exhaustive matching is O(N²). For N = 375 and k = 10 that is about 4,900 pairs against 70,125 (section 10).
3.2 The global descriptor
Decision: a bag of visual words over RootSIFT, with a fixed vocabulary of 512 words and tf-idf weights. The steps for one keyframe:
- Take the RootSIFT descriptors of the keyframe. Fact: the GPU SIFT already produces RootSIFT with
keys.rootSift(src/gpu/sift.shade.ts). - Assign each descriptor to the nearest of 512 visual words (L2, ties to the lower word index).
This is a new kernel,
globalAssign(new). It takes descriptors and the vocabulary, and writes one word index for each descriptor. - Count the words in a histogram of 512 bins. Take the square root of each count (a damping, so that repeated structure does not dominate), then multiply each bin by its idf weight.
- L2-normalize. The result is the global descriptor, 512 floats.
- The similarity of two keyframes is the dot product of their descriptors (cosine).
The vocabulary is a file of 512 × 128 floats (256 KiB) trained offline. It is trained by k-means
(Lloyd, fixed iteration count, a fixed seed) on the RootSIFT descriptors of the lab's samples. A
version number names it (rootsift-512-v1). The idf weights come from the keyframes of the run:
idf(w) = log((N + 1) / (df(w) + 1)) + 1, where df(w) is the number of keyframes that contain
word w. The weights depend on the set of keyframes and not on their order.
Why a fixed vocabulary and not one trained on each run: the descriptor of a keyframe then depends on that keyframe alone (plus the idf scale). It does not depend on the other videos or on the order in which they arrive. A run can also add a video to a finished run without retraining.
Why a bag of words and not the alternatives:
| Candidate | Dimension | For | Against |
|---|---|---|---|
| Bag of words, 512 words, tf-idf (chosen) | 512 | Invariant to the viewpoint change that SIFT survives. Small. Standard in SfM retrieval. | Needs a vocabulary file. Quantization loses detail, but only candidates need it, not the geometry. |
| VLAD with 32 words (aggregated residuals) | 4,096 | Keeps more detail per word. | Eight times larger. Needs PCA or intra-normalization to work well. Same vocabulary problem. |
| Downsampled image (for example 16 × 16 gray) | 256 | No vocabulary. Trivial kernel. | Not invariant to rotation, scale or a viewpoint change. Two videos of one object from two paths look different. |
| Global learned descriptor (NetVLAD and kin) | 4,096 | Strong on place recognition. | A neural network and its weights. Out of scope (section 1.3). |
Decision method: measure, then confirm. Step 3 measures all three non-learned candidates on the
test set (section 9) and on the lab's photo samples. The measure is recall at k = 10 of the true
pairs. A true pair is a pair of keyframes whose exhaustive SIFT matching and verification gives
at least minEdgeInliers inliers (3.4). The bag of words stays the default when its recall at 10
is at least 0.9 of the recall of the best candidate. Otherwise the record is amended with the
measured winner. The downsampled image stays in the code as the baseline of the measurement only.
Inference, not measured: the image descriptor fails across paths. An orbit and a vertical ascent show the same object under different in-plane rotation and scale, and a thumbnail of each is a different picture.
Inference: the cost of globalAssign is the number of descriptors times 512 times 128
multiply-adds. For 375 keyframes of 4,096 features that is 1.5 million descriptors and about
100 GFLOP. That is below a second on a physical GPU and tens of seconds on SwiftShader.
Parity: a CPU reference of the whole descriptor (globalDescriptorReference (new), in
src/gpu/, plain TypeScript) is the oracle of the kernel. Section 7.4 states the gate.
3.3 Candidate selection
For each keyframe i the candidate set is the union of:
- Temporal neighbours. The previous 3 keyframes and the next 3 keyframes of the same video. This keeps each video connected inside itself, as today's path does.
- Retrieved neighbours. The
k= 10 keyframes with the highest cosine similarity toi, excluding keyframes of the same video within 10 keyframes in time. The ones further away in the same video stay: they are loop-closure candidates, for an orbit that returns to its start.
Then the pairs are made unique: (i, j) and (j, i) are one pair. A pair of two keyframes with the
same frameKey is dropped (a duplicate).
Order independence: the similarity of i and j does not depend on the order, and the sort
of equal similarities breaks ties by lower viewId (canonical, 2.4). So the candidate set is the
same in every input order.
Two guards against a missed link:
- Per-video reserve. For each pair of videos (u, v), the best 3 candidate pairs between them are always kept, even when they rank below 10 for both keyframes. This makes sure that a video whose frames all rank other videos first is still tried against each other video. It adds at most 3·n(n−1)/2 pairs. For n = 15 that is 315 pairs, 6 % of the budget (section 10).
- Expansion. After the first verification round, for each verified cross-video edge (i, j), the best neighbours of j are tried against i, and the best neighbours of i against j. This is query expansion on the verified graph. It runs once. It is off by default and step 4 measures whether it adds edges on the test set.
3.4 Matching and verification
For each candidate pair:
- Match the SIFT descriptors with
matchFloatDescriptorsGpu(ratio 0.8, mutual, as today). - If the pair has fewer than
minMatches= 30 matches, drop it. This cheap test removes most false candidates before the costly verification. - Verify the matches with
verifyImagePairGpu(theverifyPairjob ofsrc/reconstruction/mapper.ts), with the two cameras of the two videos. It runs the RANSAC for the essential matrix and for a homography, and it triangulates the inliers. - Accept the pair when it has at least
minEdgeInliers= 25 inliers (Decision, a default to be tuned in step 4; COLMAP's matching accepts 15 and the lab's mapper needs 12 initial points). - Keep the result: the inlier matches, the relative pose, the inlier count, the homography inlier count and the median triangulation angle. The mapper reuses it (4.2).
The weight of an accepted edge is its inlier count. An edge is cross-video when its two keyframes belong to different videos.
Fact: verifyPairs in the mapper runs the verification again for every input pair. Without a
change, a pair would be verified twice. Step 5 adds a preVerified input to incrementalSteps:
a list of PairVerification results that verifyPairs takes in place of a new run.
3.5 Keeping the features
The matching of 4,900 pairs needs the descriptors of all keyframes, not of five. Fact: the worker keeps GPU handles for a few keyframes only.
Decision: the view-graph stage keeps the keypoints and the descriptors of every keyframe on the host, and uploads them in batches.
- The descriptors are stored as 8-bit values, as COLMAP does (
round(512 · v), clamped to 255, wherevis the RootSIFT value in [0, 1]). A keyframe of 4,096 features takes 512 KiB. For N = 375 that is 192 MiB on the host. Float storage would take 768 MiB. - The pairs are ordered by the first keyframe. A batch uploads one keyframe and matches it against all its candidates, so that one upload serves about 13 matchings.
- Open: 8-bit descriptors give matches that differ a little from the float matches of the single video path. Step 4 measures the change in the number of inliers on the test set. The result decides whether the stage stores 8-bit or 16-bit floats.
3.6 What stays the same
The single-video path keeps working as it does. A run with one video is the present run: the streaming session, the window of 32, and no view graph. The multi-video job is a second code path, chosen when the run has two or more videos (6.2).
4. Components, initialization and growth
4.1 Connected components
The components are the connected components of the view graph (union-find over the accepted edges). Each keyframe belongs to one component. Each video's keyframes can belong to several components (a video that sees two unrelated scenes).
Rules:
- A component with fewer than
minComponentViews= 5 keyframes is not reconstructed. Its videos get the tag "not connected" (6.3). - A video is connected when at least
minVideoShare= 50 % of its keyframes sit in one component that holds at least two videos. Otherwise it is not connected. - A video alone in its component is reconstructed on its own. The lab shows it as its own map with the tag "alone". It is not an error.
- Components are ordered by their number of keyframes, then by the lowest
viewId.
An unrelated video (for example the water-tower sample next to a rock formation) forms its own
component. Its edges to the other videos fall under minEdgeInliers, or it has none. The distractor
gate (7.2) tests this.
4.2 The default: incremental growth over the whole component
Decision: the default is one incremental reconstruction per component, over all its keyframes and
all its verified edges, as COLMAP does. It reuses incrementalSteps in
src/reconstruction/mapper.ts. The steps and the changes are:
Initial pair. Fact: verifyPairs scores the seed candidates by
positiveDepthCount × min(10, medianParallaxDeg) and drops pairs that are homography-dominated
(homography inliers at least 85 % of the invertible matches and 90 % of the inliers). The best score
wins, then the lower ids. Decision: keep this rule, applied to all edges of the component,
inside and across videos. The seed is therefore the pair with the most triangulated points, the
widest angle (up to 10°) and a non-planar geometry. The list of seed candidates is sorted by score
then by canonical ids, so the seed does not depend on the input order.
Inference: a seed from inside one video is likely, because neighbours in time have the most matches. The score still prefers a wider angle. A cross-video pair can win if it has a wide angle and many inliers, and then the map starts across two paths. Both give a valid map.
Next view. Fact: the next view is the unregistered view with the most 2D–3D correspondences,
with ties to the lower id (incrementalSteps). That is COLMAP's rule in the simple form (COLMAP
also weighs how the visible points spread over the image). Decision: keep it, with the canonical
id as the tie-break. No rule uses the input order or the video order.
Registration and refinement. Unchanged. PnP with RANSAC registers the view
(minPnPInliers 12). Triangulation, the local BA over the last 6 views, and the global BA when the
map grows by 10 % (globalBundleDue, PR #113) follow. When no view registers, the recovery pass
runs once at each map size and the failed views get one more try (recoveredAt). The final global
BA closes the run.
Why this is the default.
- One gauge. Scale, position and rotation are set once, by the seed. No scale between maps is estimated, and a wrong estimate cannot enter.
- The tracks cross videos.
buildTracksjoins the matches of all verified edges into tracks, so a landmark can be seen by views of several videos. The BA constrains the videos together from the start. A merge of separate maps by Sim(3) leaves this to the final BA. - It reuses code that is tested and measured (the PR #113 schedule, the recovery pass).
- The next-view rule is by content, so the order does not enter.
What changes in the mapper (step 5):
preVerified(3.4).- Seeds of the RANSAC from the ids (2.4).
- The streaming session's window of 32 is not used. The multi-video job calls
reconstructIncrementalorincrementalStepsonce for each component, with all its views. Open: the cost of the global BA over hundreds of views. The schedule of PR #113 limits the number of global passes to about log(N)/log(1.1) growth steps. - A per-video focal group in the BA (2.3).
4.3 The alternative: per-video submaps merged by Sim(3)
The alternative builds one map for each video, then joins the maps.
- For each video, reconstruct a submap from its own keyframes and its internal edges. This is the present video path.
- Build the submap graph. Its nodes are submaps. The weight of an edge between two submaps is the sum of the inliers of the cross-video edges between them.
- Take the maximum spanning tree of the submap graph (Kruskal; ties by the lower pair of
videoIds). Each tree edge is a link to merge. - For each link, estimate a Sim(3) transform between the two submaps (scale, rotation,
translation). Two ways give it. (a) Register the keyframes of submap B into submap A with PnP
against A's landmarks, and fit the Sim(3) to the camera centers (Umeyama). The existing
alignPoses(website/src/lib/pose-alignment.ts) is a similarity alignment of the same kind, used today for the reference comparison. (b) Link landmarks across the cross-video matches and fit the Sim(3) to the 3D–3D pairs with RANSAC. - Apply the transforms along the tree, from the root, which is the submap with the most views.
- Run a global BA over all views and landmarks, with the cross-video tracks.
Scale is the main hazard. Each submap has its own arbitrary scale, so the Sim(3) scale is a free parameter. A wrong scale from few correspondences bends the merged map, and the BA may not repair it. A chain of merges along the tree also compounds the errors.
Decision on which one is the default: the incremental growth (4.2). The reasons are the one gauge, the tracks across videos from the start, and the reuse of tested code (the list in 4.2).
When the submap merge runs instead:
- Stalled growth. The incremental run of a component ends with fewer than 70 % of the
component's keyframes registered, and the view graph says that more videos connect. The cause is
usually a thin cross-video link: too few shared landmarks for PnP. The run then reconstructs
each video as a submap, merges along the tree and runs the global BA. The record names the
fallback (
growth: 'submaps'). This is step 8. - A very large component. A component with more than 600 keyframes (Decision, to be measured in step 7) would make the incremental global BA too slow. The submaps run in parallel in time, and only the merge and the final BA see the whole map.
- A new video added to a finished map. The new video's submap is merged to the finished map with one Sim(3), without a run of the whole component.
The incremental path stays the reference in all three cases: a result of the submap path is compared with the incremental result on the test set, and the record reports the difference.
4.4 The tie-break rule, stated once
Wherever a choice can tie, the tie breaks by the canonical viewId (2.4) or by the lower pair of
videoIds. No tie breaks by the arrival order, the position in the upload list, the file name or
a Map iteration order of unsorted input. A test lists the places and fails if a changed order
changes the output (7.1).
5. Failure modes
Each row names the failure, what the lab shows, and the remedy. The viewpoint figures come from the SIFT literature and are background, not measurements of this project.
| Failure | Cause | What the lab shows | Remedy |
|---|---|---|---|
| Viewpoint gap | SIFT matches fail above a viewpoint change of about 30 to 45° between two videos (background). | Two components, each video tagged "not connected" to the other. The tooltip gives the best cross-video inlier count that fell under the threshold. | Add a bridging video: a path that passes through the intermediate angles, such as a vertical ascent between two orbit rings. The set then joins in one component. |
| Pure-rotation clip | A pan or tilt from one position has no baseline. Pairs are homography-dominated. | The video carries the tag "rotation". Its views register only if they see landmarks of other videos. | verifyPairs already refuses a homography-dominated seed. The clip registers by PnP against the landmarks of other videos. Its own new points are not triangulated. |
| Scale difference | One video shoots the object from far and another from close (a factor above about 2 to 3). | Few cross-video edges, or none. | A video at an intermediate distance bridges. The incremental path needs no scale estimate. |
| Lighting and exposure | Different time of day, shadows, auto exposure. | Fewer inliers across videos. The weak edges fall under the threshold. | RootSIFT tolerates moderate change. The lab's CLAHE option (clahe) may help. Not measured. |
| Distractor with false links | Repeated structure (sand, rock texture) produces a few false matches between unrelated videos. | An unrelated video joins a component. | The geometric check (E and H RANSAC) with minEdgeInliers 25 removes most. The distractor gate (7.2) measures it. Open: a check that a component's cross-video edges agree with one rigid motion, step 7. |
| Different cameras | Per-video intrinsics are wrong. | High reprojection error on one video. | A per-video focal field and a per-video focal refinement (2.3). |
| Duplicate video | The same file uploaded twice. | Tag "duplicate". One copy is used. | The videoId hash (2.4). |
| Too many keyframes | Many long videos. | The run stops at maxKeyframes of the run (Decision: 800) with a message. |
The cap per video (2.2). The user removes a video. |
6. Lab and API
The owner dislikes text-heavy UI. The lab shows state in tags, small marks and tooltips. It adds no paragraph of explanation to the page.
6.1 Upload
- The video file input takes several files (
multiple). Fact: the control now accepts one file at a time (website/src/components/SfmLab.tsx,accept="video/*,application/json,.json"). The JSON input already accepts several files. - Each file becomes a row: a thumbnail of its first keyframe, its label, its focal field, its keyframe count, and a tag (6.3). A row has a remove control.
- One "Run" starts the multi-video job when two or more videos are present. With one video the present run starts.
- The page decodes the videos one after another (one decode worker, bounded memory). It sends the frames of one video in time order. The order of the videos is the list order. It does not matter for the result (7.1).
6.2 The worker
A new job type, multiVideo (new), in website/src/workers/sfm.worker.ts. Its messages:
video: the start of one video (videoId, label, camera). The worker makes aPhaseGatefor it.frame: as today, with avideoId. The worker runs the gate, extracts SIFT for a keyframe, keeps the keypoints and descriptors on the host (3.5) and computes the global descriptor.finish: the worker assignsviewIds (2.4), builds the view graph (section 3), finds the components (4.1), reconstructs each component (4.2) and publishes the models.
The worker posts a graph message when the view graph is ready. It posts a model message for each
component, as publish does today. The messages carry the data of section 6.3, not text.
6.3 Samples, tags and the viewer
- Sample set card. The sample catalog (
website/public/sample/index.json) gets an entry of kindvideo-set(new) with amemberslist of sample ids. The card shows a stack of thumbnails and the count of videos, for example "11 videos". One click loads every member into the upload list. The first sets arechimney-rock-a(4 videos) andchimney-rock-b(11 videos) andchimney-rock(all 15), in section 9. A card for a set with an unrelated member,chimney-rock-distractor, is the manual form of the distractor gate. - Tags on a video row.
component 1,component 2, ...,alone,not connected,rotation,duplicate. A tag has a tooltip with the numbers (views registered, cross-video inliers, the best rejected edge). Each component has a color. The color also marks the cameras in the viewer. - Viewer. The viewer shows one component at a time, selected by its tag. The cameras are colored by video. No explanatory text is added.
- View-graph matrix (optional, step 6). A small grid of n × n cells. A cell's shade is the number of cross-video inliers between two videos. It shows at a glance which videos connect.
6.4 The run record
Fact: the record is typecv-run-record-v5 (website/src/lib/run-record.ts,
docs/lab-run-records.md). Earlier additions (options.keyframeGate, options.keyframes) are
optional fields and the format number stayed. This design does the same: new fields are optional and
the format number stays. The validator accepts a record without them.
| Field | Content |
|---|---|
input.videos |
A list: videoId, label, duration, keyframes, focal ratio, component number, tags. |
options.multiVideo |
descriptor (bow-512-v1), k, minMatches, minEdgeInliers, maxKeyframesPerVideo, growth. |
result.components |
A list: keyframes, registered views, landmarks, videos, mean reprojection error. |
result.crossVideoEdges |
The count of accepted cross-video edges, and the sum of their inliers. |
result.viewGraph |
Nodes, candidate pairs, matched pairs, verified edges, and the same count for exhaustive matching. |
result.notConnected |
The videoIds that join no component of two or more videos. |
timing.stages |
New operations: globalDescriptor, retrieval, pairMatching, pairVerification, components. |
The record does not hold the file names (a privacy rule of the collector: only the hash and a label the visitor typed). Open: whether the label is sent. Default: not sent for uploads, sent for lab samples.
7. Verification plan
The house rules apply: a gate shows that it can fail before it is trusted to pass.
7.1 The shuffle gate (order independence)
Input: a set of at least 4 videos (a lab sample set). Run it in three input orders: the list order, the reverse order, and one fixed random permutation. Compare the three results.
The gate passes when all of these hold:
- Same keyframes. Each video has the same set of
frameKeys in the three runs. - Same registered view set. The set of
frameKeys of registered views is equal. - Same components. The partition of the
videoIds (and of the keyframes) is equal. - Same cameras, up to similarity. After a similarity alignment of the runs' camera centers on
their common views (
alignPoses), the median center error divided by the trajectory radius is at most 0.005 and the largest at most 0.02. The rotation error is at most 0.5° median. Inference: on one device and one build the values are near zero, because all ids, seeds and ties are canonical. The tolerance is for the order of floating-point reductions on a GPU.
Three layers of test:
- CPU tier (CI). A synthetic scene with 3 camera paths (orbit, ascent, lateral) renders its
observations. The test shuffles the order of the videos and of the ids and calls
incrementalStepsand the view-graph function. It checks items 2 to 4. It runs innpm test. - Lab, SwiftShader (CI, section 10 gives the reduced set). Real frames of a sample set.
- Prove the instrument. A debug switch (
?canonical=0(new)) assignsviewIds by arrival order and turns the canonical seeds off. The gate must fail with it on a set where the order changes the seed pair. A gate that cannot fail proves nothing.
7.2 The distractor gate
Input: the chimney-rock-b set plus the water-tower lab sample (an unrelated video).
Passes when:
- The water-tower video is in its own component, tagged "alone" or "not connected".
- No accepted edge joins a water-tower keyframe to a keyframe of another video.
- The other videos' components are the same as in the run without the distractor (same
partition of their
videoIds). - The record states the margin: the largest inlier count among the water-tower candidate pairs
that were verified and rejected. A margin under half of
minEdgeInliersis the target. A larger margin is reported and discussed. It is not hidden.
7.3 The connection gate (the test set)
Input: the sets of section 9: group A (4 videos), group B (11 videos), and A and B together (15).
This gate records numbers first. The owner confirmed by eye that all 15 videos show one object, but nobody has measured whether SIFT can connect them. Step 7 runs the sets, records the numbers, and this record is amended with the thresholds before the gate becomes a CI check.
| Recorded | Meaning |
|---|---|
| Cross-video edges, per pair of videos | The matrix of 6.3. Which videos connect, and with how many inliers. |
| Registered views, per video | How many keyframes of each video are in the map. |
| Components | Whether one map forms, for A, for B, and for A and B together. |
| Mean reprojection error | Of each component. |
| Time | Of each stage (6.4), on SwiftShader and on a physical GPU. |
| Growth | incremental, or submaps after a stall (4.3). |
Provisional pass rule (to be confirmed or changed by the recorded numbers): in group B at least one component holds at least 8 of its 11 videos, with a mean reprojection error under 1 px. Group A alone forms one component of its 4 videos or the record explains why not. The A and B sets together either form one component or the record names the missing bridge (5).
7.4 CPU-reference and WebGPU parity of the global descriptor
The global descriptor adds one kernel, globalAssign, and a host step. The tests follow the house
form of the KLT and MVS work:
| Gate | Number | Where |
|---|---|---|
| Reference against a naive definition | the histogram of a hand-made descriptor set equals the hand count | npm test |
| Kernel against reference, CPU tier | the word indices are equal (the argmin ties break to the lower index); the descriptors equal to 1e-6 | npm run test:shade |
| Kernel against reference, WebGPU | at least 99.9 % of the word indices equal (a floating-point near-tie can flip a word); the cosine of each descriptor pair at least 0.9999 | npm run test:webgpu |
| Chunk invariance | the output does not depend on the chunk size of the time budget | npm run test:shade |
| Retrieval recall | recall at 10 of the true pairs, on the test set and on the photo samples (3.2) | lab, recorded |
7.5 Gates summary
| Gate | Pass condition | Where |
|---|---|---|
| Shuffle | 7.1, items 1 to 4; and it fails with ?canonical=0 |
CPU tier (CI); lab reduced set |
| Distractor | 7.2, items 1 to 3 | lab |
| Connection | 7.3, numbers recorded; thresholds set by amendment | lab, run records |
| Descriptor parity | 7.4 | test:shade, test:webgpu |
| Single-video path | the present single-video runs give the same result as before | existing lab gates |
| Physical devices | the 15-video set completes without a device loss | lab, run records |
8. Steps
Each step is one pull request. Each commit names this document.
| Step | Pull request content | What it proves |
|---|---|---|
| 1 | Publish the test set. 13 new entries in website/samples/remote-videos.json with set and group fields. scripts/lab-remote-samples.mjs catalog and publish. The video-set catalog entries. A test of the manifest. |
All 15 clips have frames in the bucket. The manifest, the catalog and the sets agree (section 9). |
| 2 | Canonical identity. videoId, frameKey, viewId (2.4). Seeds from ids in verifyPairs. The CPU shuffle test of incrementalSteps. The decoder-agreement measurement. |
Shuffled ids give the same map. How often two decodes of a frame differ. |
| 3 | Global descriptor. globalDescriptorReference, the kernel globalAssign, the vocabulary rootsift-512-v1, the idf. The recall measurement of the three candidates. A record of the choice (amendment). |
Parity of 7.4. The default of 3.2 holds, or the record is amended with the winner. |
| 4 | The view graph. buildViewGraph (new): candidates (3.3), host descriptor store (3.5), matching and verification of candidates (3.4), components (4.1). A CPU-tier test on a synthetic 3-path scene and on the photo samples. Recorded: expansion on or off. |
N·k pairs instead of N². Components are right on synthetic data. The 8-bit against float inlier change. |
| 5 | The mapper on a component. preVerified input, per-component call of incrementalSteps, per-video focal groups in the BA, the content tie-breaks of 4.4 under a test. |
A component reconstructs from verified edges with no second verification. The shuffle gate (CPU) passes. |
| 6 | Lab and worker. Multi-file upload, the multiVideo job, sample set card, tags, per-component viewer, the record fields (6.4), the optional graph matrix. ?canonical=0. |
The lab runs a set end to end. The record validates. The shuffle gate fails with ?canonical=0. |
| 7 | The gates on the test set. The shuffle gate on the lab (reduced set on SwiftShader). The distractor gate. The connection gate numbers on groups A, B and A+B, then an amendment with the thresholds. Physical-GPU runs. | 7.1 to 7.3 with numbers. The costs of section 10 against measured values. |
| 8 | Submap fallback. Per-video submaps, the maximum spanning tree, the Sim(3) of 4.3, the global BA, the stall rule, the comparison with the incremental result. | The stalled set joins, and the result is near the incremental one where both run. |
Steps 1 and 2 need no lab change. Steps 3 and 4 can run in parallel after step 2. Step 8 can wait for the numbers of step 7.
9. The test set
The owner confirmed by eye that all clips show the same object: Chimney Rock, Colorado. The
creator is EJ Merl (Pexels). The license is the Pexels license (https://www.pexels.com/license/),
checked 2026-10-20 in website/samples/remote-videos.json.
| Group | Pexels video ids | Count | Already a lab sample |
|---|---|---|---|
| A | 32291999, 32292000, 32292001, 32292002 | 4 | 32292001 is desert-monument |
| B | 32332978, 32332979, ..., 32332988 (every id in between) | 11 | 32332986 is colorado-rock |
The unrelated video for the distractor gate is water-tower (Pexels 33341651, also by EJ Merl).
Fact: it is already a lab sample.
How the samples are published (fact). website/samples/remote-videos.json lists each clip with
id, title, creator, page, file (a Pexels download URL), duration and a bilingual
description. scripts/lab-remote-samples.mjs catalog writes the entries into the sample catalog
(catalogEntry, withRemoteEntries). publish downloads one clip at a time with curl, checks the
duration with ffprobe against the manifest (it fails when they differ by 0.5 s or more), cuts the
frames at videoSampleTimes(duration) with ffmpeg, scales them to 640 × 360 and uploads them to the
R2 bucket typecv-samples under sample/<id>/. It needs CLOUDFLARE_API_TOKEN. The site's Worker
serves /sample/<id>/....
What step 1 does:
- For each of the 13 clips not yet in the manifest, read the Pexels page for the file URL
(the numeric file id in the URL is not the video id and cannot be derived from it) and the
duration. Prefer a file of about 1920 × 1080 or 3840 × 2160. Add an entry. Use the ids
chimney-rock-<pexels id>, with the title "Chimney Rock video" plus the group and the number. Keep the two existing ids (desert-monument,colorado-rock) and addsetandgroupto them. - Add the optional fields
set("chimney-rock") andgroup("A"or"B") to every entry of the set. The manifest keeps its formattypecv-remote-videos-v1.readManifestchecks only the format string, so the fields are optional and old readers ignore them. - Add the
video-setentrieschimney-rock-a,chimney-rock-b,chimney-rockandchimney-rock-distractor(B pluswater-tower) to the catalog (section 6.3). - Run
catalogandpublish --only <ids>. The publish needs the owner's token. Open: who runs it. The existing remote samples were published from CI (the script's comment says "(CI)"). - Record the date, the commit and the durations in the pull request.
Fact not yet checked: the durations and the frame times of the 13 new clips. The sampling at 2 frames
per second of the sample frames (videoSampleTimes) is not the 4 frames per second of an uploaded
video. Section 2.2 and 10 use the uploaded-video rate. The sample frames are the lab's way to run a
video without a decoder.
10. Cost estimate (inference)
This section is an inference. No multi-video run exists yet. Step 7 replaces each number with a measured one.
Keyframes per video. Fact: the video record 20261020121308655-a6fb6f3b (RTX 2080) made 27
keyframes from 566 decoded frames of one 4K clip (docs/klt-tracking.md, section 1). The clips of
the test set last about 12 to 30 s (the two known ones: 29.7 s and 19.5 s). At 4 frames per second
that is 48 to 120 sampled frames. Assume 25 keyframes for a video (range 15 to 40). For 15 videos,
N = 375 (range 225 to 600).
Candidate pairs. With k = 10 and 3 temporal neighbours on each side (13 candidates per keyframe, of which some are the same pair counted from both ends):
| Quantity | Value |
|---|---|
| Directed candidates, N·13 | 4,875 |
| Unique pairs (between 50 and 100 %) | about 2,500 to 4,875 |
| Reserve (3.3, guard 1) | up to 315 |
| Exhaustive pairs, N(N−1)/2 | 70,125 |
| Saving | about 14 to 28 times fewer pairs |
Cost per stage. Fact: the 8-view pyramid benchmark has 28 pairs
(docs/lab-run-records.md, "SwiftShader against the RTX 2080"). Its warm pass is the only table
with both devices.
| Stage | SwiftShader, 8 views | RTX 2080, 8 views | Per unit, SwiftShader | Per unit, RTX 2080 |
|---|---|---|---|---|
| Features | 58.8 s | 0.53 s | 7.4 s per keyframe | 0.066 s per keyframe |
| Matching | 159.4 s | 0.25 s | 5.7 s per pair | 0.009 s per pair |
| Pair verification | 11.3 s | 8.08 s | 0.40 s per pair | 0.29 s per pair |
Scaled to N = 375 keyframes and 4,875 pairs (the upper end), at 640 px and 4,096 features:
| Stage | SwiftShader | RTX 2080 | Note |
|---|---|---|---|
| Features, 375 keyframes | about 2,760 s (46 min) | about 25 s | Only keyframes pay (the phase gate runs first). |
| Global descriptor | tens of seconds | under 1 s | 100 GFLOP (3.2). |
| Matching, 4,875 pairs | about 27,800 s (7.7 h) | about 44 s | Arithmetic-bound on SwiftShader. |
| Verification, 4,875 pairs | about 1,950 s (33 min) | about 1,410 s (24 min) | Round-trip-bound: the RTX gains only 1.4 times. |
| Mapper (BA, PnP) | not estimated | not estimated | Depends on the size of the component. Step 7 measures. |
Three readings of the table (all inference):
- Verification dominates on a physical GPU. The matching is cheap there. The verification
costs about 0.29 s a pair, because of many small submissions. The
minMatchesfilter of 3.4 removes most false candidates before the verification. If it removes half of the pairs, the verification time halves. A batch verification of many pairs in one submission would cut it more. That is a later optimization, and the record of the resident pair verification (docs/experiments/gpu/resident-pair-verification-software.json) is the place to start. - The full set does not run on SwiftShader. Matching alone would take hours. The CI gates use
a reduced set: 5 videos (two from each group and the distractor),
maxKeyframesPerVideo8 (N = 40), 1,024 features. Matching cost scales with the square of the feature count, so 1,024 features cost about 1/16 of 4,096: about 0.36 s a pair. With 40 × 13 = 520 candidate pairs, matching takes about 3 minutes and verification about 3.5 minutes. The reduced set tests the order, the separation and the plumbing. It does not test the connection of the full set. - The connection gate needs a physical GPU or a long run. The owner's machine (RTX class) runs the full set in the order of 30 minutes by this estimate.
Memory. Host descriptors: 192 MiB for N = 375 in 8-bit form (3.5). GPU: one keyframe's descriptors and its candidates' descriptors in a batch, under the working budget of the device (64 MiB at 640 px).
11. Risks and open questions
SIFT may not connect the paths. The owner confirmed the object by eye. The paths can still be too far apart for SIFT (section 5). The test set may give two components. That is a result of the gate and a correct answer, not a defect. Step 7 records it. If groups A and B do not join, the record says which bridge is missing. Open.
The vocabulary is trained on the lab's samples. Samples are mostly outdoor, textured scenes. A scene with other statistics (indoor, a face) can retrieve worse. The idf weights adapt to the set, and the recall measurement of step 3 uses the photo samples too. Open: whether a vocabulary trained on the run's own descriptors (a deterministic k-means in a canonical order) is better. Step 3 compares the two.
Retrieval misses a true pair. A true pair outside the top 10 is lost. The guards of 3.3 (the per-video reserve and the expansion) reduce this. The recall at 10 is measured in step 3. A value under 0.9 of the exhaustive result raises k. Open: the best k.
False edges between unrelated videos. Repeated textures can give an edge that passes the check. The distractor gate measures the margin on one case. Open: a consistency check on the cross-video edges of a component (they must agree on one rigid motion between the two videos).
Verification time on a physical GPU. About 24 minutes for the full set (section 10). This is the dominant cost. Open: batch verification, and a lower RANSAC iteration count for pairs that already have a high inlier ratio.
The global BA on a large component. Hundreds of views make the global BA slow. The schedule of PR #113 helps. Open: the cap of 600 keyframes in 4.3 is a guess. Step 7 measures the time of the final BA against N.
Per-video focal refinement. One focal for each video adds parameters and can drift when a video has little parallax. Open: use the shared form unless the cameras differ. Step 7 compares.
8-bit descriptors. A change in the matches against the float path (3.5). Step 4 measures it.
Decoder dependence of the hash. frameKey can differ between two decodes of one video
(2.4). The shuffle gate runs on one decoder, so it is not affected. Open: a hash of a coarse
thumbnail, if step 2 shows a difference.
Memory on a phone. 192 MiB of host descriptors plus decoded frames. The per-video cap and
maxKeyframes (5) bound it. Open: the limit on a phone browser. Not measured.
Dense MVS. The dense path has a limit on the number of views (docs/dense-mvs.md, section 4.2).
It runs on one component at a time and selects its views as it does now. This record changes no
dense code. Open: which component a click on "Dense" uses. Default: the selected one.
Privacy of the record. The record holds hashes, not names (6.4). The label of a sample is public. Open: whether a typed label is sent. Default: no.
Publishing the samples. The publish command needs the owner's Cloudflare token (section 9).
Open: whether the CI job that published the earlier samples can run it again.
12. Record of this document
Written on 2026-10-20 from main at ebc39b6. Files read: website/src/lib/sfm-video.ts,
website/src/lib/keyframe-gate.ts, website/src/lib/video-sampling.ts,
website/src/lib/pose-alignment.ts, website/src/workers/sfm.worker.ts,
src/reconstruction/mapper.ts, src/gpu/mapper.ts, scripts/lab-remote-samples.mjs,
website/samples/remote-videos.json, docs/lab-run-records.md, docs/klt-tracking.md and
docs/dense-mvs.md (as the model of form). No code was changed, and no check other than the
formatting check of this file was run.
Not checked: the Pexels pages of the 13 new clips (their durations and file URLs), the
SampleEntry type in website/src/lib/sample-catalog.ts (the video-set kind needs a type change),
and the validator in website/src/lib/run-record.ts (the new optional fields need its update).
Step 1 and step 6 check them.
Decisions on the open questions of the draft, made by the coordinator by default on 2026-10-20:
- Step 1 publishes the test set with
scripts/lab-remote-samples.mjs publishfrom the session container, asdesert-monumentwas published. If the container has no Cloudflare credential, step 1 stops and reports it to the owner. - The
video-setcatalog kind is accepted. - The typed label of an uploaded video is not sent in the run record.
- The provisional connection-gate rule is accepted until step 7 records numbers: at least 8 of the 11 group B videos in one component, with a mean reprojection error under 1 px.
Amendment 1: step 2, decoder agreement (measured, 2026-10-20)
Step 2 implements the identity of 2.4: videoIdOf, frameKeyOf, assignViewIds and pairSeed in
src/reconstruction/identity.ts. verifyPairs seeds each pair with pairSeed(seed, first, second). The CPU tier of the shuffle gate (7.1) is tests/multi-video-identity.test.ts.
Measured. How often two decodes of one video give different frameKeys.
- Command:
ffmpeg -i <clip>.mp4 -t 10 -vf scale=1280:-2 -c:v libvpx-vp9 -an <clip>.webm, for three clips of the test set (section 9), thennode scripts/measure-decoder-agreement.mjs a.webm b.webm c.webm. - Configuration:
mainatbeb8f1eplus this change, Node 22.22.0, Playwright 1.56.1, Chromium 141.0.7390.37 (software VP9 decode, headless, no GPU), mediabunny 1.61.6, 4 frames per second, analysis edge 640, 2 decodes on each path of the lab (the page and the decode worker). - Result, 2026-10-20: 41 frames per clip, 123 frames in all, 41 different keys in each clip. Repeat on the page path: 0 of 123 keys differ. Repeat on the worker path: 0 of 123. Page path against worker path: 0 of 123.
What this does not show. The Chromium build of the container has no H.264 decoder, so the clips were transcoded to VP9 and no H.264 decode was measured. Hardware decoders, other browsers and other devices were not measured. The result holds for one machine and one browser build.
Disposition of the open question in 2.4. On one decoder the full-RGBA hash is stable, so it
stays the definition of frameKey. The question whether two devices agree stays open. The
thumbnail hash is the remedy if a later measurement on two devices shows a difference.
Found, not changed. runIncrementalGpu (src/gpu/mapper.ts) replaces the seed of a pair in a
streaming session by seed + session.verification.size, a count of the arrival order. Step 2 does
not change it: the record names verifyPairs only, and the multi-video job does not use the
session (4.2, item 3). Step 5 or step 6 decides. The seed + order.length of the PnP request in
incrementalSteps and seed + cameraIds.length in scene-mapper.ts count registered views. The
count follows the content-driven registration order, so these stay.
Amendment (step 4): the view graph (measured, 2026-10-21)
Step 4 implements buildViewGraph in src/reconstruction/view-graph.ts: the candidates of 3.3
(selectCandidates), the host descriptor store of 3.5 (DescriptorStore), the matching and
verification of 3.4 and the components of 4.1 (connectedComponents). Verification runs the
existing verifyPairs of src/reconstruction/mapper.ts once for each pair, so the seed of a pair
is pairSeed of its two view ids. The numerical steps sit behind a ViewGraphBackend:
cpuViewGraphBackend (float64 matching and the CPU verifyPair) and gpuViewGraphBackend
(src/gpu/view-graph.ts: matchFloatDescriptorsGpu and verifyImagePairGpu). The backend is
exported from the internal test entry src/gpu/internal.ts only. Step 6 decides the public export.
The global descriptor is an input of each keyframe (global, L2-normalized, so the similarity is a
dot product). Step 3 is not merged, so the tests and the measurement use stand-ins: a bag of words
built from the known landmark labels (synthetic scene), and a k-means vocabulary of the run's own
descriptors (photo samples). The recall measurement of 3.2 belongs to step 3.
Decisions.
- Expansion stays off by default. It is the
expansionoption. On the data below it tried 27 more pairs on the synthetic scene and added 27 edges, none across videos, and changed no component. On the photo samples it tried 0 pairs. The test set (section 9) was not available to this step, so the measurement on it stays open for step 7. - The stage stores 8-bit descriptors (
descriptorStorage: 'uint8', the default). On the photo samples the change in the inliers is small (below). Float32 stays as the optiondescriptorStorage: 'float32'. - Edge weight and kept result. An edge keeps the inlier matches, the inlier count, the
homography inlier count, the median triangulation angle and
seedEligible. The relative pose and triangulation (geometry) exist only for a pair that can seed a map, becauseverifyPairsreturns the geometry for seed candidates only. Step 5 can read the rest from the matches. - Interpretation of 4.1. A video is tagged from the component that holds most of its
keyframes (the lower component index on a tie). That component must have at least
minComponentViewskeyframes and at leastminVideoShareof the video. The tag is "connected" when the component holds two or more videos and "alone" when it holds one. Every other video is "not connected". - Reserve. The reserve of 3.3 takes the best 3 cross-video pairs of each pair of videos from all cross-video pairs, not from the candidate pairs. A pair that retrieval already holds counts once.
Measured.
- Command:
node scripts/measure-view-graph.mjs --scale 1 --features 1024 --words 128afternpm run build. - Configuration:
mainat92639f9plus this change, Node 22.22.0, the CPU tier of the device (ShadeDevicebackendcpu), ffmpeg for the JPEG decode, defaults of 3.3 and 3.4 (k = 10, temporal 3, window 10, reserve 3, ratio 0.8,minMatches30,minEdgeInliers25) and a 128-word stand-in vocabulary. - Synthetic scene, three paths around one cloud and a fourth around another (4 × 12 keyframes, 100 landmarks, no pixel noise beyond ±0.3 px): N = 48, 1,128 pairs exhaustive, 524 candidates (120 temporal, 404 retrieved, 0 reserve), 406 edges, 284 of them across videos. Components: one of 36 keyframes from 3 videos ("connected") and one of 12 keyframes from 1 video ("alone"). The 8-bit and float32 stores give the same 40,513 inliers.
- Photo samples (frames at 640 pixels, 1,024 SIFT features): the first six frames of
gerrard-hallas one video, the last six as another, and all 12 frames ofperson-halland ofgraham-hallas one video each. N = 36, 630 pairs exhaustive, 357 candidates (84 temporal, 273 retrieved), 66 pairs with at least 30 matches, 65 edges, 1 across videos. Components (keyframes/videos): 12/1 (person-hall, alone), 12/2 (the twogerrard-hallhalves, connected), 8/1 and 4/1 (graham-hall, in two parts: 8 of 12 keyframes in the larger, so "alone"). 8-bit against float32: 7,598 against 7,645 inliers over the same 65 edges (−0.61 %), mean change per edge 1.97 %, largest 24.4 %, same components. - Cost of one build (8-bit, expansion off) on the CPU tier: 17.7 s for the synthetic scene, 198 s for the photo samples. SIFT of the 36 frames took 346 s. RANSAC iterations were 150 (synthetic) and 300 (photos); the other settings are the defaults.
What this does not show. N is 36 and 48, so the candidate count is 0.57 and 0.46 of N², not the 0.07 of N = 375 in section 10. The test set was not used, and the global descriptor is a stand-in, so these numbers say nothing about the recall of the bag of words. The 8-bit result holds for photo samples with a ratio test of 0.8 on 1,024 features. The synthetic scene has a perfect inlier count, so it cannot show a change. Expansion on the test set is not measured.
Found, not changed. matchFloatDescriptorsGpu allocates one upload for each target. The
batch of 3.5 uploads the first keyframe once and the targets one by one. A batch of targets in
one buffer would save uploads. Step 6 measures it on a physical GPU.
Amendment (step 5): the mapper on a component (measured, 2026-10-21)
Step 5 implements the mapper on one component of the view graph. The code is preVerified and
focalGroups in IncrementalOptions (src/reconstruction/mapper.ts), the per-group form of the
CPU joint-focal BA (focalGroups in BundleAdjustmentOptions, src/reconstruction/joint-focal.ts),
and componentInput, reconstructComponent, preVerifiedOf and focalGroupsOf
(src/reconstruction/component-mapper.ts). The tests are tests/multi-video-mapper.test.ts.
Decisions.
preVerifiedis a list ofPreVerifiedPair(the inlier matches, thePairVerificationrow and, for a seed candidate, the geometry).verifyPairstakes the row of an input pair that has an entry and yields noverifyPairjob for it. A pair without an entry is verified as before. An entry that names no input pair, repeats a pair or lacks the geometry of a seed throwsRangeError. To carry the row,ViewGraphEdgegains the fielddiagnostic(step 4's type).- A component is one call.
componentInputsorts the views byviewId, takes the edges inside the component and setspreVerifiedandfocalGroups.reconstructComponentdrives it on the CPU. A device driver runscomponentInputand its own driver. - Focal groups.
focalGroupsOfranks thevideoIds of the component. The final BA getsfocalGroupsonly whenrefineSharedFocalis on. The CPU joint-focal BA refines one log-focal coordinate for each group. One group, or none, gives the previous system bit for bit. - The tie-breaks of 4.4 need no new code in the mapper. The seed list sorts by score then by ids and the next view by correspondences then by id. The test lists them (seed, registration order) and fails if a changed input order changes the output.
runIncrementalGpukeepsseed + session.verification.size. It applies to a streaming session only. The multi-video job does not use the session (4.2, item 3), so single-video behavior is unchanged. The open item of Amendment 1 is closed by this decision.
Deviations.
- The WebGPU BA has no per-group form.
bundleAdjustGputhrowsRangeErrorwhenfocalGroupsnames more than one group among the views. A GPU caller setsfocalPerVideo: falseuntil a kernel exists. Step 6 or later owns the kernel. It is open. - The step 5 row names no GPU change.
runIncrementalGpucopiespreVerifiedbefore the driver suspends, as it copies the pairs. This is the only GPU mapper edit.
Measured. Command: node --test tests/multi-video-mapper.test.ts, main at f469206 plus this
change, Node 22.22.0, CPU tier. Scene: four paths of 7 keyframes (three around one cloud, one
around another), graph options of the test (k = 4, temporal 2, window 3, reserve 2).
- Component 0 (21 keyframes, 3 videos) has 106 edges. With
preVerifiedthe run yields 0verifyPairjobs and registers 21 of 21 views. The same edges as plain input pairs yield 106. - Shuffle gate, CPU tier (7.1 items 1 to 4): the list order, the reverse order and one fixed
permutation of the keyframes give equal components, registered sets, registration order, seed
pairs, landmark counts and cameras. The comparison is exact (
deepEqualonRandt), so the median and largest center error are 0. The gate fails with view ids in arrival order, in at least one of 3 permutations (the?canonical=0switch itself is step 6). - BA with two focal groups recovers both true focal lengths to 1e-3 relative error, where one shared scale leaves a deviation above 2 %.
What this does not show. Frames are synthetic, so no real-frame shuffle gate ran (step 7). The
lab path and the GPU tier did not run a multi-video component. src/index.ts does not export the
new symbols. Step 6 decides the public export.