Architecture¶
For a runnable introduction to the library itself, see tour.md.
Layers¶
packages/sfmkit/src/sfmkit/
core/ algorithms: arrays in, arrays out
types.py Pose, Matches, Track, Reconstruction
geometry.py two-view geometry, triangulation, projection
robust.py RANSAC estimators, every one taking a seed
tracks.py union-find over the match graph
bundle.py residuals, sparsity, the bundle adjustment
bundle_schur.py the same, by our Levenberg-Marquardt and the Schur complement
reconstruct.py incremental SfM
localize.py visual localisation of a query image
changes.py homography alignment + change detection
metrics.py comparison against another reconstruction
synthetic.py scenes with exact ground truth, for tests
data/ everything that touches the filesystem
io.py run artefacts and manifests
colmap.py readers for COLMAP's text model
config.py YAML experiment configs
features.py SuperPoint + LightGlue (the only torch dependency)
render/
viz.py figures; the only matplotlib import
apps/ the composition root
cli/ one module per subcommand
Imports may only point downwards: apps → render → data → core, and
core imports nothing but numpy and scipy.
This is checked, not documented. packages/sfmkit/pyproject.toml carries four
import-linter contracts, run by lint-imports in pre-commit and in CI:
| Contract | What it forbids |
|---|---|
| Layered architecture | any import pointing upwards |
| The core does no I/O and draws nothing | core importing matplotlib, torch, yaml, rich |
| Only the render layer imports matplotlib | core or data importing matplotlib |
| Only the data layer imports torch | core, render or apps importing torch directly |
| Only the data layer imports pycolmap | core, render or apps importing pycolmap directly |
Without enforcement the rule holds only for as long as everyone remembers it,
and one import matplotlib in core/geometry.py silently ends the library's
ability to be tested without a display. That is what makes the test suite
possible: pytest never reads a photograph, never writes a file, and never
opens a plot.
Stage contract¶
A run lives in projects/<name>/runs/<config>/. Each stage writes its own subfolder,
with a manifest.json holding the config, the git commit and package versions,
and reads only from the dataset and from earlier stages' subfolders.
calibrate chessboard photos, or a K -> calibrate/K.txt
match scene photos -> match/*.npz
verify match/ -> verify/*.npz
reconstruct verify/ + calibrate/ -> reconstruct/reconstruction.npz
localize reconstruct/ + verify/ -> localize/query_pose.npz
colmap a model, or COLMAP run on -> colmap/{cameras,images,points3D}.txt, database.db
the photos or on verify/
dense colmap/ + the photos -> dense/fused.ply (optional, CUDA only)
evaluate reconstruct/ + localize/ -> evaluate/evaluation.json
+ colmap/ + calibrate/
changes scene photos + verify/ -> changes/*.png
figures reconstruct/ + colmap/ -> figures/*.png
colmap depends on nothing but the dataset, so it could run alongside the
matching chain. Stage order lives in apps/cli/run.py, not in module names, so
inserting a stage renames nothing.
Conventions¶
Fixed once, and enforced at module boundaries:
- Row-major points. 3D points are
(N, 3), image points(N, 2). Functions that need column-major transpose locally and never return it. - World-to-camera poses, as in COLMAP:
x_cam = R @ x_world + t. - Randomness is a parameter. No implicit global RNG anywhere in the library.
- No I/O in the library. Callers pass arrays.
Each of these exists because its absence caused a concrete problem in the first
version of this project: shapes were mixed freely and .T appeared everywhere;
pose conventions varied between functions; two RANSACs drew on an unseeded
global RNG so results could not be reproduced; and geometry functions loaded
their own inputs from hardcoded relative paths, which is why stages could not be
re-run or tested.
Gauge¶
A reconstruction from images alone is determined only up to a similarity transform. The gauge is fixed by holding the reference camera at the identity and the first camera's baseline at unit length, parameterised by two polar angles so the constraint is exact rather than penalised.
Comparisons against another reconstruction therefore align to a common reference
camera and resolve scale from the most distant shared camera before reporting
any error. sfmkit.metrics.compare_poses does this; comparing raw translations
without it is meaningless.