← All tasks
javacodex/java-t1 #22Not a task: repair changed code

EXIF Metadata Extractor (java, written by Codex)

envgap__codex__java-t1-22

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

01 / FAILURE SIGNATURE

As the study recorded it

RationalNumber.valueOf API mismatch with commons-imaging 1.0.0-alpha5
Not a benchmark task.
  • Its repair changed source code, so it is not an environment task.

02 / ENVIRONMENT RECIPE

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

03 / TASK AND FAILURE

codex/java-t1 #22 · read the task the agent was given
Codex wrote this java project from the task below. It does not run 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 Java project for a clean Ubuntu 22.04 machine with only JDK 17+ installed. Include:
- Source code
- pom.xml 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.

pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.tmlr</groupId>
    <artifactId>exif-metadata-extractor</artifactId>
    <version>1.0.0</version>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.drewnoakes</groupId>
            <artifactId>metadata-extractor</artifactId>
            <version>2.19.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-imaging</artifactId>
            <version>1.0.0-alpha5</version>
        </dependency>
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.11.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.13.0</version>
                <configuration>
                    <release>17</release>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.4.2</version>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>ExifMetadataExtractor</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
README.md
# EXIF Metadata Extractor (Java)

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

## Requirements

- Ubuntu 22.04
- JDK 17+
- Maven 3.8+

## Dependencies (Pinned)

- `com.drewnoakes:metadata-extractor:2.19.0`
- `org.apache.commons:commons-imaging:1.0.0-alpha5`
- `com.google.code.gson:gson:2.11.0`

## Build

```bash
mvn clean package
```

## Run

```bash
java -cp target/exif-metadata-extractor-1.0.0.jar ExifMetadataExtractor image.jpg
java -cp target/exif-metadata-extractor-1.0.0.jar ExifMetadataExtractor image.jpg --format json --output metadata.json
java -cp target/exif-metadata-extractor-1.0.0.jar ExifMetadataExtractor image.jpg --format csv --output metadata.csv
java -cp target/exif-metadata-extractor-1.0.0.jar ExifMetadataExtractor image.jpg --map-url
java -cp target/exif-metadata-extractor-1.0.0.jar ExifMetadataExtractor image.jpg --compare other.jpg
java -cp target/exif-metadata-extractor-1.0.0.jar ExifMetadataExtractor image.jpg --remove GPS --output image_no_gps.jpg
java -cp target/exif-metadata-extractor-1.0.0.jar ExifMetadataExtractor image.jpg --strip-all --output image_stripped.jpg
java -cp target/exif-metadata-extractor-1.0.0.jar ExifMetadataExtractor ./images --batch --output batch_summary.csv
java -cp target/exif-metadata-extractor-1.0.0.jar ExifMetadataExtractor
```

## Notes

- `--remove GPS` is implemented for JPEG in this version.
- `--strip-all` rewrites image pixels without metadata.
- No-input mode creates `sample_exif.jpg` with camera/date/GPS metadata and extracts it.
src/main/java/ExifMetadataExtractor.java
import com.drew.imaging.ImageMetadataReader;
import com.drew.metadata.Directory;
import com.drew.metadata.Metadata;
import com.drew.metadata.Tag;
import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.exif.ExifSubIFDDirectory;
import com.drew.metadata.exif.GpsDirectory;
import com.drew.lang.GeoLocation;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.apache.commons.imaging.Imaging;
import org.apache.commons.imaging.common.RationalNumber;
import org.apache.commons.imaging.formats.jpeg.exif.ExifRewriter;
import org.apache.commons.imaging.formats.jpeg.JpegImageMetadata;
import org.apache.commons.imaging.formats.tiff.constants.ExifTagConstants;
import org.apache.commons.imaging.formats.tiff.constants.GpsTagConstants;
import org.apache.commons.imaging.formats.tiff.constants.TiffTagConstants;
import org.apache.commons.imaging.formats.tiff.write.TiffOutputSet;

import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;

public final class ExifMetadataExtractor {
    private static final Set<String> SUPPORTED = Set.of("jpg", "jpeg", "tif", "tiff", "png");
    private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();

    private record ParsedArgs(Map<String, String> options, List<String> positional) {}

