← All tasks
cppcodex/cpp-t1 #23Not a task: repair not recorded

Image Color Palette Extractor (cpp, written by Codex)

envgap__codex__cpp-t1-23

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

01 / FAILURE SIGNATURE

As the study recorded it

OpenCV headers not found - missing target_include_directories
Not a benchmark task.
  • It was made to work, but its repair cannot be rebuilt from the saved files (the saved copy shows no change, or not all of the changes the study's notes describe), so there is no fix to score against.

02 / ENVIRONMENT RECIPE

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

03 / TASK AND FAILURE

codex/cpp-t1 #23 · read the task the agent was given
Codex wrote this cpp 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 C++ project for a clean Ubuntu 22.04 machine with only G++ 12+ and CMake 3.22+ installed. Include:
- Source code
- CMakeLists.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.

CMakeLists.txt
cmake_minimum_required(VERSION 3.22)
project(image_color_palette_extractor_cpp VERSION 1.0.0 LANGUAGES C CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

include(FetchContent)

FetchContent_Declare(
  opencv
  URL https://github.com/opencv/opencv/archive/refs/tags/4.10.0.zip
)
set(BUILD_LIST "core,imgproc,imgcodecs" CACHE STRING "" FORCE)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(BUILD_PERF_TESTS OFF CACHE BOOL "" FORCE)
set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(opencv)

FetchContent_Declare(
  nlohmann_json
  URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz
)
FetchContent_MakeAvailable(nlohmann_json)

add_executable(image_color_palette_extractor src/main.cpp)
target_link_libraries(image_color_palette_extractor PRIVATE opencv_core opencv_imgproc opencv_imgcodecs nlohmann_json::nlohmann_json)
README.md
# Image Color Palette Extractor (C++)

Extracts dominant color palettes with `kmeans` or `median-cut`, prints ANSI color tables with percentages and nearest CSS names, compares palettes with delta-E, supports batch mode, and writes JSON plus optional visualization PNG.

## Requirements

- Ubuntu 22.04
- G++ 12+
- CMake 3.22+

## Dependencies (Pinned)

- OpenCV `4.10.0`
- nlohmann/json `3.11.3`

## Build

```bash
cmake -S . -B build
cmake --build build -j
```

## Run

```bash
./build/image_color_palette_extractor image.jpg
./build/image_color_palette_extractor image.jpg --colors 12 --algorithm kmeans
./build/image_color_palette_extractor image.jpg --algorithm median-cut --crop 50,50,500,300
./build/image_color_palette_extractor image.jpg --visual palette.png --output palette.json
./build/image_color_palette_extractor image.jpg --compare other.jpg
./build/image_color_palette_extractor ./images --batch --colors 6 --output batch_palettes.json
./build/image_color_palette_extractor
```

## Notes

- `--colors` range is `2..32` (default `8`).
- `--visual` writes a swatch PNG with hex labels.
- No-input mode generates sunset/ocean/forest sample images and prints comparative tone results.
src/main.cpp
#include <nlohmann/json.hpp>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>

#include <algorithm>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <map>
#include <set>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>

namespace fs = std::filesystem;
using json = nlohmann::json;

struct CliOptions {
    std::map<std::string, std::string> kv;
    std::vector<std::string> positional;
};

struct CropRect {
    int x{};
    int y{};
    int width{};
    int height{};
};

struct PaletteEntry {
    std::string hex;
    cv::Vec3i rgb;
    cv::Vec3d hsl;
    double percentage{};
    std::string cssName;
};

const std::set<std::string> kSupported = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp"};
const std::map<std::string, cv::Vec3i> kCss = {
    {"black", {0, 0, 0}}, {"white", {255, 255, 255}}, {"red", {255, 0, 0}}, {"green", {0, 128, 0}},
    {"blue", {0, 0, 255}}, {"yellow", {255, 255, 0}}, {"orange", {255, 165, 0}}, {"purple", {128, 0, 128}},
    {"pink", {255, 192, 203}}, {"brown", {165, 42, 42}}, {"gray", {128, 128, 128}}, {"cyan", {0, 255, 255}},
    {"magenta", {255, 0, 255}}, {"gold", {255, 215, 0}}, {"olive", {128, 128, 0}}, {"teal", {0, 128, 128}},
    {"navy", {0, 0, 128}}, {"maroon", {128, 0, 0}}, {"lime", {0, 255, 0}}, {"aqua", {0, 255, 255}}
};

CliOptions parseArgs(int argc, char** argv) {
    CliOptions out;
    for (int i = 1; i < argc; ++i) {
        std::string t = argv[i];
        if (t.rfind("--", 0) == 0) {
            std::string key = t.substr(2);
            if (i + 1 < argc) {
                std::string next = argv[i + 1];
                if (next.rfind("--", 0) != 0) {
                    out.kv[key] = next;
                    ++i;
                    continue;
                }
            }
            out.kv[key] = "true";
        } else out.positional.push_back(t);
    }
    return out;
}

std::string lower(std::string v) {
    for (char& c : v) if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
    return v;
}

bool supported(const fs::path& p) {
    return p.has_extension() && kSupported.count(lower(p.extension().string())) > 0;
}

CropRect parseCrop(const std::string& raw, int width, int height) {
    if (raw.empty()) return {0, 0, width, height};
    std::stringstream ss(raw);
    std::string a, b, c, d;
    if (!std::getline(ss, a, ',') || !std::getline(ss, b, ',') || !std::getline(ss, c, ',') || !std::getline(ss, d, ',')) {
        throw std::invalid_argument("Invalid --crop. Use x,y,width,height.");
    }
    int x = std::max(0, std::stoi(a));
    int y = std::max(0, std::stoi(b));
    int w = std::min(std::stoi(c), width - x);
    int h = std::min(std::stoi(d), height - y);
    if (w <= 0 || h <= 0) throw std::invalid_argument("Crop is outside image bounds.");
    return {x, y, w, h};
}

std::string hexOf(const cv::Vec3i& rgb) {
    std::ostringstream oss;
    oss << "#" << std::uppercase << std::hex << std::setw(2) << std::setfill('0') << rgb[0]
        << std::setw(2) << rgb[1] << std::setw(2) << rgb[2];
    return oss.str();
}

cv::Vec3d hslOf(const cv::Vec3i& rgb) {
    double r = rgb[0] / 255.0, g = rgb[1] / 255.0, b = rgb[2] / 255.0;
    double mx = std::max({r, g, b}), mn = std::min({r, g, b});
    double d = mx - mn, l = (mx + mn) / 2.0, h = 0.0, s = 0.0;
    if (d != 0.0) {
        s = d / (1.0 - std::abs(2.0 * l - 1.0));
        if (mx == r) h = 60.0 * std::fmod(((g - b) / d), 6.0);
        else if (mx == g) h = 60.0 * (((b - r) / d) + 2.0);
        else h = 60.0 * (((r - g) / d) + 4.0);
        if (h < 0) h += 360.0;
    }
    return {std::round(h * 10) / 10.0, std::round(s * 1000) / 10.0, std::round(l * 1000) / 10.0};
}

cv::Vec3d labOf(const cv::Vec3i& rgb) {
    cv::Mat bgr(1, 1, CV_8UC3, cv::Scalar(rgb[2], rgb[1], rgb[0]));
    cv::Mat lab;
    cv::cvtColor(bgr, lab, cv::COLOR_BGR2Lab);
    cv::Vec3b v = lab.at<cv::Vec3b>(0, 0);
    return {static_cast<double>(v[0]), static_cast<double>(v[1]), static_cast<double>(v[2])};
}

double deltaE(const cv::Vec3i& a, const cv::Vec3i& b) {
    cv::Vec3d la = labOf(a), lb = labOf(b);
    return std::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]));
}

