Setting Up OpenDroneMap with Python

When a drone mapping operation outgrows the OpenDroneMap (ODM) desktop GUI, the bottleneck is no longer the reconstruction algorithm — it is the orchestration around it. A scripted ODM run has to provision a container, gate the input set on metadata quality, cap memory so dense matching cannot trigger the kernel OOM killer, pin an explicit coordinate reference system, and recover cleanly when a 12-hour job dies on its last stage. This page shows UAV operators, surveying technicians, and Python GIS developers how to wrap ODM in a deterministic, resumable Python pipeline that produces survey-grade orthomosaics, DSMs, and point clouds without manual babysitting. This is the reconstruction-engine stage of core photogrammetry fundamentals for Python pipelines, where ODM is treated as one auditable step in a larger automated run rather than an interactive application.

Audience prerequisites. You should be comfortable with Python 3.10+, virtual environments, and the shell. ODM itself runs inside Docker, so reconstruction does not depend on your host Python version — but the wrapper, the metadata gate, and the CRS audit do. Plan for at least 16 GB of RAM for small blocks (under ~300 images) and 32–64 GB for corridor surveys; dense matching and meshing are the memory-hungry stages. An NVMe scratch disk materially shortens the I/O-bound densification phase. This workflow assumes ODM 3.x (the opendronemap/odm image) and GDAL 3.4+ for the output audit.

Prerequisites

Install the host-side tooling into a clean virtual environment. ODM and its native dependencies (OpenSfM, OpenMVS, PDAL) stay inside the container; only the orchestration libraries are installed locally.

Component Version Install command
Docker Engine ≥ 24.0 system package manager / docker.io
opendronemap/odm image 3.x docker pull opendronemap/odm:latest
pyodm (NodeODM client) ≥ 1.5 pip install "pyodm>=1.5"
psutil (RAM probing) ≥ 5.9 pip install "psutil>=5.9"
Pillow (EXIF gate) ≥ 10.0 pip install "Pillow>=10"
GDAL (osgeo bindings) ≥ 3.4 conda install -c conda-forge gdal>=3.4

Confirm the container can see your CPU and memory budget before processing survey data: docker run --rm opendronemap/odm --help should print the argument reference, and docker info --format '{{.MemTotal}}' should report the memory the daemon is allowed to allocate. On Docker Desktop, the VM memory limit — not host RAM — is what bounds an ODM run.

Conceptual architecture

A scripted ODM pipeline is a linear sequence of gates, not a single black-box call. Imagery is validated and standardized first, because every defect that reaches reconstruction (missing GPS, mixed bit depth, inadequate overlap) costs hours before it surfaces as a failure. The validated set is then handed to ODM through one controlled invocation — either pyodm against a NodeODM instance or a direct docker run — with memory flags chosen from the live RAM budget. Finally, the georeferenced outputs are audited for CRS correctness before they are promoted to delivery storage. Each gate either passes its artifacts downstream or aborts with a diagnostic; nothing reaches the next stage implicitly.

Two upstream stages feed this one. The directory conventions from structuring drone imagery for batch processing decide how flight blocks map to the images/ folder ODM consumes, and the flight overlap validation routine must run before submission so that sparse-coverage blocks are rejected rather than reconstructed into a holed point cloud.

Scripted OpenDroneMap pipeline: gates, the reconstruction container, and the retry loop A top-down flowchart. Drone images are validated and standardized, gated on flight overlap, reconstructed in a memory-capped ODM container whose OOM or timeout failures are routed through a conservative-flag retry loop, and finally audited for a consistent coordinate reference system before delivery. overlap OK sparse exit 137 / timeout retry Raw drone images on disk · images/ per flight block EXIF / GPS gate validate metadata · standardize to TIFF Overlap sufficient? pre-submission check Reject block · abort no holed reconstruction Memory-aware ODM run docker run / pyodm · --memory cap OpenSfM → OpenMVS → mesh Conservative retry --split · low feature quality · backoff ODM outputs orthophoto.tif · dsm.tif · point cloud validate_and_assign_crs() confirm EPSG · reproject if needed Certified deliverable

1. Provision the Docker runtime and verify the image

ODM runs most predictably as a Docker container that mounts a project directory containing an images/ subfolder. Pin the image tag rather than tracking latest so a reconstruction is reproducible across machines, and confirm the daemon is reachable before any job is built.

import subprocess
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")

ODM_IMAGE = "opendronemap/odm:3.5.4"  # pin the tag for reproducible runs

