DevLab / utilities / image-exif-metadata-extractor
UTILITIES PYTHON BEGINNER

Image EXIF Metadata Extractor

Photographers, digital forensics analysts, and sysadmins frequently need to inventory large photo libraries — whether to audit what camera equipment was us

Python3.10+
OSLinux / macOS / Windows
Lines~201
Packages2 deps
Install Dependencies
$ pip install Pillow piexif
Usage
$ python3 exif_extract.py --input photos/ --output exif.csv --strip-gps
#utilities#python#beginner#Pillow#piexif

Image EXIF Metadata Extractor

Problem Statement

Photographers, digital forensics analysts, and sysadmins frequently need to inventory large photo libraries — whether to audit what camera equipment was used, track down when and where images were captured, or scrub sensitive GPS coordinates before publishing them to the web. Manually opening each image and inspecting its properties is impractical once you're dealing with hundreds or thousands of files. EXIF metadata embedded in JPEG and TIFF files contains a goldmine of information: camera make/model, ISO, aperture, shutter speed, focal length, timestamps, and often precise GPS coordinates.

This script walks a directory tree, extracts the EXIF metadata from every supported image into a structured CSV report, and optionally strips GPS tags so the originals can be shared safely. It runs from the command line, produces machine-readable output suitable for spreadsheet or SQL analysis, and fails gracefully on corrupt or EXIF-less files.

How It Works

The script uses Pillow to open and decode each image, and piexif to parse the raw EXIF byte payload into a dictionary of {IFD: {tag: value}}. Walking the input directory with os.walk yields files deterministically; each file is filtered by extension, opened in a with block (so file handles are always released), and its EXIF is extracted via piexif.load(). Human-readable tag names come from piexif.TAGS, and GPS coordinates are converted from the EXIF rational format into signed decimal degrees.

Design decisions: (1) a single ImageRecord dataclass keeps the extraction logic testable and typed; (2) failures are captured per-file into an error column rather than aborting the whole run, so one bad JPEG doesn't kill a 10,000-image batch; (3) the --strip-gps flag rewrites the file in place using piexif.dump/piexif.insert while preserving all other metadata; (4) output is streamed to CSV via csv.DictWriter to keep memory flat regardless of input size.

Full Script

#!/usr/bin/env python3
"""
Image EXIF Metadata Extractor

Walk a directory of images, extract EXIF metadata (camera, settings, timestamps,
GPS) into a CSV report, and optionally strip GPS tags from the source files.

Usage:
    python3 exif_extract.py --input photos/ --output exif.csv --strip-gps
"""

from __future__ import annotations

import argparse
import csv
import logging
import os
import sys
from dataclasses import dataclass, asdict, field
from pathlib import Path
from typing import Any, Iterable

import piexif
from PIL import Image, UnidentifiedImageError

# --- Configuration (overridable via environment variables) -------------------
LOG_LEVEL = os.environ.get("EXIF_LOG_LEVEL", "INFO").upper()
SUPPORTED_EXTS = {
    ext.strip().lower()
    for ext in os.environ.get(
        "EXIF_SUPPORTED_EXTS", ".jpg,.jpeg,.tif,.tiff,.heic,.webp"
    ).split(",")
    if ext.strip()
}
CSV_FIELDS = [
    "path", "filename", "make", "model", "lens", "datetime",
    "iso", "f_number", "exposure_time", "focal_length",
    "gps_lat", "gps_lon", "gps_alt", "orientation", "error",
]