std::string nearestCss(const cv::Vec3i& rgb) {
    std::string best;
    double bestD = std::numeric_limits<double>::infinity();
    for (const auto& e : kCss) {
        double d = deltaE(rgb, e.second);
        if (d < bestD) {
            bestD = d;
            best = e.first;
        }
    }
    return best;
}

int nearestCenter(const cv::Vec3i& p, const std::vector<cv::Vec3i>& centers) {
    int idx = 0;
    double best = std::numeric_limits<double>::infinity();
    for (int i = 0; i < static_cast<int>(centers.size()); ++i) {
        cv::Vec3d d = cv::Vec3d(p) - cv::Vec3d(centers[i]);
        double s = d.dot(d);
        if (s < best) {
            best = s;
            idx = i;
        }
    }
    return idx;
}

std::vector<cv::Vec3i> kmeansColors(const std::vector<cv::Vec3i>& pixels, int k) {
    cv::Mat samples(static_cast<int>(pixels.size()), 3, CV_32F);
    for (int i = 0; i < static_cast<int>(pixels.size()); ++i) {
        samples.at<float>(i, 0) = static_cast<float>(pixels[i][0]);
        samples.at<float>(i, 1) = static_cast<float>(pixels[i][1]);
        samples.at<float>(i, 2) = static_cast<float>(pixels[i][2]);
    }
    cv::Mat labels, centers;
    cv::kmeans(samples, k, labels, cv::TermCriteria(cv::TermCriteria::MAX_ITER + cv::TermCriteria::EPS, 14, 0.5), 3,
        cv::KMEANS_PP_CENTERS, centers);
    std::vector<cv::Vec3i> out;
    for (int i = 0; i < centers.rows; ++i) {
        out.push_back({
            static_cast<int>(std::round(centers.at<float>(i, 0))),
            static_cast<int>(std::round(centers.at<float>(i, 1))),
            static_cast<int>(std::round(centers.at<float>(i, 2)))
        });
    }
    return out;
}

