← All tasks
pythoncodex/python-t1 #22Not a task: already works

EXIF Metadata Extractor (python, written by Codex)

envgap__codex__python-t1-22

Written by a coding agent; not on GitHubWritten 2026-03-03

01 / FAILURE SIGNATURE

As the study recorded it

None
Not a benchmark task.
  • The project already builds and runs before the fix, so there is nothing to repair.

02 / ENVIRONMENT RECIPE

Base commit
Not freshly verified
Manifest
requirements.txt
Reproduce
Awaiting issue-specific recipe
Run under trace
Awaiting a meaningful runtime command

03 / TASK AND FAILURE

codex/python-t1 #22 · read the task the agent was given
Codex wrote this python project from the task below. It installed and ran on a clean Ubuntu 22.04 machine as written.

Task given to the agent:

TASK: EXIF Metadata Extractor

Write a program that reads, displays, and modifies EXIF metadata embedded in image files, supporting GPS coordinate extraction, metadata comparison between images, and bulk metadata operations.

FUNCTIONAL REQUIREMENTS:
- Accept an image file path as a command-line argument (support JPEG, TIFF, and PNG files)
- Extract and display all EXIF tags organized by category: Camera Info (make, model, serial), Capture Settings (exposure time, f-number, ISO, focal length, flash), Date/Time (original, digitized, modified), GPS (latitude, longitude, altitude with decimal degree conversion), Image Info (dimensions, orientation, color space, resolution)
- Support output in multiple formats via --format flag: text (default human-readable), json, csv
- Extract GPS coordinates and convert to both DMS (degrees/minutes/seconds) and decimal degrees format, with an optional Google Maps URL via --map-url flag
- Compare metadata between two images via --compare flag, showing differences side by side
- Support removing specific EXIF tags via --remove flag (e.g., --remove GPS to strip all GPS data for privacy)
- Support removing all metadata via --strip-all flag while preserving image quality
- Support batch extraction from all images in a directory via --batch flag, with a summary CSV output
- Print the metadata summary to console in a structured, readable format
- Save the full metadata dump to a file via --output flag
- If no input is given, generate a sample JPEG image with comprehensive EXIF metadata including GPS coordinates, camera info, and capture settings, then extract and display all metadata
- Handle errors: images without EXIF data, corrupted metadata, unsupported tag types, and read-only files

Create a complete Python project for a clean Ubuntu 22.04 machine with only Python 3.10+ installed. Include:
- Source code
- requirements.txt with all dependencies (direct and transitive) pinned to exact versions
- README.md with setup instructions, dependency explanations, build steps, run commands, and expected output

04 / LABELS

Labels from the report text only; not yet run

No supported category has been assigned.

Label rules and the text that matched
[]

05 / FILES

The project as the agent wrote it

3 files, exactly as written, before any repair.

README.md
# EXIF Metadata Extractor (Python)

Reads, displays, compares, and modifies EXIF metadata for JPEG/TIFF/PNG images, including GPS conversion and batch summary export.

## Requirements

- Ubuntu 22.04
- Python 3.10+

## Dependencies (Pinned)

- `Pillow==10.4.0`
- `piexif==1.1.3`

## Setup

```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```

## Run

```bash
python src/main.py image.jpg
python src/main.py image.jpg --format json --output metadata.json
python src/main.py image.jpg --format csv --output metadata.csv
python src/main.py image.jpg --map-url
python src/main.py image.jpg --compare other.jpg
python src/main.py image.jpg --remove GPS --output image_no_gps.jpg
python src/main.py image.jpg --strip-all --output image_stripped.jpg
python src/main.py ./images --batch --output batch_summary.csv
python src/main.py
```

## Notes

