Writing a Manifest-Driven Batch Runner for ODM

The script that processes tonight’s surveys usually starts as a for loop over a directory listing and grows a flag at a time: skip the ones already done, except re-do that one, use a smaller split for the big site, and don’t touch the one the client is still reviewing. Six months later nobody can say what it will do without running it. This page replaces that with a manifest — a declarative file naming every survey and its settings — and a runner whose only job is to reconcile that manifest against what already exists on disk, print the plan, and then execute it.

The reconciliation model is what makes it different from a loop. The manifest describes the desired state; the runner computes the difference against the actual state and acts only on the difference. That is the same shape as any infrastructure tool, and it buys the same property: running it twice in a row is safe, and running it with --plan tells you exactly what a real run would do.

Why a loop over a directory stops working

Three pressures break the ad-hoc script, and they arrive together.

Per-survey settings. A 3,000-image corridor and a 400-image quarry do not want the same --split, and a survey with control does not want the same georeferencing arguments as one without. Encoding that as if statements against filename patterns works until a filename does not match the pattern.

Partial completion. Half the surveys finished last night. A loop either re-runs them, which wastes the night, or skips anything with an output directory, which silently skips the ones that failed halfway — the case discussed in retrying failed photogrammetry jobs idempotently.

Auditability. When a client asks why their orthomosaic is at 5 cm rather than 3 cm, the answer must be a line in a file with a commit history, not an argument someone typed.

Reconciliation: desired state, actual state, and the plan between them A three-column diagram. On the left, the manifest lists five surveys with their settings — the desired state. In the middle, the runner inspects the filesystem to determine the actual state of each: two complete and matching, one complete but built with different settings, one partially built, and one absent. On the right, the resulting plan names one action per survey: skip, rebuild, rewind and resume, and build. A note states that the plan is printed before anything executes, so a dry run is the same computation without the final step. manifest (desired) quarry-north · 3 cm quarry-south · 3 cm corridor-a · 5 cm dam-face · 2 cm field-12 · 5 cm filesystem (actual) complete, 3 cm complete, 3 cm complete, 8 cm partial — died in meshing absent plan skip skip rebuild — settings differ rewind + resume build A dry run is this exact computation with the last step omitted — which is why the plan can be trusted.

Figure 1 — The runner computes a plan and then executes it. Because the plan is the same object either way, --plan is not an approximation of what will happen; it is what will happen.

Minimal reproducible solution

The manifest is a plain file with defaults and per-survey overrides. TOML reads well for this and is in the standard library from Python 3.11.

# surveys.toml — the desired state of the whole batch.
[defaults]
target_epsg      = 25832
ortho_resolution = 0.03      # metres
split            = 500
engine_digest    = "sha256:6f1c…"   # pinned, never ':latest'

[[survey]]
name       = "quarry-north"
images     = "/srv/raw/2026-08-04/quarry-north"
gcp        = "/srv/control/quarry-north.txt"

[[survey]]
name       = "corridor-a"
images     = "/srv/raw/2026-08-05/corridor-a"
split      = 900             # long thin block, larger submodels
ortho_resolution = 0.05

[[survey]]
name       = "dam-face"
images     = "/srv/raw/2026-08-06/dam-face"
gcp        = "/srv/control/dam-face.txt"
ortho_resolution = 0.02
enabled    = false           # client review in progress — do not touch

The runner loads it, merges defaults, and produces one action per survey. Notice that the only branching is over state, not over filenames.

import tomllib
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class Action:
    name: str
    verb: str            # skip | build | rebuild | resume | disabled
    reason: str
    from_stage: str | None = None


def load_manifest(path: Path) -> list[dict]:
    doc = tomllib.loads(path.read_text())
    defaults = doc.get("defaults", {})
    return [{**defaults, **s} for s in doc.get("survey", [])]


def plan(manifest: list[dict], projects_root: Path) -> list[Action]:
    """Reconcile desired settings against what is on disk. No side effects."""
    from .retries import rewind_project, _is_complete   # from the retry guide

    actions: list[Action] = []
    for spec in manifest:
        name = spec["name"]
        proj = projects_root / name

        if not spec.get("enabled", True):
            actions.append(Action(name, "disabled", "enabled = false"))
            continue

        ortho = proj / "odm_orthophoto/odm_orthophoto.tif"
        if not proj.exists():
            actions.append(Action(name, "build", "no project directory"))
        elif not _is_complete(ortho):
            stage = rewind_project(proj, dry_run=True)
            actions.append(Action(name, "resume", "incomplete output", stage))
        elif _settings_of(proj) != _settings_key(spec):
            actions.append(Action(name, "rebuild", "settings differ from manifest"))
        else:
            actions.append(Action(name, "skip", "up to date"))
    return actions

The comparison in the third branch is what makes the manifest authoritative rather than advisory. _settings_key(spec) builds a small dictionary of the settings that affect output; _settings_of(proj) reads the same dictionary back from the run manifest the previous execution wrote into the project directory. If they differ, the product on disk was not built from what the manifest now says, and the runner rebuilds it.

import json
from pathlib import Path

OUTPUT_AFFECTING = ("target_epsg", "ortho_resolution", "split", "engine_digest", "gcp")


def _settings_key(spec: dict) -> dict:
    return {k: spec.get(k) for k in OUTPUT_AFFECTING}


