← All tasks
javascriptcodex/javascript-t1 #23Not a task: already works

Image Color Palette Extractor (javascript, written by Codex)

envgap__codex__javascript-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
package.json
Reproduce
Awaiting issue-specific recipe
Run under trace
Awaiting a meaningful runtime command

03 / TASK AND FAILURE

codex/javascript-t1 #23 · read the task the agent was given
Codex wrote this javascript 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 JavaScript project for a clean Ubuntu 22.04 machine with only Node.js 20+ (LTS) installed. Include:
- Source code
- package.json 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

4 files, exactly as written, before any repair.

package-lock.json
{
  "name": "image-color-palette-extractor",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "image-color-palette-extractor",
      "version": "1.0.0",
      "dependencies": {
        "color-name": "2.0.0",
        "sharp": "0.33.5"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/color-name": {
      "version": "2.0.0"
    },
    "node_modules/sharp": {
      "version": "0.33.5"
    }
  }
}
package.json
{
  "name": "image-color-palette-extractor",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js"
  },
  "engines": {
    "node": ">=20.0.0"
  },
  "dependencies": {
    "color-name": "2.0.0",
    "sharp": "0.33.5"
  }
}
README.md
# Image Color Palette Extractor (JavaScript)

Extracts dominant color palettes using `kmeans` or `median-cut`, prints color tables with percentages and CSS color names, supports compare/batch modes, and writes JSON plus optional visualization PNG.

## Requirements

- Ubuntu 22.04
- Node.js 20+ (LTS)

## Dependencies (Pinned)

- `color-name@2.0.0`
- `sharp@0.33.5`

## Setup

```bash
npm install
```

## Run

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

## Notes

- `--colors` range is `2..32` (default `8`).
- `--visual` writes a PNG swatch strip with hex labels.
- Console output uses ANSI color blocks for swatches.
- No-input mode generates sunset/ocean/forest sample images and compares extracted tones.
src/index.js
import fs from "fs";
import path from "path";
import sharp from "sharp";
import cssColors from "color-name";

const SUPPORTED = new Set([".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp"]);

function parseArgs(argv) {
  const options = {};
  const positional = [];
  for (let i = 0; i < argv.length; i += 1) {
    const token = argv[i];
    if (token.startsWith("--")) {
      const key = token.slice(2);
      const next = argv[i + 1];
      if (next && !next.startsWith("--")) {
        options[key] = next;
        i += 1;
      } else {
        options[key] = true;
      }
    } else positional.push(token);
  }
  return { options, positional };
}

function parseCrop(raw, width, height) {
  if (!raw) return { left: 0, top: 0, width, height };
  const m = /^(-?\d+),(-?\d+),(\d+),(\d+)$/.exec(String(raw));
  if (!m) throw new Error("Invalid --crop. Use x,y,width,height.");
  const left = Math.max(0, Number(m[1]));
  const top = Math.max(0, Number(m[2]));
  const w = Math.min(Number(m[3]), width - left);
  const h = Math.min(Number(m[4]), height - top);
  if (w <= 0 || h <= 0) throw new Error("Crop is outside image bounds.");
  return { left, top, width: w, height: h };
}

function rgbToHex([r, g, b]) {
  const to2 = (n) => n.toString(16).padStart(2, "0").toUpperCase();
  return `#${to2(r)}${to2(g)}${to2(b)}`;
}

function rgbToHsl([r, g, b]) {
  const rn = r / 255;
  const gn = g / 255;
  const bn = b / 255;
  const max = Math.max(rn, gn, bn);
  const min = Math.min(rn, gn, bn);
  const d = max - min;
  let h = 0;
  const l = (max + min) / 2;
  let s = 0;
  if (d !== 0) {
    s = d / (1 - Math.abs(2 * l - 1));
    if (max === rn) h = 60 * (((gn - bn) / d) % 6);
    else if (max === gn) h = 60 * (((bn - rn) / d) + 2);
    else h = 60 * (((rn - gn) / d) + 4);
  }
  if (h < 0) h += 360;
  return [h, s * 100, l * 100];
}