- `--remove` supports category targets like `GPS`, `date`, `camera` for JPEG/TIFF EXIF.
- `--strip-all` re-saves image without metadata.
- No-input mode generates `sample_exif.jpg` with camera/date/GPS metadata and extracts it.
requirements.txt
Pillow==10.4.0
piexif==1.1.3
src/main.py
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import csv
import json
from pathlib import Path
from typing import Any

import piexif
from PIL import Image, ImageDraw, ExifTags

SUPPORTED = {".jpg", ".jpeg", ".tif", ".tiff", ".png"}


def parse_rational(value: Any) -> float | None:
    if value is None:
        return None
    if isinstance(value, (int, float)):
        return float(value)
    if isinstance(value, tuple) and len(value) == 2 and value[1] != 0:
        return value[0] / value[1]
    if hasattr(value, "numerator") and hasattr(value, "denominator") and value.denominator:
        return value.numerator / value.denominator
    return None


def decimal_to_dms(decimal: float | None, lat_lon: str) -> str | None:
    if decimal is None:
        return None
    abs_v = abs(decimal)
    deg = int(abs_v)
    minutes_float = (abs_v - deg) * 60
    minutes = int(minutes_float)
    seconds = (minutes_float - minutes) * 60
    hemi = "N" if (lat_lon == "lat" and decimal >= 0) else "S" if lat_lon == "lat" else "E" if decimal >= 0 else "W"
    return f'{deg}°{minutes}\'{seconds:.2f}" {hemi}'


def gps_to_decimal(coords: Any, ref: Any) -> float | None:
    if not coords or len(coords) != 3:
        return None
    d = parse_rational(coords[0])
    m = parse_rational(coords[1])
    s = parse_rational(coords[2])
    if d is None or m is None or s is None:
        return None
    dec = d + (m / 60.0) + (s / 3600.0)
    ref_text = ref.decode() if isinstance(ref, bytes) else str(ref)
    if ref_text in {"S", "W"}:
        dec = -dec
    return dec


def map_url(lat: float | None, lon: float | None) -> str | None:
    if lat is None or lon is None:
        return None
    return f"https://www.google.com/maps?q={lat},{lon}"


def parse_exif_dict(image_path: Path) -> tuple[dict, dict]:
    with Image.open(image_path) as img:
        exif_bytes = img.info.get("exif")
        if exif_bytes:
            try:
                exif_dict = piexif.load(exif_bytes)
                return exif_dict, img.info
            except Exception:
                pass

        # Fallback for files without standard EXIF blob
        fallback = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "thumbnail": None}
        raw_exif = img.getexif()
        for tag_id, value in raw_exif.items():
            name = ExifTags.TAGS.get(tag_id, str(tag_id))
            fallback["Exif"][name] = value
        return fallback, img.info


