Troubleshooting Ingestion and CRS Failures

Most reconstruction disasters are not reconstruction bugs — they are ingestion and coordinate-reference-system (CRS) defects that entered the pipeline unchecked and only surfaced hours later as a warped orthomosaic, a model floating in the ocean, or a hard crash inside PROJ. This page is the diagnostic reference for the entire Fundamentals stage: it collects the ingestion and CRS failure modes that recur across drone survey work — malformed or missing EXIF GPS, mixed-sensor metadata, wrong or ambiguous EPSG codes, axis-order swaps, vertical datum drift, batch-structure mistakes, and TIFF-conversion pitfalls — and gives you a single repeatable methodology to localize any of them to a specific stage before you attempt a fix. Rather than a grab-bag of tips, it is a decision procedure: reproduce the failure on the smallest possible input, inspect the EXIF and CRS metadata, isolate which stage first corrupts the data, and only then apply the targeted remediation that the deeper pages implement in full. This aggregator sits under core photogrammetry fundamentals for Python pipelines and points outward to every fix it references.

Audience and prerequisites. This guide targets Python 3.10+ on a 64-bit OS with GDAL 3.4+ and PROJ 9+ installed. You should understand EXIF sub-IFDs, EPSG authority codes, the difference between geographic and projected CRSs, and ellipsoidal versus orthometric height. The methodology is deliberately tool-light: you need only enough tooling to read metadata (Pillow or exiftool), interrogate a CRS (pyproj), and inspect a raster (GDAL). The fixes themselves live in the linked pages; this page’s job is to tell you which one you need.

Prerequisites

Install the inspection stack into an isolated environment. The single most consequential detail is that GDAL and pyproj must resolve the same PROJ build and data directory — a mismatch is itself one of the failure modes catalogued below, so verify it before you trust any CRS diagnosis.

Library Minimum version Install command Role in diagnosis
Python 3.10 (system / pyenv) match on symptom class, typed metadata records
Pillow ≥ 10.0 pip install "Pillow>=10" Read EXIF GPS and sensor sub-IFDs
exiftool ≥ 12.0 system package manager Ground-truth EXIF dump when Pillow disagrees
GDAL (osgeo) ≥ 3.4 conda install -c conda-forge gdal>=3.4 Inspect raster CRS, geotransform, band stats
pyproj ≥ 3.6 pip install "pyproj>=3.6" Parse EPSG/WKT, check axis order and area of use

Confirm parity with python -c "import pyproj, osgeo.gdal as g; print(pyproj.proj_version_str, g.__version__)". If the PROJ version pyproj reports differs from the one GDAL was built against, expect the CRS-lookup crash covered in fixing GDAL PROJ database context errors and resolve that before chasing any other symptom.

How this section is organized

This page fans out to the deep-dives that fix each failure class. Malformed GPS metadata is diagnosed with the routine in how to validate EXIF GPS data before processing; CRS parsing, axis order, and reprojection rules live in managing coordinate reference systems in GDAL; batch layout and mixed-sensor separation are covered by structuring drone imagery for batch processing; the PROJ-environment crash has its own deep dive in fixing GDAL PROJ database context errors; and the reconstruction engine that consumes the cleaned inputs is wired up in setting up OpenDroneMap with Python. The decision tree below is how you route a symptom to the right one of those pages.

Diagnostic decision tree routing an ingestion or CRS symptom to its root cause and fix A decision tree. The root, an ingestion or CRS failure, branches into four detectable symptoms across a row. Symptom one is positions collapsing to Null Island, detected when latitude and longitude are both near zero, whose cause is malformed or missing EXIF GPS. Symptom two is a kilometre-scale horizontal offset, detected when the output envelope falls outside the project area of use, whose cause is an axis-order swap or a wrong EPSG code. Symptom three is heights off by twenty to forty metres, detected by comparing against a known geoid height, whose cause is vertical datum or geoid drift. Symptom four is a hard PROJ or CRS lookup crash, detected by a PROJ exception mentioning proj.db, whose cause is a PROJ_DATA path or a GDAL to PROJ build mismatch. Ingestion / CRS failure start here · isolate the stage Null Island collapse lat, lon ≈ 0 model in the sea km-scale offset envelope outside area of use Height off 20–40 m dz vs known geoid height PROJ / CRS crash exception names proj.db Malformed / missing EXIF GPS validate before SfM Axis swap or wrong EPSG always_xy · verify code Vertical datum / geoid drift use compound CRS PROJ_DATA path / build mismatch set env · pin versions Mixed-sensor and batch-structure faults present as any of the above — separate by camera model first.

