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

EXIF Metadata Extractor (python, written by Claude Code)

envgap__claude-code__python-t1-22

Written by a coding agent; not on GitHubWritten 2026-02-27

01 / FAILURE SIGNATURE

As the study recorded it

No identifying execution failure has been captured.
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

claude-code/python-t1 #22 · read the task the agent was given
Claude Code 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.

exif_tool.py
"""
EXIF Metadata Extractor - Reads, modifies, and extracts EXIF metadata
from images with GPS coordinate conversion and batch processing support.

Dependencies:
    - Pillow (10.2.0): Image processing and EXIF reading
    - piexif (1.1.3): EXIF metadata manipulation
"""

import os
import sys
import json
import glob
import argparse
from typing import Dict, List, Optional, Tuple, Any
from pathlib import Path

from PIL import Image
from PIL.ExifTags import TAGS, GPSTAGS
import piexif


class GPSConverter:
    """Converts GPS coordinates between different formats."""

    @staticmethod
    def dms_to_decimal(degrees: float, minutes: float, seconds: float,
                       direction: str) -> float:
        """Convert degrees/minutes/seconds to decimal degrees."""
        decimal = degrees + minutes / 60.0 + seconds / 3600.0
        if direction in ('S', 'W'):
            decimal = -decimal
        return round(decimal, 8)

    @staticmethod
    def decimal_to_dms(decimal_deg: float) -> Tuple[int, int, float]:
        """Convert decimal degrees to degrees/minutes/seconds."""
        is_negative = decimal_deg < 0
        decimal_deg = abs(decimal_deg)
        degrees = int(decimal_deg)
        minutes_float = (decimal_deg - degrees) * 60
        minutes = int(minutes_float)
        seconds = round((minutes_float - minutes) * 60, 4)
        return degrees, minutes, seconds

    @staticmethod
    def rational_to_float(rational: Tuple[int, int]) -> float:
        """Convert a piexif rational number tuple to float."""
        if rational[1] == 0:
            return 0.0
        return rational[0] / rational[1]

    @staticmethod
    def float_to_rational(value: float, precision: int = 10000) -> Tuple[int, int]:
        """Convert a float to a piexif rational number tuple."""
        return (int(value * precision), precision)