def extract_metadata(image_path: Path, include_map_url: bool) -> dict:
    if image_path.suffix.lower() not in SUPPORTED:
        raise ValueError(f"Unsupported image format: {image_path}")
    if not image_path.exists() or not image_path.is_file():
        raise FileNotFoundError(f"Input not found: {image_path}")

    with Image.open(image_path) as img:
        width, height = img.size
        mode = img.mode

    exif_dict, _ = parse_exif_dict(image_path)

    zeroth = exif_dict.get("0th", {})
    exif_ifd = exif_dict.get("Exif", {})
    gps_ifd = exif_dict.get("GPS", {})

    lat = gps_to_decimal(gps_ifd.get(piexif.GPSIFD.GPSLatitude), gps_ifd.get(piexif.GPSIFD.GPSLatitudeRef))
    lon = gps_to_decimal(gps_ifd.get(piexif.GPSIFD.GPSLongitude), gps_ifd.get(piexif.GPSIFD.GPSLongitudeRef))
    alt = parse_rational(gps_ifd.get(piexif.GPSIFD.GPSAltitude))

    data = {
        "image": str(image_path.resolve()),
        "categories": {
            "camera_info": {
                "make": zeroth.get(piexif.ImageIFD.Make),
                "model": zeroth.get(piexif.ImageIFD.Model),
                "serial": exif_ifd.get(piexif.ExifIFD.BodySerialNumber),
            },
            "capture_settings": {
                "exposure_time": exif_ifd.get(piexif.ExifIFD.ExposureTime),
                "f_number": exif_ifd.get(piexif.ExifIFD.FNumber),
                "iso": exif_ifd.get(piexif.ExifIFD.ISOSpeedRatings),
                "focal_length": exif_ifd.get(piexif.ExifIFD.FocalLength),
                "flash": exif_ifd.get(piexif.ExifIFD.Flash),
            },
            "date_time": {
                "original": exif_ifd.get(piexif.ExifIFD.DateTimeOriginal),
                "digitized": exif_ifd.get(piexif.ExifIFD.DateTimeDigitized),
                "modified": zeroth.get(piexif.ImageIFD.DateTime),
            },
            "gps": {
                "latitude_decimal": lat,
                "longitude_decimal": lon,
                "altitude": alt,
                "latitude_dms": decimal_to_dms(lat, "lat"),
                "longitude_dms": decimal_to_dms(lon, "lon"),
                "map_url": map_url(lat, lon) if include_map_url else None,
            },
            "image_info": {
                "dimensions": f"{width}x{height}",
                "orientation": zeroth.get(piexif.ImageIFD.Orientation),
                "color_space": exif_ifd.get(piexif.ExifIFD.ColorSpace) or mode,
                "resolution": (
                    f"{parse_rational(zeroth.get(piexif.ImageIFD.XResolution))}x"
                    f"{parse_rational(zeroth.get(piexif.ImageIFD.YResolution))}"
                ) if zeroth.get(piexif.ImageIFD.XResolution) and zeroth.get(piexif.ImageIFD.YResolution) else None,
            },
        },
        "full_dump": {
            "0th": {str(k): str(v) for k, v in zeroth.items()},
            "Exif": {str(k): str(v) for k, v in exif_ifd.items()},
            "GPS": {str(k): str(v) for k, v in gps_ifd.items()},
            "1st": {str(k): str(v) for k, v in exif_dict.get("1st", {}).items()},
        },
    }
    return data


def flatten_csv(metadata: dict) -> list[dict]:
    rows = []
    for category, values in metadata["categories"].items():
        for key, value in values.items():
            rows.append({
                "category": category,
                "key": key,
                "value": "" if value is None else str(value),
            })
    return rows


def print_text(metadata: dict) -> str:
    lines = [f"Image: {metadata['image']}"]
    for category, values in metadata["categories"].items():
        lines.append(f"\n[{category}]")
        for key, value in values.items():
            lines.append(f"- {key}: {'N/A' if value is None else value}")
    lines.append("")
    text = "\n".join(lines)
    print(text)
    return text


def compare_metadata(a: dict, b: dict) -> list[dict]:
    keys = set()
    for category, values in a["categories"].items():
        for key in values:
            keys.add(f"{category}.{key}")
    for category, values in b["categories"].items():
        for key in values:
            keys.add(f"{category}.{key}")

    rows = []
    for key in sorted(keys):
        category, field = key.split(".", 1)
        left = a["categories"].get(category, {}).get(field)
        right = b["categories"].get(category, {}).get(field)
        rows.append({
            "key": key,
            "left": left,
            "right": right,
            "different": str(left) != str(right),
        })
    return rows