# --- Logging -----------------------------------------------------------------
logging.basicConfig(
    level=LOG_LEVEL,
    format="%(asctime)s [%(levelname)s] %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("exif_extract")


@dataclass
class ImageRecord:
    """One row in the output CSV. Defaults are empty strings for readability."""
    path: str
    filename: str
    make: str = ""
    model: str = ""
    lens: str = ""
    datetime: str = ""
    iso: str = ""
    f_number: str = ""
    exposure_time: str = ""
    focal_length: str = ""
    gps_lat: str = ""
    gps_lon: str = ""
    gps_alt: str = ""
    orientation: str = ""
    error: str = ""


def _decode(value: Any) -> str:
    """Normalize EXIF values (bytes, tuples, rationals) to a clean string."""
    if value is None:
        return ""
    if isinstance(value, bytes):
        return value.decode("utf-8", errors="replace").strip("\x00").strip()
    if isinstance(value, tuple) and len(value) == 2:
        num, den = value
        if den == 0:
            return f"{num}/0"
        return f"{num / den:g}"
    return str(value).strip()


def _rational_to_float(value: Any) -> float | None:
    """Convert piexif rational (tuple) to float; return None if malformed."""
    if isinstance(value, tuple) and len(value) == 2 and value[1] != 0:
        return value[0] / value[1]
    if isinstance(value, (int, float)):
        return float(value)
    return None


def _gps_to_decimal(gps_ifd: dict[int, Any]) -> tuple[str, str, str]:
    """Convert EXIF GPS IFD to (lat, lon, alt) as decimal-degree strings."""
    try:
        lat_dms = gps_ifd.get(piexif.GPSIFD.GPSLatitude)
        lat_ref = gps_ifd.get(piexif.GPSIFD.GPSLatitudeRef, b"N")
        lon_dms = gps_ifd.get(piexif.GPSIFD.GPSLongitude)
        lon_ref = gps_ifd.get(piexif.GPSIFD.GPSLongitudeRef, b"E")
        alt = gps_ifd.get(piexif.GPSIFD.GPSAltitude)

        if not (lat_dms and lon_dms):
            return "", "", ""

        def dms_to_deg(dms: tuple) -> float:
            d, m, s = (_rational_to_float(x) or 0.0 for x in dms)
            return d + m / 60.0 + s / 3600.0

        lat = dms_to_deg(lat_dms)
        lon = dms_to_deg(lon_dms)
        if isinstance(lat_ref, bytes) and lat_ref.upper() == b"S":
            lat = -lat
        if isinstance(lon_ref, bytes) and lon_ref.upper() == b"W":
            lon = -lon

        alt_m = _rational_to_float(alt)
        return f"{lat:.6f}", f"{lon:.6f}", f"{alt_m:.2f}" if alt_m is not None else ""
    except (TypeError, ValueError, ZeroDivisionError) as exc:
        log.debug("GPS parse failed: %s", exc)
        return "", "", ""


def extract_metadata(filepath: Path) -> ImageRecord:
    """Extract EXIF from a single image. Never raises — errors go in .error."""
    rec = ImageRecord(path=str(filepath), filename=filepath.name)
    try:
        # Verify the file is a real image before handing bytes to piexif.
        with Image.open(filepath) as img:
            img.verify()

        exif_bytes = piexif.load(str(filepath))
        zeroth = exif_bytes.get("0th", {})
        exif_ifd = exif_bytes.get("Exif", {})
        gps_ifd = exif_bytes.get("GPS", {})

        rec.make = _decode(zeroth.get(piexif.ImageIFD.Make))
        rec.model = _decode(zeroth.get(piexif.ImageIFD.Model))
        rec.orientation = _decode(zeroth.get(piexif.ImageIFD.Orientation))
        rec.datetime = _decode(
            zeroth.get(piexif.ImageIFD.DateTime)
            or exif_ifd.get(piexif.ExifIFD.DateTimeOriginal)
        )
        rec.lens = _decode(exif_ifd.get(piexif.ExifIFD.LensModel))
        rec.iso = _decode(exif_ifd.get(piexif.ExifIFD.ISOSpeedRatings))
        rec.f_number = _decode(exif_ifd.get(piexif.ExifIFD.FNumber))
        rec.exposure_time = _decode(exif_ifd.get(piexif.ExifIFD.ExposureTime))
        rec.focal_length = _decode(exif_ifd.get(piexif.ExifIFD.FocalLength))
        rec.gps_lat, rec.gps_lon, rec.gps_alt = _gps_to_decimal(gps_ifd)
    except (UnidentifiedImageError, OSError, ValueError, SyntaxError) as exc:
        rec.error = f"{type(exc).__name__}: {exc}"
        log.warning("Failed to read %s: %s", filepath, exc)
    return rec


def strip_gps(filepath: Path) -> bool:
    """Remove GPS IFD from an image, preserving all other EXIF. Returns success."""
    try:
        exif_dict = piexif.load(str(filepath))
        if not exif_dict.get("GPS"):
            return True  # Nothing to strip.
        exif_dict["GPS"] = {}
        piexif.insert(piexif.dump(exif_dict), str(filepath))
        log.info("Stripped GPS from %s", filepath)
        return True
    except (UnidentifiedImageError, OSError, ValueError, piexif.InvalidImageDataError) as exc:
        log.error("Could not strip GPS from %s: %s", filepath, exc)
        return False


def iter_images(root: Path) -> Iterable[Path]:
    """Yield image paths under root whose extension is supported."""
    for dirpath, _dirnames, filenames in os.walk(root):
        for name in sorted(filenames):
            p = Path(dirpath) / name
            if p.suffix.lower() in SUPPORTED_EXTS:
                yield p


def build_arg_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="Extract EXIF metadata from images into a CSV report."
    )
    p.add_argument("--input", required=True, type=Path,
                   help="Directory containing images (walked recursively).")
    p.add_argument("--output", type=Path, default=Path("exif.csv"),
                   help="CSV output path (default: exif.csv).")
    p.add_argument("--strip-gps", action="store_true",
                   help="Remove GPS tags from source files in place.")
    p.add_argument("--dry-run", action="store_true",
                   help="Report what would be processed without writing files.")
    return p


