Skip to content

Instantly share code, notes, and snippets.

@mariusbolik
Created October 6, 2025 15:31
Show Gist options
  • Save mariusbolik/dd0df305d5c85bba7ededc795cc5773b to your computer and use it in GitHub Desktop.
Save mariusbolik/dd0df305d5c85bba7ededc795cc5773b to your computer and use it in GitHub Desktop.
Extract Main Color from Image (TypeScript)
import { Jimp, intToRGBA } from "jimp";
type RGB = { r: number; g: number; b: number };
type DominantColor = RGB & { hex: string };
interface Options {
/** Downscale before sampling (faster, negligible accuracy loss). */
sampleSize?: number;
/** Ignore pixels with alpha below this (0–255). */
alphaThreshold?: number;
/** Ignore near-white pixels (helps when backgrounds are white). */
ignoreNearWhite?: boolean;
/** Ignore near-black pixels. */
ignoreNearBlack?: boolean;
/** Quantization step per channel; 16 => 4-bit (0–15) buckets. */
step?: number;
}
/**
* Returns the dominant color of an image as RGB + hex.
*/
async function getDominantColor(
source: string | Buffer,
opts: Options = {}
): Promise<DominantColor> {
const {
sampleSize = 64,
alphaThreshold = 10,
ignoreNearWhite = false,
ignoreNearBlack = false,
step = 16,
} = opts;
const image = await Jimp.read(source);
// downscale for speed (preserve aspect ratio)
const w = image.bitmap.width;
const h = image.bitmap.height;
const scale = Math.max(w, h) > sampleSize ? sampleSize / Math.max(w, h) : 1;
if (scale < 1) {
image.resize({
w: Math.max(1, Math.round(w * scale)),
h: Math.max(1, Math.round(h * scale))
});
}
const { width, height, data } = image.bitmap;
const bucketKey = (r: number, g: number, b: number) =>
`${Math.floor(r / step)}_${Math.floor(g / step)}_${Math.floor(b / step)}`;
type Bucket = { count: number; rSum: number; gSum: number; bSum: number };
const buckets = new Map<string, Bucket>();
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const r = data[idx + 0]!;
const g = data[idx + 1]!;
const b = data[idx + 2]!;
const a = data[idx + 3]!;
if (a <= alphaThreshold) continue;
if (ignoreNearWhite && r > 240 && g > 240 && b > 240) continue;
if (ignoreNearBlack && r < 15 && g < 15 && b < 15) continue;
const key = bucketKey(r, g, b);
const bucket = buckets.get(key) ?? { count: 0, rSum: 0, gSum: 0, bSum: 0 };
bucket.count++;
bucket.rSum += r;
bucket.gSum += g;
bucket.bSum += b;
buckets.set(key, bucket);
}
}
if (buckets.size === 0) {
const { r, g, b } = await averageColor(image);
return { r, g, b, hex: rgbToHex(r, g, b) };
}
let bestKey = "";
let bestCount = -1;
for (const [key, bucket] of buckets) {
if (bucket.count > bestCount) {
bestCount = bucket.count;
bestKey = key;
}
}
const best = buckets.get(bestKey)!;
const r = Math.round(best.rSum / best.count);
const g = Math.round(best.gSum / best.count);
const b = Math.round(best.bSum / best.count);
return { r, g, b, hex: rgbToHex(r, g, b) };
}
function rgbToHex(r: number, g: number, b: number): string {
return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
}
async function averageColor(image: any): Promise<RGB> {
const clone = image.clone().resize({ w: 1, h: 1 });
const { r, g, b } = intToRGBA(clone.getPixelColor(0, 0));
return { r, g, b };
}
// Dominant color extraction using Washington Post's image proxy to convert WebP to JPEG
const imageUrl = "https://revenuecat.wpenginepowered.com/wp-content/uploads/2025/09/image-2.png";
const proxyUrl = `https://www.washingtonpost.com/wp-apps/imrs.php?src=${encodeURIComponent(imageUrl)}&w=400&h=400`; // Smaller for faster processing
// Usage
const color = await getDominantColor(proxyUrl, {
ignoreNearWhite: true,
ignoreNearBlack: true,
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment