Sparse and Dense Reconstruction with COLMAP

This page drives COLMAP through a full reconstruction from Python — feature extraction with a shared camera model, a matching strategy chosen for the capture pattern, incremental mapping, undistortion, patch-match stereo and fusion — and reads the resulting statistics to decide whether the sparse model is worth densifying, before the result is georeferenced into EPSG:25832+7837.

Why you hit this

OpenDroneMap is the right tool for a nadir survey flight. COLMAP is the right tool when the capture is unusual: a terrestrial walk-around of a bridge pier, an interior, a hand-held sequence of a facade, imagery from several cameras, or any case where you need to intervene between stages. Its cost is that nothing is chosen for you — matching strategy, camera model, stereo parameters and fusion thresholds are all explicit, and the defaults are tuned for benchmark datasets rather than for buildings. The pipeline context is in photogrammetry processing pipelines.

Prerequisites

  • COLMAP 3.9+ with CUDA for dense reconstruction; the CPU path works for sparse but is impractical for stereo.
  • Python 3.10+ with numpy>=1.24, open3d>=0.18, pycolmap optional for reading models directly.
  • Imagery in one folder. Mixed cameras are supported but need separate camera models, which changes the extraction call.
  • Disk: dense stereo writes a depth and normal map per image, typically 20–60 MB each, so 400 images need tens of gigabytes.

Step-by-Step

1. Set up the workspace and extract features

python
import subprocess
from pathlib import Path

WS = Path("/data/colmap/pier_north")
IMAGES = WS / "images"
DB = WS / "database.db"
SPARSE = WS / "sparse"
DENSE = WS / "dense"
for d in (SPARSE, DENSE):
    d.mkdir(parents=True, exist_ok=True)

def colmap(*args):
    subprocess.run(["colmap", *map(str, args)], check=True)

colmap("feature_extractor",
       "--database_path", DB,
       "--image_path", IMAGES,
       "--ImageReader.single_camera", 1,          # one camera model for the whole set
       "--ImageReader.camera_model", "OPENCV",    # 2 focal lengths + 4 distortion terms
       "--SiftExtraction.max_image_size", 3200,
       "--SiftExtraction.estimate_affine_shape", 1,
       "--SiftExtraction.domain_size_pooling", 1)

single_camera is the flag that decides whether the solver estimates one interior orientation or one per image. For a single physical camera at a fixed zoom, sharing the model is both more accurate and far more stable; for a dataset of photographs from several phones, sharing it is wrong and produces a warped reconstruction. OPENCV is a good default camera model for consumer and drone cameras; a fisheye lens needs OPENCV_FISHEYE and a pinhole rig with known calibration can use PINHOLE with fixed parameters.

The two SIFT options increase robustness on repetitive and oblique material at a cost in extraction time — worth it for facades, unnecessary for open terrain.

2. Match with the strategy the capture pattern implies

python
n_images = len(list(IMAGES.glob("*.jpg"))) + len(list(IMAGES.glob("*.JPG")))

if n_images < 400:
    colmap("exhaustive_matcher", "--database_path", DB,
           "--SiftMatching.guided_matching", 1)
else:
    # a walked or flown sequence: match each image against its neighbours in capture order
    colmap("sequential_matcher", "--database_path", DB,
           "--SequentialMatching.overlap", 12,
           "--SequentialMatching.loop_detection", 1,
           "--SequentialMatching.vocab_tree_path", "/opt/colmap/vocab_tree_flickr100K_words32K.bin")
print(f"matched {n_images} images")

Matching is quadratic in image count, so the strategy is the difference between minutes and days. Exhaustive matching compares every pair and is the most thorough; below a few hundred images it is the right choice. Sequential matching exploits capture order and only compares each image with its neighbours, with loop detection through a vocabulary tree to catch the moment the path returns to where it started — which is what stops a walk around a building from reconstructing as a spiral.

Matching strategies and their cost Three matching patterns for twelve images arranged in a loop. Exhaustive matching connects every pair, sixty-six pairs in total. Sequential matching connects each image to its neighbours within an overlap window, about thirty pairs. Sequential matching with loop detection adds the few pairs that close the loop, which is what keeps a walk-around from drifting. exhaustive sequential sequential + loop every pair · O(n²) neighbours only neighbours + closure Without loop closure, a walk that returns to its start reconstructs as an open spiral.
The matching graph is what the bundle adjustment has to work with; a strategy that omits the loop-closing pairs cannot be rescued later.

3. Map incrementally and read the statistics

