見分けやすいN色をうまく選ぶ関数を作ったよ

この記事は、[LeapMind Advent Calendar 2019](https://adventar.org/calendars/3954) 7 日目の記事です。

## 概要

最近私が業務で開発していたあるウェブアプリケーションのGUIで、N色の互いに見分けやすい色を作りたいという場面があり（Nは1以上の整数、多くとも100程度を想定）、そのときに調べたり実験したもののメモです。
「見分けやすい」というのは曖昧な目標ですが、なんらかの指標でなるべく人間にとって見分けやすい色の選択ができれば十分ということにします。

作ったもの → [Color demo](https://color-demo-app.web.app/)

## 前提

今回作るものは、整数N(≧ 1)を受け取ってN色のリストを返す関数ですが、用途の都合で「明度と彩度を固定した上でN色を選ぶ」という条件を追加することにします。

「明度」「彩度」は聞きなれない人も多いのではないかと思うのですが（名前からなんとなく分かるかもしれません）、見た方が早いと思うので下の図をご覧ください。

![HSL色空間](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173694/1237afbe-c2eb-26ca-784b-45f59d7b1f0b.png "HSL色空間")

CSSではよく用いられる色表現には、RGB（光の三原色である赤・緑・青の各割合で色を表す）の他に、HSL色表現というものがあります。“HSL”は、“Hue”（色相）・ “Saturation”（彩度）・“Lightness”（明度）の頭文字です。Hueは0～360°の値、SaturationとLightnessは0～100%の値です。

これの何が嬉しいかというと、ある色に対して同じ色相（Hue）で明るい色や暗い色を作ったり、地味な色や派手な色を作ったりということがRGBに比べて簡単にできる点です（Google検索で「color picker」で出てくるツールをグリグリいじってみて右下のHSLの値がどう変化するのか見てみると分かりやすいかもしれません）。なるべく近い派手さ・明るさのN色を選び、それぞれに対して強調した色を作る（＝明度を上げる）、というのが最終的にやりたいことだったので、彩度（Saturation）と明度（Lightness）の値を揃えることにしたわけです。

HSL色表現においてSaturationとLightnessを固定するというのは、即ちHue（図でスライドバーになっている《赤～黄～緑～青～紫～ピンク～赤》と変化している部分）のみを変化させるということになります。

以上をまとめると、作りたい関数の入出力はこんな感じになります。

```ts:pickupHighContrastHues.ts
function pickupHighContrastHues(
  n: number,  // 何色選ぶか、正の整数
  saturation: number,  // 彩度（0～100）
  lightness: number,  // 明度（0～100）
): number[] {
  /* ここに処理を書く */
}
```

### ※2019/12/29訂正
一点勘違いしていたことが分かったので訂正します。
上のcolor pickerの図で横軸を彩度（S）、縦軸を明度（L）という書き方をしてしまっていますが、正確にはHSL表現のSまたはLのみを変えた時に横軸・縦軸に平行に点が動くとは限らないようです。SやLのみを変えていったとき、カラーピッカーの端（明度0または100%、あるいは彩度0または100%の直線上）では確かに平行に動くのですが、内部では少し曲がった軌道になるようでした。本記事ではhueのみを変えることにしているので本題には影響はなさそうです。

## N個のhue（色相）の選び方

Hue（色相）は0～360°の値をとるとさっき話しましたが、実際左右をつないで円形に描くこともあり、色相環（hue circle）と呼ばれます。どこを0°とするかはいくつか流派があるようですが、今回は赤（RGB = (255, 0, 0)）が0°となるものを使います。

<div style="display: flex; justify-content: center;">
  <img alt="hue_circle" style="" width="28%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173694/c0a068d6-c710-cef5-0f39-bbab7e1e5f7e.jpeg">
</div>

これを見ると、N色選びたいだけなら下図のようにこの円周から等間隔に色を選ぶだけな気がします。

<div style="width: 100%; height: 100px; display: flex; justify-content: center; ">
<div style="background-image='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173694/410f305b-6d04-42e9-b61a-5880023382f3.png'">
</div>
</div>

やってみます。

<div style="display: flex; justify-content: center;">
  <img alt="color_palette" style="" width="70%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173694/8be7b702-7009-294d-eac0-1e43e99e2bb1.png">
</div>

どうもNが増えてくると、緑周辺に似た色が並んで見分けづらい気がします。
そもそも色相環を見直すと、黄色・水色・ピンクのあたりは変化が大きく、その間の部分は変化が少なくのっぺりしているような感じがします。
なんとかこのあたりをうまく重みづけして、等間隔の選択より良い選び方ができないものでしょうか。


## コントラスト比の定義

2色のコントラストを図る指標が無いか探してみると、 [Web Content Accessibility Guidelines (WCAG 2.0) #contrast-ratiodef](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef) というものが見つかりました。これによると、ある2色の「コントラスト比（contrast ratio）」は次式で与えられるとあります。

$$
  \text{contrast ratio} = \frac{L1 + 0.05}{L2 + 0.05}
$$

ここで、L1とL2はそれぞれ与えられた2色のうち明るい方／暗い方の「相対輝度（relative luminance）」です。
じゃあ「相対輝度（relative luminance）」とはなんぞやという話ですが、同じくWCAGの[ここ](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef)（[日本語版](https://waic.jp/docs/WCAG20/Overview.html#relativeluminancedef)）に定義が書いてあります。RGB（正確にはsRGB）の各値に対してその色の明るさを0～1（黒は0、白は1）で表す指標のようです。

同色同士のcontrastRatioが最小値1、contrastRatioRgb(白, 黒)の場合が最大値21となります（L1 ≧ L2の条件があるので1未満にはなりません）。0.05という定数は分母が0にならないために必要という感じですかね（値の根拠はちゃんと調べてませんが）。

TypeScriptで書くと次のようになります。

```ts:contrastRatio

type RGB = [number, number, number];

function f(v: number) {
  return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
}

function relativeLuminance([r, g, b]: RGB) {
  return 0.2126 * f(r / 255) + 0.7152 * f(g / 255) + 0.0722 * f(b / 255);
}

function contrastRatioRgb(rgb1: RGB, rgb2: RGB) {
  const a = relativeLuminance(rgb1);
  const b = relativeLuminance(rgb2);
  const [rlLighter, rlDarker] = a < b ? [b, a] : [a, b];
  return (rlLighter + 0.05) / (rlDarker + 0.05);
}
```

謎の定数ばかりでほんとにこれでいいのかよく分からないので、2色のコントラスト比の値がどのようになるのか実際に試してみました。

* [Color demo](https://color-demo-app.web.app/)（「Text Color」タブの方）

![contrast_text](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173694/8ee83e9d-8462-286d-8d15-5346ce079cee.png)

各セルには、3組の彩度・明度に対して色相を0から359まで動かしたときの背景色とテキスト色（白・黒）のコントラスト比の値が書かれており、チェックボックスは各背景色に対して白と黒のどちらのテキスト色がコントラスト比が大きいかを表します。
これを見る限り、確かにコントラスト比の大きさはテキスト色の見やすさを表しているように思われます。


## 色相環の重みづけ

では、最初の問題に戻って、この式を使って色相環に重みづけしてみます。その前に、まずは相対輝度の分布がどうなっているのか可視化してみます。（[Color demo](https://color-demo-app.web.app/)「Luminance」タブの内容）

* 彩度＝80、明度＝60の場合
![relative_luminance](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173694/70fe4c5a-f22a-6e81-7c33-019a2ff45bcc.png)

このグラフの傾きが小さい領域から2色選んだりすると、コントラスト比が小さくなってしまうということなので、グラフの傾きの絶対値で重みづけすれば良さそうな気がします。

1. 隣同士の色相の差分の絶対値の累積分布にしてみます。
  ![relative_luminance_acc](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173694/0487dde9-4b09-11e4-4884-0f9632a50d3d.png)
2. 縦軸でN分割します。
  ![relative_luminance_acc_divided](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173694/373aed04-8cc2-f7fd-3c50-dd6ce3a4d9b9.png)
3. ぶつかるところのx軸（色相）の値を計算します。
  ![relative_luminance_acc_divided_get_x](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173694/690a3663-c064-9118-4d3a-f31372f9719b.png)

以上の方法により得られたN = 15のときの色リストの比較がこちらです。緑周辺などの変化の少ない領域から色が選ばれにくくなっています。

<div style="display: flex; justify-content: center;">
  <img alt="compare_color_palette" style="" width="70%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173694/fffd45a6-954d-75e6-c773-2329ac51b992.png">
</div>

厳密には、累積分布に変換した時点で、元の相対輝度分布の山や谷の頂点（たとえば黄色や水色のところ）を挟む2色の相対輝度の差が測れておらず、累積分布では差があっても元の相対輝度の値は近い値になってしまうケースがあり得ます。しかし、Nが大きくなった場合を気にしているので、そのような2色が選ばれるケースは少なくなり、あまり問題にならないかなと思います。Nが小さい場合は隣り合う2色が似た色になる問題は発生しないので、均等分割の方の関数を使うなど場合分けをしても良いかもしれません。

## 2019/12/6 追加実験

投稿直前に気づいたのですが、実は、この重みづけは少しおかしいところがありました。それは、コントラスト比は相対輝度の「比」で定義されるのにもかかわらず、相対輝度の「差」で重みづけしてしまっている点です。後ろの処理をそのまま使いたいので、差が比を表すように変換してみます。掛け算・割り算を足し算・引き算にする方法といえば、logを取ることが思いつきます。そこで、元の分布を一旦logを取って、相対輝度のlogの分布にしてやった上で同じように差分の累積を取ってみます。

すると、先ほどの1.の図がちょっとカクカクした次の図のようになりました。

![log_version_distribution.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173694/cf092cf5-bcb8-de07-e6fe-721103f147e4.png)

これを元に同じ処理をしてみると、次のN色が選ばれるようになりました。

<div style="display: flex; justify-content: center;">
  <img alt="color_palette_log_ver" style="" width="70%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173694/6db2d0e5-09db-7c92-3349-6b4f52e7c4c1.png">
</div>

元のN色はこちらです。

<div style="display: flex; justify-content: center;">
  <img alt="color_palette_not_log_ver" style="" width="70.5%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173694/a0ea6bc0-d8d0-c040-5f06-080ead915584.png">
</div>

この2つのどちらがより良いのかあまりはっきりとは分かりませんが、個人的には前半（赤～黄あたり）と後半（緑～紫）の変化量がより均等になったような感じがしており、若干改善している気がします。


## 2020/1/27 追加実験

2019/12/6時点では対数を使うようにした場合について、なんとなく見た目で良くなった気がすると思って放置していたのですが、contrastRatioの計算も念のためやってみました。

https://color-demo-app.web.app/ （同じURLですが再掲）

隣り合う二色のコントラスト比とその集合の分散を表示するようにしてみました。コントラスト比リストの値をぱっと見て、対数をとっても思ったよりばらつくなと一瞬思ったのですが、分散をとってみたら明らかに対数版の方が小さくなっていたので、無事確認することができました。


## 補足

* 途中でHSL→RGBの変換を行う必要があるのですが、そのような色表現の変換を行う関数が色々入っている [@ctrl/tinycolor](https://github.com/TypeCtrl/tinycolor) というライブラリがあり、今回はこれを利用しました。
* [WCAG 2.0 解説書（日本語版）のコントラストの章](https://waic.jp/docs/UNDERSTANDING-WCAG20/visual-audio-contrast7.html)

* ソースコード https://github.com/noshiro-pf/mono/tree/master/packages/apps/color_demo_app