std::vector<cv::Vec3i> medianCut(const std::vector<cv::Vec3i>& pixels, int k) {
    std::vector<std::vector<cv::Vec3i>> buckets = {pixels};
    while (static_cast<int>(buckets.size()) < k) {
        int bi = -1, best = -1, channel = 0;
        for (int i = 0; i < static_cast<int>(buckets.size()); ++i) {
            const auto& b = buckets[i];
            if (b.size() < 2) continue;
            int minv[3] = {255, 255, 255}, maxv[3] = {0, 0, 0};
            for (const auto& p : b) {
                for (int c = 0; c < 3; ++c) {
                    minv[c] = std::min(minv[c], p[c]);
                    maxv[c] = std::max(maxv[c], p[c]);
                }
            }
            int ranges[3] = {maxv[0] - minv[0], maxv[1] - minv[1], maxv[2] - minv[2]};
            int r = std::max({ranges[0], ranges[1], ranges[2]});
            if (r > best) {
                best = r;
                bi = i;
                channel = ranges[0] >= ranges[1] && ranges[0] >= ranges[2] ? 0 : (ranges[1] >= ranges[2] ? 1 : 2);
            }
        }
        if (bi < 0) break;
        auto bucket = buckets[bi];
        buckets.erase(buckets.begin() + bi);
        std::sort(bucket.begin(), bucket.end(), [channel](const cv::Vec3i& a, const cv::Vec3i& b) { return a[channel] < b[channel]; });
        int mid = static_cast<int>(bucket.size()) / 2;
        buckets.push_back(std::vector<cv::Vec3i>(bucket.begin(), bucket.begin() + mid));
        buckets.push_back(std::vector<cv::Vec3i>(bucket.begin() + mid, bucket.end()));
    }
    std::vector<cv::Vec3i> out;
    for (const auto& b : buckets) {
        if (b.empty()) continue;
        cv::Vec3i sum(0, 0, 0);
        for (const auto& p : b) sum += p;
        out.push_back(sum / static_cast<int>(b.size()));
    }
    return out;
}

