Best Practices for Storing Raw UAV Datasets

You re-open a survey folder three weeks after the flight, launch the reconstruction, and the run dies with Not enough images — or worse, it finishes but the orthomosaic is visibly warped, with seams and doubled rooftops. The imagery looks fine in a thumbnail viewer, so the failure feels random. It is not. The dataset was stored on cloud-synced or consumer media that silently stripped EXIF, locked files during indexing, or flipped a single bit during transfer, and nothing in the pipeline checked. This page fixes the root layer: a deterministic, integrity-verified storage layout for raw imagery, telemetry, RTK/PPK corrections, and calibration files, with a single Python ingest routine that refuses to let a corrupted dataset reach the expensive stages.

Why raw storage quietly breaks photogrammetry pipelines

Raw UAV imagery is the immutable source of truth for every downstream product — tie-point matching, bundle adjustment, point-cloud densification, and the final orthomosaic. Once the structure-from-motion engine has consumed it, there is no recovering geometry the storage layer threw away. Three storage-side failure modes account for the overwhelming majority of “the data looks fine but the reconstruction is wrong” tickets:

  1. Silent EXIF loss. Cloud-sync clients (Dropbox, OneDrive, Google Drive) and some image-management tools rewrite JPEGs to generate previews, dropping the GPSLatitude, GPSLongitude, GPSAltitude, and Make/Model tags. OpenDroneMap then skips those frames or falls back to GPS-free relative reconstruction, which drifts. The fix at parse time is covered in how to validate EXIF GPS data before processing, but prevention starts with never storing active datasets where a sync daemon can touch them.
  2. Bit-level corruption during offload. SD-card reads, USB hubs, and SMB/NFS mounts occasionally flip bytes. A corrupted JPEG either fails to decode (a hard error you at least notice) or decodes with garbage in a tile region, injecting phantom features that derail matching. Without a checksum captured at the source, you cannot tell a good copy from a bad one.
  3. Non-deterministic paths. Flat dumps, OS-dependent separators, and dynamically named folders break the relative-path parsing that batch processors rely on, producing FileNotFoundError or, on locked files, PermissionError mid-run. A predictable tree is a hard dependency of any batch processing structure, not a tidiness preference.

The canonical layout that defeats all three is a project-centric, ISO 8601 date-stamped hierarchy with explicit role separation:

/project_root/
├── 2025-05-12_site_alpha/
│   ├── raw_imagery/
│   │   ├── flight_01/
│   │   │   ├── DJI_0001.JPG
│   │   │   └── DJI_0001.XMP
│   │   └── flight_02/
│   ├── telemetry/
│   │   ├── flight_01.csv
│   │   └── rtk_corrections.pos
│   ├── calibration/
│   │   └── camera_lens_profile.json
│   └── metadata/
│       └── exif_manifest.csv

Names use ISO 8601 dates (YYYY-MM-DD), flight identifiers (flight_XX), and zero-padded frame numbers (DJI_0001.JPG) so a glob sorts in capture order and no path exceeds the Windows 260-character limit.

Verified ingest flow for raw UAV datasets Left to right across the top: the SD card DCIM directory is hashed with SHA-256 at the source, then rsync copies it with checksum into the project root. The copy drops into a verify-manifest decision diamond that re-hashes every file. The PASS branch routes right and down into the canonical tree, a box listing raw_imagery, telemetry, calibration, and metadata directories. The FAIL branch routes left and down into a quarantine-and-reflight box, where the dataset is never queued for OpenDroneMap. A dashed forbidden-destinations annotation marks cloud-sync clients and Git-tracked directories as invalid copy targets, requiring local NVMe or SSD storage. Verified ingest — hash at the source, then assert integrity before reconstruction SD card / DCIM read-only source Compute SHA-256 at source, pre-copy rsync --checksum → project_root Verify manifest Canonical tree raw_imagery/ telemetry/ calibration/ metadata/ Quarantine + reflight never queued for ODM Forbidden destinations cloud-sync: Dropbox / OneDrive / Drive Git-tracked / version-controlled dirs destination must be local NVMe / SSD PASS FAIL

Minimal reproducible solution: a verified ingest

The routine below is the whole fix in under 60 lines. It computes a SHA-256 for every image at the source before any copy, materializes the canonical tree, and writes a manifest. Verification (next section) replays the same hashes against the destination, so a flipped bit is caught before the dataset is ever queued for reconstruction.

import hashlib
import shutil
from pathlib import Path

# Directories every project must contain; ingest creates them deterministically.
REQUIRED_DIRS = ("raw_imagery", "telemetry", "calibration", "metadata")
IMAGE_EXTS = {".jpg", ".jpeg", ".tif", ".tiff", ".dng"}