function rgbToLab([r, g, b]) {
  const srgb = [r, g, b].map((v) => v / 255).map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
  const x = srgb[0] * 0.4124 + srgb[1] * 0.3576 + srgb[2] * 0.1805;
  const y = srgb[0] * 0.2126 + srgb[1] * 0.7152 + srgb[2] * 0.0722;
  const z = srgb[0] * 0.0193 + srgb[1] * 0.1192 + srgb[2] * 0.9505;
  const xr = x / 0.95047;
  const yr = y / 1.0;
  const zr = z / 1.08883;
  const f = (t) => (t > 0.008856 ? t ** (1 / 3) : (7.787 * t) + (16 / 116));
  const fx = f(xr);
  const fy = f(yr);
  const fz = f(zr);
  return [(116 * fy) - 16, 500 * (fx - fy), 200 * (fy - fz)];
}

function deltaE(rgbA, rgbB) {
  const a = rgbToLab(rgbA);
  const b = rgbToLab(rgbB);
  return Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2);
}

function closestCssName(rgb) {
  let best = null;
  let bestD = Number.POSITIVE_INFINITY;
  for (const [name, c] of Object.entries(cssColors)) {
    const d = deltaE(rgb, c);
    if (d < bestD) {
      bestD = d;
      best = name;
    }
  }
  return best;
}

function nearestIndex(color, centers) {
  let idx = 0;
  let best = Number.POSITIVE_INFINITY;
  for (let i = 0; i < centers.length; i += 1) {
    const c = centers[i];
    const d = ((color[0] - c[0]) ** 2) + ((color[1] - c[1]) ** 2) + ((color[2] - c[2]) ** 2);
    if (d < best) {
      best = d;
      idx = i;
    }
  }
  return idx;
}

function kmeans(pixels, k, iterations = 12) {
  const centers = [];
  for (let i = 0; i < k; i += 1) centers.push([...pixels[Math.floor((i * pixels.length) / k)]]);
  for (let it = 0; it < iterations; it += 1) {
    const sums = Array.from({ length: k }, () => [0, 0, 0, 0]);
    for (const p of pixels) {
      const idx = nearestIndex(p, centers);
      sums[idx][0] += p[0];
      sums[idx][1] += p[1];
      sums[idx][2] += p[2];
      sums[idx][3] += 1;
    }
    for (let i = 0; i < k; i += 1) {
      if (sums[i][3] > 0) {
        centers[i] = [
          Math.round(sums[i][0] / sums[i][3]),
          Math.round(sums[i][1] / sums[i][3]),
          Math.round(sums[i][2] / sums[i][3]),
        ];
      }
    }
  }
  return centers;
}

function medianCut(pixels, k) {
  let buckets = [pixels];
  while (buckets.length < k) {
    let bi = -1;
    let br = -1;
    let splitChannel = 0;
    for (let i = 0; i < buckets.length; i += 1) {
      const b = buckets[i];
      if (b.length < 2) continue;
      const mins = [255, 255, 255];
      const maxs = [0, 0, 0];
      for (const p of b) {
        for (let c = 0; c < 3; c += 1) {
          mins[c] = Math.min(mins[c], p[c]);
          maxs[c] = Math.max(maxs[c], p[c]);
        }
      }
      const ranges = [maxs[0] - mins[0], maxs[1] - mins[1], maxs[2] - mins[2]];
      const r = Math.max(...ranges);
      if (r > br) {
        br = r;
        bi = i;
        splitChannel = ranges.indexOf(r);
      }
    }
    if (bi < 0) break;
    const b = buckets.splice(bi, 1)[0];
    b.sort((x, y) => x[splitChannel] - y[splitChannel]);
    const mid = Math.floor(b.length / 2);
    buckets.push(b.slice(0, mid), b.slice(mid));
  }
  return buckets.map((b) => {
    const sum = b.reduce((acc, p) => [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]], [0, 0, 0]);
    return [Math.round(sum[0] / b.length), Math.round(sum[1] / b.length), Math.round(sum[2] / b.length)];
  });
}

function toneAnalysis(palette) {
  let warm = 0;
  let cool = 0;
  let neutral = 0;
  for (const p of palette) {
    const h = p.hsl[0];
    if (h <= 60 || h >= 300) warm += p.percentage;
    else if (h >= 120 && h <= 260) cool += p.percentage;
    else neutral += p.percentage;
  }
  if (warm > cool + 10) return "warm-toned";
  if (cool > warm + 10) return "cool-toned";
  return "neutral";
}

