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.

One frame, from raw JPEG to reconstruction-grade GeoTIFF — and the three shortcuts that break it A vertical four-stage converter (decode to RGB, read GPS sub-IFD, write lossless tiled GeoTIFF, reconstruction-grade input) with three amber side-branches showing the naive shortcuts that lead to failed feature matching, lost georeference, and a corrupt camera position. Correct converter Naive shortcut → failure Raw drone JPEG lossy DCT · GPS in sub-IFD Decode → 8-bit RGB array palette / grayscale → 3 bands Read GPS sub-IFD get_ifd(GPSInfo) · drop missing / out-of-range Write lossless tiled GeoTIFF deflate · 512 px tiles · identity transform · GPS_* tags Reconstruction-grade input SfM / ODM ingests cleanly re-encode with compress="jpeg" → DCT smears texture · feature matching fails read top-level getexif()[GPSInfo] → only a byte offset · no georeference fabricate affine from one GPS point → corrupt camera position fights SfM

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:

Setting Up OpenDroneMap with Python