class ExifExtractor:
    """Reads and extracts EXIF metadata from image files."""

    SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.tiff', '.tif', '.png', '.webp'}

    def __init__(self, image_path: str):
        self.image_path = Path(image_path)
        self._validate_file()
        self._exif_dict = None
        self._pil_image = None

    def _validate_file(self):
        """Validate that the file exists and is a supported format."""
        if not self.image_path.exists():
            raise FileNotFoundError(f"Image not found: {self.image_path}")
        if self.image_path.suffix.lower() not in self.SUPPORTED_FORMATS:
            raise ValueError(
                f"Unsupported format: {self.image_path.suffix}. "
                f"Supported: {', '.join(self.SUPPORTED_FORMATS)}"
            )

    def _load_exif(self):
        """Load EXIF data using piexif."""
        if self._exif_dict is None:
            try:
                self._exif_dict = piexif.load(str(self.image_path))
            except piexif.InvalidImageDataError:
                self._exif_dict = {"0th": {}, "Exif": {}, "GPS": {},
                                   "1st": {}, "Interop": {}}

    def _load_pil(self):
        """Load image with Pillow."""
        if self._pil_image is None:
            self._pil_image = Image.open(str(self.image_path))

    def get_basic_info(self) -> Dict[str, Any]:
        """Get basic image information."""
        self._load_pil()
        return {
            "filename": self.image_path.name,
            "format": self._pil_image.format,
            "mode": self._pil_image.mode,
            "size": {
                "width": self._pil_image.width,
                "height": self._pil_image.height
            },
            "file_size_bytes": self.image_path.stat().st_size
        }

    def get_all_exif(self) -> Dict[str, Any]:
        """Extract all EXIF metadata as a human-readable dictionary."""
        self._load_pil()
        exif_data = {}

        raw_exif = self._pil_image.getexif()
        if not raw_exif:
            return exif_data

        for tag_id, value in raw_exif.items():
            tag_name = TAGS.get(tag_id, f"Unknown_{tag_id}")
            if isinstance(value, bytes):
                try:
                    value = value.decode('utf-8', errors='replace')
                except Exception:
                    value = str(value)
            exif_data[tag_name] = value

        return exif_data

    def get_gps_data(self) -> Optional[Dict[str, Any]]:
        """Extract GPS data with coordinate conversion."""
        self._load_exif()

        gps_ifd = self._exif_dict.get("GPS", {})
        if not gps_ifd:
            return None

        gps_data = {}
        converter = GPSConverter()

        # Extract latitude
        if piexif.GPSIFD.GPSLatitude in gps_ifd and \
           piexif.GPSIFD.GPSLatitudeRef in gps_ifd:
            lat_dms = gps_ifd[piexif.GPSIFD.GPSLatitude]
            lat_ref = gps_ifd[piexif.GPSIFD.GPSLatitudeRef].decode('ascii')

            lat_deg = converter.rational_to_float(lat_dms[0])
            lat_min = converter.rational_to_float(lat_dms[1])
            lat_sec = converter.rational_to_float(lat_dms[2])

            gps_data["latitude"] = {
                "degrees": lat_deg,
                "minutes": lat_min,
                "seconds": lat_sec,
                "direction": lat_ref,
                "decimal": converter.dms_to_decimal(
                    lat_deg, lat_min, lat_sec, lat_ref
                )
            }

        # Extract longitude
        if piexif.GPSIFD.GPSLongitude in gps_ifd and \
           piexif.GPSIFD.GPSLongitudeRef in gps_ifd:
            lon_dms = gps_ifd[piexif.GPSIFD.GPSLongitude]
            lon_ref = gps_ifd[piexif.GPSIFD.GPSLongitudeRef].decode('ascii')

            lon_deg = converter.rational_to_float(lon_dms[0])
            lon_min = converter.rational_to_float(lon_dms[1])
            lon_sec = converter.rational_to_float(lon_dms[2])

            gps_data["longitude"] = {
                "degrees": lon_deg,
                "minutes": lon_min,
                "seconds": lon_sec,
                "direction": lon_ref,
                "decimal": converter.dms_to_decimal(
                    lon_deg, lon_min, lon_sec, lon_ref
                )
            }

        # Extract altitude
        if piexif.GPSIFD.GPSAltitude in gps_ifd:
            alt = converter.rational_to_float(
                gps_ifd[piexif.GPSIFD.GPSAltitude]
            )
            alt_ref = gps_ifd.get(piexif.GPSIFD.GPSAltitudeRef, 0)
            if alt_ref == 1:
                alt = -alt
            gps_data["altitude_meters"] = round(alt, 2)

        # Google Maps link
        if "latitude" in gps_data and "longitude" in gps_data:
            lat = gps_data["latitude"]["decimal"]
            lon = gps_data["longitude"]["decimal"]
            gps_data["google_maps_url"] = (
                f"https://www.google.com/maps?q={lat},{lon}"
            )

        return gps_data


