← All tasks
pythoncodex/python-t1 #23Not a task: already works

Image Color Palette Extractor (python, written by Codex)

envgap__codex__python-t1-23

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

01 / FAILURE SIGNATURE

As the study recorded it

None
Not a benchmark task.
  • The project already builds and runs before the fix, so there is nothing to repair.

02 / ENVIRONMENT RECIPE

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

03 / TASK AND FAILURE

codex/python-t1 #23 · read the task the agent was given
Codex wrote this python project from the task below. It installed and ran on a clean Ubuntu 22.04 machine as written.

Task given to the agent:

TASK: 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 Python project for a clean Ubuntu 22.04 machine with only Python 3.10+ installed. Include:
- Source code
- requirements.txt with all dependencies (direct and transitive) pinned to exact versions
- README.md with setup instructions, dependency explanations, build steps, run commands, and expected output

04 / LABELS

Labels from the report text only; not yet run

No supported category has been assigned.

Label rules and the text that matched
[]

05 / FILES

The project as the agent wrote it

3 files, exactly as written, before any repair.

README.md
# Image Color Palette Extractor (Python)

Extracts dominant color palettes using `kmeans` or `median-cut`, reports percentage coverage and CSS color names, supports compare/batch modes, writes JSON, and optionally renders a palette visualization PNG.

## Requirements

- Ubuntu 22.04
- Python 3.10+

## Dependencies (Pinned)

- `numpy==2.1.1`
- `Pillow==10.4.0`
- `webcolors==24.11.1`

## Setup

```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```

## Run

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

## Notes

- `--colors` range is `2..32` (default `8`).
- Console output uses ANSI color swatches.
- `--visual` writes a labeled swatch strip as PNG.
- No-input mode generates sunset/ocean/forest sample images and prints comparative tone results.
requirements.txt
numpy==2.1.1
Pillow==10.4.0
webcolors==24.11.1
src/main.py
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import math
from pathlib import Path

import numpy as np
import webcolors
from PIL import Image, ImageDraw

SUPPORTED = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp"}


def parse_crop(raw: str | None, width: int, height: int) -> tuple[int, int, int, int]:
    if not raw:
        return 0, 0, width, height
    try:
        x, y, w, h = [int(v.strip()) for v in raw.split(",", 3)]
    except Exception as exc:
        raise ValueError("Invalid --crop. Use x,y,width,height.") from exc
    x = max(0, x)
    y = max(0, y)
    w = min(w, width - x)
    h = min(h, height - y)
    if w <= 0 or h <= 0:
        raise ValueError("Crop is outside image bounds.")
    return x, y, w, h


def rgb_to_hex(rgb: tuple[int, int, int]) -> str:
    return f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}"


def rgb_to_hsl(rgb: tuple[int, int, int]) -> tuple[float, float, float]:
    r, g, b = [x / 255.0 for x in rgb]
    mx = max(r, g, b)
    mn = min(r, g, b)
    d = mx - mn
    l = (mx + mn) / 2.0
    if d == 0:
        return 0.0, 0.0, l * 100
    s = d / (1 - abs(2 * l - 1))
    if mx == r:
        h = 60 * (((g - b) / d) % 6)
    elif mx == g:
        h = 60 * (((b - r) / d) + 2)
    else:
        h = 60 * (((r - g) / d) + 4)
    return h, s * 100, l * 100


def rgb_to_lab(rgb: tuple[int, int, int]) -> tuple[float, float, float]:
    def pivot_rgb(v: float) -> float:
        return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4

    r, g, b = [pivot_rgb(v / 255.0) for v in rgb]
    x = r * 0.4124 + g * 0.3576 + b * 0.1805
    y = r * 0.2126 + g * 0.7152 + b * 0.0722
    z = r * 0.0193 + g * 0.1192 + b * 0.9505
    x /= 0.95047
    y /= 1.00000
    z /= 1.08883

    def pivot_xyz(v: float) -> float:
        return v ** (1 / 3) if v > 0.008856 else (7.787 * v) + (16 / 116)

    fx = pivot_xyz(x)
    fy = pivot_xyz(y)
    fz = pivot_xyz(z)
    return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz))


def delta_e(rgb_a: tuple[int, int, int], rgb_b: tuple[int, int, int]) -> float:
    la = rgb_to_lab(rgb_a)
    lb = rgb_to_lab(rgb_b)
    return float(math.sqrt((la[0] - lb[0]) ** 2 + (la[1] - lb[1]) ** 2 + (la[2] - lb[2]) ** 2))


CSS_COLORS = {name: webcolors.hex_to_rgb(hex_code) for name, hex_code in webcolors._definitions._CSS3_NAMES_TO_HEX.items()}


def nearest_css_name(rgb: tuple[int, int, int]) -> str:
    best_name = ""
    best_dist = 10**9
    for name, c in CSS_COLORS.items():
        d = delta_e(rgb, (c.red, c.green, c.blue))
        if d < best_dist:
            best_dist = d
            best_name = name
    return best_name