std::string tone(const std::vector<PaletteEntry>& palette) {
    double warm = 0, cool = 0;
    for (const auto& c : palette) {
        double h = c.hsl[0];
        if (h <= 60 || h >= 300) warm += c.percentage;
        else if (h >= 120 && h <= 260) cool += c.percentage;
    }
    if (warm > cool + 10) return "warm-toned";
    if (cool > warm + 10) return "cool-toned";
    return "neutral";
}

std::string ansi(const cv::Vec3i& rgb) {
    return "\033[48;2;" + std::to_string(rgb[0]) + ";" + std::to_string(rgb[1]) + ";" + std::to_string(rgb[2]) + "m  \033[0m";
}

std::pair<json, std::vector<PaletteEntry>> analyze(const fs::path& imagePath, const CliOptions& options) {
    if (!supported(imagePath)) throw std::invalid_argument("Unsupported format: " + imagePath.string());
    int colors = std::max(2, std::min(32, std::stoi(options.kv.count("colors") ? options.kv.at("colors") : "8")));
    std::string algorithm = lower(options.kv.count("algorithm") ? options.kv.at("algorithm") : "kmeans");
    if (algorithm != "kmeans" && algorithm != "median-cut") throw std::invalid_argument("Invalid --algorithm. Use kmeans|median-cut.");

    cv::Mat img = cv::imread(imagePath.string(), cv::IMREAD_COLOR);
    if (img.empty()) throw std::runtime_error("Corrupted image.");
    CropRect crop = parseCrop(options.kv.count("crop") ? options.kv.at("crop") : "", img.cols, img.rows);
    cv::Mat roi = img(cv::Rect(crop.x, crop.y, crop.width, crop.height));

    int stride = std::max(1, (roi.cols * roi.rows) / 120000);
    std::vector<cv::Vec3i> pixels;
    std::set<int> uniq;
    int c = 0;
    for (int y = 0; y < roi.rows; ++y) {
        for (int x = 0; x < roi.cols; ++x) {
            if ((c++ % stride) != 0) continue;
            cv::Vec3b p = roi.at<cv::Vec3b>(y, x);
            cv::Vec3i rgb(p[2], p[1], p[0]);
            pixels.push_back(rgb);
            uniq.insert((rgb[0] << 16) | (rgb[1] << 8) | rgb[2]);
        }
    }
    if (pixels.size() < 8) throw std::invalid_argument("Very small image.");
    int k = std::min(colors, static_cast<int>(uniq.size()));
    if (k < 2) throw std::invalid_argument("Image has very few unique colors.");

    std::vector<cv::Vec3i> centers = (algorithm == "kmeans") ? kmeansColors(pixels, k) : medianCut(pixels, k);
    std::vector<int> counts(centers.size(), 0);
    for (const auto& p : pixels) counts[nearestCenter(p, centers)]++;
    int total = std::max(1, std::accumulate(counts.begin(), counts.end(), 0));

    std::vector<PaletteEntry> entries;
    for (int i = 0; i < static_cast<int>(centers.size()); ++i) {
        entries.push_back({
            hexOf(centers[i]),
            centers[i],
            hslOf(centers[i]),
            std::round((counts[i] * 10000.0) / total) / 100.0,
            nearestCss(centers[i])
        });
    }
    std::sort(entries.begin(), entries.end(), [](const PaletteEntry& a, const PaletteEntry& b) { return a.percentage > b.percentage; });

    json palette = json::array();
    for (const auto& e : entries) {
        palette.push_back({
            {"hex", e.hex},
            {"rgb", {e.rgb[0], e.rgb[1], e.rgb[2]}},
            {"hsl", {e.hsl[0], e.hsl[1], e.hsl[2]}},
            {"percentage", e.percentage},
            {"css_name", e.cssName}
        });
    }

    json out = {
        {"image", fs::absolute(imagePath).string()},
        {"algorithm", algorithm},
        {"requested_colors", colors},
        {"extracted_colors", entries.size()},
        {"crop", {{"x", crop.x}, {"y", crop.y}, {"width", crop.width}, {"height", crop.height}}},
        {"tone", tone(entries)},
        {"palette", palette}
    };
    return {out, entries};
}