class ExifModifier:
    """Modifies EXIF metadata in image files."""

    def __init__(self, image_path: str):
        self.image_path = Path(image_path)
        self._exif_dict = piexif.load(str(self.image_path))

    def set_description(self, description: str) -> None:
        """Set the image description tag."""
        self._exif_dict["0th"][piexif.ImageIFD.ImageDescription] = \
            description.encode('utf-8')

    def set_artist(self, artist: str) -> None:
        """Set the artist/author tag."""
        self._exif_dict["0th"][piexif.ImageIFD.Artist] = \
            artist.encode('utf-8')

    def set_copyright(self, copyright_text: str) -> None:
        """Set the copyright tag."""
        self._exif_dict["0th"][piexif.ImageIFD.Copyright] = \
            copyright_text.encode('utf-8')

    def set_gps_coordinates(self, latitude: float, longitude: float,
                            altitude: Optional[float] = None) -> None:
        """Set GPS coordinates from decimal degrees."""
        converter = GPSConverter()

        lat_ref = b'N' if latitude >= 0 else b'S'
        lon_ref = b'E' if longitude >= 0 else b'W'

        lat_d, lat_m, lat_s = converter.decimal_to_dms(latitude)
        lon_d, lon_m, lon_s = converter.decimal_to_dms(longitude)

        gps_ifd = {
            piexif.GPSIFD.GPSLatitudeRef: lat_ref,
            piexif.GPSIFD.GPSLatitude: (
                (lat_d, 1), (lat_m, 1),
                converter.float_to_rational(lat_s)
            ),
            piexif.GPSIFD.GPSLongitudeRef: lon_ref,
            piexif.GPSIFD.GPSLongitude: (
                (lon_d, 1), (lon_m, 1),
                converter.float_to_rational(lon_s)
            ),
        }

        if altitude is not None:
            alt_ref = 0 if altitude >= 0 else 1
            gps_ifd[piexif.GPSIFD.GPSAltitudeRef] = alt_ref
            gps_ifd[piexif.GPSIFD.GPSAltitude] = \
                converter.float_to_rational(abs(altitude))

        self._exif_dict["GPS"] = gps_ifd

    def remove_all_exif(self) -> None:
        """Remove all EXIF data from the image."""
        self._exif_dict = {"0th": {}, "Exif": {}, "GPS": {},
                           "1st": {}, "Interop": {}}

    def remove_gps_data(self) -> None:
        """Remove only GPS data from EXIF."""
        self._exif_dict["GPS"] = {}

    def save(self, output_path: Optional[str] = None) -> str:
        """Save the modified EXIF data back to the image."""
        output = Path(output_path) if output_path else self.image_path
        exif_bytes = piexif.dump(self._exif_dict)

        img = Image.open(str(self.image_path))
        img.save(str(output), exif=exif_bytes)

        return str(output)


class BatchProcessor:
    """Processes multiple images for EXIF extraction and modification."""

    def __init__(self, directory: str, recursive: bool = False):
        self.directory = Path(directory)
        self.recursive = recursive
        self._files = self._discover_files()

    def _discover_files(self) -> List[Path]:
        """Discover all supported image files in the directory."""
        supported = ExifExtractor.SUPPORTED_FORMATS
        files = []

        if self.recursive:
            for ext in supported:
                files.extend(self.directory.rglob(f"*{ext}"))
                files.extend(self.directory.rglob(f"*{ext.upper()}"))
        else:
            for ext in supported:
                files.extend(self.directory.glob(f"*{ext}"))
                files.extend(self.directory.glob(f"*{ext.upper()}"))

        return sorted(set(files))

    @property
    def file_count(self) -> int:
        """Number of discovered image files."""
        return len(self._files)

    def extract_all_metadata(self) -> List[Dict[str, Any]]:
        """Extract EXIF metadata from all discovered images."""
        results = []
        for file_path in self._files:
            try:
                extractor = ExifExtractor(str(file_path))
                result = {
                    "file": str(file_path),
                    "basic_info": extractor.get_basic_info(),
                    "exif": extractor.get_all_exif(),
                    "gps": extractor.get_gps_data(),
                    "status": "success"
                }
            except Exception as e:
                result = {
                    "file": str(file_path),
                    "status": "error",
                    "error": str(e)
                }
            results.append(result)
        return results

    def extract_gps_report(self) -> List[Dict[str, Any]]:
        """Generate a GPS-only report for all images with location data."""
        report = []
        for file_path in self._files:
            try:
                extractor = ExifExtractor(str(file_path))
                gps = extractor.get_gps_data()
                if gps and "latitude" in gps and "longitude" in gps:
                    report.append({
                        "file": file_path.name,
                        "latitude": gps["latitude"]["decimal"],
                        "longitude": gps["longitude"]["decimal"],
                        "altitude": gps.get("altitude_meters"),
                        "maps_url": gps.get("google_maps_url")
                    })
            except Exception:
                continue
        return report

    def batch_strip_exif(self, output_dir: Optional[str] = None) -> Dict[str, Any]:
        """Strip all EXIF data from all discovered images."""
        out_dir = Path(output_dir) if output_dir else self.directory / "stripped"
        out_dir.mkdir(parents=True, exist_ok=True)

        results = {"processed": 0, "errors": 0, "files": []}
        for file_path in self._files:
            try:
                modifier = ExifModifier(str(file_path))
                modifier.remove_all_exif()
                output_file = out_dir / file_path.name
                modifier.save(str(output_file))
                results["processed"] += 1
                results["files"].append(str(output_file))
            except Exception as e:
                results["errors"] += 1
                results["files"].append({"error": str(e), "file": str(file_path)})
        return results

    def batch_strip_gps(self, output_dir: Optional[str] = None) -> Dict[str, Any]:
        """Strip only GPS data from all discovered images."""
        out_dir = Path(output_dir) if output_dir else self.directory / "no_gps"
        out_dir.mkdir(parents=True, exist_ok=True)

        results = {"processed": 0, "errors": 0, "files": []}
        for file_path in self._files:
            try:
                modifier = ExifModifier(str(file_path))
                modifier.remove_gps_data()
                output_file = out_dir / file_path.name
                modifier.save(str(output_file))
                results["processed"] += 1
                results["files"].append(str(output_file))
            except Exception as e:
                results["errors"] += 1
                results["files"].append({"error": str(e), "file": str(file_path)})
        return results

    def export_to_json(self, output_path: str) -> str:
        """Export all metadata to a JSON file."""
        metadata = self.extract_all_metadata()
        with open(output_path, 'w', encoding='utf-8') as f:
            json.dump(metadata, f, indent=2, default=str)
        return output_path