def ensure_odm_image(image: str = ODM_IMAGE) -> None:
    """Verify the Docker daemon is up and the pinned ODM image is present."""
    # `docker info` returns non-zero if the daemon is unreachable.
    info = subprocess.run(["docker", "info"], capture_output=True, text=True)
    if info.returncode != 0:
        raise RuntimeError("Docker daemon is not reachable. Start Docker before processing.")

    have = subprocess.run(
        ["docker", "image", "inspect", image],
        capture_output=True, text=True,
    )
    if have.returncode != 0:
        logging.info("Image %s not found locally; pulling...", image)
        pull = subprocess.run(["docker", "pull", image], capture_output=True, text=True)
        if pull.returncode != 0:
            raise RuntimeError(f"Failed to pull {image}: {pull.stderr}")
    logging.info("ODM image %s is ready.", image)

2. Validate and standardize the input image set

Raw drone imagery rarely meets photogrammetric ingestion standards untouched. Corrupted or absent EXIF metadata silently degrades georeferencing, so a metadata gate is mandatory before reconstruction — the full check is covered in validating EXIF GPS data before processing. Standardizing formats removes codec-related stalls during feature extraction; converting mixed JPEG/PNG/RAW inputs to uncompressed TIFF gives consistent bit depth and color space, as the drone-image-to-TIFF conversion script shows in depth.

import logging
from pathlib import Path
from PIL import Image, ExifTags

def validate_and_standardize_images(input_dir: Path, output_dir: Path,
                                    min_images: int = 50) -> int:
    """Reject images without GPS EXIF and write the rest as uncompressed TIFF."""
    output_dir.mkdir(parents=True, exist_ok=True)
    valid = 0

    for img_path in sorted(input_dir.glob("*.[jJ][pP][gG]")):
        try:
            with Image.open(img_path) as img:
                exif = img.getexif()
                if not exif:
                    logging.warning("No EXIF in %s; skipping.", img_path.name)
                    continue

                # The top-level GPS tag (0x8825) is only an offset; resolve the
                # GPS sub-IFD explicitly or the latitude lookup below fails.
                gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo)
                if not gps_ifd or ExifTags.GPS.GPSLatitude not in gps_ifd:
                    logging.warning("Missing GPS metadata in %s; skipping.", img_path.name)
                    continue

                tiff_path = output_dir / f"{img_path.stem}.tif"
                img.save(tiff_path, format="TIFF", compression=None,
                         exif=img.info.get("exif"))
                valid += 1
        except Exception as exc:  # noqa: BLE001 — log and continue the batch
            logging.error("Failed on %s: %s", img_path.name, exc)
            continue

    if valid < min_images:
        raise ValueError(f"Insufficient valid images ({valid}/{min_images}); aborting.")
    logging.info("Standardized %d images into %s", valid, output_dir)
    return valid

3. Build a memory-aware ODM invocation

The single most common cause of a failed unattended run is an out-of-memory kill during dense matching or meshing. A robust wrapper reads the live RAM budget and injects conservative flags — submodel splitting, lower feature quality — before the container ever starts, rather than discovering the limit by crashing. Always set --memory and --memory-swap to the same value so the container cannot lean on swap, which turns an OOM crash into hours of thrashing.

import psutil
import subprocess
import logging
from pathlib import Path

def run_odm(project_dir: Path, image: str = ODM_IMAGE,
            timeout_s: int = 86_400) -> None:
    """Run ODM via Docker with RAM-aware flag injection and a hard timeout."""
    gib = 1024 ** 3
    available = psutil.virtual_memory().available

    flags = [
        "docker", "run", "--rm",
        "--memory=24g", "--memory-swap=24g",  # equal values disable swap
        "-v", f"{project_dir}/images:/images:ro",
        "-v", f"{project_dir}/odm:/odm",
        image, "--project-path", "/odm",
    ]

    if available < 16 * gib:
        logging.warning("Low RAM (%.1f GiB); enabling conservative flags.", available / gib)
        flags += ["--feature-quality", "low",
                  "--split", "500", "--split-overlap", "100"]
    else:
        flags += ["--feature-quality", "high", "--dsm", "--orthophoto"]

    logging.info("Launching ODM container...")
    try:
        result = subprocess.run(flags, capture_output=True, text=True, timeout=timeout_s)
    except subprocess.TimeoutExpired:
        logging.error("ODM exceeded the %d s timeout; terminating.", timeout_s)
        raise

    if result.returncode != 0:
        # Exit 137 is the OOM killer; 1 is usually alignment / bundle failure.
        logging.error("ODM exited %d: %s", result.returncode, result.stderr[-2000:])
        raise RuntimeError("ODM processing failed; inspect the tail of stderr.")
    logging.info("ODM processing completed.")

