Run the sample · splat_video.ts · src/splats/sfm/
The Splat Trainer cheats: Taos Engine renders their training views, so every frame arrives with its exact camera. This sample removes the cheat. A drone video (or a folder of overlapping photos) goes in, and the camera poses have to be recovered before a single splat can be trained — the same problem every real-world 3D Gaussian Splatting pipeline solves with an offline COLMAP stage. Here that stage runs inside the tab:
video / photos structure from motion train / view
┌──────────────────┐ ┌─────────────────────────────────────┐ ┌────────────────┐
│ pick sharp │ │ SIFT (worker pool) → GPU matching │ │ undistort → │
│ frames (Laplace │──►│ → tracks → focal search → two-view │──►│ seed from the │
│ variance) → │ │ init → incremental PnP + interim │ │ sparse cloud → │
│ decode two res │ │ BA → sparse Schur BA (f, k1, k2) │ │ GsTrainer │
└──────────────────┘ └─────────────────────────────────────┘ └────────────────┘The solver is a port of the open-source splat.js
front end (MIT), whose poses match COLMAP's to 0.00% of path length on the
Tanks & Temples Truck scene. The trainer behind it is the same sorted-tile GPU
trainer the other splat samples use (gs_trainer.ts).
Frames from a video#
A video is not a photo set. Consecutive frames are nearly identical (no
parallax, no information), a drone's gimbal pans produce motion-blurred frames
that would teach the model blur, and an edited clip is several unrelated
scenes — only frames of one shot can be solved together.
video_frames.ts handles that in
stages, and the sample puts a range editor between them:
- Scan — play the clip muted at 3× with
requestVideoFrameCallbackand, every ~0.1 s of video time, draw a 320-px grayscale and keep three numbers: sharpness (Laplacian variance — blur kills second derivatives), the mean frame difference, and the mean difference of 16×9 block means. The last one is the shot-cut signal: a few pixels of motion barely moves a 20-px block average; a new scene moves most of them. (Plain pixel differences are useless for cuts on aerial footage — dense texture makes 0.1 s of motion change as many pixels as a new scene.) The scan is the slow part of opening a long clip (~2 min for 6 min of 1080p), so it is cached inlocalStorageper file. - Shots —
detectShotsturns the block-difference signal into shot boundaries. A cut must be prominent against the ±2 s local baseline, isolated (≥ 2× its immediate neighbors — a fast pan raises the whole neighborhood, a cut is one spike), and among the clip's largest differences overall; a weaker spike is still accepted when the sharpness level changes abruptly across it. Measured on the Santa Fe clip: the six real cuts stand 4–180× above their neighbors, motion jitter at most 1.5×. - Trim — before any scan, the raw video is shown on the timeline: scrub or play it, drag the in/out handles (or press I / O at the playhead) around the section you want, and press Scan range. Only that section is scanned — for a long clip where you already know the interesting stretch, this is the difference between a 13-second scan and a two-minute one. (The whole video is the default selection, so you can scan everything if you want the shot detector to split an edited clip for you.)
- Range editor — the timeline now zooms to the scanned section and shows the sharpness curve, the shot bands and cut markers; drag the in/out handles, click a shot chip, scrub or play the preview, set the frame count and the sampling policy. The default selection is the longest shot.
- Select —
selectFrameTimespicks N frames inside the range: even spacing, sharpest per slot (equal time slots, the sharpest scan sample of each — even baselines for SfM, blur avoided) or sharpest overall with a minimum gap (the original splat.js policy; it tends to bunch frames in the sharpest few seconds). - Capture — seek to the chosen times in order (forward-only decode is fast) and encode full-resolution JPEGs.
Frame spacing is the number that decides whether SfM succeeds. Consecutive frames must overlap enough to share tracks. On the Santa Fe flyover — a fast, low pass — 100 frames spread over the whole 3-minute first shot (1.9 s apart) registered 2 cameras; 100 frames over 40 s (0.4 s apart) registered 100/100. The editor shows the resulting spacing, warns when it gets wide, and the fit 0.4 s spacing button shrinks the range from its start so the chosen frame count lands at that spacing. To cover a longer flight, raise the frame count rather than the spacing.
Those JPEGs then go through the same decoder as photos
(frames.ts), which produces two
resolutions of every frame: a ≤960-px grayscale for feature detection (feature
localization is the pose-precision ceiling, and 960 measurably beats 640), and
an RGBA8 training target at the chosen train resolution. The frame's sharpness
rides along: the blurriest 45%-below-median cameras keep their poses but are
excluded from the loss.
Structure from motion#
sfm.ts is incremental SfM in the COLMAP
tradition, with the pieces that matter for quality spelled out:
- Features. Real scale-space SIFT (
sift.ts): DoG extrema, 3D quadratic sub-pixel refinement, orientation histograms, 128-D gradient descriptors with RootSIFT normalization. It is ~1 s of scalar JS per image, so it runs on a Web Worker pool (sift_worker.ts). Images that come back feature-poor get a second pass with the base octave upsampled 2×. - Matching. Brute-force 128-D L2 is the dominant cost of a SIFT pipeline;
gpu_match.tsdoes the whole pair graph on WebGPU (a tiled workgroup reduction, best + second-best per query, ratio test and mutual cross-check on the CPU). On a desktop GPU the ~1500-pair graph of a 100-frame video takes about a second; a CPU fallback exists. - Tracks. Mutual matches are chained with union-find into multi-frame tracks; a track that lands on two features of one image is a bad merge and is dropped whole. Pairs that cannot support an essential matrix are rejected before they can poison the graph, with a component rescue for the rare capture seam where every bridging pair sits just under the gate.
- Focal search. No EXIF is read. Four candidate focal lengths (relative to
1.2 × max(w, h)) each get a cheap incremental reconstruction; the one that registers (nearly) the most cameras with the lowest pixel error wins and is re-run with the full bundle-adjustment stack. - Initialization + registration. The best two-view pair (by shared tracks and parallax — a near-degenerate init pair poisons everything downstream) seeds the reconstruction; remaining images register by LM refinement from their best-connected neighbor's pose, falling back to DLT-PnP RANSAC, with re-triangulation and periodic interim bundle adjustment so the growing chain never drifts into a bent basin that no final BA can escape.
- Bundle adjustment.
ba.tsis a sparse Levenberg–Marquardt solver with the Schur complement over points, a Huber loss, and shared intrinsics: focal length plus radial distortionk1, k2. Between passes, observations are refined to sub-pixel against a reference patch (Lucas–Kanade), tracks are extended by projecting adjusted points into frames that missed them, and high-residual observations are filtered — each round reveals outliers the previous bent model was hiding. - Global init (
global.ts) is an opt-in alternative: rotation averaging over pairwise relative rotations, then a joint camera + point positioning from ray directions, for scenes where incremental growth locks into the wrong basin.
Everything runs in normalized image coordinates (OpenCV convention: x
right, y down, z forward, p_c = R p + t), which is exactly what the trainer's
GsCamera expects — no axis flip on the way in. Only the focal length is
rescaled from feature scale to training scale
(recon_to_trainer.ts).
From reconstruction to splats#
The trainer models a pinhole; the solver found a lens. So the training images
are undistorted by the solved k1, k2 (bilinear resample, out-of-frame
samples get alpha 0 and leave the loss), the sparse cloud seeds the initial
Gaussians (initGaussiansFromPoints, with jittered clones up to ~60k), and the
trainer starts with two options the synthetic samples leave off:
camOpt— photometric camera-pose refinement. SfM poses are good to a fraction of a pixel, but the splat loss can polish the last bit.expComp— per-image exposure gain/bias. A drone's auto-exposure drifts across a flight; without this the model learns an average brightness and fights every frame.
The rest is the familiar loop: adaptive batching, MCMC-style refinement,
holdout PSNR on a sharp mid-sequence frame, plateau auto-stop, View 3D
through GaussianSplatPass (the SfM world is y-down, so the pass's 180° X
flip is on and the free camera is parked at a training camera's pose), and a
standard .ply export.
Trying it#
- Synthetic test set renders a 12-view textured room corner in-page with
known ground truth. It is the regression gate: 12/12 cameras register, the
recovered focal is within 0.5% of the true 620 px, BA rms ≈ 0.46 px, and the
splat reaches 34 dB on the held-out view within the first ~7k iterations.
?auto=synthetic&view3ddrives it headlessly. - Open video / photos (or drag and drop) runs the real thing: scan →
range editor → capture → solve → train. On a phone the same button opens
the photo gallery (it is a plain
<input type="file" accept="video/*">).?video=<url>fetches a same-origin video on load;&range=auto|<start>-<end>&n=<count>&mode=uniform|sharpestskips both editor stages for scripted runs, and&trim=<start>-<end>presets the trim handles. - Santa Fe drone test set is the real-footage regression gate, bundled in
samples/splat_video/santa_fe/: the 41 frames of one continuous shot of a YouTube drone flyover of Santa Fe, NM (credit and source inframes.json), downscaled to 960×540 (6.9 MB). They were harvested by running the full video through this very sample and keeping the frames that registered. Solving them from the files takes ~24 s (41/41 cameras, ~9.8k points, BA rms 0.55 px, focal 678 px ≈ a wide drone lens, k1 ≈ 0.04) and training reaches ~24 dB train / 22 dB holdout by 8k iterations — typical for compressed video.?auto=santafe&view3ddrives it headlessly. On the original 6-minute clip (100 picked frames) the sharpness scan takes ~2.3 min and only that shot's ~40 frames register: the cuts split an edited clip into separate components, and only the largest survives. - Things that hurt, in order: moving objects (cars, people — the scene must be static), hard cuts between shots (each shot is its own reconstruction and only the largest component survives), a zoom during the flight (one shared focal is assumed), and low-texture frames (sky, water).
The unit gates live in tests/splats/sfm/:
geometry (two-view, PnP, triangulation with outliers), bundle adjustment on a
drifted 25-camera chain with a distorted lens, global rotation averaging on a
noisy orbit, and SIFT on a procedural texture.