def write_csv(path_value: Path, rows: list[dict], columns: list[str]) -> None:
    path_value.parent.mkdir(parents=True, exist_ok=True)
    with path_value.open("w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=columns)
        writer.writeheader()
        for row in rows:
            writer.writerow(row)


def strip_all(input_path: Path, output_path: Path) -> None:
    with Image.open(input_path) as img:
        save_kwargs = {}
        ext = output_path.suffix.lower()
        if ext in {".jpg", ".jpeg"}:
            save_kwargs.update({"quality": 95, "optimize": True})
            img.convert("RGB").save(output_path, **save_kwargs)
        else:
            img.save(output_path, **save_kwargs)


def remove_tags(input_path: Path, output_path: Path, remove_value: str) -> None:
    targets = [x.strip().lower() for x in remove_value.split(",") if x.strip()]
    if not targets:
        raise ValueError("No tags provided to --remove.")

    if input_path.suffix.lower() not in {".jpg", ".jpeg", ".tif", ".tiff"}:
        raise ValueError("Specific EXIF tag removal is supported for JPEG/TIFF in this implementation.")

    exif_dict, _ = parse_exif_dict(input_path)
    if "gps" in targets:
        exif_dict["GPS"] = {}
    if "date" in targets or "datetime" in targets:
        exif_dict["Exif"].pop(piexif.ExifIFD.DateTimeOriginal, None)
        exif_dict["Exif"].pop(piexif.ExifIFD.DateTimeDigitized, None)
        exif_dict["0th"].pop(piexif.ImageIFD.DateTime, None)
    if "camera" in targets:
        exif_dict["0th"].pop(piexif.ImageIFD.Make, None)
        exif_dict["0th"].pop(piexif.ImageIFD.Model, None)
        exif_dict["Exif"].pop(piexif.ExifIFD.BodySerialNumber, None)

    exif_bytes = piexif.dump(exif_dict)
    with Image.open(input_path) as img:
        if output_path.suffix.lower() in {".jpg", ".jpeg"}:
            img.convert("RGB").save(output_path, exif=exif_bytes, quality=95)
        else:
            img.save(output_path, exif=exif_bytes)


def maybe_modify(input_path: Path, args: argparse.Namespace) -> Path:
    if not args.strip_all and not args.remove:
        return input_path
    out = Path(args.output).resolve() if args.output else input_path.with_name(f"{input_path.stem}_modified{input_path.suffix}")
    try:
        out.parent.mkdir(parents=True, exist_ok=True)
    except Exception as exc:
        raise PermissionError(f"Cannot write output path: {out}") from exc

    if args.strip_all:
        strip_all(input_path, out)
    else:
        remove_tags(input_path, out, args.remove)
    return out


def generate_sample(path_value: Path) -> Path:
    path_value.parent.mkdir(parents=True, exist_ok=True)
    img = Image.new("RGB", (1280, 720), "#5f96f5")
    draw = ImageDraw.Draw(img)
    draw.text((70, 80), "Sample EXIF Photo", fill="white")

    exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "thumbnail": None}
    exif_dict["0th"][piexif.ImageIFD.Make] = "CodexCam"
    exif_dict["0th"][piexif.ImageIFD.Model] = "Model X"
    exif_dict["0th"][piexif.ImageIFD.Orientation] = 1
    exif_dict["0th"][piexif.ImageIFD.DateTime] = "2024:04:11 10:15:22"
    exif_dict["Exif"][piexif.ExifIFD.ExposureTime] = (1, 125)
    exif_dict["Exif"][piexif.ExifIFD.FNumber] = (28, 10)
    exif_dict["Exif"][piexif.ExifIFD.ISOSpeedRatings] = 200
    exif_dict["Exif"][piexif.ExifIFD.FocalLength] = (35, 1)
    exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = "2024:04:11 10:15:22"
    exif_dict["Exif"][piexif.ExifIFD.DateTimeDigitized] = "2024:04:11 10:15:22"
    exif_dict["GPS"][piexif.GPSIFD.GPSLatitudeRef] = "N"
    exif_dict["GPS"][piexif.GPSIFD.GPSLatitude] = ((37, 1), (46, 1), (2964, 100))
    exif_dict["GPS"][piexif.GPSIFD.GPSLongitudeRef] = "W"
    exif_dict["GPS"][piexif.GPSIFD.GPSLongitude] = ((122, 1), (25, 1), (984, 100))
    exif_dict["GPS"][piexif.GPSIFD.GPSAltitude] = (15, 1)

    img.save(path_value, "JPEG", quality=95, exif=piexif.dump(exif_dict))
    return path_value