async function loadPixels(imagePath, cropArg) {
  const meta = await sharp(imagePath).metadata();
  if (!meta.width || !meta.height) throw new Error("Invalid/corrupted image.");
  const crop = parseCrop(cropArg, meta.width, meta.height);
  const { data, info } = await sharp(imagePath).extract(crop).raw().toBuffer({ resolveWithObject: true });
  const pixels = [];
  const step = Math.max(1, Math.floor((info.width * info.height) / 120000));
  for (let i = 0; i < data.length; i += info.channels * step) {
    pixels.push([data[i], data[i + 1], data[i + 2]]);
  }
  return { pixels, crop };
}

function buildPalette(colors, pixels) {
  const counts = new Array(colors.length).fill(0);
  for (const p of pixels) {
    counts[nearestIndex(p, colors)] += 1;
  }
  const total = counts.reduce((a, b) => a + b, 0) || 1;
  const entries = colors.map((rgb, i) => {
    const hsl = rgbToHsl(rgb);
    return {
      hex: rgbToHex(rgb),
      rgb,
      hsl: [Number(hsl[0].toFixed(1)), Number(hsl[1].toFixed(1)), Number(hsl[2].toFixed(1))],
      percentage: Number(((counts[i] / total) * 100).toFixed(2)),
      cssName: closestCssName(rgb),
    };
  });
  entries.sort((a, b) => b.percentage - a.percentage);
  return entries;
}

function ansiBlock([r, g, b]) {
  return `\u001b[48;2;${r};${g};${b}m  \u001b[0m`;
}

function printPalette(analysis) {
  console.log(`Image: ${analysis.image}`);
  console.log(`Tone analysis: ${analysis.tone}`);
  console.log("Palette:");
  console.log("Swatch HEX       RGB             HSL                %      Name");
  for (const c of analysis.palette) {
    const rgb = `${c.rgb[0]},${c.rgb[1]},${c.rgb[2]}`;
    const hsl = `${c.hsl[0]},${c.hsl[1]}%,${c.hsl[2]}%`;
    console.log(`${ansiBlock(c.rgb)} ${c.hex.padEnd(9)} ${rgb.padEnd(15)} ${hsl.padEnd(18)} ${String(c.percentage).padEnd(6)} ${c.cssName}`);
  }
  console.log("");
}

async function writeVisual(palette, outPath) {
  const sw = 220;
  const sh = 120;
  const width = sw * palette.length;
  const height = sh;
  const rects = palette.map((p, i) => `<g>
    <rect x="${i * sw}" y="0" width="${sw}" height="${sh}" fill="${p.hex}" />
    <rect x="${i * sw}" y="${sh - 28}" width="${sw}" height="28" fill="rgba(0,0,0,0.45)" />
    <text x="${(i * sw) + 12}" y="${sh - 10}" font-size="16" fill="white">${p.hex}</text>
  </g>`).join("");
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${rects}</svg>`;
  await sharp(Buffer.from(svg)).png().toFile(outPath);
}

async function analyzeImage(imagePath, options) {
  if (!SUPPORTED.has(path.extname(imagePath).toLowerCase())) throw new Error("Unsupported format.");
  const colors = Math.max(2, Math.min(32, Number.parseInt(String(options.colors || "8"), 10)));
  const algorithm = String(options.algorithm || "kmeans").toLowerCase();
  if (!["kmeans", "median-cut"].includes(algorithm)) throw new Error("Invalid --algorithm. Use kmeans|median-cut.");
  const { pixels, crop } = await loadPixels(imagePath, options.crop);
  if (pixels.length < 8) throw new Error("Very small image region.");

  const unique = new Set(pixels.map((p) => `${p[0]}-${p[1]}-${p[2]}`));
  const k = Math.min(colors, unique.size);
  if (k < 2) throw new Error("Image has very few unique colors.");

  const centers = algorithm === "kmeans" ? kmeans(pixels, k) : medianCut(pixels, k);
  const palette = buildPalette(centers, pixels);
  const tone = toneAnalysis(palette);
  return {
    image: path.resolve(imagePath),
    algorithm,
    requestedColors: colors,
    extractedColors: palette.length,
    crop,
    tone,
    palette,
  };
}

function comparePalettes(a, b) {
  const common = [];
  const uniqueA = [];
  const uniqueB = [];

  for (const ca of a.palette) {
    let best = { deltaE: Number.POSITIVE_INFINITY, color: null };
    for (const cb of b.palette) {
      const d = deltaE(ca.rgb, cb.rgb);
      if (d < best.deltaE) best = { deltaE: d, color: cb };
    }
    if (best.deltaE < 15) common.push({ a: ca.hex, b: best.color.hex, deltaE: Number(best.deltaE.toFixed(2)) });
    else uniqueA.push(ca.hex);
  }
  for (const cb of b.palette) {
    let minD = Number.POSITIVE_INFINITY;
    for (const ca of a.palette) minD = Math.min(minD, deltaE(cb.rgb, ca.rgb));
    if (minD >= 15) uniqueB.push(cb.hex);
  }
  return { common, uniqueA, uniqueB };
}

async function generateSample(name, colors) {
  const out = path.resolve(name);
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="500">
    <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
      ${colors.map((c, i) => `<stop offset="${(i / (colors.length - 1)) * 100}%" stop-color="${c}" />`).join("")}
    </linearGradient></defs>
    <rect width="900" height="500" fill="url(#g)" />
  </svg>`;
  await sharp(Buffer.from(svg)).png().toFile(out);
  return out;
}