def _sha256(path: Path, chunk: int = 1 << 16) -> str:
    """Stream a file through SHA-256 so large frames never load fully into RAM."""
    h = hashlib.sha256()
    with path.open("rb") as fh:
        for block in iter(lambda: fh.read(chunk), b""):
            h.update(block)
    return h.hexdigest()


def ingest_flight(source: Path, project: Path, flight_id: str) -> Path:
    """Copy one flight into the canonical tree and emit a SHA-256 manifest.

    `source`  — the SD-card flight directory (read-only).
    `project` — e.g. project_root / "2025-05-12_site_alpha".
    Returns the manifest path; raises on a structurally empty source.
    """
    for sub in REQUIRED_DIRS:                       # idempotent: safe to re-run.
        (project / sub).mkdir(parents=True, exist_ok=True)

    dest = project / "raw_imagery" / flight_id
    dest.mkdir(parents=True, exist_ok=True)
    images = sorted(p for p in source.iterdir() if p.suffix.lower() in IMAGE_EXTS)
    if not images:
        raise ValueError(f"No imagery found in {source} — wrong card or path?")

    manifest = project / "metadata" / f"{flight_id}.sha256"
    with manifest.open("w") as mf:
        for img in images:
            digest = _sha256(img)                   # hash BEFORE the copy.
            shutil.copy2(img, dest / img.name)      # copy2 preserves mtime + EXIF.
            mf.write(f"{digest}  {flight_id}/{img.name}\n")
    return manifest

shutil.copy2 is deliberate: a plain copy discards the file modification time that some PPK tooling correlates against, and any re-encoding step would mutate the EXIF block. The hash is taken from the original bytes, so it certifies the source, not the copy.

Edge-case matrix

Storage ingest meets messier inputs than a clean SD card. Handle each explicitly rather than letting it surface as a confusing failure three stages later.

Input variant Symptom if ignored Expected handling
Cloud-synced destination (Dropbox/OneDrive) PermissionError / .JPG locked mid-read; EXIF stripped on sync Refuse to write under a synced or Git-tracked path; store on local NVMe/SSD only
Path exceeds 260 chars (Windows/NTFS) FileNotFoundError on otherwise-present files Validate full path length at ingest; shorten the project slug, never the frame name
Mixed sensor frames in one folder Single sensor profile assumed; calibration mismatch Split by EXIF Model into separate flights — see handling mixed sensor data in photogrammetry pipelines
Sidecar .XMP/.DNG without a JPEG twin Orphaned metadata or skipped raw frames Treat raw extensions as imagery; copy sidecars alongside their stem, never standalone
Re-running ingest after a partial copy Duplicate or half-written frames mkdir(exist_ok=True) + manifest replay makes ingest idempotent; re-verify before processing
Bit flip during transfer Garbage tile injects phantom tie-points Source-side SHA-256 + destination verification rejects the dataset before reconstruction

Verify the copy before anything reads it

Ingest is only trustworthy if you assert on it. The check below replays the manifest against the destination tree; a single mismatch raises, halting the pipeline before a corrupted frame reaches the flight overlap validation routine or the reconstruction engine.

def verify_manifest(manifest: Path, project: Path) -> int:
    """Re-hash every listed file; raise on the first mismatch. Returns count verified."""
    raw_root = project / "raw_imagery"
    verified = 0
    with manifest.open() as mf:
        for line in mf:
            if not line.strip():
                continue
            expected, rel = line.split(maxsplit=1)
            target = raw_root / rel.strip()
            if not target.exists():
                raise FileNotFoundError(f"Missing after copy: {rel.strip()}")
            if _sha256(target) != expected:
                raise RuntimeError(f"CHECKSUM MISMATCH (corrupt copy): {rel.strip()}")
            verified += 1
    assert verified > 0, "Empty manifest — ingest wrote nothing."
    print(f"Integrity OK: {verified} frames match their source hashes.")
    return verified

For network transfers, rsync -avz --checksum --progress followed by sha256sum -c manifest.sha256 --status enforces the same guarantee at the shell level, and tar --zstd produces a lossless archive once a dataset is verified and ready for cold storage.

When to escalate

This storage-layer fix is necessary but not always sufficient. Escalate to the parent workflow when:

  • Verification passes but reconstruction still warps the orthomosaic. The bytes are intact, so the problem is upstream geometry, not storage — re-run the flight overlap validation routine to confirm the capture actually met its forward and side overlap budget.
  • Manifests verify but ODM skips frames. EXIF was lost before ingest (in-camera or during a prior copy), not in storage. Move to how to validate EXIF GPS data before processing and reinject corrections from the telemetry logs.
  • Storage is sound but the run is I/O-bound or OOMs. This is a processing-configuration concern; tune cache and streaming in setting up OpenDroneMap with Python rather than touching the dataset.

Calculating Optimal Flight Overlap for Python Processing