The four steps below are the methodology that populates that tree: you reproduce, you inspect metadata, you isolate the stage, and you dispatch to the fix.

Step 1: Reproduce the failure on the smallest input and pin the environment

An intermittent ingestion bug that “only happens on the big flight” is almost always deterministic on a two-image subset once you control the environment. The first move is to shrink the input to the smallest set that still fails and to snapshot the versions that govern CRS behaviour, because a result that changes between machines is itself a diagnosis (a PROJ mismatch) rather than random noise. Capture the environment and the failing set together so the reproduction is portable to a colleague or a CI runner.

import sys
import shutil
import logging
from pathlib import Path

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

def snapshot_environment() -> dict[str, str]:
    """Record the versions that actually decide CRS and metadata behaviour."""
    import pyproj
    from osgeo import gdal
    from PIL import __version__ as pil_version
    env = {
        "python": sys.version.split()[0],
        "gdal": gdal.__version__,
        "proj": pyproj.proj_version_str,
        "pyproj": pyproj.__version__,
        "pillow": pil_version,
        "proj_data_dir": pyproj.datadir.get_data_dir(),
    }
    for k, v in env.items():
        logging.info("env %-14s %s", k, v)
    return env

def minimal_failing_set(src: Path, dst: Path, names: list[str]) -> Path:
    """Copy just the frames that reproduce the failure into an isolated folder."""
    (dst / "images").mkdir(parents=True, exist_ok=True)
    for name in names:
        shutil.copy2(src / name, dst / "images" / name)
    logging.info("Reproduction set: %d frames in %s", len(names), dst)
    return dst

The proj_data_dir line is the one operators overlook: if it points somewhere that does not contain proj.db, every CRS lookup downstream is already doomed, and you save hours by catching it here rather than mid-reconstruction. With a portable reproduction in hand, move to the metadata inspection that tells you what is wrong.

Step 2: Inspect EXIF GPS and sensor metadata

Ingestion failures that manifest as collapsed or offset positions begin in the EXIF. Read the GPS sub-IFD and, critically, the camera make and model in the same pass — because a “mixed-sensor” flight silently blends two intrinsic models and two GPS-offset conventions into one bundle, and the only reliable tell is that the frames report different Make/Model tags. This inspection does not fix anything; it classifies the frame so Step 4 can dispatch it.

from pathlib import Path
from PIL import Image
from PIL.ExifTags import GPSTAGS, IFD

def inspect_metadata(path: Path) -> dict[str, object]:
    """Extract sensor identity and raw GPS presence for one frame."""
    with Image.open(path) as img:
        exif = img.getexif()
        make = exif.get(0x010F)                 # Make
        model = exif.get(0x0110)                # Model
        gps = exif.get_ifd(IFD.GPSInfo)         # GPS sub-IFD (0x8825)
    tags = {GPSTAGS.get(k, k): v for k, v in gps.items()} if gps else {}
    has_coords = "GPSLatitude" in tags and "GPSLongitude" in tags
    return {
        "file": path.name,
        "sensor": f"{make} {model}".strip(),
        "has_gps_ifd": bool(gps),
        "has_coords": has_coords,
        "lat_ref": tags.get("GPSLatitudeRef"),
        "lon_ref": tags.get("GPSLongitudeRef"),
    }

def sensor_set(image_dir: Path) -> set[str]:
    """More than one entry means a mixed-sensor batch that must be split."""
    return {inspect_metadata(p)["sensor"] for p in sorted(image_dir.glob("*.JPG"))}

If sensor_set returns more than one distinct camera, stop and separate the flight by model before anything else — the mixed-sensor handling in structuring drone imagery for batch processing is the correct fix, and no amount of CRS tuning will rescue a bundle built from two cameras. If the sensor is uniform but coordinates are missing, zeroed, or hemisphere-flipped, hand the frame to the EXIF GPS validation routine, which enumerates every GPS failure code.

