PyODM vs Direct ODM CLI for Automation
When you automate OpenDroneMap (ODM) from Python you have two structurally different control paths, and the choice shapes every operational property that follows: how you read progress, how you run more than one reconstruction at once, how you recover from a mid-job failure, and how much infrastructure you have to keep alive. The first path drives ODM through PyODM, the official Python client for a running NodeODM REST service — you submit an image list, receive a task handle, and poll a structured status object over HTTP. The second path treats ODM as a black-box binary and shells out to docker run opendronemap/odm with subprocess, reading an exit code and a stream of console text. Both invoke the identical reconstruction engine (OpenSfM → OpenMVS → meshing → orthophoto); what differs is the API surface around it. This page submits the same job both ways, then compares the two on the dimensions that actually decide a production pipeline. It is a decision-stage companion to setting up OpenDroneMap with Python, which builds the resumable wrapper this page’s recommendation plugs into.
Audience and prerequisites. This guide targets Python 3.10+ on a 64-bit host running Docker Engine 24+, with 16 GB RAM for small blocks and 32–64 GB for corridor surveys. You should be comfortable with subprocess, blocking versus polling I/O, and the fact that ODM’s heavy native dependencies live inside the container, so your host Python version never touches reconstruction. Both approaches assume the imagery has already passed an ingestion gate; if EXIF, sensor, or CRS problems reach either control path they fail identically and opaquely, which is why the diagnostic methodology in troubleshooting ingestion and CRS failures belongs upstream of whichever driver you pick.
Prerequisites
Install the host-side orchestration stack into a clean virtual environment. Only PyODM and Docker are strictly required to reproduce the two paths below; tenacity is optional but makes the retry comparison concrete rather than hand-rolled.
| Library | Minimum version | Install command | Role in the comparison |
|---|---|---|---|
| Python | 3.10 | (system / pyenv) | match on task status, structural typing |
| Docker Engine | 24.0 | system package manager | Runs opendronemap/odm and opendronemap/nodeodm |
| pyodm | ≥ 1.5 | pip install "pyodm>=1.5" |
REST client for the NodeODM path |
| opendronemap/nodeodm | 3.x | docker pull opendronemap/nodeodm:latest |
Long-lived REST service PyODM talks to |
| opendronemap/odm | 3.x | docker pull opendronemap/odm:latest |
One-shot CLI image for the subprocess path |
| tenacity | ≥ 8.2 | pip install "tenacity>=8.2" |
Declarative retry/backoff for the resilience section |
Confirm both images are present with docker image inspect opendronemap/nodeodm opendronemap/odm, and start a NodeODM instance for the REST path with docker run -d -p 3000:3000 opendronemap/nodeodm. The CLI path needs no long-running service — it starts and tears down a container per job.
Conceptual architecture
The two paths diverge the moment control leaves your Python process. On the PyODM path, your code is a thin REST client: it hands an image list and an options dictionary to a NodeODM daemon that owns the task queue, spawns the reconstruction worker, and exposes a structured status endpoint. Your process can disconnect and reconnect to the same task by UUID, because the daemon — not your script — is the durable owner of the job. On the CLI path, your Python process is the parent of the container: it blocks (or times out) on a single subprocess call, the job’s lifetime is bound to that call, and the only channel back is an exit code plus whatever the container wrote to stdout/stderr. Neither is universally better; they trade a persistent service and richer telemetry against a zero-infrastructure, single-binary invocation.
Each numbered step below submits an identical reconstruction — a densified survey with a DSM and a 2 cm orthophoto — first through PyODM and then through the CLI, so the differences you read in the comparison table are grounded in code you can run side by side.
Step 1: Prepare one job specification for both drivers
Define the job once so neither path can drift from the other. The shared specification is an image list, an ODM options mapping, and an output directory. The subtle detail here is that PyODM accepts options as a Python dict with hyphenated keys, while the CLI wants the same options expanded into --flag value argument pairs; a single translator keeps them in lock-step and prevents the classic bug where one driver runs at high feature quality and the other at medium.
from pathlib import Path
# One source of truth for the reconstruction, shared by both drivers.
JOB_OPTIONS: dict[str, object] = {
"feature-quality": "high",
"pc-quality": "medium",
"dsm": True, # boolean flags are presence-only on the CLI
"orthophoto-resolution": 2.0,
}
def image_list(project_dir: Path) -> list[str]:
"""Absolute paths to every JPEG in the project's images/ folder."""
imgs = sorted(str(p) for ext in ("*.JPG", "*.jpg")
for p in (project_dir / "images").glob(ext))
if not imgs:
raise FileNotFoundError(f"No JPEGs under {project_dir / 'images'}")
return imgs
def options_to_cli_args(options: dict[str, object]) -> list[str]:
"""Expand the shared options dict into ODM CLI argument tokens."""
args: list[str] = []
for key, value in options.items():
if isinstance(value, bool):
if value: # presence-only: --dsm, no value
args.append(f"--{key}")
else:
args += [f"--{key}", str(value)]
return args
The options_to_cli_args translator is the load-bearing piece: boolean options such as --dsm are presence-only flags on the CLI (you never write --dsm true), whereas PyODM expects "dsm": True. Getting this wrong silently disables the DSM on one path only, so both drivers pull from the same JOB_OPTIONS dict and the expansion is tested rather than duplicated by hand.
Step 2: Submit and monitor via PyODM (NodeODM REST)
PyODM’s contract is a handle-and-poll model. Node.create_task uploads the images and returns a Task whose info() method yields a structured object — a TaskStatus enum plus a floating-point progress percentage and a processing_time. Because the task lives in the NodeODM daemon, the monitor loop below can be killed and restarted against the same UUID without losing the job. This is the property that makes PyODM the natural fit for a scheduler or a web backend that must survive its own restarts.
import time
import logging
from pathlib import Path
from pyodm import Node
from pyodm.types import TaskStatus
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
def run_via_pyodm(project_dir: Path, host: str = "localhost",
port: int = 3000, poll_s: int = 5) -> str:
"""Submit the shared job to NodeODM and poll structured status until done."""
node = Node(host, port)
task = node.create_task(image_list(project_dir), options=JOB_OPTIONS)
logging.info("NodeODM task created: %s", task.uuid)
while True:
info = task.info() # one REST round-trip
if info.status == TaskStatus.COMPLETED:
break
if info.status in (TaskStatus.FAILED, TaskStatus.CANCELED):
# Server-side failure carries a human-readable message.
raise RuntimeError(f"NodeODM task {info.status.name}: {info.last_error}")
logging.info("status=%s progress=%.1f%%", info.status.name, info.progress)
time.sleep(poll_s)
out = project_dir / "odm_assets"
task.download_assets(str(out)) # pulls the asset archive
logging.info("Assets downloaded to %s", out)
return task.uuid
The info.progress value is the feature the CLI path cannot cheaply match: it is a real percentage the daemon computes from the stage it is executing, so a dashboard can show a moving bar rather than a spinner. Note also that download_assets retrieves a structured archive by UUID — you never touch the container filesystem directly. For long jobs on a shared node, gate submission behind the daemon health check and reconnect logic covered in the OpenDroneMap setup workflow rather than assuming the service is always reachable.
Step 3: Submit and monitor via the direct ODM CLI (subprocess)
The CLI path collapses submission and monitoring into a single blocking call. You build the docker run argument vector, mount a datasets directory so the container can read images/ and write results back to the host, and inspect the returned CompletedProcess. There is no task handle and no progress endpoint — the process either returns 0 with the outputs on the mounted volume, or a non-zero code with the diagnosis buried in the console tail.
import subprocess
import logging
from pathlib import Path
ODM_IMAGE = "opendronemap/odm:3.5.4" # pin the tag for reproducibility
def run_via_cli(datasets_dir: Path, project_name: str,
timeout_s: int = 86_400) -> Path:
"""Run the identical job as a one-shot docker container via subprocess."""
cmd = [
"docker", "run", "--rm",
"--memory=24g", "--memory-swap=24g", # equal values disable swap
"-v", f"{datasets_dir}:/datasets",
ODM_IMAGE,
"--project-path", "/datasets", project_name,
*options_to_cli_args(JOB_OPTIONS), # same options as PyODM
]
logging.info("exec: %s", " ".join(cmd))
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s)
except subprocess.TimeoutExpired:
logging.error("ODM exceeded %d s; container terminated.", timeout_s)
raise
if proc.returncode != 0:
# 137 = OOM kill, 1 = pipeline/alignment failure, 139 = native segfault.
tail = proc.stderr[-1500:] or proc.stdout[-1500:]
logging.error("ODM exit %d\n%s", proc.returncode, tail)
raise RuntimeError(f"ODM CLI failed with exit code {proc.returncode}")
ortho = datasets_dir / project_name / "odm_orthophoto" / "odm_orthophoto.tif"
logging.info("ODM CLI completed; orthophoto at %s", ortho)
return ortho
The whole telemetry story here is the exit code and the console text, which is why decoding those codes is a skill in itself — a 137 is the kernel OOM killer, a 1 is a pipeline failure such as bundle-adjustment divergence, and the two demand opposite fixes. The dedicated walkthroughs for resolving ODM exit code 1 and 137 and fixing OpenSfM bundle adjustment divergence are the CLI path’s substitute for a structured error object.
Step 4: Add retries and parallelism to each path
Resilience looks different on each path. On PyODM you retry the submission — a busy node returns a queue error — and let the daemon own the running job, so a retry never restarts a reconstruction that is already progressing. On the CLI you retry the whole container, because the job and the call are the same thing; that makes CLI retries simpler to reason about but far more expensive, since a failure on hour eleven restarts from zero unless you checkpoint. Parallelism inverts the same asymmetry: NodeODM (or a ClusterODM front end) queues and schedules concurrent tasks for you, whereas concurrent CLI jobs are just multiple subprocess calls you must throttle yourself so you do not oversubscribe RAM.
import concurrent.futures as cf
from pathlib import Path
from tenacity import retry, stop_after_attempt, wait_exponential
@retry(stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=30, max=300))
def submit_pyodm_with_retry(project_dir: Path) -> str:
"""Retry only the submission; the daemon owns the running task."""
return run_via_pyodm(project_dir)
def run_cli_blocks_parallel(datasets_dir: Path, names: list[str],
max_parallel: int = 2) -> list[Path]:
"""Throttle concurrent CLI containers so total RAM stays bounded."""
with cf.ThreadPoolExecutor(max_workers=max_parallel) as pool:
futures = {pool.submit(run_via_cli, datasets_dir, n): n for n in names}
return [f.result() for f in cf.as_completed(futures)]
The max_parallel cap is not optional on the CLI path: two --pc-quality high containers on a 32 GB host will race each other into the OOM killer. NodeODM makes the same guarantee by refusing to start a queued task until the running one frees resources, which is the operational reason teams running many jobs gravitate to the REST path even though the CLI is simpler for a single reconstruction.
Comparison table
Every dimension below flows from the one architectural difference: PyODM talks to a durable service that owns the job, while the CLI makes your process the transient owner of a container.
| Dimension | PyODM (NodeODM REST) | Direct ODM CLI (subprocess) |
|---|---|---|
| Job control | Handle + UUID; reconnect after client restart | Bound to one blocking subprocess call |
| Progress / status | Structured TaskStatus + numeric progress % |
Exit code + free-text console scraping |
| Parallel jobs | Node/ClusterODM queues and schedules for you | Manual ThreadPoolExecutor throttling required |
| Artifact retrieval | download_assets() archive by UUID |
Read files off the mounted host volume |
| Error handling | last_error message on the status object |
Decode exit codes 137 / 1 / 139 from stderr |
| Retries | Retry submission; daemon keeps the running job | Retry the whole container from scratch |
| Ops complexity | Must run and monitor a long-lived NodeODM service | Zero standing infrastructure; one image |
| Best fit | Schedulers, web backends, many concurrent jobs | Cron jobs, CI, single reproducible reconstructions |
Parameter deep-dive
These are the knobs that most change the behavior of each driver. They are orthogonal to ODM’s own reconstruction flags (--feature-quality, --pc-quality), which are shared by both paths through JOB_OPTIONS.
| Parameter | Applies to | Default | Valid range | Effect |
|---|---|---|---|---|
poll_s |
PyODM | 5 |
1–60 |
Seconds between status polls; lower gives a smoother bar but more REST traffic. |
port |
PyODM | 3000 |
1–65535 |
NodeODM listen port; must match the docker run -p publish. |
timeout_s |
CLI | 86_400 |
> 0 |
Hard wall-clock cap; a corridor block at ultra can legitimately need a full day. |
--memory / --memory-swap |
CLI | host default | ≤ host RAM | Equal values disable swap so an OOM fails fast instead of thrashing. |
max_parallel |
CLI | 2 |
1–N |
Concurrent containers; set from RAM / per-job peak, not core count. |
stop_after_attempt |
Both | 3 |
1–10 |
Submission (PyODM) or whole-job (CLI) retry ceiling. |
Verification and output inspection
Regardless of driver, never trust an exit status alone — assert that the reconstruction actually produced a readable, georeferenced orthophoto with a plausible size. The check below is driver-agnostic: PyODM’s download_assets and the CLI’s mounted volume both land an odm_orthophoto.tif you can open and interrogate.
from pathlib import Path
from osgeo import gdal
gdal.UseExceptions()
def verify_orthophoto(ortho: Path, min_bytes: int = 1_000_000) -> None:
"""Assert the orthophoto exists, is non-trivial, and carries a projection."""
assert ortho.exists(), f"missing orthophoto: {ortho}"
assert ortho.stat().st_size >= min_bytes, "orthophoto suspiciously small"
ds = gdal.Open(str(ortho))
assert ds is not None, "orthophoto is unreadable / corrupt"
assert ds.GetProjection(), "orthophoto has no CRS — reconstruction lost georeferencing"
gt = ds.GetGeoTransform()
assert gt and abs(gt[1]) > 0, "degenerate geotransform"
print(f"verified {ortho.name}: {ds.RasterXSize}x{ds.RasterYSize} px")
ds = None
The GetProjection() assertion is the one that catches a job which “succeeded” but lost its coordinate reference system — a failure that surfaces identically no matter which driver ran it, and that the ingestion and CRS troubleshooting methodology traces back to its upstream cause.
Troubleshooting
PyODM raises NodeConnectionError the moment I call create_task.
The NodeODM daemon is not reachable at the host and port you passed to Node(). This is the standing-service tax the CLI path avoids entirely. Confirm the container is running and the port is published with docker run -d -p 3000:3000 opendronemap/nodeodm, and that no firewall blocks it. For unattended pipelines, health-check the endpoint before submission and wrap create_task in the bounded retry from Step 4.
The CLI job exits 137 but the same images finish fine through PyODM.
Your docker run used a lower --memory cap than the NodeODM container was granted, so dense matching hit the kernel OOM killer on the CLI path only. The reconstruction engine is identical; only the memory budget differed. Raise --memory/--memory-swap to match, lower --pc-quality, or split the block, as detailed in resolving ODM exit code 1 and 137.
I need a live progress bar and the CLI only gives me a spinner.
The CLI writes stage banners to stdout but exposes no numeric progress, so a true percentage bar is a PyODM feature, not a CLI one. If you must stay on the CLI, stream stdout line by line and map the known stage banners (OpenSfM, OpenMVS, odm_meshing, odm_orthophoto) to coarse checkpoints — but accept that it is an approximation of the info.progress value the REST path gives you for free.
Running four CLI reconstructions at once thrashes the machine to a halt.
Concurrent subprocess containers do not coordinate memory, so four high-quality jobs oversubscribe RAM and swap. Cap concurrency with the ThreadPoolExecutor(max_workers=...) throttle from Step 4, sized from total RAM / per-job peak. This is exactly the scheduling NodeODM (or ClusterODM) performs automatically, which is why the REST path scales to many jobs with less bespoke code.
A client restart orphaned my running CLI job but PyODM survived.
On the CLI, the job’s lifetime is bound to the subprocess call, so killing the parent kills the container. On PyODM the daemon owns the task, so you reconnect with Node(host, port) and re-attach by UUID. If durability across restarts matters, that asymmetry alone is the deciding factor in favor of the REST path.
Both drivers “succeed” but the orthophoto opens with no CRS.
Neither driver invented this failure — the reconstruction lost its georeference because the inputs carried malformed or ambiguous coordinate metadata. Run the verify_orthophoto assertion after every job and treat a missing projection as a hard failure, then follow the ingestion and CRS troubleshooting tree to the offending stage.