async function batchRun(dir, options) {
  const files = fs.readdirSync(dir)
    .map((n) => path.join(dir, n))
    .filter((f) => fs.statSync(f).isFile() && SUPPORTED.has(path.extname(f).toLowerCase()));
  const analyses = [];
  for (const f of files) {
    const a = await analyzeImage(f, options);
    analyses.push(a);
    printPalette(a);
  }
  const out = path.resolve(options.output || "palette.json");
  fs.writeFileSync(out, `${JSON.stringify({ mode: "batch", analyses }, null, 2)}\n`, "utf8");
}

async function main() {
  const { options, positional } = parseArgs(process.argv.slice(2));
  if (!positional.length && !options.batch) {
    const sunset = await generateSample("sample_sunset.png", ["#ff7e5f", "#feb47b", "#ff9966"]);
    const ocean = await generateSample("sample_ocean.png", ["#2193b0", "#6dd5ed", "#0f2027"]);
    const forest = await generateSample("sample_forest.png", ["#355c2d", "#6b8e23", "#a7c957"]);
    const analyses = [];
    for (const f of [sunset, ocean, forest]) {
      const a = await analyzeImage(f, options);
      analyses.push(a);
      printPalette(a);
    }
    console.log("Comparative tone summary:");
    for (const a of analyses) console.log(`- ${path.basename(a.image)}: ${a.tone}`);
    fs.writeFileSync(path.resolve(options.output || "palette.json"), `${JSON.stringify({ mode: "demo", analyses }, null, 2)}\n`, "utf8");
    return;
  }

  if (!positional.length) throw new Error("Input path required.");
  const input = path.resolve(positional[0]);

  if (options.batch) {
    if (!fs.existsSync(input) || !fs.statSync(input).isDirectory()) throw new Error("--batch requires a directory input.");
    await batchRun(input, options);
    return;
  }

  const analysis = await analyzeImage(input, options);
  printPalette(analysis);

  if (options.visual) {
    const outVisual = options.visual === true ? path.resolve("palette_visual.png") : path.resolve(String(options.visual));
    await writeVisual(analysis.palette, outVisual);
    console.log(`Palette visual: ${outVisual}`);
  }

  let comparison = null;
  if (options.compare) {
    const other = await analyzeImage(path.resolve(String(options.compare)), options);
    comparison = comparePalettes(analysis, other);
    console.log("Palette comparison:");
    console.log(JSON.stringify(comparison, null, 2));
  }

  const out = path.resolve(options.output || "palette.json");
  fs.writeFileSync(out, `${JSON.stringify({ mode: "single", analysis, comparison }, null, 2)}\n`, "utf8");
}

main().catch((error) => {
  console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
  process.exit(1);
});