~/satyajit

Spirula Studio deleted its dependencies instead of wrapping them

mdjsonmcp

2026-09-22 · 14 min · 3d · gaussian-splatting · performance · explainer · cuda

When a project describes itself as "one self-contained binary" covering camera alignment through to 3D Gaussian Splatting training, my first move is to open the CMake and count the FetchContent_Declare calls. That is where the seams usually are: a launcher, a config schema, and six other people's repositories doing the work.

Spirula Studio has two. glfw and imgui. That is the entire external fetch list.

An excerpt (frames 72–135 of 212) from the project's own hero animation, at its native 5 fps. The captions and the figures in them are the project's claims, not my measurements — I have not run the binary. The source is silent. (Spirula Studio, spirula.studio hero animation; GPLv3, licence and asset credits committed beside the clip.)

This matters beyond tidiness. Every Gaussian-splatting pipeline I have had to put into production died at the same place: not at the training, but at the seams. A COLMAP that has to be built with the right CUDA arch. A PyTorch wheel that fights the driver. A masking script that pulls a second CUDA stack in beside the first. An ffmpeg that is on the machine but wrong. The model was never the hard part; the bill of materials was.

So the interesting question about Spirula Studio is not whether it trains good splats. It is whether "no Python, no PyTorch, no separate COLMAP install" is a packaging claim or a rewrite.

It is a rewrite

what "no Python, no COLMAP" costs82,074 lines
dependency removedwhat replaced itlines
colmap (subprocess)
src/sfm/
still borrowed: COLMAP's behaviour and parameter defaults, as the reference
44,310
PyTorch + CUDA (inference)
src/nn/
still borrowed: nothing — Vulkan + Slang, its own tensor and op set
10,886
lang-segment-anything
src/sam/
still borrowed: Meta's SAM 2.1 / SAM 3 checkpoints, fetched on first use
7,962
ffmpeg (frame extraction)
src/video/
still borrowed: nothing — VK_KHR_video_decode plus its own demuxers
9,145
onnxruntime (features)
src/aliked/ + src/loma/
still borrowed: COLMAP's and LoMa's own ONNX exports, parsed in process
5,712
torch.hub + mmcv (depth)
src/moge/ + src/metric3d/
still borrowed: onnx-community and Ruicheng ONNX exports of MoGe-2 / Metric3D v2
4,059

What is left of Python in the repository is 39 files under reference/, kept as the behavioural reference the ports were checked against, plus the build's own codegen. Nothing in the shipped binary runs them.

Read the module READMEs and the same sentence appears six times in slightly different words: "with no onnxruntime, no PyTorch and no converter." Each module says what it replaced and why:

The checkpoints are the honest exception and the project is precise about it. ALIKED's weights are "COLMAP's, fetched from COLMAP's release URL and parsed in process. We host nothing and convert nothing." The ONNX reader is nn/io/Onnx.cpp, a ~400-line varint walk of protobuf that reads initializers only — the graph structure is hard-coded in C++, so nothing has to understand an operator or a shape rule. The point of that is stated plainly: parity. Both sides run the same bytes, so a difference against colmap feature_extractor --FeatureExtraction.type ALIKED_N16ROT is yours and can be bisected, "which is exactly how the two bugs below were found."

That is the tell of a real port rather than a reimplementation-by-vibes. The modules report their parity as a number: MoGe-2's forward pass matches onnxruntime on the same checkpoint to a relative L2 of 3.8e-6, Metric3D v2's to 1.5e-3, and LoMa's three networks each match on the same bytes.

The -DSS_ENABLE_PATENTED=ON line

Buried in the build instructions is the most grown-up licensing decision I have seen in a hobby-scale repository:

Nine thousand lines of decoder, written, tested, and fenced behind a flag that defaults to off, with the fallback wired and the user told why. The upside of turning it on is stated too — roughly 15× faster frame extraction, a 127-second 1080p30 clip in ten seconds, masking that rides along on the same device pass, and no ffmpeg to install.

Where the numbers are