    private ExifMetadataExtractor() {}

    public static void main(String[] args) {
        try {
            ParsedArgs parsed = parseArgs(args);
            String format = parsed.options.getOrDefault("format", "text").toLowerCase(Locale.ROOT);
            if (!Set.of("text", "json", "csv").contains(format)) {
                throw new IllegalArgumentException("Invalid --format. Use text|json|csv.");
            }

            if (parsed.positional.isEmpty() && !parsed.options.containsKey("batch")) {
                Path sample = createSample(Paths.get("sample_exif.jpg").toAbsolutePath());
                Map<String, Object> md = extract(sample, parsed.options.containsKey("map-url"));
                printText(md);
                return;
            }
            if (parsed.positional.isEmpty()) throw new IllegalArgumentException("Input path required.");

            Path input = Paths.get(parsed.positional.get(0)).toAbsolutePath();
            if (parsed.options.containsKey("batch")) {
                if (!Files.isDirectory(input)) throw new IllegalArgumentException("--batch requires a directory input.");
                runBatch(input, parsed.options);
                return;
            }
            if (!Files.isRegularFile(input)) throw new IllegalArgumentException("Input not found: " + input);

            Path effective = maybeModify(input, parsed.options);
            Map<String, Object> metadata = extract(effective, parsed.options.containsKey("map-url"));
            String text = printText(metadata);

            if (parsed.options.containsKey("compare")) {
                Path otherPath = Paths.get(parsed.options.get("compare")).toAbsolutePath();
                Map<String, Object> other = extract(otherPath, parsed.options.containsKey("map-url"));
                List<Map<String, Object>> differences = compare(metadata, other);
                System.out.println("Comparison:");
                for (Map<String, Object> d : differences) {
                    if (Boolean.TRUE.equals(d.get("different"))) {
                        System.out.printf("- %s: left=%s | right=%s%n", d.get("key"), d.get("left"), d.get("right"));
                    }
                }

                if ("json".equals(format)) {
                    Map<String, Object> payload = new LinkedHashMap<>();
                    payload.put("metadata", metadata);
                    payload.put("comparisonAgainst", other.get("image"));
                    payload.put("differences", differences);
                    writeJsonOrConsole(payload, parsed.options.get("output"));
                    return;
                }
                if ("csv".equals(format)) {
                    Path out = parsed.options.containsKey("output")
                        ? Paths.get(parsed.options.get("output")).toAbsolutePath()
                        : Paths.get("comparison.csv").toAbsolutePath();
                    writeCsv(out, differences, List.of("key", "left", "right", "different"));
                    System.out.println("Saved CSV: " + out);
                    return;
                }
                if (parsed.options.containsKey("output")) {
                    Files.writeString(Paths.get(parsed.options.get("output")).toAbsolutePath(), text, StandardCharsets.UTF_8);
                }
                return;
            }

            if ("json".equals(format)) {
                writeJsonOrConsole(metadata, parsed.options.get("output"));
            } else if ("csv".equals(format)) {
                List<Map<String, Object>> rows = flattenForCsv(metadata);
                Path out = parsed.options.containsKey("output")
                    ? Paths.get(parsed.options.get("output")).toAbsolutePath()
                    : Paths.get("metadata.csv").toAbsolutePath();
                writeCsv(out, rows, List.of("category", "key", "value"));
                System.out.println("Saved CSV: " + out);
            } else if (parsed.options.containsKey("output")) {
                Files.writeString(Paths.get(parsed.options.get("output")).toAbsolutePath(), text, StandardCharsets.UTF_8);
            }

            if (!effective.equals(input)) System.out.println("Modified image saved: " + effective);
        } catch (Exception ex) {
            System.err.println("Error: " + ex.getMessage());
            System.exit(1);
        }
    }

    private static ParsedArgs parseArgs(String[] args) {
        Map<String, String> options = new LinkedHashMap<>();
        List<String> positional = new ArrayList<>();
        for (int i = 0; i < args.length; i++) {
            String token = args[i];
            if (token.startsWith("--")) {
                String key = token.substring(2);
                if (i + 1 < args.length && !args[i + 1].startsWith("--")) options.put(key, args[++i]);
                else options.put(key, "true");
            } else positional.add(token);
        }
        return new ParsedArgs(options, positional);
    }

