Color Theory
The Complete Guide to Accurate Brand Color Palettes
Near-duplicate shades are the most common flaw in an extracted palette. Here is how CIELAB and delta E collapse them into the colors a brand actually uses.

You point an extractor at a logo, ask for the brand palette, and get five colors back. Three of them are the same blue.
[
{ "hex": "#1F6FEB", "percentage": 34 },
{ "hex": "#1F70EC", "percentage": 12 },
{ "hex": "#2070EB", "percentage": 9 },
{ "hex": "#1E6EE9", "percentage": 4 },
{ "hex": "#F5A623", "percentage": 6 }
]Nobody at that company would tell you their brand has four blues. It has one blue and one orange. The other three entries are measurement noise that survived a deduplication step, and the reason they survived is almost always the same: the deduplication compared the colors in RGB.
#Where the near-duplicates come from
Before fixing the comparison, it helps to know why a logo that visually contains two colors produces dozens of distinct pixel values.
Anti-aliasing. Every curved or diagonal edge in a rasterized logo is a ramp. A blue mark on white does not stop at the edge of the mark; it fades through a few pixels of progressively lighter blue. A 512px logo can easily contain several thousand distinct values along its edges alone, all of them blends nobody chose.
Lossy compression. If the logo is served as a JPEG, or as a WebP at anything below lossless, the encoder works in a chroma-subsampled space and reconstructs flat areas with small ringing artifacts. A field that was one exact hex before encoding comes back as a cloud of values scattered around it.
Your own quantizer. Any frequency-based extractor buckets channel values before counting, because counting 16.7 million possible colors individually is pointless when you want the top five. Bucketing is what makes the count tractable, and it is also what splits one real color across two adjacent buckets when its value happens to sit near a bucket boundary.
All three produce the same shape of error: a tight cluster of values around the true color, any of which can pick up enough pixel share to look like a separate finding.
#RGB distance is not perceptual distance
The obvious fix is to merge colors that are close together, and the obvious way to measure closeness is Euclidean distance in RGB:
const distance = (a, b) => Math.sqrt(
(a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2
)This does not work, and it fails in a way that is worse than being merely imprecise. sRGB is not perceptually uniform, so the same numeric distance describes wildly different amounts of visible difference depending on where in the space you are standing. Here are five pairs at nearly identical RGB distances, with the perceptual difference measured alongside:
| Pair | RGB distance | ΔE76 |
|---|---|---|
#000000 / #0A0A0A | 17.3 | 2.7 |
#E0E0E0 / #EAEAEA | 17.3 | 3.5 |
#787878 / #828282 | 17.3 | 3.9 |
#8FA00B / #8FB00B | 16.0 | 9.8 |
#0B1F8F / #0B2F8F | 16.0 | 12.7 |
The last pair is roughly five times more perceptually different than the first despite a slightly smaller RGB distance. There is no single RGB threshold that gets both rows right. Set it tight enough to keep those two navies apart and you leave every anti-aliasing artifact in the palette. Set it loose enough to clean up the artifacts and you start merging colors a designer would never call the same.
#Convert to CIELAB first
CIELAB was designed so that Euclidean distance approximates perceived difference. It is not perfect, but it is close enough that one threshold behaves consistently across the whole space, which is the property the RGB version lacks.
Getting there from sRGB is two steps. Linearize the channels to undo the sRGB transfer curve, convert to XYZ under a D65 white point, then convert XYZ to LAB:
const linearize = (channel) => {
const c = channel / 255
return c > 0.04045 ? ((c + 0.055) / 1.055) ** 2.4 : c / 12.92
}
const rgbToXyz = (r, g, b) => {
const [rr, gg, bb] = [linearize(r), linearize(g), linearize(b)]
return [
(rr * 0.4124564 + gg * 0.3575761 + bb * 0.1804375) * 100,
(rr * 0.2126729 + gg * 0.7151522 + bb * 0.0721750) * 100,
(rr * 0.0193339 + gg * 0.1191920 + bb * 0.9503041) * 100,
]
}
const xyzToLab = (x, y, z) => {
// D65 reference white.
const [refX, refY, refZ] = [95.047, 100.0, 108.883]
const f = (t) => (t > 0.008856 ? Math.cbrt(t) : (903.3 * t + 16) / 116)
const [fx, fy, fz] = [f(x / refX), f(y / refY), f(z / refZ)]
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]
}
const rgbToLab = (r, g, b) => xyzToLab(...rgbToXyz(r, g, b))Two details in there are worth not getting wrong. The linearization is a piecewise function, not a plain pow(c, 2.2) gamma; using the approximation shifts dark colors enough to matter when you are working near a threshold. And the white point has to be consistent. D65 is the right choice for anything that came off a screen, which is everything in this problem.
#Choosing a delta E formula
With both colors in LAB, the distance between them is the delta E. There are several formulas, and the differences between them are practical rather than academic.
ΔE76 is the plain Euclidean distance between two LAB triples. It is four lines of code and has one known weakness: it overstates differences in highly saturated colors, particularly saturated blues.
ΔE94 and ΔE2000 add weighting terms that correct for that. ΔE2000 is the current CIE recommendation and the most accurate of the three, at the cost of a much longer implementation involving hue rotation terms and several arctangents.
For deduplicating a palette, ΔE76 is usually enough. You are asking a yes or no question about colors that are either nearly identical or obviously different, and in that regime all three formulas agree. ΔE2000 earns its complexity when you are ranking near-matches against each other, for example finding the closest Pantone to an extracted hex, where the ordering of several close candidates is the actual output.
const deltaE76 = (lab1, lab2) => Math.sqrt(
(lab1[0] - lab2[0]) ** 2 + (lab1[1] - lab2[1]) ** 2 + (lab1[2] - lab2[2]) ** 2
)The published interpretation of the scale is a useful starting point:
| ΔE | What it means |
|---|---|
| Under 1 | Not perceptible to the human eye |
| 1 to 2 | Perceptible on close inspection |
| 2 to 10 | Perceptible at a glance |
| 11 to 49 | Colors are more similar than opposite |
| 100 | Exact opposites |
Run the four blues from the top of this article through it and the picture is unambiguous. Against #1F6FEB, the three near-duplicates measure ΔE 0.42, 0.79 and 0.63. All three are below the threshold of human perception. They are not colors, they are rounding.
#Merging the cluster
The merge itself is a greedy pass. Walk the candidates in order of importance, keep a color if it is far enough from everything kept so far, discard it otherwise:
const mergeCloseColors = (colors, threshold) => {
// Convert once. Doing it inside the comparison makes this O(n²) conversions
// instead of O(n), and LAB conversion is the expensive part.
const labs = new Map(colors.map((c) => [c, rgbToLab(...c.rgb)]))
const kept = []
for (const color of colors) {
const lab = labs.get(color)
const isDuplicate = kept.some((k) => deltaE76(lab, labs.get(k)) < threshold)
if (!isDuplicate) kept.push(color)
}
return kept
}Two things about this deserve more attention than they usually get.
Input order is the algorithm. Greedy merging keeps the first member of each cluster and throws away the rest, so whatever order you feed it decides which value represents the cluster in the final palette. Sort by pixel coverage, or by whatever score you trust, before you call this. Feeding it an arbitrary order means the representative blue is chosen at random from four almost identical candidates, which is harmless visually and deeply confusing when someone diffs two runs and sees the hex change.
Comparing against kept colors, not against the previous color, is what makes this a clustering pass rather than a chain. If you compare each color only to its neighbour you get transitive drift: A merges into B, B merges into C, and A and C end up in the same cluster despite being far apart.
#Picking the threshold
This is the part that is genuinely tuning rather than math, and the single most useful thing to know is that the threshold is coupled to your quantizer.
If you bucket channel values in steps of 8 before counting, two adjacent buckets are already several ΔE apart in most of the space. Set your merge threshold below that and quantization noise survives by construction, because you have told the merger that two buckets which differ only because of bucketing are meaningfully different colors. The threshold has to sit above the granularity of whatever produced the numbers.
Push it too far the other way and you start losing real palette entries. Brands with a light and dark variant of one hue, or a primary and a hover state, live close enough together that an aggressive threshold silently collapses them into one. That failure is worse than the duplicate problem, because a duplicate is visible and a missing color is not.
The practical method is to build a set of twenty or thirty logos where you already know the right answer, including at least one brand with two close shades and one with a gradient, and sweep the threshold until it is right on all of them. There is no correct value in the abstract, only a value that is correct for your pipeline.
#Filter achromatics separately
One last thing that looks like a deduplication problem and is not. Extracted logo palettes are full of white, near-white, black and mid-gray, because those are the colors of logo backgrounds, text, borders and the fringe left behind when a transparent PNG is composited.
Merging does not remove them. They are genuinely distinct colors that are genuinely present in the image, and a ΔE test will correctly keep them. They need their own filter, based on saturation and lightness rather than on distance:
const isAchromatic = ([r, g, b]) => {
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
// Nearly no separation between channels means there is no hue to speak of.
return max - min <= 10
}Guard it. Some brands really are monochrome, and a filter that empties the palette for them is a bug. Only drop achromatic entries when chromatic ones remain.
#What good output looks like
Run the same logo through comparison in LAB, a threshold matched to the quantizer, a stable input order and an achromatic filter, and the five noisy entries become the two colors that were always there:
#1F6FEB Accent
#F5A623
That is the whole gap between an extractor that produces a list of pixel values and one that produces a brand palette. The pixels were never wrong. The comparison was.
If you want to see the finished version of this on real brands, the brand color directory is the output of a pipeline built on exactly these rules, and How to Extract Brand Colors From Any Website covers the extraction step that happens before any of this deduplication runs.
Do you want API access?
Access our API to integrate color, brand & screenshot extraction into your app:
Brand Logos
Site Assets & Screenshot
Color Extraction & Grouping
Categorization & Company Details