The feature list is the marketing surface: cross-vendor via Vulkan, one strategy combining MCMC/IGS+/MRNF, 10 million SH3 Gaussians in 8 GB of VRAM under quantized training, native 360 and equirectangular support, a modified bilateral grid and PPISP for exposure and white balance. I have not run the binary and have no measurement of my own to put against any of it.

What I can read is the engineering documentation, and there the numbers have the shape of things someone actually measured and then had to live with. My favourite is the one that argues against a speedup.

bundle adjustment · three identical runs, 379 imagesAUC@10
9092949698run 196.196.5run 296.192.3run 396.191.5fp64mean 96.1spread 0.0fp32mean 93.4spread 5.0Same capture, same flags, same machine — only the scalar differs.fp32 would be worth 25–35% of the mapping stage.It is not taken: --ba-real-coarse float exists, and is not the default.

The mapping stage spends most of its time in bundle adjustment, and most of those solves are provisional — a growth-phase refinement followed by more growth, a merge-tree level followed by another level. Running them in fp32 is worth 25–35% of the stage, and the README does the arithmetic for why: "on solves large enough to be arithmetic-bound, halving the bytes every kernel moves is worth ~2×, and below a few dozen images the scalar makes no difference at all because those solves are bounded by dispatch count instead."

It is fp64 anyway. The reason is the one that only shows up if you run the same job three times:

The README reports three identical runs of a 379-image capture: 96.1 AUC@10 every time in fp64, and 96.5 / 92.3 / 91.5 in fp32. Noisier and 2.7 points worse on average. The flag to take the speed anyway exists, --ba-real-coarse float, and the default does not use it.

This is a familiar bruise. Non-determinism from atomic ordering is the thing that makes a CUDA pipeline impossible to debug — you fix a bug, the number moves, and you cannot tell whether you fixed it. Paying 30% to keep a pipeline bit-reproducible is the right trade, and almost nobody makes it, because the 30% is visible in a benchmark and the reproducibility is not.

The second measurement in that family is about an abandoned design rather than a scalar. An earlier "manage" loop repaired a model by growing it without a bound: "on a 5356-image capture it spent 65 minutes to merge three models and recover 119 images, and ended with three near-copies of one reconstruction." That is in the README, describing code that has since been replaced, under the switch that still turns it off.

The 360 path

The claim I most wanted to check is the one about 360 cameras, because it is where most pipelines cheat. The cheap version is: undistort the panorama into six or ten pinhole faces, hand those to COLMAP, and call it support. Spirula does not do that.

one .OSV file → a levelled, metric reconstructionno undistortion step
  1. container

    ISO-BMFF picked by content, not extension; the file's two video tracks are enumerated, never merged — a 360 camera's two fisheye lenses.

    one output folder per track

  2. decode

    VK_KHR_video_decode on the same device as everything else; the H.264/H.265/AV1 bitstream parsing and reference-picture state are Spirula's own.

    127 s of 1080p30 in 10 s · ~15× vs shelling out to ffmpeg

  3. rig

    kind: dual-fisheye — the second lens is the first turned 180° about the image vertical, and the baseline between them is its only translation (refined as axial).

    Insta360 X, Osmo 360 and a PortalCam all calibrate within 0.8–1.4° of that rotation; the Osmo's baseline lands within a millimetre of the axis

  4. matching

    --rig-pairs extends every verified frame pair to the other lens: cam0–cam0 brings cam1–cam1, cam0–cam1 brings cam1–cam0. A second, smaller verification pass over what the first confirmed.

    72–95% of rig-mates verify on the dual fisheyes measured; 6% on a .360's narrow views, which is why it stops there

  5. registration

    Once a member is calibrated the FRAME is what PnP estimates: every calibrated lens's 2D–3D correspondences go into one pool, one LO-RANSAC over all of it.

    a hypothesis explaining one lens and contradicting the other nine loses to one that explains the frame

  6. gauge

    The file's own sensors fix scale and up. The Osmo 360 carries an attitude stream but no raw gyro, so pre-integration runs from the attitude instead; the IMU-to-lens rotation is calibrated from the reconstruction itself.

    gravity solves to 9.82 m/s² within 0.3° of up on a 118 s walk; the whole sensor fit takes 0.3 s

  7. record

    sparse/N/gauge.txt states oriented and metric and which source settled each. A gauge a sensor settled is never overwritten by the guess it replaced.

    scale and orientation uncertainties are printed and never gated on — measured against GPS they under-state the real error 3.9–4.5×

