ポケモンのすがたから実際に使われている色を抽出して、イメージカラーのアイコンを作るWebアプリ『ポケモンパレット』を公開しています。 全国図鑑001〜809に対応、登録不要、スマホのブラウザでも動きます。
この記事は2020年の公開後、2026年9月に全面的に書き直しました。 公開当時は Vue と3つの色抽出ライブラリ(Vibrant.js / color-thief / RGBaster)を組み合わせ、DOMを html2canvas で画像化する作りでしたが、いまは外部ライブラリを使わず、色抽出もアイコン描画も自前で実装しています。
この記事では、なぜ作り直したのかと、色の抽出をどうやっているのかを書きます。
スポンサーリンク
ポケモンパレットで何ができるか
ポケモンを選ぶと、そのすがたに使われている色が面積の大きい順に並び、その色でアイコンを作ってPNGで書き出せます。
| 対応ポケモン | 全国図鑑 001〜809(809種) |
|---|---|
| 抽出色 | 面積順に最大8色。各色の占有率(%)とパレット全体のカバー率も表示 |
| アイコン | 8スタイル(グラデーション/メッシュ/バー/オーブ/アーチ/ウェーブ/リング/ストライプ) |
| アイコンの形 | 四角 / 角丸 / 円 |
| 書き出し | PNG、256 / 512 / 1024 px から選択 |
| コピー | HEXの一覧、CSSカスタムプロパティ形式 |
| その他 | PWA対応(ホーム画面に追加可・オフライン動作)、登録不要、無料 |
技術構成はHTML / CSS / JavaScriptのみ。 ビルド工程も外部ライブラリもありません。
なぜ作り直したのか
旧実装は、色を取るライブラリを3つ同時に使っていました。 Vibrant.js、color-thief、RGBaster の3つです。当時は「1つだと取れる色が少ないから増やす」という発想でした。
ただ、これには構造的な問題が2つありました。
| 問題 | 何が起きるか |
|---|---|
| 3つの結果が非同期に混ざる | 取りこぼしが起きる。タイミングによって結果が変わる |
| 面積の大きい色が上位に来る保証がない | 「イメージカラー」を作るのに、いちばん広く使われている色が拾えないことがある |
2つ目が致命的でした。 このアプリの目的は「そのポケモンのイメージカラー」を出すことなのに、ライブラリの出す「特徴的な色」は面積順ではありません。
そこで、「実際のピクセル数」を一次情報として扱い、面積の大きい順に並んだパレットを必ず返す方針に変えました。ライブラリを捨てて自分で数えるほうが、目的に対して素直だったからです。
スポンサーリンク
色の抽出をどうやっているか
canvas に画像を描いて、ピクセルを直接数えます。 処理は4段階です。
1. 透過の縁を捨てる
アルファ値が200未満のピクセルは無視します。 透過PNGの輪郭は半透明のにじみになっていて、そこを数えると存在しない中間色が混ざるためです。
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < alphaThreshold) continue; // alphaThreshold = 200
// ...
}
縮小するときもスムージングを切って最近傍サンプリングにしています。補間を効かせると、やはり元の絵に無い色が生まれます。
scratchCtx.imageSmoothingEnabled = false;
scratchCtx.drawImage(image, 0, 0, w, h);
2. ヒストグラムを作る(ビンの中心ではなく平均色を持つ)
RGB各チャンネルを5bit(32段階)に量子化して、32×32×32=32,768個のビンに振り分けます。
ここで工夫が1つあります。代表色にビンの中心値を使わず、そのビンに入ったピクセルの実際の平均色を使うことです。
const key = ((r >> SHIFT) << (BITS * 2)) | ((g >> SHIFT) << BITS) | (b >> SHIFT);
counts[key]++;
sumR[key] += r; // 総和を持っておき、あとで平均を取る
sumG[key] += g;
sumB[key] += b;
ビン中心を使うと量子化誤差がそのまま出力に混ざります。 平均を取れば、32段階に丸めても元の色に近い値が残ります。
3. CIE L*a*b* 上で貪欲にまとめる
ビンをピクセル数の多い順に走査し、既存クラスタとの距離がしきい値未満なら併合、超えていれば新しいクラスタを立てます。
距離の計算はRGBではなく CIE L\*a\*b\* で行います。 RGB空間の距離は人間の見た目の差と一致しないため、RGBで「近い」と判断すると、目には別の色に見えるものが混ざります。
// しきい値は CIE76 の ΔE で 11
const thr2 = mergeThreshold * mergeThreshold;
for (const bin of bins) {
// 最も近いクラスタを探す
if (best >= 0 && (bestD < thr2 || clusters.length >= maxClusters)) {
// 併合して重心を更新
} else {
clusters.push({ ... }); // 新しいクラスタ
}
}
多い順に処理するのが要です。 面積の大きい色が必ずクラスタの核になるので、出力の先頭に「いちばん広く使われている色」が来ることが保証されます。
4. 面積順に並べて上位8色を返す
近すぎるクラスタをもう一段まとめてから、面積順に並べます。各色には全体に占める割合が付き、選ばれた色の合計がパレットのカバー率になります。
割合が0.5%未満の色は捨てますが、最低3色は残すようにしています。単色に近いポケモンでパレットが1色になってしまうのを防ぐためです。
混色は Oklab で補間する
アイコンのグラデーションで一番効いた変更がこれです。
canvas の createLinearGradient は sRGB を線形補間します。色相が離れた2色をつなぐと、中間が濁ります。 橙と青緑を混ぜると茶色くなる、という現象です。
そこで、2色の補間はすべて Oklab 上で行い、細かいストップを打ち直しています。
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;
}
ブラウザ側のグラデーション補間には手を出せないので、代わりに自分で細かく色を打つという回避策です。14分割あれば、目には連続したグラデーションに見えます。
スポンサーリンク
アイコンの4色はどう決めているか
面積順の上位4色をそのまま使うと、地味なアイコンになります。 ポケモンのイラストは輪郭線が黒く、ハイライトが白いので、面積だけで選ぶと黒と白が主役に来てしまうからです。
そこで、面積と彩度の複合スコアで主役を決めています。
const score = (c) => {
const area = Math.sqrt(c.ratio); // 面積(平方根で効きを緩める)
const vivid = 0.3 + 0.7 * Math.min(1, c.chroma / 55); // 彩度
const lightness = c.lab[0];
const extreme = lightness > 93 || lightness < 12 ? 0.45 : 1; // 白飛び・黒潰れは減点
return area * vivid * extreme;
};
2色目以降は、スコアに「すでに選んだ色との色差」を掛けて選びます。似た色ばかりが並ぶのを防ぐためです。
それでも好みに合わないことはあるので、「配色をシャッフル」ボタンで抽出色の中からランダムに4色を選び直せるようにしています。
html2canvas をやめて canvas に直接描く
旧実装はDOMを組んで html2canvas で画像化していました。これには2つの制約がありました。
- 表現がCSSで作れる形に限られる
- 書き出し解像度を上げられない
いまは canvas に直接描いているので、任意サイズで劣化なく書き出せます。 256 / 512 / 1024 px から選べるのはこのためです。
波、放射状のぼかし、リングといったCSSでは面倒な表現も素直に書けるようになりました。
PWAにした理由とキャッシュ戦略
ホーム画面に追加すればアプリのように使えます。 一度表示したポケモンなら、オフラインでも色の抽出とアイコン生成まで動きます。
ただし画像が全部で120MB近くあるので、事前キャッシュに全部入れるわけにはいきません。Service Worker では対象ごとに戦略を分けています。
| 対象 | 戦略 |
|---|---|
| HTML | ネットワーク優先(更新を取り逃さない) |
| CSS / JS / アイコン | stale-while-revalidate(即表示しつつ裏で更新) |
| ポケモン画像 | キャッシュ優先。表示したものだけ貯め、400件を超えたら古い順に破棄 |
もう1つ、はまりどころがあります。 sw.js 自体を他のJSと同じくキャッシュすると、更新を配っても古い Service Worker が居座り続けます。 .htaccess で sw.js だけ Cache-Control: no-cache にして回避しています。
開発で詰まった点
file:// で開くと色が読めない
ローカルで index.html をダブルクリックして開くと、canvas が汚染扱いになって getImageData() が例外を投げます。 同一オリジンとみなされないためです。
python3 -m http.server 8000
# → http://localhost:8000/ で開く
アプリ側でもこの例外を捕まえて、「HTTPサーバー経由で表示してください」というメッセージを出すようにしています。
Linuxはファイル名の大小を区別する
スプライトのファイル名が 001MS.png のように大文字を含みます。 macOSのローカルでは動いても、サーバーに小文字でアップロードすると404になります。
まとめ
- ポケモンパレットは809種のすがたから面積順に色を抽出し、アイコンをPNGで書き出せる無料ツール
- 旧実装の問題は「面積の大きい色が上位に来る保証がない」こと。ライブラリ3つを捨てて自前実装に変更
- 抽出はcanvasのピクセルを直接数える。透過の縁はアルファ200未満で除外
- ヒストグラムはビン中心ではなく実際の平均色を持つ(量子化誤差を持ち込まない)
- 色の併合はRGBではなく CIE L\*a\*b\* の距離で判定する
- 多い順に処理するので、面積最大の色が必ず先頭に来る
- 混色はOklabで補間。sRGBのままだと中間が濁る
- アイコンの4色は面積×彩度のスコアで選ぶ。面積だけだと黒と白が主役になる
- html2canvasをやめてcanvasに直接描画したので、1024pxまで劣化なく書き出せる
- PWAのキャッシュは画像だけLRUで400件上限。
sw.jsはキャッシュさせない
外部ライブラリを外したことで、「思ったような色が出ない」ときに原因を自分で追えるようになったのが一番の収穫でした。
※ポケットモンスターおよびポケモンの画像・名称は株式会社ポケモン/任天堂の著作物です。本ツールは非公式のファンメイドです。