Repairing Corrupt or Truncated EXIF Headers

The ingest run reports forty frames with no GPS. They open fine in an image viewer, they look correct, and every one of them was shot in the middle of a flight where the frames on either side are perfect. The card filled, or the camera lost power, or a transfer was interrupted — and the result is a set of files whose pixel data survived and whose metadata did not.

This page classifies the damage, recovers the frames that can be recovered, and quarantines the rest with a reason attached, so that a partially damaged batch produces a smaller good dataset rather than a silently biased one.

Why the pixels survive and the metadata does not

A JPEG is a sequence of segments. The EXIF block sits near the front, in an APP1 segment, and the compressed image data follows. That layout produces two asymmetric failure modes.

Truncation at the tail removes image data and leaves the header intact. The decoder produces a partial image — grey below some scanline — and every EXIF tag reads correctly. These frames have valid metadata and unusable pixels.

Corruption in the header damages the APP1 segment while the image data is untouched. The decoder skips the malformed segment and renders the frame perfectly; EXIF parsing returns nothing, or returns a subset. These frames have usable pixels and no position.

The second case is the one that reaches a reconstruction, because nothing about the image looks wrong. A pipeline that drops frames without GPS silently discards them; one that does not check ends up feeding the solver a block with a hole in its position coverage.

JPEG segment layout and the two ways damage lands A JPEG file drawn as a horizontal sequence of segments: the start-of-image marker, the APP1 segment containing EXIF including the GPS sub-directory, quantisation and Huffman tables, the start-of-scan marker, the compressed image data, and the end-of-image marker. Below it, two damage cases. Truncation removes the tail, so the image data and end marker are lost while every EXIF tag reads correctly, producing a partial image with good metadata. Header corruption damages the APP1 segment only, so the decoder skips it and renders a perfect image with no metadata at all. SOI APP1 — EXIF camera, GPS sub-IFD, timestamps DQT / DHT SOS compressed image data EOI truncated tail image data cut, EOI missing EXIF reads perfectly good metadata, unusable pixels damaged APP1 decoder skips the segment image renders perfectly usable pixels, no position — reaches the solver Only the right-hand case survives a visual check, which is why it is the one worth automating against.

Figure 1 — The layout that makes the two failures asymmetric. A viewer renders the dangerous case flawlessly, because rendering is exactly the operation that does not consult EXIF.

Minimal reproducible solution

Classification comes first: read the structure rather than trusting a parser’s exception. Four states are distinguishable from the bytes.

from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class Diagnosis:
    path: Path
    state: str       # ok | truncated | no_exif | not_jpeg
    detail: str


def classify(path: Path) -> Diagnosis:
    """Structural classification from the file's own markers."""
    data = path.read_bytes()
    if len(data) < 4 or data[:2] != b"\xff\xd8":
        return Diagnosis(path, "not_jpeg", "missing start-of-image marker")

    if data[-2:] != b"\xff\xd9":
        return Diagnosis(path, "truncated",
                         f"no end-of-image marker; {len(data):,} bytes")

    # APP1 immediately follows SOI on a normal camera JPEG.
    if data[2:4] != b"\xff\xe1":
        return Diagnosis(path, "no_exif", "no APP1 segment after SOI")

    seg_len = int.from_bytes(data[4:6], "big")
    if data[6:12] != b"Exif\x00\x00":
        return Diagnosis(path, "no_exif", "APP1 present but not EXIF")
    if 2 + seg_len > len(data):
        return Diagnosis(path, "no_exif",
                         f"APP1 claims {seg_len} bytes, file has {len(data) - 2}")
    return Diagnosis(path, "ok", f"APP1 {seg_len} bytes")

Checking the declared segment length against the file size catches the case a parser reports as a generic error: a header whose length field was written before the write failed, so it describes a segment larger than the file that contains it.

With the classification in hand, recovery is possible in exactly one of the four states. A frame with a damaged APP1 but intact pixels can be given metadata from its neighbours — not its position, which must not be invented, but its camera model, focal length and orientation, which are constant across a flight and which the reconstruction needs in order to use the frame at all.

import piexif


def restore_camera_tags(damaged: Path, donor: Path, out: Path) -> None:
    """Copy the invariant camera tags from a good neighbouring frame.

    Deliberately does NOT copy GPS or timestamps: those are per-frame
    measurements, and a borrowed one is a fabricated observation.
    """
    good = piexif.load(str(donor))
    keep_0th = {piexif.ImageIFD.Make, piexif.ImageIFD.Model,
                piexif.ImageIFD.Orientation}
    keep_exif = {piexif.ExifIFD.FocalLength, piexif.ExifIFD.FocalLengthIn35mmFilm,
                 piexif.ExifIFD.PixelXDimension, piexif.ExifIFD.PixelYDimension}

    rebuilt = {
        "0th": {k: v for k, v in good["0th"].items() if k in keep_0th},
        "Exif": {k: v for k, v in good["Exif"].items() if k in keep_exif},
        "GPS": {},                       # left empty on purpose
        "1st": {}, "thumbnail": None,
    }
    piexif.insert(piexif.dump(rebuilt), str(damaged), str(out))

