Python Script to Convert Drone Images to TIFF
You point a reconstruction job at a folder of converted TIFFs and the run either dies during feature matching or produces a holed, low-confidence sparse cloud — even though the source JPEGs looked fine. The usual cause is the conversion itself: a one-line PIL.Image.open(src).save(dst) re-encodes the frame with lossy compression, drops the GPS sub-IFD, or writes an untiled strip image that the engine cannot stream. This page gives you a small, deterministic Python converter that turns raw drone captures into lossless, tiled GeoTIFFs that a photogrammetry engine ingests cleanly. It is the input-preparation step that feeds setting up OpenDroneMap with Python, and it sits inside the broader core photogrammetry fundamentals for Python pipelines.
Why naive JPEG-to-TIFF conversion breaks the pipeline
Three properties of the conversion decide whether the output is reconstruction-grade, and the obvious shortcuts get all three wrong.
Lossy re-encoding destroys feature descriptors. Structure-from-motion matches frames by computing local descriptors (SIFT/AKAZE-style) around keypoints. JPEG and the GDAL JPEG TIFF compressor both apply a DCT quantization that smears high-frequency texture — exactly the signal those descriptors key on. Saving a TIFF with compress="jpeg" therefore looks like a TIFF but matches like a degraded JPEG. The fix is to use a strictly lossless compressor (deflate, lzw, or zstd) so pixel values survive the round trip bit-for-bit.
The GPS fix lives in a sub-IFD, not the main EXIF dictionary. With Pillow, img.getexif()[GPSInfo] returns only an offset into the file, not the coordinate values. The latitude/longitude rationals live in a separate GPS sub-IFD that you must open explicitly with get_ifd(ExifTags.IFD.GPSInfo). Code that reads the top-level dictionary silently produces georeferencing-free TIFFs, and the camera positions never reach the bundle adjustment. This is the same EXIF-GPS extraction concern handled at folder scale in structuring drone imagery for batch processing.
A single frame must not carry a geotransform. A common mistake is to synthesize an affine transform from one GPS point so the TIFF “looks georeferenced”. A single nadir frame has no scale, rotation, or footprint until it is oriented against its neighbours — fabricating a transform writes a corrupt position that fights the reconstruction. The correct behavior is to store the camera position as metadata tags and let the SfM/ODM stage solve georeferencing, using the project CRS later enforced through managing coordinate reference systems in GDAL.
Minimal reproducible converter
This is the focused, single-frame solution. It forces 8-bit RGB, writes a lossless tiled GeoTIFF, and records the GPS fix as metadata only. Every line that prevents one of the failures above is commented.
from pathlib import Path
import numpy as np
import rasterio
from PIL import Image, ExifTags
def _dms_to_deg(dms, ref):
"""Convert an EXIF (deg, min, sec) rational triple to signed decimal degrees."""
d, m, s = (float(x) for x in dms)
deg = d + m / 60.0 + s / 3600.0
return -deg if ref in ("S", "W") else deg # southern/western hemisphere is negative
def read_gps(img):
"""Read lat/lon from the GPS sub-IFD — getexif()[GPSInfo] is only a byte offset."""
gps = img.getexif().get_ifd(ExifTags.IFD.GPSInfo)
if not gps:
return None # missing GPS is normal indoors; do not invent a position
try:
lat = _dms_to_deg(gps[ExifTags.GPS.GPSLatitude], gps.get(ExifTags.GPS.GPSLatitudeRef, "N"))
lon = _dms_to_deg(gps[ExifTags.GPS.GPSLongitude], gps.get(ExifTags.GPS.GPSLongitudeRef, "E"))
except (KeyError, TypeError, ZeroDivisionError):
return None # malformed rational → drop the fix rather than write garbage
if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
return None # out-of-range value is corrupt, not a real coordinate
return lat, lon
def to_geotiff(src: str, dst: str) -> None:
"""Write one drone frame as a lossless, tiled RGB GeoTIFF with GPS metadata."""
with Image.open(src) as im:
if im.mode != "RGB":
im = im.convert("RGB") # force 3-band 8-bit for feature matching
arr = np.asarray(im) # shape: height x width x 3
gps = read_gps(im) # read GPS before the file handle closes
profile = {
"driver": "GTiff", "height": arr.shape[0], "width": arr.shape[1],
"count": 3, "dtype": arr.dtype, "photometric": "RGB",
"compress": "deflate", # lossless — JPEG recompression kills descriptors
"tiled": True, "blockxsize": 512, "blockysize": 512, # power-of-two tiles stream well
"bigtiff": "IF_SAFER", # auto-promote past the 4 GB classic-TIFF limit
}
with rasterio.open(dst, "w", **profile) as out:
out.write(np.moveaxis(arr, -1, 0)) # rasterio wants band-first: 3 x height x width
if gps: # store position as tags; never a fake transform
out.update_tags(GPS_LATITUDE=f"{gps[0]:.8f}", GPS_LONGITUDE=f"{gps[1]:.8f}")
if __name__ == "__main__":
to_geotiff("raw/DJI_0001.JPG", "tif/DJI_0001.tif")
Install the two dependencies into a virtual environment first: pip install "rasterio>=1.3" "Pillow>=10" "numpy>=1.24". Rasterio bundles GDAL wheels on most platforms, so no separate GDAL build is required for this step.
Edge-case matrix
The converter is small, but real flight folders are full of irregular frames. This is the behavior you should expect — and assert — for each variant.
| Input variant | What goes wrong naively | Expected handling here |
|---|---|---|
| No GPS sub-IFD (indoor / RTK-denied) | Code crashes on a missing key, or writes a 0,0 position | read_gps returns None; TIFF is written without GPS tags |
Southern / western hemisphere (S / W ref) |
Sign dropped → point lands in the wrong hemisphere | GPSLatitudeRef/GPSLongitudeRef negate the decimal degrees |
| Malformed rational (zero denominator) | ZeroDivisionError aborts the whole batch |
Caught; the single frame is dropped, others continue |
| Latitude > 90 or longitude > 180 | Corrupt fix written as a real camera position | Range check returns None, tags omitted |
| Palette / grayscale PNG (telemetry overlay) | 1-band or indexed array breaks 3-band write | convert("RGB") normalizes to 8-bit, 3 channels |
| 100 MP+ medium-format frame | numpy allocates many GB and may OOM |
bigtiff="IF_SAFER" + tiled write streams blocks; cap dimensions upstream if RAM-bound |
Verify the output before you trust it
Conversion failures are silent — a bad TIFF opens fine and only collapses during reconstruction. Run this assertion pass on the output before promoting a batch.
import rasterio
from rasterio import Affine
def verify(path: str) -> None:
with rasterio.open(path) as ds:
assert ds.count == 3, "expected 3 RGB bands for feature matching"
assert ds.profile["compress"].lower() in {"deflate", "lzw", "zstd"}, "lossy compression"
assert ds.profile["tiled"], "writer produced strips, not tiles"
assert ds.transform == Affine.identity(), "single frame must not carry a geotransform"
has_gps = "GPS_LATITUDE" in ds.tags()
print(f"{path}: OK (georeferenced={has_gps})")
verify("tif/DJI_0001.tif")
A passing run guarantees a 3-band, losslessly compressed, tiled image with no fabricated transform — the exact contract the reconstruction engine expects. Equivalent one-liners are gdalinfo tif/DJI_0001.tif (look for Block=512x512, COMPRESSION=DEFLATE, and the absence of a Pixel Size/Origin line).
When to escalate
This single-frame converter is deliberately narrow. Move up to the parent workflow when:
- You need to process whole flight folders with logging, retries, and a manifest — that batch loop and its CLI belong to setting up OpenDroneMap with Python, which consumes these TIFFs directly.
- You actually need to reproject or stamp a survey CRS onto a true orthomosaic (not a single frame) — that is a reprojection job covered in managing coordinate reference systems in GDAL.
- Many frames are being dropped for missing GPS or the block has thin coverage — diagnose capture geometry with the flight overlap validation routine before blaming the converter.