python
colmap("mapper",
       "--database_path", DB,
       "--image_path", IMAGES,
       "--output_path", SPARSE,
       "--Mapper.ba_refine_principal_point", 1,
       "--Mapper.min_num_matches", 30)

models = sorted(p for p in SPARSE.iterdir() if p.is_dir())
print(f"{len(models)} model(s): {[m.name for m in models]}")
out = subprocess.run(["colmap", "model_analyzer", "--path", str(models[0])],
                     capture_output=True, text=True)
print(out.stdout)

The mapper can produce more than one model, and that is the most important thing to check before going further. Two models mean the matching graph was disconnected — two sets of images with nothing in common — and densifying either one reconstructs half the subject. The usual cause is a gap in the capture or a matching strategy that missed the connection.

model_analyzer reports the number of registered images, the number of points, the mean track length and the mean reprojection error. A mean track length above about three means points are seen in enough images to be well determined; a value near two means the geometry rests on pairs and will be weak.

Camera models and when each is right A table of COLMAP camera models. PINHOLE has four parameters and suits a pre-calibrated rig. SIMPLE_RADIAL has four and suits a well-behaved consumer lens. OPENCV has eight and is the default choice for drone and consumer cameras. OPENCV_FISHEYE suits fisheye lenses. Sharing one model across all images is correct for a single camera and wrong for a mixed set. modelparameterswhen it is right PINHOLE4a rig calibrated beforehand, held fixed SIMPLE_RADIAL4a well-behaved consumer lens, few images OPENCV8the default for drone and consumer cameras OPENCV_FISHEYE8fisheye and action cameras More parameters fit better and need more, better-distributed observations to be determined.
The model choice is a trade between flexibility and stability; eight parameters on forty images of a flat wall will fit something meaningless.

4. Undistort, then run patch-match stereo

python
colmap("image_undistorter",
       "--image_path", IMAGES,
       "--input_path", models[0],
       "--output_path", DENSE,
       "--output_type", "COLMAP",
       "--max_image_size", 2400)

colmap("patch_match_stereo",
       "--workspace_path", DENSE,
       "--workspace_format", "COLMAP",
       "--PatchMatchStereo.geom_consistency", 1,
       "--PatchMatchStereo.filter", 1,
       "--PatchMatchStereo.num_samples", 15,
       "--PatchMatchStereo.window_radius", 5)

Undistortion rewrites the images as ideal pinhole views, which is what the stereo stage requires. max_image_size here is the dial that dominates dense reconstruction time: halving it quarters the stereo cost, and 2400 px is a sensible compromise for building-scale work whose GSD is a few centimetres.

Geometric consistency makes each depth map agree with its neighbours’ before a pixel is kept. It roughly doubles the stereo time and removes most of the speckle that otherwise reaches the fused cloud, which saves more time in filtering later than it costs here.

5. Fuse, and control the noise at the fusion stage

python
colmap("stereo_fusion",
       "--workspace_path", DENSE,
       "--workspace_format", "COLMAP",
       "--input_type", "geometric",
       "--output_path", DENSE / "fused.ply",
       "--StereoFusion.min_num_pixels", 5,
       "--StereoFusion.max_reproj_error", 2.0,
       "--StereoFusion.max_depth_error", 0.01,
       "--StereoFusion.max_normal_error", 10.0)

import open3d as o3d
pcd = o3d.io.read_point_cloud(str(DENSE / "fused.ply"))
print(f"fused cloud: {len(pcd.points):,} points, has normals: {pcd.has_normals()}, "
      f"has colours: {pcd.has_colors()}")

min_num_pixels is the most effective noise control in the whole pipeline: it requires a 3D point to be supported by that many consistent pixels across images before it is emitted. Raising it from the default to five removes the thin fog of spurious points around every surface, at the cost of thinning genuinely single-view detail such as a narrow railing.

The fused cloud carries colours and normals, which is a real advantage over LiDAR for downstream reconstruction — the normals come from the stereo geometry rather than from an estimation over neighbours, so they are reliable on thin structures where a k-nearest-neighbour estimate struggles.

Where the time goes in a COLMAP run Bars of wall-clock time for 380 images on one GPU. Feature extraction takes about eight minutes, matching about twelve with the sequential strategy, mapping about fourteen, undistortion about three, patch-match stereo about a hundred and ten, and fusion about nine. Dense stereo dominates, and its cost scales with the undistortion image size. feature extraction · 8 min sequential matching · 12 min mapper · 14 min undistortion · 3 min patch-match stereo · 110 min fusion · 9 min 380 images, one GPU, undistorted to 2400 px — halving that size quarters the red bar
Every decision that matters for run time is made before stereo starts: matching strategy, undistortion size and whether geometric consistency is enabled.