Step 3: Interrogate and reconcile the CRS metadata

Once the EXIF is trusted, the remaining failures are coordinate-system failures: an ambiguous or wrong EPSG code, a swapped axis order, or a vertical datum mismatch. Interrogate the declared CRS directly — do not assume the number on the deliverable spec is the number in the file. The routine below parses the CRS, reports whether it is geographic or projected, prints the authority-declared axis order (the source of the classic easting/northing swap), and surfaces the area of use so an out-of-envelope output is caught as a wrong-zone error rather than a mystery.

import pyproj

def interrogate_crs(crs_input: str) -> dict[str, object]:
    """Report the properties that cause silent CRS ingestion failures."""
    crs = pyproj.CRS.from_user_input(crs_input)
    axes = [(ax.abbrev, ax.direction) for ax in crs.axis_info]
    aou = crs.area_of_use
    return {
        "name": crs.name,
        "authority": crs.to_authority(),        # (auth, code) or None if ambiguous
        "is_geographic": crs.is_geographic,
        "is_projected": crs.is_projected,
        "axis_order": axes,                     # e.g. lat-first vs lon-first
        "is_vertical_compound": len(crs.sub_crs_list) > 1 if crs.is_compound else False,
        "area_of_use": (aou.west, aou.south, aou.east, aou.north) if aou else None,
    }

# A wrong-zone diagnosis: does the output envelope fall inside the CRS area of use?
def envelope_in_area_of_use(crs_input: str, cx: float, cy: float) -> bool:
    info = interrogate_crs(crs_input)
    w, s, e, n = info["area_of_use"]
    return w <= cx <= e and s <= cy <= n

Two results here map straight to fixes. If to_authority() returns None, the CRS string is ambiguous and must be pinned to an explicit EPSG code, per managing coordinate reference systems in GDAL. If axis_order shows a latitude-first authority, any code that fed raw (x, y) without always_xy=True produced swapped coordinates — the km-scale-offset branch of the tree. And if the CRS is not a vertical compound while the deliverable needs orthometric height, the 20–40 m height error is a geoid model that PROJ never applied.

Step 4: Isolate the failing stage and dispatch the fix

With a classification in hand, the last step is a small dispatcher that maps the symptom to the stage that first corrupts the data and to the page that fixes it. Isolation matters because the symptom stage (a warped orthomosaic at export) is rarely the cause stage (a swapped axis at ingestion); fixing the export does nothing. The dispatcher below is not a magic repair — it is a router that turns a triage result into a single, unambiguous next action.

from enum import Enum

class Symptom(Enum):
    NULL_ISLAND = "coords collapse to 0,0"
    KM_OFFSET = "envelope outside area of use"
    HEIGHT_DRIFT = "z off by 20-40 m"
    PROJ_CRASH = "PROJ cannot find proj.db"
    MIXED_SENSOR = "more than one camera model"

FIX_ROUTES = {
    Symptom.NULL_ISLAND: "validate EXIF GPS before processing",
    Symptom.KM_OFFSET: "managing coordinate reference systems in GDAL (axis/EPSG)",
    Symptom.HEIGHT_DRIFT: "managing coordinate reference systems in GDAL (compound CRS)",
    Symptom.PROJ_CRASH: "fixing GDAL PROJ database context errors",
    Symptom.MIXED_SENSOR: "structuring drone imagery for batch processing",
}

def dispatch(symptom: Symptom) -> str:
    """Route a classified symptom to the stage-specific remediation page."""
    route = FIX_ROUTES[symptom]
    logging.info("Isolated symptom %s -> fix: %s", symptom.name, route)
    return route

The value of the enum is that it forces you to name the failure before acting, which is what stops the common anti-pattern of re-running the whole reconstruction hoping a defect resolves itself. Each route corresponds to exactly one linked page below.

Symptom-signature table

Use this table as a fast lookup once you have run the inspection steps. Each row pairs a concrete symptom or error string with the stage it originates in, the Python detection you run, and the page that carries the full remediation.

