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);
});