The whole thing hangs on treating the two lenses as a rig with known geometry rather than as two unrelated cameras. kind: dual-fisheye says the first two members are back-to-back lenses of one camera: the second turned 180° about the image vertical, the baseline between them its only translation. The repository reports how well that nominal geometry holds — Insta360 X, DJI Osmo 360 and a PortalCam's two fisheyes "all calibrate within 0.8–1.4 degrees of that rotation, so it is refined; the Osmo and the PortalCam, measured against something metric, put the baseline within a millimetre of the axis" — and then uses it from the seed pair on, instead of waiting for each lens to register independently.

Once a member is calibrated, registration changes shape: "a candidate whose frame has no lens placed yet brings the whole frame, and the frame — not a lens — is what the PnP estimates: every calibrated member's 2D–3D correspondences go into one pool, and one LO-RANSAC over that pool solves for the frame's pose… scored against all the members, so a hypothesis that explains one lens and contradicts the other nine loses to one that explains the frame."

The rig-mate extension is the part with the most useful negative result. Extending a verified pair to the other lens verifies 72–95% of the time on dual fisheyes and 6% on a .360's ten narrow views — so it is enabled for the first and stopped for the second, with the measurement printed rather than a heuristic asserted.

And the metric gauge is where the Osmo 360 gets special-cased by its own deficiency rather than by name. Scale and up normally come from the accelerometer through pre-integration and from the GPS log. What is missing is refused by its own uncertainty rather than by a rule: "a camera that only pans gets up and no scale, a stale phone fix gets no GPS, a file with an attitude stream but no raw gyro (the Osmo 360) pre-integrates from the attitude instead." The check that it worked is gravity: 9.82 m/s² within 0.3° of up on a 118-second walk, and the whole fit takes 0.3 s.

Then the uncertainties get printed and not enforced, for a reason worth quoting at every team that has ever shipped a quality gate:

What I would want before putting it in a pipeline

The gap is comparison. The SfM README's own unstarted list names it twice: "what is left is a scored comparison of the three frontends on a public dataset — nothing here says which to reach for", and, separately, "parity benchmarking on ETH3D / IMC." So the port is verified against onnxruntime and COLMAP for agreement, which is the harder and more useful check, but nothing says whether the finished reconstruction is as good as COLMAP's on a dataset both have seen.

The support surface is also one person's. The README says so — "developed and maintained almost entirely by one person" — and the git history says the same: 50 commits in the public tree, the most recent three on the day before I wrote this, all titled "render: misc UI/UX fixes". Against that, 471,999 lines of C++, Slang and CUDA across src/, of which roughly 286,000 is hand-written once the generated kernel instantiations and the translation catalogues are taken out.

harry7557558/spirula-studio@ae779a0 · snapshot 2026-09-22
tracked files
1,353
license
GPL-3.0
branch
master
tests
79 files
source
24.0 MB
commit date
2026-09-22
source by language
CUDA9.7 MB(433)C++6.7 MB(322)C6.7 MB(327)Python448.9 kB(39)JavaScript352.4 kB(18)HTML69.8 kB(3)Shell35.0 kB(11)

by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded

local clone, 2026-09-22 at ae779a0 branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow, testFileCount

shallow clone: counts describe the pinned tree, not the history