def main():
    parser = argparse.ArgumentParser(
        description="EXIF Metadata Extractor - Read, modify, and batch process "
                    "EXIF metadata from images"
    )
    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    # Read command
    read_parser = subparsers.add_parser("read", help="Read EXIF from a single image")
    read_parser.add_argument("image", help="Path to the image file")
    read_parser.add_argument("--gps-only", action="store_true",
                             help="Show only GPS data")
    read_parser.add_argument("--json", action="store_true",
                             help="Output as JSON")

    # Modify command
    mod_parser = subparsers.add_parser("modify", help="Modify EXIF metadata")
    mod_parser.add_argument("image", help="Path to the image file")
    mod_parser.add_argument("--output", "-o", help="Output file path")
    mod_parser.add_argument("--description", help="Set image description")
    mod_parser.add_argument("--artist", help="Set artist name")
    mod_parser.add_argument("--copyright", help="Set copyright text")
    mod_parser.add_argument("--gps", nargs=2, type=float,
                            metavar=("LAT", "LON"),
                            help="Set GPS coordinates (decimal degrees)")
    mod_parser.add_argument("--strip-exif", action="store_true",
                            help="Remove all EXIF data")
    mod_parser.add_argument("--strip-gps", action="store_true",
                            help="Remove GPS data only")

    # Batch command
    batch_parser = subparsers.add_parser("batch", help="Batch process images")
    batch_parser.add_argument("directory", help="Directory containing images")
    batch_parser.add_argument("--recursive", "-r", action="store_true",
                              help="Search subdirectories")
    batch_parser.add_argument("--gps-report", action="store_true",
                              help="Generate GPS location report")
    batch_parser.add_argument("--strip-exif", action="store_true",
                              help="Strip EXIF from all images")
    batch_parser.add_argument("--strip-gps", action="store_true",
                              help="Strip GPS from all images")
    batch_parser.add_argument("--export-json", help="Export metadata to JSON file")
    batch_parser.add_argument("--output-dir", help="Output directory for modified images")

    args = parser.parse_args()

    if args.command == "read":
        extractor = ExifExtractor(args.image)

        if args.gps_only:
            gps = extractor.get_gps_data()
            if gps:
                if args.json:
                    print(json.dumps(gps, indent=2))
                else:
                    print("GPS Data:")
                    if "latitude" in gps:
                        lat = gps["latitude"]
                        print(f"  Latitude:  {lat['degrees']}d {lat['minutes']}' "
                              f"{lat['seconds']}\" {lat['direction']} "
                              f"({lat['decimal']})")
                    if "longitude" in gps:
                        lon = gps["longitude"]
                        print(f"  Longitude: {lon['degrees']}d {lon['minutes']}' "
                              f"{lon['seconds']}\" {lon['direction']} "
                              f"({lon['decimal']})")
                    if "altitude_meters" in gps:
                        print(f"  Altitude:  {gps['altitude_meters']} m")
                    if "google_maps_url" in gps:
                        print(f"  Maps:      {gps['google_maps_url']}")
            else:
                print("No GPS data found in image.")
        else:
            info = extractor.get_basic_info()
            exif = extractor.get_all_exif()
            gps = extractor.get_gps_data()

            if args.json:
                output = {"basic_info": info, "exif": exif, "gps": gps}
                print(json.dumps(output, indent=2, default=str))
            else:
                print(f"File: {info['filename']}")
                print(f"Format: {info['format']} | Mode: {info['mode']}")
                print(f"Size: {info['size']['width']}x{info['size']['height']}")
                print(f"File Size: {info['file_size_bytes']} bytes")
                print("\nEXIF Tags:")
                for tag, value in exif.items():
                    val_str = str(value)
                    if len(val_str) > 80:
                        val_str = val_str[:77] + "..."
                    print(f"  {tag}: {val_str}")

    elif args.command == "modify":
        modifier = ExifModifier(args.image)

        if args.strip_exif:
            modifier.remove_all_exif()
        elif args.strip_gps:
            modifier.remove_gps_data()
        else:
            if args.description:
                modifier.set_description(args.description)
            if args.artist:
                modifier.set_artist(args.artist)
            if args.copyright:
                modifier.set_copyright(args.copyright)
            if args.gps:
                modifier.set_gps_coordinates(args.gps[0], args.gps[1])

        saved = modifier.save(args.output)
        print(f"Saved to: {saved}")

    elif args.command == "batch":
        processor = BatchProcessor(args.directory, args.recursive)
        print(f"Found {processor.file_count} image(s)")

        if args.gps_report:
            report = processor.extract_gps_report()
            print(f"\nGPS Report ({len(report)} images with location):")
            for entry in report:
                print(f"  {entry['file']}: "
                      f"{entry['latitude']}, {entry['longitude']}")
                if entry.get('maps_url'):
                    print(f"    {entry['maps_url']}")

        elif args.strip_exif:
            results = processor.batch_strip_exif(args.output_dir)
            print(f"Processed: {results['processed']}, Errors: {results['errors']}")

        elif args.strip_gps:
            results = processor.batch_strip_gps(args.output_dir)
            print(f"Processed: {results['processed']}, Errors: {results['errors']}")

        elif args.export_json:
            path = processor.export_to_json(args.export_json)
            print(f"Exported metadata to: {path}")

        else:
            metadata = processor.extract_all_metadata()
            for item in metadata:
                print(f"\n--- {item['file']} ---")
                if item['status'] == 'success':
                    info = item['basic_info']
                    print(f"  Size: {info['size']['width']}x{info['size']['height']}")
                    print(f"  EXIF tags: {len(item['exif'])}")
                    if item['gps']:
                        print("  GPS: Yes")
                else:
                    print(f"  Error: {item['error']}")
    else:
        parser.print_help()