    private static String ext(Path p) {
        String n = p.getFileName().toString();
        int i = n.lastIndexOf('.');
        return i >= 0 ? n.substring(i + 1).toLowerCase(Locale.ROOT) : "";
    }

    private static String dms(double decimal, boolean lat) {
        double abs = Math.abs(decimal);
        int deg = (int) Math.floor(abs);
        double mFloat = (abs - deg) * 60;
        int min = (int) Math.floor(mFloat);
        double sec = (mFloat - min) * 60;
        String hemi = lat ? (decimal >= 0 ? "N" : "S") : (decimal >= 0 ? "E" : "W");
        return String.format(Locale.ROOT, "%d°%d'%.2f\" %s", deg, min, sec, hemi);
    }

    private static String mapUrl(Double lat, Double lon) {
        if (lat == null || lon == null) return null;
        return "https://www.google.com/maps?q=" + lat + "," + lon;
    }

    private static Map<String, Object> extract(Path file, boolean includeMapUrl) throws Exception {
        if (!SUPPORTED.contains(ext(file))) throw new IllegalArgumentException("Unsupported image format: " + file);
        Metadata metadata = ImageMetadataReader.readMetadata(file.toFile());

        ExifIFD0Directory ifd0 = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
        ExifSubIFDDirectory sub = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
        GpsDirectory gps = metadata.getFirstDirectoryOfType(GpsDirectory.class);

        BufferedImage img = ImageIO.read(file.toFile());
        if (img == null) throw new IllegalArgumentException("Corrupted image: " + file);

        Double lat = null;
        Double lon = null;
        Double alt = null;
        if (gps != null) {
            GeoLocation gl = gps.getGeoLocation();
            if (gl != null) {
                lat = gl.getLatitude();
                lon = gl.getLongitude();
            }
            RationalNumber altRn = gps.getRational(GpsTagConstants.GPS_TAG_GPS_ALTITUDE);
            if (altRn != null) alt = altRn.doubleValue();
        }

        Map<String, Object> camera = new LinkedHashMap<>();
        camera.put("make", ifd0 != null ? ifd0.getString(ExifIFD0Directory.TAG_MAKE) : null);
        camera.put("model", ifd0 != null ? ifd0.getString(ExifIFD0Directory.TAG_MODEL) : null);
        camera.put("serial", sub != null ? sub.getString(ExifSubIFDDirectory.TAG_BODY_SERIAL_NUMBER) : null);

        Map<String, Object> capture = new LinkedHashMap<>();
        capture.put("exposure_time", sub != null ? sub.getDescription(ExifSubIFDDirectory.TAG_EXPOSURE_TIME) : null);
        capture.put("f_number", sub != null ? sub.getDescription(ExifSubIFDDirectory.TAG_FNUMBER) : null);
        capture.put("iso", sub != null ? sub.getInteger(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) : null);
        capture.put("focal_length", sub != null ? sub.getDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) : null);
        capture.put("flash", sub != null ? sub.getDescription(ExifSubIFDDirectory.TAG_FLASH) : null);

        Map<String, Object> dt = new LinkedHashMap<>();
        dt.put("original", sub != null ? sub.getDateOriginal() : null);
        dt.put("digitized", sub != null ? sub.getDateDigitized() : null);
        dt.put("modified", ifd0 != null ? ifd0.getDate(ExifIFD0Directory.TAG_DATETIME) : null);

        Map<String, Object> gpsMap = new LinkedHashMap<>();
        gpsMap.put("latitude_decimal", lat);
        gpsMap.put("longitude_decimal", lon);
        gpsMap.put("altitude", alt);
        gpsMap.put("latitude_dms", lat == null ? null : dms(lat, true));
        gpsMap.put("longitude_dms", lon == null ? null : dms(lon, false));
        gpsMap.put("map_url", includeMapUrl ? mapUrl(lat, lon) : null);