For a long-running NodeODM service you can swap the docker run call for pyodm, which gives structured progress polling instead of a blocking subprocess:

from pyodm import Node

def submit_via_nodeodm(image_paths: list[str], host: str = "localhost",
                       port: int = 3000) -> str:
    """Submit a task to a running NodeODM instance and block until done."""
    node = Node(host, port)
    task = node.create_task(
        image_paths,
        options={"feature-quality": "high", "dsm": True, "orthophoto-resolution": 2.0},
    )
    task.wait_for_completion()       # raises on server-side failure
    task.download_assets("./odm_out")
    return task.info().uuid

4. Enforce the coordinate reference system on outputs

ODM defaults to WGS84 with the EGM96 geoid unless told otherwise, which introduces a vertical datum shift the moment outputs are merged with a local survey grid. Validate the CRS of every georeferenced raster ODM emits and reproject explicitly — never let a downstream tool guess. The detailed enforcement and audit patterns live in managing coordinate reference systems in GDAL; the routine below is the minimal guard that belongs at the end of an ODM run.

import logging
from pathlib import Path
from osgeo import gdal, osr

gdal.UseExceptions()

def validate_and_assign_crs(geo_tiff: Path, target_epsg: int = 32610) -> Path:
    """Confirm a raster's CRS and reproject to the target EPSG if it differs."""
    ds = gdal.Open(str(geo_tiff))
    if ds is None:
        raise RuntimeError(f"Failed to open raster: {geo_tiff}")
    try:
        srs = osr.SpatialReference()
        srs.ImportFromWkt(ds.GetProjection())
        # AutoIdentifyEPSG populates the AUTHORITY node; without it
        # GetAuthorityCode often returns None and reprojection is skipped.
        srs.AutoIdentifyEPSG()
        current = srs.GetAuthorityCode(None)
        if current is None:
            raise RuntimeError(f"Could not identify source CRS for {geo_tiff}.")

        if int(current) == target_epsg:
            logging.info("%s already in EPSG:%d.", geo_tiff.name, target_epsg)
            return geo_tiff

        out_path = geo_tiff.parent / f"{geo_tiff.stem}_epsg{target_epsg}.tif"
        gdal.Warp(
            str(out_path), ds,
            options=gdal.WarpOptions(
                dstSRS=f"EPSG:{target_epsg}", resampleAlg="cubic",
                format="GTiff", creationOptions=["COMPRESS=LZW", "BIGTIFF=YES"],
            ),
        )
        logging.info("Reprojected %s → EPSG:%d", geo_tiff.name, target_epsg)
        return out_path
    finally:
        ds = None  # release the file descriptor even if Warp raises

5. Assemble a resumable production pipeline

A production run chains the gates above into discrete, checkpointed stages so a failure on stage four does not force a re-run of stages one through three. Record a completion marker per stage, add bounded retries for the transient parts (a NodeODM submission can fail on a busy server), and only promote outputs once the CRS audit passes.

import time
import logging
from pathlib import Path

def run_pipeline(project_dir: Path, target_epsg: int = 32610) -> None:
    """End-to-end ODM run with per-stage checkpoints and a single retry."""
    raw = project_dir / "raw"
    images = project_dir / "images"
    odm_out = project_dir / "odm"

    if not (images / ".validated").exists():
        validate_and_standardize_images(raw, images)
        (images / ".validated").touch()

    if not (odm_out / "odm_orthophoto" / "odm_orthophoto.tif").exists():
        for attempt in range(2):
            try:
                ensure_odm_image()
                run_odm(project_dir)
                break
            except RuntimeError as exc:
                wait = 2 ** attempt * 30  # exponential backoff
                logging.warning("ODM attempt %d failed (%s); retry in %ds.",
                                attempt + 1, exc, wait)
                time.sleep(wait)
        else:
            raise RuntimeError("ODM failed after retries; aborting promotion.")

    ortho = odm_out / "odm_orthophoto" / "odm_orthophoto.tif"
    validate_and_assign_crs(ortho, target_epsg)
    logging.info("Pipeline complete for %s", project_dir)

Parameter deep-dive

These are the flags that most affect the quality-versus-runtime trade-off in a scripted run. Pass them after the image arguments in docker run or inside the pyodm options dict.

