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)