def kmeans(pixels: np.ndarray, k: int, iterations: int = 14) -> np.ndarray:
    centers = pixels[np.linspace(0, len(pixels) - 1, k, dtype=int)].astype(np.float32)
    for _ in range(iterations):
        diffs = pixels[:, None, :] - centers[None, :, :]
        dist = np.sum(diffs * diffs, axis=2)
        labels = np.argmin(dist, axis=1)
        for i in range(k):
            pts = pixels[labels == i]
            if len(pts) > 0:
                centers[i] = np.mean(pts, axis=0)
    return np.round(centers).astype(np.uint8)


def median_cut(pixels: np.ndarray, k: int) -> np.ndarray:
    buckets: list[np.ndarray] = [pixels]
    while len(buckets) < k:
        idx = -1
        best_range = -1
        channel = 0
        for i, b in enumerate(buckets):
            if len(b) < 2:
                continue
            mins = np.min(b, axis=0)
            maxs = np.max(b, axis=0)
            ranges = maxs - mins
            r = int(np.max(ranges))
            if r > best_range:
                best_range = r
                idx = i
                channel = int(np.argmax(ranges))
        if idx < 0:
            break
        b = buckets.pop(idx)
        order = np.argsort(b[:, channel])
        b = b[order]
        mid = len(b) // 2
        buckets.append(b[:mid])
        buckets.append(b[mid:])
    centers = [np.mean(b, axis=0) for b in buckets if len(b) > 0]
    return np.round(np.array(centers)).astype(np.uint8)


def tone_from_palette(palette: list[dict]) -> str:
    warm = cool = neutral = 0.0
    for c in palette:
        h = c["hsl"][0]
        p = c["percentage"]
        if h <= 60 or h >= 300:
            warm += p
        elif 120 <= h <= 260:
            cool += p
        else:
            neutral += p
    if warm > cool + 10:
        return "warm-toned"
    if cool > warm + 10:
        return "cool-toned"
    return "neutral"


def analyze_image(image_path: Path, args: argparse.Namespace) -> dict:
    if image_path.suffix.lower() not in SUPPORTED:
        raise ValueError(f"Unsupported format: {image_path}")
    colors = max(2, min(32, int(args.colors)))
    algorithm = args.algorithm.lower()
    if algorithm not in {"kmeans", "median-cut"}:
        raise ValueError("Invalid --algorithm. Use kmeans|median-cut.")

    with Image.open(image_path) as img:
        x, y, w, h = parse_crop(args.crop, img.width, img.height)
        region = img.convert("RGB").crop((x, y, x + w, y + h))
    arr = np.array(region)
    if arr.size < 3 * 16:
        raise ValueError("Very small image.")
    pixels = arr.reshape(-1, 3)
    step = max(1, len(pixels) // 120000)
    pixels = pixels[::step]
    unique = np.unique(pixels, axis=0)
    if len(unique) < 2:
        raise ValueError("Image has very few unique colors.")
    k = min(colors, len(unique))

    centers = kmeans(pixels, k) if algorithm == "kmeans" else median_cut(pixels, k)
    diffs = pixels[:, None, :] - centers[None, :, :]
    dist = np.sum(diffs * diffs, axis=2)
    labels = np.argmin(dist, axis=1)
    counts = np.bincount(labels, minlength=len(centers))
    total = max(1, int(np.sum(counts)))

    palette = []
    for i, c in enumerate(centers):
        rgb = (int(c[0]), int(c[1]), int(c[2]))
        h, s, l = rgb_to_hsl(rgb)
        palette.append({
            "hex": rgb_to_hex(rgb),
            "rgb": [rgb[0], rgb[1], rgb[2]],
            "hsl": [round(h, 1), round(s, 1), round(l, 1)],
            "percentage": round((counts[i] / total) * 100, 2),
            "css_name": nearest_css_name(rgb),
        })
    palette.sort(key=lambda row: row["percentage"], reverse=True)

    return {
        "image": str(image_path.resolve()),
        "algorithm": algorithm,
        "requested_colors": colors,
        "extracted_colors": len(palette),
        "crop": {"x": x, "y": y, "width": w, "height": h},
        "tone": tone_from_palette(palette),
        "palette": palette,
    }


def ansi_block(rgb: list[int]) -> str:
    return f"\033[48;2;{rgb[0]};{rgb[1]};{rgb[2]}m  \033[0m"


def print_palette(analysis: dict) -> None:
    print(f"Image: {analysis['image']}")
    print(f"Tone analysis: {analysis['tone']}")
    print("Palette:")
    print("Swatch HEX       RGB             HSL                %      Name")
    for c in analysis["palette"]:
        rgb = f"{c['rgb'][0]},{c['rgb'][1]},{c['rgb'][2]}"
        hsl = f"{c['hsl'][0]},{c['hsl'][1]}%,{c['hsl'][2]}%"
        print(f"{ansi_block(c['rgb'])} {c['hex']:<9} {rgb:<15} {hsl:<18} {c['percentage']:<6} {c['css_name']}")
    print()


def write_visual(palette: list[dict], path_value: Path) -> None:
    sw, sh = 220, 120
    canvas = Image.new("RGB", (sw * len(palette), sh), "white")
    draw = ImageDraw.Draw(canvas)
    for i, c in enumerate(palette):
        x0 = i * sw
        x1 = x0 + sw
        draw.rectangle((x0, 0, x1, sh), fill=c["hex"])
        draw.rectangle((x0, sh - 28, x1, sh), fill=(0, 0, 0))
        draw.text((x0 + 12, sh - 22), c["hex"], fill="white")
    path_value.parent.mkdir(parents=True, exist_ok=True)
    canvas.save(path_value, format="PNG")


def compare_palettes(a: dict, b: dict) -> dict:
    common = []
    unique_a = []
    unique_b = []

    for ca in a["palette"]:
        best = min((delta_e(tuple(ca["rgb"]), tuple(cb["rgb"])), cb["hex"]) for cb in b["palette"])
        if best[0] < 15:
            common.append({"a": ca["hex"], "b": best[1], "delta_e": round(best[0], 2)})
        else:
            unique_a.append(ca["hex"])
    for cb in b["palette"]:
        best = min(delta_e(tuple(cb["rgb"]), tuple(ca["rgb"])) for ca in a["palette"])
        if best >= 15:
            unique_b.append(cb["hex"])
    return {"common": common, "unique_a": unique_a, "unique_b": unique_b}


def generate_sample(name: str, colors: list[str]) -> Path:
    out = Path(name).resolve()
    w, h = 900, 500
    img = Image.new("RGB", (w, h))
    draw = ImageDraw.Draw(img)
    stops = [webcolors.hex_to_rgb(c) for c in colors]
    for y in range(h):
        t = y / max(1, h - 1)
        idx = t * (len(stops) - 1)
        i0 = int(math.floor(idx))
        i1 = min(len(stops) - 1, i0 + 1)
        f = idx - i0
        r = int(stops[i0].red * (1 - f) + stops[i1].red * f)
        g = int(stops[i0].green * (1 - f) + stops[i1].green * f)
        b = int(stops[i0].blue * (1 - f) + stops[i1].blue * f)
        draw.line((0, y, w, y), fill=(r, g, b))
    img.save(out, format="PNG")
    return out


def run_batch(folder: Path, args: argparse.Namespace) -> dict:
    analyses = []
    for f in folder.iterdir():
        if f.is_file() and f.suffix.lower() in SUPPORTED:
            analysis = analyze_image(f.resolve(), args)
            analyses.append(analysis)
            print_palette(analysis)
    return {"mode": "batch", "analyses": analyses}


def parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description="Image Color Palette Extractor")
    p.add_argument("input", nargs="?")
    p.add_argument("--colors", type=int, default=8)
    p.add_argument("--algorithm", default="kmeans")
    p.add_argument("--visual", nargs="?", const="palette_visual.png")
    p.add_argument("--crop")
    p.add_argument("--compare")
    p.add_argument("--output", default="palette.json")
    p.add_argument("--batch", action="store_true")
    return p