Parameter Type Default Valid range Effect
--feature-quality enum high ultra / high / medium / low / lowest Lower settings downsample images before feature extraction — large RAM/time savings, fewer tie points.
--split int 999999 ≥ 1 Images per submodel; splitting bounds peak memory on large blocks at the cost of merge overhead.
--split-overlap float (m) 150 ≥ 0 Buffer shared between submodels; too low causes seams, too high inflates redundant compute.
--min-num-features int 10000 1000–100000 Features kept per image; raise for low-texture terrain, lower to cut matching time.
--matcher-distance int (m) 0 (off) ≥ 0 Restricts matching to neighbors within N meters using GPS — speeds up high-overlap corridor flights.
--orthophoto-resolution float (cm/px) 5 > 0 Output ground sampling distance; finer values multiply orthophoto size and write time.
--dsm / --dtm flag off Generate the surface / terrain model; each adds a gridding pass over the dense cloud.
--pc-quality enum medium ultra / high / medium / low / lowest Dense point-cloud density; the single biggest driver of meshing memory.

Verification and output inspection

Never promote ODM outputs on exit code alone — assert that the expected artifacts exist, are readable, carry a defined CRS, and have plausible extent. A truncated orthophoto or a raster with a null projection passes the process check but fails delivery.

import logging
from pathlib import Path
from osgeo import gdal, osr

def verify_outputs(odm_out: Path, expected_epsg: int) -> None:
    """Assert ODM produced a readable, georeferenced orthophoto."""
    ortho = odm_out / "odm_orthophoto" / "odm_orthophoto.tif"
    assert ortho.exists() and ortho.stat().st_size > 0, "orthophoto missing or empty"

    ds = gdal.Open(str(ortho))
    assert ds is not None, "orthophoto is unreadable / corrupt"

    srs = osr.SpatialReference()
    srs.ImportFromWkt(ds.GetProjection())
    srs.AutoIdentifyEPSG()
    code = srs.GetAuthorityCode(None)
    assert code is not None and int(code) == expected_epsg, \
        f"orthophoto CRS is {code}, expected {expected_epsg}"

    gt = ds.GetGeoTransform()
    assert gt and abs(gt[1]) > 0, "orthophoto has a degenerate geotransform"
    logging.info("Outputs verified: %d x %d px, EPSG:%s",
                 ds.RasterXSize, ds.RasterYSize, code)
    ds = None

Troubleshooting

ODM exits with code 137 during dense matching or meshing

Exit 137 is the kernel OOM killer: the container’s working set exceeded its --memory cap. Lower --pc-quality and --feature-quality, enable --split/--split-overlap to bound peak memory per submodel, and make sure --memory and --memory-swap are equal so the run cannot silently thrash on swap. On Docker Desktop, raise the VM memory limit — the host’s free RAM is irrelevant if the VM is capped lower.

Reconstruction finishes but the orthophoto has holes or floats away from the survey grid

This is almost always an input-geometry or datum problem rather than an ODM bug. Run the overlap check before submission so sparse blocks are rejected, and confirm every image passed the GPS EXIF gate — a handful of frames with null GPS pull the bundle adjustment off true. If the geometry is sound but the model sits in the wrong place, the CRS audit in step 4 will catch the EGM96-vs-local-grid vertical shift.

pyodm.exceptions.NodeConnectionError when submitting a task

The NodeODM service is not reachable at the host/port given to Node(). Confirm the container is running and the port is published (docker run -p 3000:3000 opendronemap/nodeodm), and that no firewall blocks it. For unattended pipelines, gate submission behind the ensure_odm_image / daemon check and wrap the call in the bounded retry shown in step 5.

The run is killed by the 24-hour subprocess timeout

A corridor block at ultra quality can legitimately exceed a day. Either raise timeout_s, or cut runtime by lowering --feature-quality/--pc-quality and enabling --matcher-distance so matching only considers GPS-adjacent neighbors. Splitting the dataset and merging submodels usually finishes faster than one monolithic high-quality pass.

GetAuthorityCode() returns None on an ODM output raster

ODM wrote a valid projection WKT without an embedded EPSG authority node. Call srs.AutoIdentifyEPSG() before GetAuthorityCode(None) — without it the lookup returns None and the conditional reprojection in step 4 is silently skipped. If identification still fails, the source CRS is genuinely ambiguous and must be set explicitly rather than assumed.

Core Photogrammetry Fundamentals for Python Pipelines