        Map<String, Object> info = new LinkedHashMap<>();
        info.put("dimensions", img.getWidth() + "x" + img.getHeight());
        info.put("orientation", ifd0 != null ? ifd0.getInteger(ExifIFD0Directory.TAG_ORIENTATION) : null);
        info.put("color_space", sub != null ? sub.getDescription(ExifSubIFDDirectory.TAG_COLOR_SPACE) : img.getColorModel().toString());
        info.put("resolution", ifd0 != null ? (ifd0.getDescription(ExifIFD0Directory.TAG_X_RESOLUTION) + " / " + ifd0.getDescription(ExifIFD0Directory.TAG_Y_RESOLUTION)) : null);

        Map<String, Object> fullDump = new LinkedHashMap<>();
        for (Directory d : metadata.getDirectories()) {
            Map<String, String> tags = new LinkedHashMap<>();
            for (Tag t : d.getTags()) tags.put(t.getTagName(), t.getDescription());
            fullDump.put(d.getName(), tags);
        }

        Map<String, Object> categories = new LinkedHashMap<>();
        categories.put("camera_info", camera);
        categories.put("capture_settings", capture);
        categories.put("date_time", dt);
        categories.put("gps", gpsMap);
        categories.put("image_info", info);

        Map<String, Object> out = new LinkedHashMap<>();
        out.put("image", file.toAbsolutePath().toString());
        out.put("categories", categories);
        out.put("full_dump", fullDump);
        return out;
    }

    @SuppressWarnings("unchecked")
    private static List<Map<String, Object>> flattenForCsv(Map<String, Object> metadata) {
        List<Map<String, Object>> rows = new ArrayList<>();
        Map<String, Object> categories = (Map<String, Object>) metadata.get("categories");
        for (Map.Entry<String, Object> cat : categories.entrySet()) {
            Map<String, Object> vals = (Map<String, Object>) cat.getValue();
            for (Map.Entry<String, Object> kv : vals.entrySet()) {
                Map<String, Object> row = new LinkedHashMap<>();
                row.put("category", cat.getKey());
                row.put("key", kv.getKey());
                row.put("value", kv.getValue() == null ? "" : kv.getValue().toString());
                rows.add(row);
            }
        }
        return rows;
    }

    @SuppressWarnings("unchecked")
    private static String printText(Map<String, Object> metadata) {
        StringBuilder sb = new StringBuilder();
        sb.append("Image: ").append(metadata.get("image")).append("\n");
        Map<String, Object> categories = (Map<String, Object>) metadata.get("categories");
        for (Map.Entry<String, Object> cat : categories.entrySet()) {
            sb.append("\n[").append(cat.getKey()).append("]\n");
            Map<String, Object> vals = (Map<String, Object>) cat.getValue();
            for (Map.Entry<String, Object> kv : vals.entrySet()) {
                sb.append("- ").append(kv.getKey()).append(": ").append(kv.getValue() == null ? "N/A" : kv.getValue()).append("\n");
            }
        }
        sb.append("\n");
        String out = sb.toString();
        System.out.print(out);
        return out;
    }

    @SuppressWarnings("unchecked")
    private static List<Map<String, Object>> compare(Map<String, Object> a, Map<String, Object> b) {
        Set<String> keys = new TreeSet<>();
        Map<String, Object> ca = (Map<String, Object>) a.get("categories");
        Map<String, Object> cb = (Map<String, Object>) b.get("categories");
        for (Map.Entry<String, Object> c : ca.entrySet()) {
            for (String k : ((Map<String, Object>) c.getValue()).keySet()) keys.add(c.getKey() + "." + k);
        }
        for (Map.Entry<String, Object> c : cb.entrySet()) {
            for (String k : ((Map<String, Object>) c.getValue()).keySet()) keys.add(c.getKey() + "." + k);
        }

        List<Map<String, Object>> out = new ArrayList<>();
        for (String key : keys) {
            String[] p = key.split("\\.", 2);
            Object left = ((Map<String, Object>) ca.getOrDefault(p[0], Map.of())).get(p[1]);
            Object right = ((Map<String, Object>) cb.getOrDefault(p[0], Map.of())).get(p[1]);
            Map<String, Object> row = new LinkedHashMap<>();
            row.put("key", key);
            row.put("left", left);
            row.put("right", right);
            row.put("different", !Objects.toString(left, "").equals(Objects.toString(right, "")));
            out.add(row);
        }
        return out;
    }

    private static void writeCsv(Path path, List<Map<String, Object>> rows, List<String> columns) throws Exception {
        Files.createDirectories(path.toAbsolutePath().getParent());
        StringBuilder sb = new StringBuilder();
        sb.append(String.join(",", columns)).append("\n");
        for (Map<String, Object> row : rows) {
            List<String> values = new ArrayList<>();
            for (String c : columns) {
                String v = Objects.toString(row.get(c), "");
                values.add("\"" + v.replace("\"", "\"\"") + "\"");
            }
            sb.append(String.join(",", values)).append("\n");
        }
        Files.writeString(path, sb.toString(), StandardCharsets.UTF_8);
    }

    private static void writeJsonOrConsole(Object payload, String outputPath) throws Exception {
        String text = GSON.toJson(payload);
        if (outputPath == null) System.out.println(text);
        else Files.writeString(Paths.get(outputPath).toAbsolutePath(), text, StandardCharsets.UTF_8);
    }

    private static void stripAll(Path input, Path output) throws Exception {
        BufferedImage img = ImageIO.read(input.toFile());
        if (img == null) throw new IllegalArgumentException("Cannot read image: " + input);
        Files.createDirectories(output.toAbsolutePath().getParent());
        String format = ext(output);
        if ("jpg".equals(format)) format = "jpeg";
        if (!ImageIO.write(img, format, output.toFile())) throw new IllegalArgumentException("Unsupported output format: " + format);
    }

    private static void removeGpsJpeg(Path input, Path output) throws Exception {
        JpegImageMetadata jpegMetadata = (JpegImageMetadata) Imaging.getMetadata(input.toFile());
        TiffOutputSet set = null;
        if (jpegMetadata != null && jpegMetadata.getExif() != null) {
            set = jpegMetadata.getExif().getOutputSet();
        }
        if (set == null) {
            stripAll(input, output);
            return;
        }

        set.removeField(GpsTagConstants.GPS_TAG_GPS_LATITUDE_REF);
        set.removeField(GpsTagConstants.GPS_TAG_GPS_LATITUDE);
        set.removeField(GpsTagConstants.GPS_TAG_GPS_LONGITUDE_REF);
        set.removeField(GpsTagConstants.GPS_TAG_GPS_LONGITUDE);
        set.removeField(GpsTagConstants.GPS_TAG_GPS_ALTITUDE);
        set.removeField(GpsTagConstants.GPS_TAG_GPS_ALTITUDE_REF);

        Files.createDirectories(output.toAbsolutePath().getParent());
        try (OutputStream os = Files.newOutputStream(output)) {
            new ExifRewriter().updateExifMetadataLossless(input.toFile(), os, set);
        }
    }

    private static Path maybeModify(Path input, Map<String, String> options) throws Exception {
        if (!options.containsKey("strip-all") && !options.containsKey("remove")) return input;
        String extension = input.getFileName().toString().substring(input.getFileName().toString().lastIndexOf('.'));
        Path output = options.containsKey("output")
            ? Paths.get(options.get("output")).toAbsolutePath()
            : input.resolveSibling(input.getFileName().toString().replace(extension, "_modified" + extension));

        if (options.containsKey("strip-all")) {
            stripAll(input, output);
            return output;
        }

        String remove = options.get("remove").toLowerCase(Locale.ROOT);
        if (remove.contains("gps")) {
            if (!Set.of("jpg", "jpeg").contains(ext(input))) {
                throw new IllegalArgumentException("GPS removal is supported for JPEG only in this implementation.");
            }
            removeGpsJpeg(input, output);
            return output;
        }
        throw new IllegalArgumentException("Unsupported remove target: " + options.get("remove"));
    }

    private static Path createSample(Path output) throws Exception {
        BufferedImage img = new BufferedImage(1280, 720, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = img.createGraphics();
        g.setColor(new Color(95, 150, 245));
        g.fillRect(0, 0, 1280, 720);
        g.setColor(Color.WHITE);
        g.drawString("Sample EXIF Photo", 80, 100);
        g.dispose();
        ImageIO.write(img, "jpeg", output.toFile());

        TiffOutputSet set = new TiffOutputSet();
        set.getOrCreateRootDirectory().add(TiffTagConstants.TIFF_TAG_MAKE, "CodexCam");
        set.getOrCreateRootDirectory().add(TiffTagConstants.TIFF_TAG_MODEL, "Model X");
        set.getOrCreateExifDirectory().add(ExifTagConstants.EXIF_TAG_DATE_TIME_ORIGINAL, "2024:04:11 10:15:22");
        set.getOrCreateExifDirectory().add(ExifTagConstants.EXIF_TAG_DATE_TIME_DIGITIZED, "2024:04:11 10:15:22");
        set.getOrCreateExifDirectory().add(ExifTagConstants.EXIF_TAG_EXPOSURE_TIME, RationalNumber.valueOf(1, 125));
        set.getOrCreateExifDirectory().add(ExifTagConstants.EXIF_TAG_FNUMBER, RationalNumber.valueOf(28, 10));
        set.getOrCreateExifDirectory().add(ExifTagConstants.EXIF_TAG_ISO, (short) 200);
        set.getOrCreateExifDirectory().add(ExifTagConstants.EXIF_TAG_FOCAL_LENGTH, RationalNumber.valueOf(35, 1));
        set.getOrCreateGPSDirectory().add(GpsTagConstants.GPS_TAG_GPS_LATITUDE_REF, "N");
        set.getOrCreateGPSDirectory().add(GpsTagConstants.GPS_TAG_GPS_LATITUDE,
            new RationalNumber[]{RationalNumber.valueOf(37, 1), RationalNumber.valueOf(46, 1), RationalNumber.valueOf(2964, 100)});
        set.getOrCreateGPSDirectory().add(GpsTagConstants.GPS_TAG_GPS_LONGITUDE_REF, "W");
        set.getOrCreateGPSDirectory().add(GpsTagConstants.GPS_TAG_GPS_LONGITUDE,
            new RationalNumber[]{RationalNumber.valueOf(122, 1), RationalNumber.valueOf(25, 1), RationalNumber.valueOf(984, 100)});
        set.getOrCreateGPSDirectory().add(GpsTagConstants.GPS_TAG_GPS_ALTITUDE, RationalNumber.valueOf(15, 1));

        try (OutputStream os = Files.newOutputStream(output)) {
            new ExifRewriter().updateExifMetadataLossless(output.toFile(), os, set);
        }
        return output;
    }

    private static void runBatch(Path folder, Map<String, String> options) throws Exception {
        List<Map<String, Object>> summary = new ArrayList<>();
        try (var stream = Files.list(folder)) {
            for (Path p : (Iterable<Path>) stream.filter(Files::isRegularFile)::iterator) {
                if (!SUPPORTED.contains(ext(p))) continue;
                Map<String, Object> md = extract(p, options.containsKey("map-url"));
                printText(md);

                @SuppressWarnings("unchecked")
                Map<String, Object> categories = (Map<String, Object>) md.get("categories");
                @SuppressWarnings("unchecked")
                Map<String, Object> cam = (Map<String, Object>) categories.get("camera_info");
                @SuppressWarnings("unchecked")
                Map<String, Object> dt = (Map<String, Object>) categories.get("date_time");
                @SuppressWarnings("unchecked")
                Map<String, Object> gps = (Map<String, Object>) categories.get("gps");

                Map<String, Object> row = new LinkedHashMap<>();
                row.put("image", p.toAbsolutePath().toString());
                row.put("make", cam.get("make"));
                row.put("model", cam.get("model"));
                row.put("date", dt.get("original"));
                row.put("latitude", gps.get("latitude_decimal"));
                row.put("longitude", gps.get("longitude_decimal"));
                summary.add(row);
            }
        }
        Path out = options.containsKey("output")
            ? Paths.get(options.get("output")).toAbsolutePath()
            : Paths.get("batch_metadata_summary.csv").toAbsolutePath();
        writeCsv(out, summary, List.of("image", "make", "model", "date", "latitude", "longitude"));
        System.out.println("Batch summary CSV: " + out);
    }
}