if __name__ == "__main__":
    main()
README.md
# EXIF Metadata Extractor (Python - Trial 1)

A Python tool for reading, modifying, and batch processing EXIF metadata from images, with GPS coordinate extraction and conversion.

## Dependencies

- **Pillow** (10.2.0): Image processing and EXIF reading
- **piexif** (1.1.3): EXIF metadata manipulation and writing

## Installation

```bash
pip install -r requirements.txt
```

## Usage

### Read EXIF metadata from an image
```bash
python exif_tool.py read photo.jpg
python exif_tool.py read photo.jpg --gps-only
python exif_tool.py read photo.jpg --json
```

### Modify EXIF metadata
```bash
python exif_tool.py modify photo.jpg --artist "John Doe" --output modified.jpg
python exif_tool.py modify photo.jpg --gps 40.7128 -74.0060
python exif_tool.py modify photo.jpg --strip-exif --output clean.jpg
python exif_tool.py modify photo.jpg --strip-gps --output no_gps.jpg
```

### Batch processing
```bash
python exif_tool.py batch ./photos --recursive
python exif_tool.py batch ./photos --gps-report
python exif_tool.py batch ./photos --strip-exif --output-dir ./cleaned
python exif_tool.py batch ./photos --export-json metadata.json
```

## Features

- Read all EXIF tags from JPEG, TIFF, and other image formats
- Extract and convert GPS coordinates (DMS to decimal and vice versa)
- Generate Google Maps links from GPS data
- Modify EXIF tags (description, artist, copyright, GPS)
- Strip all EXIF data or GPS data only
- Batch process entire directories with recursive support
- Export metadata to JSON format
requirements.txt
Pillow==10.2.0
piexif==1.1.3