Running OpenDroneMap in Docker for City Blocks
This page runs OpenDroneMap in Docker over a city block or a district — laying out the project so a run is reproducible, screening blurred images before committing compute, choosing quality settings that match the ground sampling distance, using split-merge when the dataset outgrows one machine, and verifying the outputs’ CRS and point counts against EPSG:25832+7837.
Why you hit this
A photogrammetry run is hours of compute that either produces twin-ready geometry or a plausible-looking failure, and the difference is decided by inputs and settings before it starts. Running the container by hand also makes the result irreproducible: nobody remembers which flags produced last quarter’s mesh. Putting the whole invocation in code, with the screening and verification around it, turns the run into a pipeline stage that can be repeated when the imagery is reflown. The theory it rests on is in photogrammetry processing pipelines.
Prerequisites
- Docker with the
opendronemap/odmimage pulled, and enough RAM for the dataset: roughly 1 GB per 10 images at high quality, so 32 GB for 300 images. - Python 3.10+ with
numpy>=1.24,pillow>=10,laspy>=2.5,rasterio>=1.3. - Imagery in one folder, one camera model, with EXIF positions; a
gcp_list.txtif control points are used. - Local SSD for the project directory. Photogrammetry writes tens of gigabytes of intermediates and a network share will dominate the run time.
Step-by-Step
1. Lay the project out the way the container expects
from pathlib import Path
import shutil
PROJECT = Path("/data/odm/block_09")
(PROJECT / "images").mkdir(parents=True, exist_ok=True)
for src in sorted(Path("/deliveries/flight_2026_09/images").glob("*.JPG")):
dst = PROJECT / "images" / src.name
if not dst.exists():
shutil.copy2(src, dst)
shutil.copy2("/deliveries/flight_2026_09/gcp_list.txt", PROJECT / "gcp_list.txt")
print(f"{len(list((PROJECT / 'images').glob('*.JPG')))} images staged in {PROJECT}")
The container takes a datasets directory and a project name inside it, so the mount point and the project name have to agree — this is the single most common reason a run reports “no images found”. Copying rather than symlinking matters too: a symlink into a path that is not mounted inside the container resolves to nothing.
2. Screen for blur and exposure before spending the compute
import numpy as np
from PIL import Image
def sharpness(path, max_side=1200):
img = Image.open(path).convert("L")
img.thumbnail((max_side, max_side))
a = np.asarray(img, dtype=np.float64)
# Laplacian via a 4-neighbour kernel, variance as the focus measure
lap = (-4 * a[1:-1, 1:-1] + a[:-2, 1:-1] + a[2:, 1:-1] + a[1:-1, :-2] + a[1:-1, 2:])
return float(lap.var()), float(a.mean())
scores = {p.name: sharpness(p) for p in sorted((PROJECT / "images").glob("*.JPG"))}
vals = np.array([s for s, _ in scores.values()])
threshold = np.percentile(vals, 5)
suspect = [n for n, (s, m) in scores.items() if s < threshold or m < 40 or m > 215]
print(f"sharpness: median {np.median(vals):.0f}, p5 {threshold:.0f}")
print(f"{len(suspect)} images flagged: {suspect[:8]}")
The threshold is relative to the dataset, not absolute: a sharpness score depends on the scene’s texture as much as on focus, so the fifth percentile of this flight is a far better cut-off than a number carried over from another project. Flagged images go to a human, because a genuinely blurred image should be removed while an image of a featureless roof scores low and is fine.
3. Run the container with explicit settings
import subprocess
def run_odm(project_root, project_name, extra=()):
cmd = [
"docker", "run", "--rm",
"-v", f"{project_root}:/datasets",
"opendronemap/odm",
"--project-path", "/datasets", project_name,
"--feature-quality", "high",
"--pc-quality", "high",
"--pc-las",
"--dsm", "--dtm",
"--orthophoto-resolution", "2", # cm per pixel
"--gcp", f"/datasets/{project_name}/gcp_list.txt",
"--use-3dmesh", # a full 3D mesh, not a 2.5D surface
"--mesh-size", "300000",
"--max-concurrency", "14",
*extra,
]
print(" ".join(cmd))
subprocess.run(cmd, check=True)
run_odm("/data/odm", "block_09")
Four of those flags decide the character of the output. --pc-quality high sets the dense-matching resolution, which is the main compute-versus-detail dial. --use-3dmesh produces a true 3D surface rather than a 2.5D height field, which is essential when facades matter and unnecessary for bare terrain. --orthophoto-resolution should be close to the GSD — asking for 1 cm from a 2 cm GSD flight invents nothing and doubles the file. --mesh-size caps triangles before any decimation the twin pipeline applies later.
For iteration, --rerun-from restarts at a named stage instead of from scratch: --rerun-from odm_meshing after changing mesh settings saves the hours of dense matching.
4. Split large areas into submodels
A district that will not fit in memory is processed as overlapping submodels and merged.
run_odm("/data/odm", "district_north", extra=(
"--split", "400", # target images per submodel
"--split-overlap", "120", # metres of overlap between submodels
))
Split-merge reconstructs each submodel independently, aligns them using the overlap and the control points, and merges the point clouds, orthophotos and surface models. The overlap has to be generous enough to contain shared control points and plenty of common texture; 100–150 m works for typical urban flights at 80–120 m altitude. Too small an overlap produces visible steps between submodels in both geometry and colour.
5. Verify the outputs before accepting the run
import json
import laspy
import rasterio
OUT = PROJECT
expected = {
"cloud": OUT / "odm_georeferencing" / "odm_georeferenced_model.laz",
"mesh": OUT / "odm_texturing" / "odm_textured_model_geo.obj",
"ortho": OUT / "odm_orthophoto" / "odm_orthophoto.tif",
"dsm": OUT / "odm_dem" / "dsm.tif",
"dtm": OUT / "odm_dem" / "dtm.tif",
}
missing = {k: str(p) for k, p in expected.items() if not p.exists()}
assert not missing, f"run did not produce: {missing}"
with laspy.open(expected["cloud"]) as f:
h = f.header
crs = h.parse_crs()
print(f"cloud: {h.point_count:,} points, CRS {crs.to_epsg() if crs else None}, "
f"z range {h.mins[2]:.1f}–{h.maxs[2]:.1f} m")
with rasterio.open(expected["ortho"]) as o:
print(f"ortho: {o.width}×{o.height}, {o.res[0] * 100:.1f} cm/px, CRS {o.crs.to_epsg()}")
stats_path = OUT / "opensfm" / "stats" / "stats.json"
if stats_path.exists():
stats = json.loads(stats_path.read_text())
recon = stats.get("reconstruction_statistics", {})
print(f"images reconstructed: {recon.get('reconstructed_images')} of {recon.get('initial_shots')}, "
f"mean reprojection error {recon.get('reprojection_error_normalized', 'n/a')}")
Asserting the outputs exist is not pedantry: OpenDroneMap completes with a zero exit status after skipping stages whose inputs were missing, so a run that produced no DSM looks successful from the outside. The point count, the CRS and the z range together catch the georeferencing failures — an unset CRS, or ellipsoidal heights where orthometric were expected.
Expected Output & Verification
412 images staged in /data/odm/block_09
sharpness: median 1184, p5 402
19 images flagged: ['DJI_0207.JPG', 'DJI_0208.JPG', 'DJI_0209.JPG', 'DJI_0341.JPG']
cloud: 78,412,006 points, CRS 25832, z range 498.2–556.4 m
ortho: 18420×14904, 2.0 cm/px, CRS 25832
images reconstructed: 412 of 412, mean reprojection error 0.42
Three of those lines are the acceptance test. Every image reconstructed means no region was dropped. A reprojection error under about 1.5 px means the solution is internally consistent. And the z range has to match the site: a 58 m spread over a block with 20 m buildings on flat ground means either noise above the site — birds, or matched cloud — or a control point with a typo in its height.
Then check accuracy against the points held back from the adjustment:
import numpy as np
from pyproj import Transformer
check = np.loadtxt("check_points.csv", delimiter=",", skiprows=1) # E, N, H in EPSG:25832+7837
with rasterio.open(expected["dsm"]) as dsm:
sampled = np.array([v[0] for v in dsm.sample(check[:, :2])])
resid = sampled - check[:, 2]
print(f"vertical residuals at check points (m): {np.round(resid, 3)}")
print(f"RMSEz {np.sqrt(np.mean(resid ** 2)):.3f} m")
Performance Notes
- Dense matching dominates and scales with pixels. Halving the working resolution roughly quarters its cost, which is what the quality settings do internally.
--max-concurrencyshould leave headroom. Setting it to the core count starves the process that feeds workers and can push a machine into swap, which is far slower than a lower concurrency.- Keep intermediates on local NVMe. A run writes and re-reads tens of gigabytes; network storage can double the wall clock.
- Use
--rerun-fromwhile tuning meshing or texturing, and only re-run from scratch when matching settings change. - Split-merge is for memory, not speed. It adds alignment work, so use it when a single model will not fit rather than as a default.
Common Errors
No images found although the folder is full. The mount point and --project-path disagree, or the images are one directory deeper than the container expects. The layout is <mounted>/<project>/images.
The run stops after the sparse reconstruction. Not enough memory for dense matching; the container is killed by the OOM killer and the exit status can still be zero in some configurations. Check dmesg and reduce quality or split.
The orthophoto has holes over water. Expected: water has no stable features. Mask water bodies from the imagery, or accept the holes and fill them from another source.
The mesh has a smooth lump where a building should be. Insufficient oblique coverage on that facade, so MVS had only near-nadir views. Re-fly with a 45° pass; no setting recovers geometry that was never observed.
Outputs are in the wrong place after a split-merge run. Merged products live in the parent project directory while submodels keep their own; scripts that hard-code the single-model paths silently read a submodel. Resolve output paths after checking whether submodels/ exists.
Frequently Asked Questions
Should I use the ODM container or a native install?
The container, for reproducibility: the toolchain has many native dependencies, and pinning an image digest makes a run repeatable a year later. Pin the digest rather than latest, and record it with the outputs.
How do I process repeat flights of the same site consistently?
Same container digest, same settings, same control points, and the same target CRS — then differences between epochs are real change rather than processing change. That is the precondition for change detection between LiDAR scan epochs working on photogrammetric data.
Can the textured mesh go straight into 3D Tiles?
It has to be decimated and re-textured for streaming first — a full-resolution photogrammetric mesh is hundreds of millions of triangles and gigabytes of texture. The path is decimation, atlas packing and tiling, in that order.
Related Guides
- Preparing Ground Control Point Files — the file this run consumes
- Reading Photogrammetry Quality Reports — interpreting
stats.jsonin depth - Fusing LiDAR and Photogrammetry Point Clouds — what to do with the output alongside LiDAR
Back to Photogrammetry Processing Pipelines.