void printPalette(const json& analysis, const std::vector<PaletteEntry>& entries) {
    std::cout << "Image: " << analysis["image"] << "\n";
    std::cout << "Tone analysis: " << analysis["tone"] << "\n";
    std::cout << "Palette:\n";
    std::cout << "Swatch HEX       RGB             HSL                %      Name\n";
    for (const auto& e : entries) {
        std::ostringstream rgb, hsl;
        rgb << e.rgb[0] << "," << e.rgb[1] << "," << e.rgb[2];
        hsl << e.hsl[0] << "," << e.hsl[1] << "%," << e.hsl[2] << "%";
        std::cout << ansi(e.rgb) << " " << std::left << std::setw(9) << e.hex << " "
                  << std::setw(15) << rgb.str() << " " << std::setw(18) << hsl.str() << " "
                  << std::setw(6) << e.percentage << " " << e.cssName << "\n";
    }
    std::cout << "\n";
}

void writeVisual(const std::vector<PaletteEntry>& entries, const fs::path& out) {
    int sw = 220, sh = 120;
    cv::Mat image(sh, sw * static_cast<int>(entries.size()), CV_8UC3, cv::Scalar(255, 255, 255));
    for (int i = 0; i < static_cast<int>(entries.size()); ++i) {
        auto rgb = entries[i].rgb;
        int x = i * sw;
        cv::rectangle(image, cv::Rect(x, 0, sw, sh), cv::Scalar(rgb[2], rgb[1], rgb[0]), cv::FILLED);
        cv::rectangle(image, cv::Rect(x, sh - 28, sw, 28), cv::Scalar(0, 0, 0), cv::FILLED);
        cv::putText(image, entries[i].hex, cv::Point(x + 12, sh - 9), cv::FONT_HERSHEY_SIMPLEX, 0.55, cv::Scalar(255, 255, 255), 1, cv::LINE_AA);
    }
    fs::create_directories(out.parent_path());
    cv::imwrite(out.string(), image);
}

json compare(const std::vector<PaletteEntry>& a, const std::vector<PaletteEntry>& b) {
    json common = json::array(), uniqueA = json::array(), uniqueB = json::array();
    for (const auto& ca : a) {
        double best = std::numeric_limits<double>::infinity();
        const PaletteEntry* bestB = nullptr;
        for (const auto& cb : b) {
            double d = deltaE(ca.rgb, cb.rgb);
            if (d < best) {
                best = d;
                bestB = &cb;
            }
        }
        if (best < 15 && bestB) common.push_back({{"a", ca.hex}, {"b", bestB->hex}, {"delta_e", std::round(best * 100) / 100.0}});
        else uniqueA.push_back(ca.hex);
    }
    for (const auto& cb : b) {
        double best = std::numeric_limits<double>::infinity();
        for (const auto& ca : a) best = std::min(best, deltaE(ca.rgb, cb.rgb));
        if (best >= 15) uniqueB.push_back(cb.hex);
    }
    return {{"common", common}, {"unique_a", uniqueA}, {"unique_b", uniqueB}};
}

fs::path makeSample(const fs::path& name, const std::vector<cv::Vec3b>& stops) {
    int w = 900, h = 500;
    cv::Mat img(h, w, CV_8UC3);
    for (int y = 0; y < h; ++y) {
        double t = y / static_cast<double>(h - 1);
        double idx = t * (stops.size() - 1);
        int i0 = static_cast<int>(std::floor(idx));
        int i1 = std::min(static_cast<int>(stops.size() - 1), i0 + 1);
        double f = idx - i0;
        cv::Vec3b c(
            static_cast<uchar>(stops[i0][0] * (1 - f) + stops[i1][0] * f),
            static_cast<uchar>(stops[i0][1] * (1 - f) + stops[i1][1] * f),
            static_cast<uchar>(stops[i0][2] * (1 - f) + stops[i1][2] * f)
        );
        for (int x = 0; x < w; ++x) img.at<cv::Vec3b>(y, x) = c;
    }
    fs::path out = fs::absolute(name);
    cv::imwrite(out.string(), img);
    return out;
}