def run_batch(folder: Path, args: argparse.Namespace) -> None:
    files = [p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in SUPPORTED]
    summary = []
    for f in files:
        md = extract_metadata(f, args.map_url)
        print_text(md)
        summary.append({
            "image": str(f.resolve()),
            "make": md["categories"]["camera_info"]["make"],
            "model": md["categories"]["camera_info"]["model"],
            "date": md["categories"]["date_time"]["original"],
            "latitude": md["categories"]["gps"]["latitude_decimal"],
            "longitude": md["categories"]["gps"]["longitude_decimal"],
        })
    out = Path(args.output).resolve() if args.output else Path("batch_metadata_summary.csv").resolve()
    write_csv(out, summary, ["image", "make", "model", "date", "latitude", "longitude"])
    print(f"Batch summary CSV: {out}")


def parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description="EXIF Metadata Extractor")
    p.add_argument("input", nargs="?")
    p.add_argument("--format", choices=["text", "json", "csv"], default="text")
    p.add_argument("--map-url", action="store_true")
    p.add_argument("--compare")
    p.add_argument("--remove")
    p.add_argument("--strip-all", action="store_true")
    p.add_argument("--batch", action="store_true")
    p.add_argument("--output")
    return p


def main() -> int:
    args = parser().parse_args()

    if not args.input and not args.batch:
        sample = generate_sample(Path("sample_exif.jpg").resolve())
        metadata = extract_metadata(sample, args.map_url)
        print_text(metadata)
        return 0

    if not args.input:
        raise ValueError("Input path required.")
    input_path = Path(args.input).resolve()

    if args.batch:
        if not input_path.is_dir():
            raise ValueError("--batch requires a directory input.")
        run_batch(input_path, args)
        return 0

    if not input_path.is_file():
        raise FileNotFoundError(f"Input not found: {input_path}")

    effective_path = maybe_modify(input_path, args)
    metadata = extract_metadata(effective_path, args.map_url)
    text = print_text(metadata)

    if args.compare:
        other = extract_metadata(Path(args.compare).resolve(), args.map_url)
        differences = compare_metadata(metadata, other)
        print("Comparison:")
        for row in differences:
            if row["different"]:
                print(f"- {row['key']}: left={row['left']} | right={row['right']}")
        if args.format == "json":
            payload = {"metadata": metadata, "comparison_against": other["image"], "differences": differences}
            out = Path(args.output).resolve() if args.output else None
            if out:
                out.write_text(json.dumps(payload, indent=2), encoding="utf-8")
            else:
                print(json.dumps(payload, indent=2))
            return 0
        if args.format == "csv":
            out = Path(args.output).resolve() if args.output else Path("comparison.csv").resolve()
            write_csv(out, differences, ["key", "left", "right", "different"])
            print(f"Saved CSV: {out}")
            return 0
        if args.output:
            Path(args.output).resolve().write_text(text, encoding="utf-8")
        return 0

    if args.format == "json":
        payload = json.dumps(metadata, indent=2)
        if args.output:
            Path(args.output).resolve().write_text(payload, encoding="utf-8")
        else:
            print(payload)
    elif args.format == "csv":
        rows = flatten_csv(metadata)
        out = Path(args.output).resolve() if args.output else Path("metadata.csv").resolve()
        write_csv(out, rows, ["category", "key", "value"])
        print(f"Saved CSV: {out}")
    elif args.output:
        Path(args.output).resolve().write_text(text, encoding="utf-8")

    if effective_path != input_path:
        print(f"Modified image saved: {effective_path}")
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except Exception as exc:
        print(f"Error: {exc}")
        raise SystemExit(1)