ジャンルから探す

Web Design English

ポケモンカラーのアイコンを作成できるWebアプリ『ポケモンパレット』を作りました

ポケモンのすがたから実際に使われている色を抽出して、イメージカラーのアイコンを作る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 が居座り続けます。 .htaccesssw.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 はキャッシュさせない

外部ライブラリを外したことで、「思ったような色が出ない」ときに原因を自分で追えるようになったのが一番の収穫でした。

※ポケットモンスターおよびポケモンの画像・名称は株式会社ポケモン/任天堂の著作物です。本ツールは非公式のファンメイドです。