void writeJson(const fs::path& out, const json& payload) {
    fs::create_directories(out.parent_path());
    std::ofstream ofs(out);
    ofs << payload.dump(2) << "\n";
}

void runBatch(const fs::path& folder, const CliOptions& options) {
    if (!fs::is_directory(folder)) throw std::invalid_argument("--batch requires a directory input.");
    json analyses = json::array();
    for (const auto& e : fs::directory_iterator(folder)) {
        if (!e.is_regular_file() || !supported(e.path())) continue;
        auto [analysis, entries] = analyze(e.path(), options);
        analyses.push_back(analysis);
        printPalette(analysis, entries);
    }
    writeJson(fs::absolute(options.kv.count("output") ? options.kv.at("output") : "palette.json"), {{"mode", "batch"}, {"analyses", analyses}});
}

void runDemo(const CliOptions& options) {
    fs::path sunset = makeSample("sample_sunset.png", {cv::Vec3b(95, 126, 255), cv::Vec3b(123, 180, 254), cv::Vec3b(102, 153, 255)});
    fs::path ocean = makeSample("sample_ocean.png", {cv::Vec3b(176, 147, 33), cv::Vec3b(237, 213, 109), cv::Vec3b(39, 32, 15)});
    fs::path forest = makeSample("sample_forest.png", {cv::Vec3b(45, 92, 53), cv::Vec3b(35, 142, 107), cv::Vec3b(87, 201, 167)});
    json analyses = json::array();
    std::vector<std::pair<json, std::vector<PaletteEntry>>> out;
    for (const auto& p : {sunset, ocean, forest}) {
        auto a = analyze(p, options);
        out.push_back(a);
        analyses.push_back(a.first);
        printPalette(a.first, a.second);
    }
    std::cout << "Comparative tone summary:\n";
    for (const auto& p : out) {
        std::cout << "- " << fs::path(p.first["image"].get<std::string>()).filename().string() << ": " << p.first["tone"] << "\n";
    }
    writeJson(fs::absolute(options.kv.count("output") ? options.kv.at("output") : "palette.json"), {{"mode", "demo"}, {"analyses", analyses}});
}

int main(int argc, char** argv) {
    try {
        CliOptions options = parseArgs(argc, argv);
        if (options.positional.empty() && !options.kv.count("batch")) {
            runDemo(options);
            return 0;
        }
        if (options.positional.empty()) throw std::invalid_argument("Input path required.");
        fs::path input = fs::absolute(options.positional.front());

        if (options.kv.count("batch")) {
            runBatch(input, options);
            return 0;
        }
        if (!fs::exists(input) || !fs::is_regular_file(input)) throw std::invalid_argument("Input not found: " + input.string());

        auto [analysis, entries] = analyze(input, options);
        printPalette(analysis, entries);
        json comp = nullptr;
        if (options.kv.count("compare")) {
            auto [other, otherEntries] = analyze(fs::absolute(options.kv.at("compare")), options);
            comp = compare(entries, otherEntries);
            std::cout << "Palette comparison:\n" << comp.dump(2) << "\n";
        }
        if (options.kv.count("visual")) {
            fs::path out = options.kv.at("visual") == "true" ? fs::absolute("palette_visual.png") : fs::absolute(options.kv.at("visual"));
            writeVisual(entries, out);
            std::cout << "Palette visual: " << out << "\n";
        }
        writeJson(fs::absolute(options.kv.count("output") ? options.kv.at("output") : "palette.json"),
            {{"mode", "single"}, {"analysis", analysis}, {"comparison", comp}});
        return 0;
    } catch (const std::exception& ex) {
        std::cerr << "Error: " << ex.what() << "\n";
        return 1;
    }
}