Symptom / error Origin stage Detection Remediation
Model collapses to 0°N 0°E EXIF ingestion abs(lat) < 1e-9 and abs(lon) < 1e-9 Validate EXIF GPS before processing
Whole model offset by kilometres CRS / axis order always_xy omitted; envelope outside area_of_use Managing CRS in GDAL
Heights wrong by 20–40 m Vertical datum dz vs known geoid height exceeds tolerance Managing CRS in GDAL
PROJ: Cannot find proj.db PROJ environment pyproj.datadir.get_data_dir() lacks proj.db Fixing GDAL PROJ database context errors
Two intrinsic models in one bundle Batch structure sensor_set() returns > 1 entry Structuring drone imagery for batch processing
KeyError mid-batch on GPS tag EXIF ingestion tag-presence check fails on one frame Validate EXIF GPS before processing
TIFF loses geotag after conversion Format standardization output raster GetProjection() empty Managing CRS in GDAL
CRSError: Invalid projection CRS parsing to_authority() returns None Managing CRS in GDAL

Verification and output inspection

After a fix, verify that the failure class you diagnosed is actually gone before re-running the full flight. The assertions below encode the three most common repairs — a non-null coordinate, a metre-based projected CRS with a known authority, and an output envelope that falls inside the CRS area of use.

import pyproj

def verify_ingestion(lat: float, lon: float, crs_input: str,
                     cx: float, cy: float) -> None:
    """Assert the diagnosed failure class is resolved."""
    # 1. Not the Null Island collapse.
    assert not (abs(lat) < 1e-9 and abs(lon) < 1e-9), "coords still at 0,0"

    # 2. CRS is unambiguous, projected, and metre-based.
    crs = pyproj.CRS.from_user_input(crs_input)
    assert crs.to_authority() is not None, "CRS is still ambiguous"
    assert crs.is_projected, "target CRS is not projected"
    assert crs.axis_info[0].unit_name in {"metre", "meter"}, "CRS is not metre-based"

    # 3. Output centroid lands inside the CRS area of use (not a wrong zone).
    aou = crs.area_of_use
    assert aou.west <= cx <= aou.east and aou.south <= cy <= aou.north, "wrong zone"
    print("ingestion verified:", crs.to_authority())

Passing all three does not certify the whole pipeline, but it certifies that the specific defect you isolated no longer reaches the reconstruction engine. Feed the cleaned set forward to setting up OpenDroneMap with Python and audit the final raster’s CRS one more time at export.

Troubleshooting

Every image validates individually, yet the merged reconstruction still warps. Per-frame validation cannot catch a mixed-sensor batch: each camera’s GPS and intrinsics are internally consistent, but blending two models warps the block. Run sensor_set() from Step 2 first; if it returns more than one camera, split the flight by model before reconstruction rather than tuning CRS parameters that were never the problem.

The horizontal position is correct but the whole surface sits 30 m too low. You are mixing ellipsoidal and orthometric height. PROJ only applies a geoid shift when the target is a compound or 3D CRS and the geoid grid is installed. Confirm with interrogate_crs, build a compound target CRS, and verify the grid is present — the same vertical-datum handling described in managing coordinate reference systems in GDAL.

Results differ between my laptop and the CI runner for identical inputs. The two machines resolve different PROJ builds or data directories, so CRS operations select different paths — a non-reproducible result is a version diagnosis, not a data one. Run snapshot_environment() on both, pin pyproj and GDAL exactly, and if the crash names proj.db, follow fixing GDAL PROJ database context errors.

Converting my JPEGs to TIFF stripped the geotag and now the CRS is gone. Format standardization that does not carry the EXIF/geo metadata forward silently unreferences the frame. Preserve the EXIF block on save and re-inspect with GetProjection(); the correct conversion is the one that round-trips the geotag, as shown in the drone-image-to-TIFF workflow.

pyproj.CRS.from_user_input accepts my string but the output lands in the wrong hemisphere. The CRS parsed fine, so this is an axis-order or sign issue at use, not at parse. Check axis_order from Step 3: a latitude-first authority combined with raw (x, y) input swaps your coordinates. Rebuild every transformer with always_xy=True and re-verify the envelope against the area of use.

A single corrupt frame kills the whole ingestion batch with an exception. One malformed EXIF record should never abort a run. Wrap per-frame inspection so a failure flags the frame and continues, exactly as the EXIF GPS validation routine does by returning a status instead of raising, then quarantine flagged frames for review.

Core Photogrammetry Fundamentals for Python Pipelines