Bulk Rewriting EXIF Without Recompressing JPEGs

PPK processing has produced corrected camera positions for a survey, and they need to go back into the images so the reconstruction picks them up. The obvious approach — open each image, set the tags, save — quietly re-encodes every JPEG, costing a generation of quality on ten thousand files and several hours of processing.

Metadata lives in segments that sit alongside the compressed image data, and editing it does not require touching the pixels at all. This page covers doing that correctly at survey scale: which tools edit losslessly, how to write atomically so an interrupted run does not corrupt a dataset, and how to verify that the pixels really were untouched. It completes the metadata work in validating and repairing EXIF metadata at scale.

Why a naive rewrite is expensive

A JPEG is a sequence of segments: metadata segments, a quantisation table, Huffman tables, and the entropy-coded image data. Changing a metadata segment means rewriting that segment and copying the rest byte for byte — an operation that takes microseconds and alters nothing about the image.

An imaging library that opens a JPEG decodes it to pixels, and saving re-encodes them. That costs time, costs a compression generation, and on a survey introduces artefacts that will show up in the reconstruction’s feature matching. It also silently changes the file’s quantisation tables to the library’s defaults, so the delivered imagery no longer matches what the camera produced.

The difference is not marginal. On ten thousand 20-megapixel images, a metadata-only rewrite is a few minutes and a re-encode is a few hours plus a measurable loss of detail.

Metadata-only rewrite against a decode and re-encode Two paths through a JPEG file. The metadata-only path replaces the application segment holding EXIF and copies the entropy-coded image data unchanged, taking about four minutes for ten thousand images with no quality change. The decode and re-encode path expands the image data to pixels and compresses it again, taking about three hours, losing a compression generation and replacing the camera's quantisation tables with the library's defaults. A note records that the second path also changes the file size and the byte-level content of every image. metadata-only rewrite new EXIF segment image data copied byte for byte 4 min decode and re-encode new EXIF segment pixels decoded, then compressed again a compression generation lost · tables replaced 3 h Forty-five times slower, and the delivered imagery no longer matches the camera's output. On a survey, the lost detail shows up in feature matching before anybody looks at an image. The choice is entirely in which tool is used, not in how the tags are written.

Figure 1 — Two paths, one of which never touches a pixel.

Minimal reproducible solution

ExifTool edits metadata in place without decoding, and its batch mode handles a survey in one process.

import csv
import subprocess
from pathlib import Path


def write_positions(updates: list[dict], *, backup: bool = True) -> dict:
    """Write corrected GPS positions to many images in one ExifTool call.

    A CSV-driven update is the fastest safe form: ExifTool reads the file,
    matches on SourceFile, and applies each row. One process handles ten
    thousand images in minutes, where one process per image would take hours
    in startup cost alone.
    """
    csv_path = Path("exif_updates.csv")
    fields = ["SourceFile", "GPSLatitude", "GPSLongitude", "GPSAltitude",
              "GPSLatitudeRef", "GPSLongitudeRef", "GPSAltitudeRef"]
    with csv_path.open("w", newline="") as fh:
        writer = csv.DictWriter(fh, fieldnames=fields)
        writer.writeheader()
        for row in updates:
            writer.writerow({k: row.get(k, "") for k in fields})

    cmd = ["exiftool", "-csv=" + str(csv_path), "-preserve"]
    if not backup:
        cmd.append("-overwrite_original")
    cmd.append(str(Path(updates[0]["SourceFile"]).parent))

    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        raise RuntimeError(f"exiftool failed: {result.stderr[:400]}")
    return {"updated": len(updates), "stdout": result.stdout.strip()[-200:]}

-preserve keeps the file’s modification time, which matters more than it sounds: a pipeline that sorts by file time to recover capture order will otherwise find every image stamped with the moment of the rewrite.

-overwrite_original is the flag to think about. Without it ExifTool keeps a _original copy of every file, which doubles the storage for a survey; with it, an interrupted run leaves some images updated and some not, with no record of which.

Writing atomically

For a survey that matters, neither of those is acceptable, and the answer is to write to a new directory and swap.

import shutil
import subprocess
from pathlib import Path


def rewrite_to_new_directory(src_dir: str, dst_dir: str, csv_path: str) -> dict:
    """Copy, then edit the copy, so the originals are never at risk.

    ExifTool's -o option writes new files rather than editing in place, so an
    interrupted run leaves a partial output directory and an untouched input
    — which is recoverable by simply running it again.
    """
    src, dst = Path(src_dir), Path(dst_dir)
    if dst.exists() and any(dst.iterdir()):
        raise FileExistsError(f"{dst} is not empty; refusing to write into it")
    dst.mkdir(parents=True, exist_ok=True)

    result = subprocess.run(
        ["exiftool", f"-csv={csv_path}", "-preserve", "-o", str(dst) + "/", str(src)],
        capture_output=True, text=True)
    if result.returncode != 0:
        raise RuntimeError(f"exiftool failed: {result.stderr[:400]}")

    src_count = len(list(src.glob("*.JPG"))) + len(list(src.glob("*.jpg")))
    dst_count = len(list(dst.glob("*.JPG"))) + len(list(dst.glob("*.jpg")))
    if src_count != dst_count:
        raise RuntimeError(f"wrote {dst_count} of {src_count} images — incomplete run")
    return {"images": dst_count, "source": str(src), "output": str(dst)}
