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:
- 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, andMake/Modeltags. 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. - 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.
- Non-deterministic paths. Flat dumps, OS-dependent separators, and dynamically named folders break the relative-path parsing that batch processors rely on, producing
FileNotFoundErroror, on locked files,PermissionErrormid-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.
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.