6. Hand the result over with a known scale and frame

A COLMAP reconstruction is metrically arbitrary: correct in shape, unknown in scale, position and orientation. Two routes fix that. Either supply known camera positions to the mapper so the bundle adjustment solves in the target frame, or fit a similarity transformation afterwards from control points — which is what georeferencing photogrammetric point clouds covers.

python
colmap("model_aligner",
       "--input_path", models[0],
       "--output_path", SPARSE / "aligned",
       "--ref_images_path", WS / "camera_positions.txt",   # image_name X Y Z per line
       "--ref_is_gps", 0,
       "--alignment_type", "custom",
       "--robust_alignment_max_error", 0.5)

model_aligner estimates the similarity transformation that best maps the reconstruction’s camera positions onto the supplied ones, and writes an aligned model. Passing projected coordinates with ref_is_gps 0 keeps everything in metres; the robust threshold rejects positions that disagree badly, which is what stops one bad RTK fix from rotating the model.

Expected Output & Verification

text
matched 380 images
1 model(s): ['0']
Cameras: 1
Images: 378
Registered images: 378
Points: 214,882
Observations: 1,204,338
Mean track length: 5.6047
Mean observations per image: 3186.08
Mean reprojection error: 0.58431px
fused cloud: 41,204,882 points, has normals: True, has colours: True

Read that output as an acceptance test. One model, nearly all images registered, a mean track length above three and a reprojection error under about one pixel is a healthy sparse reconstruction. Two images unregistered out of 380 is normal — usually the first and last frames of a sequence.

Then verify the dense result against something independent:

python
import numpy as np

pts = np.asarray(pcd.points)
print("extent (model units):", (pts.max(axis=0) - pts.min(axis=0)).round(2))

# after alignment: check a measured distance between two control points
a = np.array([691204.412, 5335818.221, 519.310])
b = np.array([691286.901, 5335836.978, 519.290])
print(f"control distance: {np.linalg.norm(a - b):.3f} m")

Comparing a surveyed distance with the same distance in the aligned cloud is the check that catches a scale error, which is the failure mode unique to reconstructions from imagery alone. A 1% scale error is invisible in every visual inspection and fatal for any measurement.

Performance Notes

  • Undistortion size sets the stereo bill. It is the first parameter to change when a run does not fit the available time.
  • Stereo is per image and embarrassingly parallel across GPUs; COLMAP supports multiple GPU indices, and a two-GPU machine halves the dominant stage.
  • Sequential matching for sequences, exhaustive for unordered sets. Getting this wrong either wastes hours or produces a disconnected model.
  • Cache the database. Feature extraction and matching results live in database.db; re-running the mapper with different settings costs minutes, not hours, as long as that file is kept.
  • Fuse with min_num_pixels tuned per subject: five for buildings, three for thin structures where detail matters more than noise.

Common Errors

Two or more sparse models. The matching graph is disconnected. Add loop-closure matching, or match the two groups explicitly with a custom match list.

ERROR: No images with matches found in the database. Extraction ran on a different image_path than matching, or the images are in a subdirectory. COLMAP’s paths are absolute inside the workspace and easy to get subtly wrong.

Dense stereo runs out of GPU memory. Reduce max_image_size at undistortion, or lower PatchMatchStereo.window_radius and num_samples. Both reduce quality gracefully rather than failing.

The reconstruction has correct shape and wrong size. Expected — imagery alone has no scale. Align with control or with known camera positions.

Facades reconstruct with holes where windows are. Glass has no stable features and reflects the sky. That is a capture limitation; fill it in meshing, or model windows as flat surfaces, as in ball pivoting reconstruction for building facades.

Frequently Asked Questions

Can COLMAP use ground control points directly?

Not in the image-observation sense that survey software uses. The practical routes are model_aligner with known camera positions, or aligning the finished cloud onto control points with a similarity transformation. For projects where control-in-adjustment matters, use survey-oriented software.

Is pycolmap worth using instead of the CLI?

For reading models and writing analysis, yes — it gives direct access to cameras, images and points without parsing binaries. For running the pipeline, the CLI is what the documentation and the community troubleshoot against.

How does the fused cloud compare with a LiDAR cloud of the same site?

Denser on textured surfaces, noisier at a few millimetres to centimetres, absent on glass and water, with no penetration through vegetation, and carrying true colour and reliable normals. The two are complementary, which is the subject of fusing LiDAR and photogrammetry point clouds.

Back to Photogrammetry Processing Pipelines.