I built and published Pokémon Palette, a web app that extracts the colours actually used in a Pokémon’s artwork and turns them into an icon. It covers National Dex 001-809, needs no account, and works in a phone browser.

This article was published in 2020 and completely rewritten in September 2026. The original build combined Vue with three colour-extraction libraries (Vibrant.js, color-thief and RGBaster) and rasterised the DOM with html2canvas. The current version uses no external libraries at all — both the colour extraction and the icon rendering are written from scratch.

This article covers why I rebuilt it and how the colour extraction works.

What Pokémon Palette does

Pick a Pokémon and the colours used in its artwork are listed largest-area-first, then turned into an icon you can export as PNG.

Coverage National Dex 001-809 (809 species)
Extracted colours Up to 8, ordered by area. Each shows its share (%), plus the palette’s total coverage
Icon styles 8 (gradient, mesh, bars, orb, arcs, wave, ring, stripes)
Icon shape Square / rounded / circle
Export PNG at 256, 512 or 1024 px
Copy HEX list, or CSS custom properties
Other PWA (installable, works offline), no account, free

The stack is HTML, CSS and JavaScript only. No build step and no dependencies.

 

Why I rebuilt it

The original used three colour libraries at once: Vibrant.js, color-thief and RGBaster. The reasoning at the time was “one library returns too few colours, so use more”.

That produced two structural problems.

Problem Consequence
Three results merge asynchronously Colours get dropped, and the output changes with timing
No guarantee the largest-area colour ranks first The most widely used colour can be missed entirely — in an app about image colour

The second one was fatal. The point of the app is the Pokémon’s image colour, and what these libraries return is “characteristic” colours, not area-ordered ones.

So I switched to treating the actual pixel count as the primary source of truth, always returning a palette ordered by area. Counting the pixels myself turned out to be the straightforward answer.

 

How the extraction works

The image is drawn to a canvas and the pixels are counted directly, in four stages.

1. Discard the transparent fringe

Pixels with an alpha below 200 are ignored. The outline of a transparent PNG is a semi-transparent bleed, and counting it introduces intermediate colours that are not in the artwork.

for (let i = 0; i < data.length; i += 4) {
  const a = data[i + 3];
  if (a < alphaThreshold) continue;   // alphaThreshold = 200
  // ...
}

 

Downscaling also has smoothing turned off, so sampling is nearest-neighbour. Interpolation likewise invents colours that were never in the original.

scratchCtx.imageSmoothingEnabled = false;
scratchCtx.drawImage(image, 0, 0, w, h);

 

2. Build a histogram that stores mean colours, not bin centres

Each RGB channel is quantised to 5 bits (32 levels), giving 32 x 32 x 32 = 32,768 bins.

One detail matters here: the representative colour is not the bin centre but the actual mean of the pixels that landed in it.

const key = ((r >> SHIFT) << (BITS * 2)) | ((g >> SHIFT) << BITS) | (b >> SHIFT);
counts[key]++;
sumR[key] += r;   // keep the sums, take the mean later
sumG[key] += g;
sumB[key] += b;

 

Using bin centres carries the quantisation error straight into the output. Taking the mean keeps a value close to the original even at 32 levels.

3. Merge greedily in CIE L*a*b*

Bins are scanned in descending order of pixel count. If a bin is within the threshold distance of an existing cluster it is merged; otherwise it starts a new one.

The distance is computed in CIE L\*a\*b\*, not RGB. Distances in RGB do not correspond to perceived difference, so judging “close” in RGB merges colours that look distinct.

// threshold is a CIE76 delta-E of 11
const thr2 = mergeThreshold * mergeThreshold;
for (const bin of bins) {
  // find the nearest cluster
  if (best >= 0 && (bestD < thr2 || clusters.length >= maxClusters)) {
    // merge and update the centroid
  } else {
    clusters.push({ ... });   // new cluster
  }
}

 

Processing in descending order is the key. The largest-area colour always becomes the seed of a cluster, which guarantees that the most widely used colour appears first in the output.

4. Sort by area and return the top 8

Clusters that are still too close are merged once more, then sorted by area. Each colour carries its share of the image, and the sum of the selected colours is the palette’s coverage.

Colours below 0.5% are dropped, but at least three are always kept — otherwise a near-monochrome Pokémon would produce a single-colour palette.

 

Blending happens in Oklab

This was the single most effective change for the icon gradients.