def main() -> int:
    args = parser().parse_args()

    if not args.input and not args.batch:
        sunset = generate_sample("sample_sunset.png", ["#ff7e5f", "#feb47b", "#ff9966"])
        ocean = generate_sample("sample_ocean.png", ["#2193b0", "#6dd5ed", "#0f2027"])
        forest = generate_sample("sample_forest.png", ["#355c2d", "#6b8e23", "#a7c957"])
        analyses = [analyze_image(p, args) for p in (sunset, ocean, forest)]
        for analysis in analyses:
            print_palette(analysis)
        print("Comparative tone summary:")
        for analysis in analyses:
            print(f"- {Path(analysis['image']).name}: {analysis['tone']}")
        Path(args.output).resolve().write_text(json.dumps({"mode": "demo", "analyses": analyses}, indent=2), encoding="utf-8")
        return 0

    if not args.input:
        raise ValueError("Input path required.")
    input_path = Path(args.input).resolve()

    if args.batch:
        if not input_path.is_dir():
            raise ValueError("--batch requires a directory input.")
        payload = run_batch(input_path, args)
        Path(args.output).resolve().write_text(json.dumps(payload, indent=2), encoding="utf-8")
        return 0

    if not input_path.is_file():
        raise FileNotFoundError(f"Input not found: {input_path}")
    analysis = analyze_image(input_path, args)
    print_palette(analysis)

    comparison = None
    if args.compare:
        other = analyze_image(Path(args.compare).resolve(), args)
        comparison = compare_palettes(analysis, other)
        print("Palette comparison:")
        print(json.dumps(comparison, indent=2))

    if args.visual:
        write_visual(analysis["palette"], Path(args.visual).resolve())

    payload = {"mode": "single", "analysis": analysis, "comparison": comparison}
    Path(args.output).resolve().write_text(json.dumps(payload, indent=2), encoding="utf-8")
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except Exception as exc:
        print(f"Error: {exc}")
        raise SystemExit(1)