The Spirula Studio desktop application. A dark ImGui interface with a left panel of training parameters and a large viewport showing a Gaussian-splat reconstruction of an outdoor scene, with a training progress readout and camera frustums drawn over the splats.
The GUI. The same TrainerCore drives this and `spirula train` on the command line; the dataset parsers, the viewer server and the training driver were each once implemented twice, in Python and C++, and the Python halves were deleted after a parity gate proved the pairs agreed. (Spirula Studio, assets/screenshot.png; GPLv3, licence committed beside the image.)

The other thing I would want is a licence read before adopting. Spirula Studio is GPLv3, not Apache or MIT, which is a different conversation from most of the splatting ecosystem — gsplat is Apache-2.0, nerfstudio is Apache-2.0. If you are shipping a product, that binary is a subprocess boundary you have to think about, and -DSS_ENABLE_PATENTED=ON is a second one.

None of which changes the thing I keep coming back to. The reason a splat pipeline is painful in production is that it is six stacks pretending to be one. This is one stack. Somebody sat down and wrote the SfM, the inference runtime, the video decoders and the segmentation model as compute shaders, checked each against the thing it replaced to six decimal places, and then refused the 30% speedup that would have made the result irreproducible.

What would change my mind

5 claims above, and what would falsify each

  1. Spirula Studio wraps almost nothing: its only external build-time fetches are glfw and imgui, and the components it replaced — COLMAP, PyTorch, ffmpeg, onnxruntime — were rewritten rather than vendored.

    Checked by grepping FetchContent_Declare and GIT_REPOSITORY across CMakeLists.txt and cmake/*.cmake at the tip of the public tree, and by reading the six module READMEs. A vendored third-party tree under a path I did not search, a submodule, or a runtime dlopen of a system library doing the real work would overturn it. Note the scope: this is a claim about code, not about weights — the checkpoints are all somebody else's and the project says so.

  2. Keeping bundle adjustment in fp64 buys bit-reproducibility, and fp32 costs 2.7 points of mean AUC@10 with a five-point spread across identical runs.

    Three runs of one 379-image capture, reported by the project, not by me. Three runs is thin. Run the same capture ten times at each scalar on a different GPU and vendor: if fp32's spread collapses, the atomic-ordering story is specific to one driver's scheduling and the default is over-cautious. If fp64 ever varies at all, the reproducibility claim is wrong outright.

  3. The dual-fisheye rig path is real 360 support — the two lenses are reconstructed as a rig with known relative geometry, not undistorted into pinhole faces first.

    Read from src/sfm/README.md and src/sfm/core/Rig.h's documentation, plus the .insv/.OSV multi-track handling in DatasetPrep.cpp. If the frames are in fact resampled to pinhole faces before matching — app/GeometryWarp.h does exactly that for the depth models, so the machinery exists — then the rig is operating on rectified crops and the distinction I am drawing is thinner than I claim. Running spirula sfm auto on a real .OSV with --rig logging on would settle it in one run.

  4. Nothing establishes that Spirula Studio's finished reconstructions are as accurate as COLMAP's; the verification is parity of components, not of results.

    An absence claim, and the project asserts it too — item 17 of its own unstarted list is "parity benchmarking on ETH3D / IMC", and item 15 says "nothing here says which [frontend] to reach for". A published ETH3D or IMC table, from the author or anyone else, kills this immediately, and I would like it to.

  5. The hero animation's captions — 5,374 images in 25 minutes on an RTX 5070 Laptop GPU, 10 M SH3 Gaussians in 8 GB — are the project's claims and are unverified here.

    This one is trivially falsifiable in the right direction: anyone with the binary and one of those captures can reproduce or fail to reproduce it. I am flagging it because the video in this article is evidence of what the software looks like running, not evidence that the numbers burned into its captions are right, and those two are easy to conflate.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Spirula Studio deleted its dependencies instead of wrapping them", ai.thesatyajit.com, September 2026.

bibtex
@misc{ghana2026spirulastudio,
  author = {Satyajit Ghana},
  title  = {Spirula Studio deleted its dependencies instead of wrapping them},
  url    = {https://ai.thesatyajit.com/articles/spirula-studio},
  year   = {2026}
}
share