def _settings_of(project_dir: Path) -> dict | None:
    """Read back what the previous run recorded. Absent means 'unknown'."""
    f = project_dir / "run_manifest.json"
    if not f.exists():
        return None                       # unknown provenance → rebuild
    return {k: json.loads(f.read_text()).get(k) for k in OUTPUT_AFFECTING}

Returning None for a project with no recorded provenance, and treating that as a mismatch, is deliberate: a directory whose origin is unknown is not evidence that the work was done under the current settings.

Edge-case matrix

Manifest / disk state Plan Why
Survey absent from disk build Nothing exists
Complete, settings match skip Already the desired state
Complete, settings differ rebuild Product does not match the manifest
Complete, no run_manifest.json rebuild Provenance unknown
Partial output resume from the incomplete stage Cheaper than a rebuild, still correct
enabled = false disabled Explicitly excluded, and reported as such
On disk but not in the manifest reported, never deleted The runner does not own deletion
Two entries with the same name error before planning Ambiguous desired state
images path missing error before planning Fail on the whole batch, not mid-night

Two of those rows are policy rather than mechanism. The runner never deletes a project that has fallen out of the manifest: reporting it is useful, acting on it is how a batch tool eats a deliverable. And validation errors fail the whole plan before any survey runs, because discovering a bad path at 03:00 after four hours of work is strictly worse than discovering it at 23:00 before any.

Verify the plan before you run it

The plan is data, so it can be asserted on. Two checks are worth running every night before execution.

def assert_plan_sane(actions: list[Action], manifest: list[dict]) -> None:
    """Cheap guards against a plan that would waste or destroy a night."""
    names = [s["name"] for s in manifest]
    assert len(names) == len(set(names)), "duplicate survey names in the manifest"

    rebuilds = [a for a in actions if a.verb == "rebuild"]
    if len(rebuilds) > len(actions) * 0.5:
        raise SystemExit(
            f"{len(rebuilds)}/{len(actions)} surveys would rebuild — "
            "a shared default probably changed; re-run with --plan and confirm"
        )

    for a in actions:
        assert a.verb in {"skip", "build", "rebuild", "resume", "disabled"}
        assert a.reason, f"{a.name}: action without a reason"

The rebuild threshold is the useful one. Changing a value under [defaults] — the engine digest, say — legitimately invalidates every survey, and that is sometimes exactly what you want. But it is also what an accidental edit looks like, and the difference between the two is a human confirming it. Failing loudly at that point costs a minute; not failing costs the night.

# The night's actual invocation: plan, eyeball, then run.
python -m batch_runner --manifest surveys.toml --plan
python -m batch_runner --manifest surveys.toml --run --jobs 2
Why the recorded settings live beside the output A project directory containing its outputs and a run manifest file that records the settings used to produce them. Two questions are answered from that file rather than from memory: whether the product matches the current desired settings, and what a client's product was actually built with. A contrasting directory without the file is shown, where neither question can be answered, so the only safe action is to rebuild. A note observes that writing the file costs one line at the end of a run and removes an entire category of "was this rebuilt after we changed the resolution?" conversations. with provenance odm_orthophoto.tif odm_dem/dsm.tif run_manifest.json epsg · resolution · split · digest · gcp "does this match the manifest?" — answerable "what did the client receive?" — answerable without provenance odm_orthophoto.tif odm_dem/dsm.tif (nothing else) "does this match?" — unknowable only safe plan: rebuild One file written at the end of a run is what makes reconciliation possible at all. Without it the runner has to choose between rebuilding everything and trusting a directory whose origin it cannot check.

Figure 2 — Reconciliation needs something to reconcile against. The run manifest is the only record of what the product on disk was actually built from, and its absence is correctly treated as a mismatch.

When to escalate

  • The plan says rebuild for a survey nobody touched. The recorded settings and the manifest disagree on a field that does not actually affect output — a path written as absolute in one place and relative in the other is the usual culprit. Normalise before comparing rather than removing the field from OUTPUT_AFFECTING.
  • resume keeps being chosen and the survey never completes. The job is failing at the same stage every time, so the runner correctly resumes and correctly fails. This is a job problem, not a runner problem; the exit code will say whether it is a resource failure or a deterministic one, as set out in resolving ODM exit code 1 and 137 OOM.
  • The batch runs longer than the window every night despite skipping most surveys. The runner is scheduling correctly and the hardware is the constraint; the memory-gated queue in orchestrating photogrammetry jobs with Python schedulers will overlap the cheap stages, but past that point the answer is fewer surveys per night or more hardware.

Orchestrating Photogrammetry Jobs with Python Schedulers

Nightly batch: where the confirmation step sits A nightly sequence drawn as five steps. The manifest is validated, the plan is computed, the plan is checked against sanity thresholds such as the proportion of rebuilds, a human or an automated rule confirms, and only then does execution begin. A branch shows the sanity check failing and stopping the run before any compute is spent. Beneath, the elapsed time of each step is annotated: everything up to the confirmation takes seconds, while execution takes hours, which is why the confirmation is placed where it is. validate manifest compute plan sanity check rebuild ratio confirm execute stop — nothing has run a default probably changed < 1 s seconds instant seconds hours Every cheap step happens before the expensive one, which is the only reason the check is worth having. A confirmation placed after execution is a report; placed before it, it is a control.

Figure 3 — The whole value of a plan-then-run design is the ordering. Validation, planning and sanity checks all complete in seconds, so the one expensive step is the last thing that happens and the easiest to withhold.