The canvas createLinearGradient interpolates in sRGB. Connect two hues that are far apart and the middle goes muddy — orange to teal turns brown on the way.

So every two-colour interpolation happens in Oklab, with fine stops written out manually.

function smoothGradient(gradient, stops) {
  const STEPS = 14;
  for (let i = 0; i < stops.length - 1; i++) {
    const a = stops[i];
    const b = stops[i + 1];
    for (let k = 0; k <= STEPS; k++) {
      const t = k / STEPS;
      gradient.addColorStop(a.at + (b.at - a.at) * t, mix(a.color, b.color, t));
    }
  }
  return gradient;
}

 

The browser’s own gradient interpolation is not reachable, so the workaround is to place the intermediate colours yourself. Fourteen subdivisions is enough to read as continuous.

 

How the icon’s four colours are chosen

Taking the top four by area alone produces a dull icon. Pokémon artwork has black outlines and white highlights, so pure area ranking puts black and white in the lead.

Instead, the lead colour is chosen by a combined area-and-chroma score.

const score = (c) => {
  const area = Math.sqrt(c.ratio);                        // area, damped
  const vivid = 0.3 + 0.7 * Math.min(1, c.chroma / 55);   // chroma
  const lightness = c.lab[0];
  const extreme = lightness > 93 || lightness < 12 ? 0.45 : 1;  // penalise blown/crushed
  return area * vivid * extreme;
};

 

From the second colour onward, the score is multiplied by the distance from the colours already chosen, so the four do not end up near-identical.

It still will not always match taste, so a “shuffle” button reselects four colours at random from the extracted palette.

 

Dropping html2canvas for direct canvas drawing

The original assembled DOM and rasterised it with html2canvas, which imposed two limits:

  • Expression was restricted to what CSS can build
  • Export resolution could not be raised

Drawing straight to canvas means exporting at any size without degradation — which is why 256, 512 and 1024 px are all available.

Waves, radial blurs and rings — awkward in CSS — became straightforward to write.

 

Why it is a PWA, and how caching works

Add it to your home screen and it behaves like an app. Any Pokémon you have already viewed still extracts colours and generates icons offline.

But the images total nearly 120 MB, so precaching everything is out. The Service Worker splits strategy by target.

Target Strategy
HTML Network first, so updates are not missed
CSS / JS / icons stale-while-revalidate
Pokémon images Cache first, storing only what has been shown, evicting oldest past 400 entries

One more trap. Caching sw.js like any other JS means an old Service Worker keeps squatting even after you ship an update. The .htaccess sets Cache-Control: no-cache on sw.js alone to avoid it.

 

Two things that caught me out

Opening with file:// makes the colours unreadable

Double-clicking index.html locally taints the canvas and makes getImageData() throw, because it is not treated as same-origin.

python3 -m http.server 8000
# then open http://localhost:8000/

 

The app catches that exception and tells you to serve it over HTTP instead of failing silently.

Linux is case-sensitive about filenames

Sprite filenames contain uppercase, as in 001MS.png. It works locally on macOS and 404s on the server if uploaded in lowercase.

 

Summary

  • Pokémon Palette extracts colours by area from 809 species and exports icons as PNG, free
  • The original’s flaw was no guarantee the largest-area colour ranked first. Three libraries were dropped for a hand-written implementation
  • Extraction counts canvas pixels directly, discarding the transparent fringe below alpha 200
  • The histogram stores actual mean colours, not bin centres, so quantisation error stays out
  • Merging is judged by CIE L\*a\*b\* distance, not RGB
  • Processing in descending count order guarantees the largest colour comes first
  • Blending happens in Oklab; sRGB muddies the midpoint
  • The icon’s four colours come from an area-times-chroma score — area alone gives you black and white
  • Dropping html2canvas for direct canvas drawing allows clean export up to 1024 px
  • The PWA caches images with a 400-entry LRU, and never caches sw.js

The real gain from removing the libraries was being able to trace why a colour came out wrong myself.

 

Pokémon and its images and names are the property of The Pokémon Company and Nintendo. This is an unofficial, fan-made tool.

ABOUT ME
りん
On this blog, I mainly share information about web development and programming, along with my daily thoughts and what I’ve learned. I aim to create a blog that lets readers enjoy both technology and everyday life, so I also include topics about daily experiences, books, and gourmet. I’d be delighted if you could drop by casually and find something useful or enjoyable here.