def main(argv: list[str] | None = None) -> int:
    args = build_arg_parser().parse_args(argv)

    if not args.input.is_dir():
        log.error("Input is not a directory: %s", args.input)
        return 2

    images = list(iter_images(args.input))
    if not images:
        log.warning("No images found under %s (looked for %s)",
                    args.input, ", ".join(sorted(SUPPORTED_EXTS)))
        return 1

    log.info("Found %d image(s) under %s", len(images), args.input)

    if args.dry_run:
        for p in images:
            log.info("Would process: %s", p)
        return 0

    if args.strip_gps:
        stripped = sum(1 for p in images if strip_gps(p))
        log.info("GPS strip complete: %d/%d succeeded", stripped, len(images))

    # Stream results to CSV so memory stays flat on huge libraries.
    errors = 0
    try:
        with args.output.open("w", newline="", encoding="utf-8") as fh:
            writer = csv.DictWriter(fh, fieldnames=CSV_FIELDS)
            writer.writeheader()
            for i, path in enumerate(images, 1):
                rec = extract_metadata(path)
                if rec.error:
                    errors += 1
                writer.writerow(asdict(rec))
                if i % 500 == 0:
                    log.info("Processed %d/%d...", i, len(images))
    except OSError as exc:
        log.error("Cannot write CSV to %s: %s", args.output, exc)
        return 3

    log.info("Wrote %d rows to %s (%d with errors)",
             len(images), args.output, errors)
    return 0


if __name__ == "__main__":
    sys.exit(main())

Configuration

Name Type Default Purpose
EXIF_LOG_LEVEL str INFO Python logging level (DEBUG, INFO, WARNING, ERROR). Use DEBUG to see per-file GPS parse failures.
EXIF_SUPPORTED_EXTS str .jpg,.jpeg,.tif,.tiff,.heic,.webp Comma-separated list of file extensions to scan. Adjust for RAW formats (.cr2, .nef) if your Pillow build supports them.

CLI flags:

  • --input (required): root directory; walked recursively.
  • --output: CSV destination (default exif.csv).
  • --strip-gps: rewrites each source file in place with the GPS IFD removed.
  • --dry-run: lists files that would be processed without touching anything.

Usage Examples

1. Basic extraction to CSV:

$ python3 exif_extract.py --input photos/ --output exif.csv
2024-06-12 10:14:02 [INFO] Found 342 image(s) under photos/
2024-06-12 10:14:05 [INFO] Wrote 342 rows to exif.csv (0 with errors)

2. Strip GPS from a directory before publishing to the web:

$ python3 exif_extract.py --input web_upload/ --output /tmp/report.csv --strip-gps
2024-06-12 10:20:11 [INFO] Found 87 image(s) under web_upload/
2024-06-12 10:20:11 [INFO] Stripped GPS from web_upload/IMG_0042.jpg
2024-06-12 10:20:12 [INFO] GPS strip complete: 87/87 succeeded
2024-06-12 10:20:13 [INFO] Wrote 87 rows to /tmp/report.csv (0 with errors)

3. Dry-run with debug logging to preview a large library:

$ EXIF_LOG_LEVEL=DEBUG python3 exif_extract.py --input /mnt/archive --dry-run
2024-06-12 10:30:00 [INFO] Found 12480 image(s) under /mnt/archive
2024-06-12 10:30:00 [INFO] Would process: /mnt/archive/2023/01/DSC_0001.jpg
...

The resulting CSV looks like:

path,filename,make,model,lens,datetime,iso,f_number,exposure_time,focal_length,gps_lat,gps_lon,gps_alt,orientation,error
photos/a.jpg,a.jpg,NIKON CORPORATION,NIKON D750,24.0-70.0 mm f/2.8,2023:08:14 18:22:01,400,2.8,1/250,50,37.774929,-122.419418,12.30,1,
photos/bad.jpg,bad.jpg,,,,,,,,,,,,,UnidentifiedImageError: cannot identify image file

Error Handling

  • Corrupt or non-image files — Image.verify() catches them early; the row is still written with an error column populated, and processing continues.
  • Files without EXIF (e.g., PNG screenshots) — piexif.load returns empty IFDs; the row is emitted with blank metadata, not an error.
  • Malformed GPS rationals — _gps_to_decimal guards against zero denominators and non-tuple values, returning empty strings instead of crashing.
  • Permission / I/O errors — caught per-file as OSError; the CSV write itself is wrapped in a separate try that returns exit code 3 on failure.
  • Missing input directory — validated up front, exit code 2.
  • No matching files — logged as a warning, exit code 1.
  • In-place rewriting risk — --strip-gps uses piexif.insert, which writes atomically at the byte level but does modify the source. Always test on a copy first, or use --dry-run.

Exit codes: 0 success, 1 no images found, 2 bad input dir, 3 CSV write failure.

Extending Ideas

  • Parallel extraction — swap the sequential loop for concurrent.futures.ProcessPoolExecutor; EXIF parsing is CPU-bound and scales linearly with cores on large libraries.
  • Persist to SQLite/Postgres — replace the csv.DictWriter with sqlite3.executemany to enable SQL queries like "all photos shot at ISO > 3200 with no GPS".
  • Reverse-geocode GPS — pipe gps_lat/gps_lon through a local reverse_geocoder or Nominatim API to add a location column for human-friendly reporting.
  • Watch mode — wrap iter_images in a watchdog observer so newly dropped files are extracted and GPS-stripped automatically, ideal for a photo-ingest pipeline.
// RELATED_SCRIPTS
← Back to DevLab valtersit.com