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

Image Color Palette Extractor (java, written by Codex)

envgap__codex__java-t1-23

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

01 / FAILURE SIGNATURE

As the study recorded it

local variables referenced from lambda must be final or effectively final
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 #23 · 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: Image Color Palette Extractor

Write a program that extracts the dominant color palette from images using color quantization algorithms, outputting the palette in multiple formats with percentage breakdowns.

FUNCTIONAL REQUIREMENTS:
- Accept an image file path as a command-line argument
- Extract a configurable number of dominant colors via --colors flag (default: 8, range 2-32)
- Use k-means clustering or median cut algorithm for color quantization (selectable via --algorithm flag)
- Output each color in multiple formats: hex (#RRGGBB), RGB (r,g,b), and HSL (h,s%,l%)
- Report the percentage of the image each dominant color represents
- Support color name mapping: find the closest named CSS/HTML color for each extracted color
- Generate a color palette visualization as a PNG image showing color swatches with hex labels via --visual flag
- Support extracting palette from a specific region of the image via --crop flag (x,y,width,height)
- Support color space analysis: report if the image is primarily warm-toned, cool-toned, or neutral based on the palette hue distribution
- Compare palettes between two images via --compare flag, showing common colors and unique colors with delta-E color difference scores
- Print the palette to console as a formatted table with color swatches represented by ANSI colored blocks
- Save the palette data as JSON with --output flag (default: palette.json)
- Support batch extraction from a directory of images via --batch flag with a summary showing all palettes
- If no input is given, generate three sample images (a sunset scene using warm gradients, an ocean scene using cool gradients, a forest scene using green tones), extract palettes from each, and display comparative results
- Handle errors: unsupported formats, very small images, images with very few unique colors

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>image-color-palette-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.twelvemonkeys.imageio</groupId>
            <artifactId>imageio-core</artifactId>
            <version>3.11.0</version>
        </dependency>
        <dependency>
            <groupId>com.twelvemonkeys.imageio</groupId>
            <artifactId>imageio-jpeg</artifactId>
            <version>3.11.0</version>
        </dependency>
        <dependency>
            <groupId>com.twelvemonkeys.imageio</groupId>
            <artifactId>imageio-bmp</artifactId>
            <version>3.11.0</version>
        </dependency>
        <dependency>
            <groupId>com.twelvemonkeys.imageio</groupId>
            <artifactId>imageio-tiff</artifactId>
            <version>3.11.0</version>
        </dependency>
        <dependency>
            <groupId>org.sejda.imageio</groupId>
            <artifactId>webp-imageio</artifactId>
            <version>0.1.6</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>ImageColorPaletteExtractor</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
README.md
# Image Color Palette Extractor (Java)

Extracts dominant color palettes via `kmeans` or `median-cut`, prints percentage breakdowns with ANSI swatches, maps colors to nearest CSS names, compares palettes (delta-E), and writes JSON plus optional visualization PNG.

## Requirements

- Ubuntu 22.04
- JDK 17+
- Maven 3.8+

## Dependencies (Pinned)

- `com.twelvemonkeys.imageio:imageio-core:3.11.0`
- `com.twelvemonkeys.imageio:imageio-jpeg:3.11.0`
- `com.twelvemonkeys.imageio:imageio-bmp:3.11.0`
- `com.twelvemonkeys.imageio:imageio-tiff:3.11.0`
- `org.sejda.imageio:webp-imageio:0.1.6`
- `com.google.code.gson:gson:2.11.0`

## Build

```bash
mvn clean package
```

## Run

```bash
java -cp target/image-color-palette-extractor-1.0.0.jar ImageColorPaletteExtractor image.jpg
java -cp target/image-color-palette-extractor-1.0.0.jar ImageColorPaletteExtractor image.jpg --colors 12 --algorithm kmeans
java -cp target/image-color-palette-extractor-1.0.0.jar ImageColorPaletteExtractor image.jpg --algorithm median-cut --crop 50,50,500,300
java -cp target/image-color-palette-extractor-1.0.0.jar ImageColorPaletteExtractor image.jpg --visual palette.png --output palette.json
java -cp target/image-color-palette-extractor-1.0.0.jar ImageColorPaletteExtractor image.jpg --compare other.jpg
java -cp target/image-color-palette-extractor-1.0.0.jar ImageColorPaletteExtractor ./images --batch --colors 6 --output batch_palettes.json
java -cp target/image-color-palette-extractor-1.0.0.jar ImageColorPaletteExtractor
```

## Notes

- `--colors` range is `2..32` (default `8`).
- `--visual` writes swatch PNG with hex labels.
- No-input mode generates sunset/ocean/forest sample images and shows comparative tones.
src/main/java/ImageColorPaletteExtractor.java
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;

import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
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 ImageColorPaletteExtractor {
    private static final Set<String> SUPPORTED = Set.of("png", "jpg", "jpeg", "bmp", "tif", "tiff", "webp");
    private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();

    private static final Map<String, int[]> CSS = Map.ofEntries(
        Map.entry("black", new int[]{0, 0, 0}),
        Map.entry("white", new int[]{255, 255, 255}),
        Map.entry("red", new int[]{255, 0, 0}),
        Map.entry("green", new int[]{0, 128, 0}),
        Map.entry("blue", new int[]{0, 0, 255}),
        Map.entry("yellow", new int[]{255, 255, 0}),
        Map.entry("orange", new int[]{255, 165, 0}),
        Map.entry("purple", new int[]{128, 0, 128}),
        Map.entry("pink", new int[]{255, 192, 203}),
        Map.entry("brown", new int[]{165, 42, 42}),
        Map.entry("gray", new int[]{128, 128, 128}),
        Map.entry("cyan", new int[]{0, 255, 255}),
        Map.entry("magenta", new int[]{255, 0, 255}),
        Map.entry("gold", new int[]{255, 215, 0}),
        Map.entry("olive", new int[]{128, 128, 0}),
        Map.entry("teal", new int[]{0, 128, 128}),
        Map.entry("navy", new int[]{0, 0, 128}),
        Map.entry("maroon", new int[]{128, 0, 0}),
        Map.entry("lime", new int[]{0, 255, 0}),
        Map.entry("aqua", new int[]{0, 255, 255})
    );

    private record ParsedArgs(Map<String, String> options, List<String> positional) {}
    private record CropRect(int x, int y, int width, int height) {}
    private record PaletteEntry(String hex, int[] rgb, double[] hsl, double percentage, String cssName) {}

    private ImageColorPaletteExtractor() {}

    public static void main(String[] args) {
        try {
            ParsedArgs parsed = parseArgs(args);
            if (parsed.positional.isEmpty() && !parsed.options.containsKey("batch")) {
                runDemo(parsed.options);
                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);

            Map<String, Object> analysis = analyzeImage(input, parsed.options);
            printPalette(analysis);
            Map<String, Object> comparison = null;
            if (parsed.options.containsKey("compare")) {
                Map<String, Object> other = analyzeImage(Paths.get(parsed.options.get("compare")).toAbsolutePath(), parsed.options);
                comparison = comparePalettes(analysis, other);
                System.out.println("Palette comparison:");
                System.out.println(GSON.toJson(comparison));
            }
            if (parsed.options.containsKey("visual")) {
                String v = parsed.options.get("visual");
                Path out = "true".equals(v) ? Paths.get("palette_visual.png").toAbsolutePath() : Paths.get(v).toAbsolutePath();
                writeVisual((List<PaletteEntry>) analysis.get("paletteEntries"), out);
            }
            Path out = Paths.get(parsed.options.getOrDefault("output", "palette.json")).toAbsolutePath();
            writeJson(out, Map.of("mode", "single", "analysis", analysis, "comparison", comparison));
        } 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 t = args[i];
            if (t.startsWith("--")) {
                String key = t.substring(2);
                if (i + 1 < args.length && !args[i + 1].startsWith("--")) options.put(key, args[++i]);
                else options.put(key, "true");
            } else positional.add(t);
        }
        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 CropRect parseCrop(String raw, int width, int height) {
        if (raw == null || raw.isBlank()) return new CropRect(0, 0, width, height);
        String[] p = raw.split(",", 4);
        if (p.length != 4) throw new IllegalArgumentException("Invalid --crop. Use x,y,width,height.");
        int x = Math.max(0, Integer.parseInt(p[0].trim()));
        int y = Math.max(0, Integer.parseInt(p[1].trim()));
        int w = Math.min(Integer.parseInt(p[2].trim()), width - x);
        int h = Math.min(Integer.parseInt(p[3].trim()), height - y);
        if (w <= 0 || h <= 0) throw new IllegalArgumentException("Crop is outside image bounds.");
        return new CropRect(x, y, w, h);
    }

    private static String hex(int[] rgb) {
        return String.format("#%02X%02X%02X", rgb[0], rgb[1], rgb[2]);
    }

    private static double[] hsl(int[] rgb) {
        double r = rgb[0] / 255.0;
        double g = rgb[1] / 255.0;
        double b = rgb[2] / 255.0;
        double max = Math.max(r, Math.max(g, b));
        double min = Math.min(r, Math.min(g, b));
        double d = max - min;
        double h = 0.0;
        double l = (max + min) / 2.0;
        double s = 0.0;
        if (d != 0) {
            s = d / (1 - Math.abs(2 * l - 1));
            if (max == r) h = 60 * (((g - b) / d) % 6);
            else if (max == g) h = 60 * (((b - r) / d) + 2);
            else h = 60 * (((r - g) / d) + 4);
            if (h < 0) h += 360;
        }
        return new double[]{round(h), round(s * 100), round(l * 100)};
    }

    private static double[] lab(int[] rgb) {
        double[] s = new double[3];
        for (int i = 0; i < 3; i++) {
            double v = rgb[i] / 255.0;
            s[i] = v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
        }
        double x = (s[0] * 0.4124 + s[1] * 0.3576 + s[2] * 0.1805) / 0.95047;
        double y = (s[0] * 0.2126 + s[1] * 0.7152 + s[2] * 0.0722) / 1.00000;
        double z = (s[0] * 0.0193 + s[1] * 0.1192 + s[2] * 0.9505) / 1.08883;
        double fx = x > 0.008856 ? Math.cbrt(x) : (7.787 * x) + (16.0 / 116.0);
        double fy = y > 0.008856 ? Math.cbrt(y) : (7.787 * y) + (16.0 / 116.0);
        double fz = z > 0.008856 ? Math.cbrt(z) : (7.787 * z) + (16.0 / 116.0);
        return new double[]{(116 * fy) - 16, 500 * (fx - fy), 200 * (fy - fz)};
    }

    private static double deltaE(int[] a, int[] b) {
        double[] la = lab(a);
        double[] lb = lab(b);
        return Math.sqrt(
            (la[0] - lb[0]) * (la[0] - lb[0]) +
                (la[1] - lb[1]) * (la[1] - lb[1]) +
                (la[2] - lb[2]) * (la[2] - lb[2])
        );
    }

    private static String closestCssName(int[] rgb) {
        String best = "";
        double bestD = Double.POSITIVE_INFINITY;
        for (Map.Entry<String, int[]> e : CSS.entrySet()) {
            double d = deltaE(rgb, e.getValue());
            if (d < bestD) {
                bestD = d;
                best = e.getKey();
            }
        }
        return best;
    }

    private static int nearestCenter(int[] color, List<int[]> centers) {
        int idx = 0;
        double best = Double.POSITIVE_INFINITY;
        for (int i = 0; i < centers.size(); i++) {
            int[] c = centers.get(i);
            double d = sq(color[0] - c[0]) + sq(color[1] - c[1]) + sq(color[2] - c[2]);
            if (d < best) {
                best = d;
                idx = i;
            }
        }
        return idx;
    }

    private static List<int[]> kmeans(List<int[]> pixels, int k) {
        List<int[]> centers = new ArrayList<>();
        for (int i = 0; i < k; i++) centers.add(Arrays.copyOf(pixels.get((i * pixels.size()) / k), 3));
        for (int it = 0; it < 12; it++) {
            int[][] sums = new int[k][4];
            for (int[] p : pixels) {
                int idx = nearestCenter(p, centers);
                sums[idx][0] += p[0];
                sums[idx][1] += p[1];
                sums[idx][2] += p[2];
                sums[idx][3] += 1;
            }
            for (int i = 0; i < k; i++) {
                if (sums[i][3] > 0) {
                    centers.set(i, new int[]{
                        sums[i][0] / sums[i][3],
                        sums[i][1] / sums[i][3],
                        sums[i][2] / sums[i][3],
                    });
                }
            }
        }
        return centers;
    }

    private static List<int[]> medianCut(List<int[]> pixels, int k) {
        List<List<int[]>> buckets = new ArrayList<>();
        buckets.add(new ArrayList<>(pixels));
        while (buckets.size() < k) {
            int bi = -1;
            int bestRange = -1;
            int splitChannel = 0;
            for (int i = 0; i < buckets.size(); i++) {
                List<int[]> b = buckets.get(i);
                if (b.size() < 2) continue;
                int[] min = {255, 255, 255};
                int[] max = {0, 0, 0};
                for (int[] p : b) {
                    for (int c = 0; c < 3; c++) {
                        min[c] = Math.min(min[c], p[c]);
                        max[c] = Math.max(max[c], p[c]);
                    }
                }
                int[] ranges = {max[0] - min[0], max[1] - min[1], max[2] - min[2]};
                int r = Math.max(ranges[0], Math.max(ranges[1], ranges[2]));
                if (r > bestRange) {
                    bestRange = r;
                    bi = i;
                    splitChannel = ranges[0] >= ranges[1] && ranges[0] >= ranges[2] ? 0 : (ranges[1] >= ranges[2] ? 1 : 2);
                }
            }
            if (bi < 0) break;
            List<int[]> b = buckets.remove(bi);
            b.sort(Comparator.comparingInt(p -> p[splitChannel]));
            int mid = b.size() / 2;
            buckets.add(new ArrayList<>(b.subList(0, mid)));
            buckets.add(new ArrayList<>(b.subList(mid, b.size())));
        }
        List<int[]> out = new ArrayList<>();
        for (List<int[]> b : buckets) {
            if (b.isEmpty()) continue;
            int[] sum = {0, 0, 0};
            for (int[] p : b) {
                sum[0] += p[0];
                sum[1] += p[1];
                sum[2] += p[2];
            }
            out.add(new int[]{sum[0] / b.size(), sum[1] / b.size(), sum[2] / b.size()});
        }
        return out;
    }

    private static String tone(List<PaletteEntry> palette) {
        double warm = 0, cool = 0;
        for (PaletteEntry p : palette) {
            double h = p.hsl[0];
            if (h <= 60 || h >= 300) warm += p.percentage;
            else if (h >= 120 && h <= 260) cool += p.percentage;
        }
        if (warm > cool + 10) return "warm-toned";
        if (cool > warm + 10) return "cool-toned";
        return "neutral";
    }

    private static Map<String, Object> analyzeImage(Path imagePath, Map<String, String> options) throws Exception {
        if (!SUPPORTED.contains(ext(imagePath))) throw new IllegalArgumentException("Unsupported format: " + imagePath);
        int colors = Math.max(2, Math.min(32, Integer.parseInt(options.getOrDefault("colors", "8"))));
        String algorithm = options.getOrDefault("algorithm", "kmeans").toLowerCase(Locale.ROOT);
        if (!algorithm.equals("kmeans") && !algorithm.equals("median-cut")) {
            throw new IllegalArgumentException("Invalid --algorithm. Use kmeans|median-cut.");
        }

        BufferedImage image = ImageIO.read(imagePath.toFile());
        if (image == null) throw new IllegalArgumentException("Corrupted image: " + imagePath);
        CropRect crop = parseCrop(options.get("crop"), image.getWidth(), image.getHeight());

        List<int[]> pixels = new ArrayList<>();
        int stride = Math.max(1, (crop.width * crop.height) / 120000);
        int c = 0;
        Set<Integer> uniq = new HashSet<>();
        for (int y = crop.y; y < crop.y + crop.height; y++) {
            for (int x = crop.x; x < crop.x + crop.width; x++) {
                int rgb = image.getRGB(x, y);
                if ((c++ % stride) != 0) continue;
                int r = (rgb >> 16) & 0xFF;
                int g = (rgb >> 8) & 0xFF;
                int b = rgb & 0xFF;
                pixels.add(new int[]{r, g, b});
                uniq.add((r << 16) | (g << 8) | b);
            }
        }
        if (pixels.size() < 8) throw new IllegalArgumentException("Very small image.");
        int k = Math.min(colors, uniq.size());
        if (k < 2) throw new IllegalArgumentException("Image has very few unique colors.");

        List<int[]> centers = algorithm.equals("kmeans") ? kmeans(pixels, k) : medianCut(pixels, k);
        int[] counts = new int[centers.size()];
        for (int[] p : pixels) counts[nearestCenter(p, centers)]++;
        int total = Math.max(1, Arrays.stream(counts).sum());

        List<PaletteEntry> entries = new ArrayList<>();
        for (int i = 0; i < centers.size(); i++) {
            int[] rgb = centers.get(i);
            entries.add(new PaletteEntry(
                hex(rgb),
                rgb,
                hsl(rgb),
                round((counts[i] * 100.0) / total),
                closestCssName(rgb)
            ));
        }
        entries.sort((a, b) -> Double.compare(b.percentage, a.percentage));

        List<Map<String, Object>> paletteJson = new ArrayList<>();
        for (PaletteEntry e : entries) {
            paletteJson.add(Map.of(
                "hex", e.hex,
                "rgb", List.of(e.rgb[0], e.rgb[1], e.rgb[2]),
                "hsl", List.of(e.hsl[0], e.hsl[1], e.hsl[2]),
                "percentage", e.percentage,
                "css_name", e.cssName
            ));
        }

        Map<String, Object> out = new LinkedHashMap<>();
        out.put("image", imagePath.toAbsolutePath().toString());
        out.put("algorithm", algorithm);
        out.put("requested_colors", colors);
        out.put("extracted_colors", entries.size());
        out.put("crop", Map.of("x", crop.x, "y", crop.y, "width", crop.width, "height", crop.height));
        out.put("tone", tone(entries));
        out.put("palette", paletteJson);
        out.put("paletteEntries", entries);
        return out;
    }

    private static String ansi(int[] rgb) {
        return "\u001b[48;2;" + rgb[0] + ";" + rgb[1] + ";" + rgb[2] + "m  \u001b[0m";
    }

    @SuppressWarnings("unchecked")
    private static void printPalette(Map<String, Object> analysis) {
        System.out.println("Image: " + analysis.get("image"));
        System.out.println("Tone analysis: " + analysis.get("tone"));
        System.out.println("Palette:");
        System.out.println("Swatch HEX       RGB             HSL                %      Name");
        for (PaletteEntry e : (List<PaletteEntry>) analysis.get("paletteEntries")) {
            String rgb = e.rgb[0] + "," + e.rgb[1] + "," + e.rgb[2];
            String hsl = e.hsl[0] + "," + e.hsl[1] + "%," + e.hsl[2] + "%";
            System.out.printf("%s %-9s %-15s %-18s %-6.2f %s%n", ansi(e.rgb), e.hex, rgb, hsl, e.percentage, e.cssName);
        }
        System.out.println();
    }

    private static void writeVisual(List<PaletteEntry> palette, Path out) throws Exception {
        int sw = 220;
        int sh = 120;
        BufferedImage img = new BufferedImage(sw * palette.size(), sh, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = img.createGraphics();
        g.setFont(new Font("SansSerif", Font.PLAIN, 16));
        for (int i = 0; i < palette.size(); i++) {
            PaletteEntry p = palette.get(i);
            int x = i * sw;
            g.setColor(new Color(p.rgb[0], p.rgb[1], p.rgb[2]));
            g.fillRect(x, 0, sw, sh);
            g.setColor(new Color(0, 0, 0));
            g.fillRect(x, sh - 28, sw, 28);
            g.setColor(Color.WHITE);
            g.drawString(p.hex, x + 12, sh - 10);
        }
        g.dispose();
        Files.createDirectories(out.toAbsolutePath().getParent());
        ImageIO.write(img, "png", out.toFile());
    }

    @SuppressWarnings("unchecked")
    private static Map<String, Object> comparePalettes(Map<String, Object> a, Map<String, Object> b) {
        List<PaletteEntry> pa = (List<PaletteEntry>) a.get("paletteEntries");
        List<PaletteEntry> pb = (List<PaletteEntry>) b.get("paletteEntries");
        List<Map<String, Object>> common = new ArrayList<>();
        List<String> uniqueA = new ArrayList<>();
        List<String> uniqueB = new ArrayList<>();

        for (PaletteEntry ca : pa) {
            double best = Double.POSITIVE_INFINITY;
            PaletteEntry bestB = null;
            for (PaletteEntry cb : pb) {
                double d = deltaE(ca.rgb, cb.rgb);
                if (d < best) {
                    best = d;
                    bestB = cb;
                }
            }
            if (best < 15 && bestB != null) common.add(Map.of("a", ca.hex, "b", bestB.hex, "delta_e", round(best)));
            else uniqueA.add(ca.hex);
        }
        for (PaletteEntry cb : pb) {
            double best = Double.POSITIVE_INFINITY;
            for (PaletteEntry ca : pa) best = Math.min(best, deltaE(ca.rgb, cb.rgb));
            if (best >= 15) uniqueB.add(cb.hex);
        }
        return Map.of("common", common, "unique_a", uniqueA, "unique_b", uniqueB);
    }

    private static Path sample(String file, List<Color> colors) throws Exception {
        int w = 900, h = 500;
        BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = img.createGraphics();
        for (int y = 0; y < h; y++) {
            double t = y / (double) (h - 1);
            double idx = t * (colors.size() - 1);
            int i0 = (int) Math.floor(idx);
            int i1 = Math.min(colors.size() - 1, i0 + 1);
            double f = idx - i0;
            int r = (int) (colors.get(i0).getRed() * (1 - f) + colors.get(i1).getRed() * f);
            int gr = (int) (colors.get(i0).getGreen() * (1 - f) + colors.get(i1).getGreen() * f);
            int b = (int) (colors.get(i0).getBlue() * (1 - f) + colors.get(i1).getBlue() * f);
            g.setColor(new Color(r, gr, b));
            g.drawLine(0, y, w, y);
        }
        g.dispose();
        Path out = Paths.get(file).toAbsolutePath();
        ImageIO.write(img, "png", out.toFile());
        return out;
    }

    private static void runDemo(Map<String, String> options) throws Exception {
        Path sunset = sample("sample_sunset.png", List.of(new Color(0xFF7E5F), new Color(0xFEB47B), new Color(0xFF9966)));
        Path ocean = sample("sample_ocean.png", List.of(new Color(0x2193B0), new Color(0x6DD5ED), new Color(0x0F2027)));
        Path forest = sample("sample_forest.png", List.of(new Color(0x355C2D), new Color(0x6B8E23), new Color(0xA7C957)));
        List<Map<String, Object>> analyses = new ArrayList<>();
        for (Path p : List.of(sunset, ocean, forest)) {
            Map<String, Object> a = analyzeImage(p, options);
            analyses.add(a);
            printPalette(a);
        }
        System.out.println("Comparative tone summary:");
        for (Map<String, Object> a : analyses) {
            System.out.println("- " + Paths.get((String) a.get("image")).getFileName() + ": " + a.get("tone"));
        }
        writeJson(Paths.get(options.getOrDefault("output", "palette.json")).toAbsolutePath(), Map.of("mode", "demo", "analyses", analyses));
    }

    private static void runBatch(Path folder, Map<String, String> options) throws Exception {
        List<Map<String, Object>> analyses = 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> a = analyzeImage(p, options);
                analyses.add(a);
                printPalette(a);
            }
        }
        writeJson(Paths.get(options.getOrDefault("output", "palette.json")).toAbsolutePath(), Map.of("mode", "batch", "analyses", analyses));
    }

    private static void writeJson(Path out, Object payload) throws Exception {
        Files.createDirectories(out.toAbsolutePath().getParent());
        Files.writeString(out, GSON.toJson(payload), StandardCharsets.UTF_8);
    }

    private static double sq(double x) {
        return x * x;
    }

    private static double round(double v) {
        return Math.round(v * 100.0) / 100.0;
    }
}