Leaving GPS empty is the important half. A frame with borrowed camera parameters and no position is a usable image the solver will place by feature matching. A frame with a borrowed position is a false observation that will pull the block, and it is indistinguishable from a real one once written.

Edge-case matrix

Damage Classification Recovery
Tail truncated, EXIF intact truncated None — pixels are incomplete; quarantine
APP1 damaged, pixels intact no_exif Restore camera tags from a neighbour; no GPS
APP1 length field overruns the file no_exif Same; the segment cannot be trusted
Zero-length file not_jpeg Re-copy from the card if it still exists
Whole file is zeros not_jpeg Card failure; do not attempt recovery
GPS sub-IFD only is damaged ok structurally Caught later by the bounding-box check
Non-fatal maker-note corruption ok Ignore; maker notes are not used
Renamed .jpg that is a PNG not_jpeg Convert or exclude

The sixth row is why structural classification is necessary but not sufficient: a file can be structurally perfect and contain a GPS sub-directory full of nonsense. That is caught downstream by the plausibility checks in how to validate EXIF GPS data before processing, and the two checks are complementary rather than alternatives.

Routing a damaged batch: three destinations, one report An ingest flow that classifies every frame and routes it to one of three destinations. Structurally sound frames go straight to the working set. Frames with a damaged header but intact pixels are repaired with camera tags borrowed from a neighbour, left without GPS, and also join the working set. Truncated or unreadable frames go to quarantine with the reason recorded. All three paths write to a single report that states how many frames took each route, so a partially damaged batch is visible rather than merely smaller. classify from the file's markers ok — straight through no change repair camera tags GPS deliberately left empty quarantine truncated or unreadable one ingest report counts per route A batch that lost forty frames should say so. Silently processing 600 of 640 is how a coverage gap becomes a reconstruction defect.

Figure 2 — The routing. Two of the three destinations still contribute imagery; what matters is that the third is counted rather than merely absent.

Verification snippet

After repair, the frames that were fixed must decode and must still lack GPS — the second half being the part that is easy to get wrong by copying a whole EXIF block.

import piexif
from PIL import Image


def assert_repair_sound(path) -> None:
    """A repaired frame decodes fully and carries no borrowed position."""
    with Image.open(path) as im:
        im.load()                            # raises on truncated data
        assert im.size[0] > 0 and im.size[1] > 0

    tags = piexif.load(str(path))
    assert tags["0th"].get(piexif.ImageIFD.Model), "camera model not restored"
    assert tags["Exif"].get(piexif.ExifIFD.FocalLength), "focal length not restored"
    assert not tags["GPS"], (
        "repaired frame carries GPS tags — a borrowed position is a fabricated "
        "observation and must not be written")

im.load() rather than Image.open() alone is what makes the decode check real: opening a JPEG reads only the header, and a truncated file opens without complaint. Forcing the decode is what surfaces the missing scanlines.

One further guard belongs in the ingest step rather than in the repair. Frames that lose their position but keep their pixels create a coverage gap in the position graph even when the imagery is complete, and on a block that relies on camera priors for its initial poses that gap can be the difference between a converging reconstruction and one that splits. Counting positionless frames per flight strip, rather than per batch, is what makes such a gap visible: forty frames without GPS spread evenly across a survey are a nuisance, while forty consecutive ones are a hole in the trajectory that the solver will feel.

When to escalate

  • More than a few per cent of a batch is damaged. This is a hardware signal rather than a per-file problem. A card producing scattered truncations is failing, and the correct response is to stop using it and re-copy from the original if one exists, not to repair forty frames and continue.
  • Damage is contiguous in capture order. A run of consecutive damaged frames is a write interruption — a battery event or a card swap — and the frames are usually genuinely lost. The coverage gap that leaves is a flight-geometry problem, evaluated against the overlap floor rather than fixed here.
  • Frames repair cleanly and the reconstruction still drops them. The pixels are intact and something else disqualifies them, most often a dimension or camera model that does not match the rest of the block. That is the mixed-sensor case described in handling mixed sensor data in photogrammetry pipelines.

Troubleshooting Ingestion and CRS Failures

Where a checksum at the card would have caught this A timeline of a damaged batch's life. The damage occurs during the card-to-archive copy. Without a checksum, it is discovered at ingest, after the flight crew have left the site and the card may already have been reformatted, so the frames are unrecoverable. With a checksum computed at the card and verified after the archive write, the mismatch is discovered while the card is still present and the affected files can simply be copied again. A note gives the cost of the checksum as one read of data that is being read anyway. without a checksum — discovered at ingest copy from card crew leave, card reused damage found at ingest unrecoverable with a checksum at the card copy from card verify — mismatch re-copy affected files recovered, card still present The repair techniques on this page exist for batches where that verification did not happen. The checksum costs one read of data already being read, and it moves the discovery to the only moment recovery is possible. Everything below that line is salvage, and salvage never returns the frames that were truncated.

Figure 3 — The cheapest fix is upstream of this page entirely. Repair recovers metadata; nothing recovers pixels that were never written.