Rewriting metadata in place compared with re-encoding the image Two columns. The metadata-only rewrite column notes that the compressed image data is copied byte for byte, that image quality is therefore unchanged, that it is fast because no decoding occurs, and that the file's own pixel content remains bit-identical to what the camera produced. The re-encode column notes that the image is decoded and recompressed, that quality is lost even at a high setting because JPEG is lossy on every encode, that it is slow, and that a survey processed from re-encoded images carries artefacts the camera never produced. metadata-only rewrite compressed data copied byte for byte quality unchanged fast — nothing is decoded pixels bit-identical to the camera's decode and re-encode the image is recompressed quality lost on every encode slow artefacts the camera never produced Any tool that opens an image to change a metadata field is the wrong tool for a survey.

Figure 3 — The distinction is whether the pixel data is touched at all.

Edge-case matrix

Situation Risk Handling
Imaging library used to save Re-encode, quality loss Use a metadata-only tool
In-place edit interrupted Partial dataset, no record Write to a new directory
_original backups kept Storage doubled Decide deliberately; clean up after verification
File mtime changed Capture order lost if derived from it -preserve
GPS reference tags omitted Position in the wrong hemisphere Always write the Ref tags with the values
Altitude sign Below sea level needs GPSAltitudeRef 1 Set it explicitly
Read-only source media Silent failure or partial write Copy first
XMP also needs updating EXIF updated, XMP stale Update both, or strip the stale XMP

The hemisphere row causes the most spectacular failures. GPSLatitude is written as an unsigned value with a separate GPSLatitudeRef of N or S, so an update that sets the value and not the reference places a southern-hemisphere survey in the north. The mirrored result is unmistakable and entirely avoidable.

def gps_tags(lat: float, lon: float, alt: float) -> dict:
    """Position tags with their reference tags, which must never be omitted."""
    return {
        "GPSLatitude": abs(lat), "GPSLatitudeRef": "N" if lat >= 0 else "S",
        "GPSLongitude": abs(lon), "GPSLongitudeRef": "E" if lon >= 0 else "W",
        "GPSAltitude": abs(alt), "GPSAltitudeRef": 0 if alt >= 0 else 1,
    }

Verification snippet

Verification has two parts: the metadata is what was intended, and the pixels are untouched.

import hashlib
import subprocess
from pathlib import Path


def image_data_digest(path: str) -> str:
    """Hash only the compressed image data, ignoring every metadata segment.

    Two files with identical image data and different metadata produce the
    same digest, which is exactly the property needed to prove a rewrite was
    lossless.
    """
    raw = subprocess.run(["exiftool", "-b", "-JpgFromRaw", "-ThumbnailImage", path],
                         capture_output=True).stdout
    data = Path(path).read_bytes()
    start = data.find(b"\xff\xda")            # start of scan
    if start < 0:
        raise ValueError(f"{path}: no scan marker found")
    return hashlib.sha256(data[start:]).hexdigest()[:16]


def verify_rewrite(originals: list[str], rewritten: list[str],
                   expected: dict[str, dict]) -> dict:
    """Pixels unchanged, metadata as intended."""
    problems = []
    for a, b in zip(sorted(originals), sorted(rewritten)):
        if image_data_digest(a) != image_data_digest(b):
            problems.append(f"{Path(b).name}: image data changed — this was a re-encode")
    out = subprocess.run(["exiftool", "-j", "-GPSLatitude", "-GPSLatitudeRef",
                          *rewritten[:50]], capture_output=True, text=True)
    return {"checked": len(rewritten), "problems": problems,
            "sample_metadata": out.stdout[:400]}

The scan-marker digest is the definitive test. It hashes the entropy-coded data and nothing else, so it is unaffected by any metadata change and changes immediately if a pixel was touched.

In-place rewrite against writing to a new directory Two strategies shown at the moment a run is interrupted. The in-place strategy leaves a directory where some images carry the new positions and some carry the old, with no record of which, and recovery requires comparing every file. The new-directory strategy leaves the source untouched and a partial output directory, and recovery is simply deleting the partial output and running again. A note records that the second costs disk space and the first costs an afternoon when it goes wrong. in place, interrupted new directory, interrupted mixed old and new, no record of which source partial output delete the output, run again One strategy costs disk space; the other costs an afternoon when it goes wrong. Survey imagery is not the place to economise on the first.

Figure 2 — What each strategy leaves behind when a run does not finish.

Whether to rewrite at all

Before writing anything, it is worth asking whether the positions need to be in the images. Several reconstruction tools accept an external position file, which avoids touching the imagery entirely and keeps the originals as the single source of truth.

Writing into the images wins when the imagery will be handed to somebody else’s software, or archived and reprocessed later by a tool whose position-file format is unknown. It loses when the pipeline is self-contained, because every rewrite is an opportunity for a partial run, a hemisphere error or an accidental re-encode.

The middle position — keep the originals untouched, write a corrected copy for delivery, and record the position file alongside both — costs storage and removes every failure mode on this page from the archive. On a survey programme where the raw imagery is kept anyway, that is usually the right trade.

When to escalate

  • The source media is read-only or nearly full. Copy to working storage first. An interrupted write on full media is the one case that can corrupt files.
  • Both EXIF and XMP carry positions. Update both or strip the stale one, or a downstream reader that prefers XMP will use the old values.
  • A re-encode has already happened. The lost detail is not recoverable. Re-derive from the originals if they exist, and add a digest check to the pipeline so it cannot recur.

Validating and Repairing EXIF Metadata at Scale