Notes

色当てゲームの勝敗判定をどう作るか

RGB距離、OKLab距離、CIEDE2000を実装して、色の近さをゲームの勝敗判定として扱う方法を整理したメモ。時間短縮のため大部分をAIで生成しました。後で読み返します。

この記事は、時間短縮のため大部分をAIで生成しました。後で読み返しながら、自分用の備忘録として整えていきます。

親が想像した色を、子が予想して当てる「色当てゲーム」を作っている。

ルールはシンプル。

  1. 親が頭の中で色をイメージする
  2. 親がGUIで正解色を作る
  3. 子もGUIで予想色を作る
  4. 正解色と各回答色の距離を計算する
  5. もっとも近い色を出した子が勝つ

最初は、色を #FFFFFF のようなHEXカラーとして扱い、RGB距離で勝敗判定していた。

ただ、実装しているうちに気になった。

RGBの数値が近いことと、人間が見て「近い色だ」と感じることは、本当に同じなのか?

この記事は、その疑問から始まった調査と実装の備忘録である。

最終的には、JavaScriptの色変換・色差ライブラリである culori を使い、次の3方式を切り替えられるようにした。

differenceEuclidean("oklab")
differenceEuclidean("rgb")
differenceCiede2000()

デフォルト判定は OKLab距離。RGB距離とCIEDE2000は、比較・学習用として残した。

目次

このゲームで本当に測りたいもの

このゲームで測りたいのは、厳密には「RGB値の差」ではない。

測りたいのは、プレイヤーが結果を見たときに、

たしかに、この子の色が一番近いね。

と納得できる近さである。

色の近さには、いくつかの見方がある。

近さの種類意味ゲームでの重要度
RGB数値上の近さR/G/Bの数値差が小さい低〜中
物理的な近さ光の波長分布や反射率が近い
測色上の近さLabやDelta Eなどで定義される色差中〜高
見た目の近さ人間が見て似ていると感じる

このゲームは、塗料や印刷物を測定するゲームではない。Webアプリ上のGUIで作った色を比較するゲームである。

つまり、必要なのは「Web UI上に表示された色同士を見比べたときの納得感」だ。

ここを間違えると、数式としては正しくても、ゲームとしては気持ち悪い結果になる。

入力色はsRGBとして扱う

このゲームでは、親も子もGUIで色を作る。アプリ内では、色を次のようなRGB値として持っている。

{
  red: 0-255,
  green: 0-255,
  blue: 0-255
}

見た目としては、#FFFFFF のようなHEXカラーに近い。

CSS Color Module Level 4 では、#RRGGBB のようなHEXカラー記法は、sRGBの各成分を16進数で指定するものとして扱われる。つまり、このゲームの入力色は基本的に sRGBのコード値 として考えればよい。

ただし、ここで注意が必要。

#3FA7F5

これは、見た目の明るさ・色相・鮮やかさを直接表す値ではない。sRGBの赤・緑・青のコード値である。

たとえば、#3FA7F5 は次のように分解できる。

R = 0x3F = 63
G = 0xA7 = 167
B = 0xF5 = 245

アプリ内の値をculoriに渡すときは、0〜255を0〜1に正規化する。

{
  mode: "rgb",
  r: red / 255,
  g: green / 255,
  b: blue / 255
}

ここで得られる r, g, b は、あくまでsRGBの値である。OKLab距離やCIEDE2000を使う場合は、このRGB値をそのまま距離計算するのではなく、ライブラリに色空間変換を任せる。

なぜRGB距離だけでは不安なのか

RGB距離は、RGB空間上のユークリッド距離である。

dRGB = sqrt(
  (R1 - R2)^2 +
  (G1 - G2)^2 +
  (B1 - B2)^2
)

0〜1に正規化したRGBなら、最大距離は黒 #000000 から白 #FFFFFF までの距離で、sqrt(3) になる。

RGB距離は実装が簡単で、直感的にも分かりやすい。しかし、RGB距離が測っているのは、あくまでRGBコード値の差である。

人間の目が感じる明るさ、色味、鮮やかさの差を均等に表しているわけではない。

たとえば、白 #FFFFFF に対して次の3色を比べる。

#FFFF00 yellow
#00FFFF cyan
#FF00FF magenta

RGB距離では、どれも1チャンネルだけが255違う。

比較違うチャンネル
#FFFFFF#FFFF00Bだけ違う
#FFFFFF#00FFFFRだけ違う
#FFFFFF#FF00FFGだけ違う

そのため、RGB距離では全部同点になる。

でも、見た目として本当に同じだけ白から離れているように感じるかというと、かなり怪しい。

これがRGB距離の限界である。

LabとDelta Eの考え方

RGB距離の弱点を避けるために出てくるのが、Lab色空間やDelta Eである。

Labは、ざっくり言えば、人間の色の見え方に寄せて作られた色空間である。RGBが赤・緑・青の量で色を表すのに対して、Labはおおまかに次の成分で色を表す。

成分意味
L*明るさ
a*緑 ↔ 赤
b*青 ↔ 黄

Delta Eは、Labなどの色空間をもとに、2つの色の差を数値化する考え方である。

代表的なものに、次の3つがある。

  • ΔE76
  • ΔE94
  • CIEDE2000 / ΔE00

ΔE76は、Lab空間上の単純なユークリッド距離。ΔE94は、明度・彩度・色相の差に補正を入れたもの。CIEDE2000はさらに補正を増やし、人間が感じる色差との相関を高めようとした色差式である。

CIEDE2000は本格的で、色差評価としてはかなり有力。ただし、式は複雑で、自前実装するには向いていない。

このゲームでは、culoriの differenceCiede2000() に任せている。

OKLabとは何か

今回のデフォルト判定にしたのが、OKLab距離である。

OKLab は、Björn Ottossonによって提案された知覚寄りの色空間である。OKLabは、知覚的な明るさ・彩度・色相を扱いやすくしつつ、画像処理や実装で扱いやすいシンプルな色空間として設計されている。

OKLabは、Labと同じように3成分で色を表す。

成分意味
L知覚的な明るさ
a緑 ↔ 赤方向
b青 ↔ 黄方向

OKLab距離は、このOKLab空間上のユークリッド距離である。

dOKLab = sqrt(
  (L1 - L2)^2 +
  (a1 - a2)^2 +
  (b1 - b2)^2
)

考え方はかなりシンプル。

  1. RGBをOKLabに変換する
  2. OKLab空間上で距離を測る
  3. 距離が一番小さい子が勝つ

プレイヤー向けにも説明しやすい。

RGBの数字の差ではなく、人間の見た目に近くなるよう設計されたOKLab空間で、正解色との距離が一番小さい回答を勝ちにしています。

ゲームとしては、この説明しやすさが大きい。

culoriで3方式を実装する

今回の実装では、色差計算にculoriを使っている。

culoriのAPIには、色差を測るための関数群が用意されている。differenceEuclidean(mode) は指定した色空間上のユークリッド距離を返す関数を作り、differenceCiede2000() はCIEDE2000系の色差を計算する関数を返す。

実装した判定方式は3つ。

differenceEuclidean("oklab")
differenceEuclidean("rgb")
differenceCiede2000()

OKLab距離

differenceEuclidean("oklab")

RGBをOKLab色空間に変換してから、OKLab上の距離で近さを判定する。現在のデフォルト判定。

RGB距離

differenceEuclidean("rgb")

RGBを0〜1に正規化したうえで、RGB空間上のユークリッド距離で判定する。以前の判定に近いが、自前計算ではなくculori経由にした。

Delta E

differenceCiede2000()

CIEDE2000系の色差で判定する。より標準的な知覚色差に近いが、ゲームの説明としてはやや専門的。

スコア化の設計

距離そのものをUIに出しても、プレイヤーには分かりづらい。

そこで、距離を100点満点に変換している。

score = Math.max(0, Math.round((1 - distance / maxDistance) * 100));

意味はこう。

距離点数
distance = 0100点
distanceが小さい高得点
distanceが大きい低得点
最大距離以上0点

最大距離は方式ごとに変えている。

方式スコア化の基準
OKLab距離1
RGB距離Math.sqrt(3)
Delta E111.42

RGB距離の Math.sqrt(3) は分かりやすい。RGBを0〜1で扱うと、黒から白までの対角線距離が最大になるからである。

OKLab距離の 1 は、スコア化のための実用的な基準値として扱っている。

Delta Eの 111.42 は、sRGB内の代表的な最大級の距離として使っている。ただし、これは厳密な理論上限として扱わない方がよい。CIEDE2000はRGBキューブの対角線のように単純な最大距離を持つ感覚では扱いにくく、111.42 を超える組み合わせもあり得る。

その場合は、今の式だと0点に丸められる。

Math.max(0, ...)

ゲーム用途では、最大級に遠い色は0点、という扱いで十分自然だと思う。

ランキングはraw distanceで決める

ここはかなり重要。

スコアは Math.round() で丸めている。そのため、距離が少し違っていても、表示点数は同じになることがある。

たとえば、AもBも75点でも、内部距離ではAの方がわずかに近い、というケースがある。

したがって、実装上は分けるべき。

用途使う値
ランキング判定raw distance
UI表示score

これはゲームの納得感にも関わる。

もし表示点数が同じなのに順位が違うなら、UI上で「僅差」「ほぼ同点」「距離ではAがわずかに近い」のように補足してもよい。

判定方式を切り替えられるようにした

見出しの「判定: OKLab距離」はボタン化している。

タップすると、次の順で切り替わる。

OKLab距離 → RGB距離 → Delta E

切り替えると、ランキング計算とスコア計算もその方式で再計算する。

これは、単に機能として便利なだけではない。学習用としてかなり面白い。

同じ正解色、同じ回答色でも、方式によって勝者が変わる。

RGB距離ではAが勝つ
OKLab距離ではBが勝つ
Delta EではCが勝つ

色距離の違いを、ゲームの中で体験できるようになった。

方式差が出やすいエッジケース

ここからは、方式差が出やすい具体例。

数値は、次の前提での距離とスコアである。

OKLab距離: differenceEuclidean("oklab")
RGB距離: differenceEuclidean("rgb")
Delta E: differenceCiede2000()
score = Math.max(0, Math.round((1 - distance / maxDistance) * 100))

ケース1: 白に対する黄色・シアン・マゼンタ

target: #FFFFFF
A: #FFFF00 yellow
B: #00FFFF cyan
C: #FF00FF magenta
候補RGB距離 / 点OKLab距離 / 点CIEDE2000 / 点
A #FFFF001.0000 / 420.2134 / 7930.52 / 73
B #00FFFF1.0000 / 420.1812 / 8225.50 / 77
C #FF00FF1.0000 / 420.4393 / 5642.21 / 62
方式勝者
RGB距離A/B/C 同点
OKLab距離B #00FFFF
CIEDE2000B #00FFFF

RGBでは、白から見てどれも1チャンネルだけが最大差なので同点になる。しかし、OKLabやCIEDE2000では差が出る。

これは、RGB距離の限界を説明するのにとても分かりやすい。

ケース2: 黒に対する赤・緑・青

target: #000000
A: #FF0000 red
B: #00FF00 green
C: #0000FF blue
候補RGB距離 / 点OKLab距離 / 点CIEDE2000 / 点
A #FF00001.0000 / 420.6788 / 3250.41 / 55
B #00FF001.0000 / 420.9152 / 887.86 / 21
C #0000FF1.0000 / 420.5499 / 4539.68 / 64
方式勝者
RGB距離A/B/C 同点
OKLab距離C #0000FF
CIEDE2000C #0000FF

RGB距離では、黒から赤・緑・青はすべて同じ距離になる。だが、見た目の明るさや知覚上の差まで考えると、同じ扱いにはならない。

特に緑は黒からかなり遠く出る。

ケース3: グレーに対する赤寄り・緑寄り・青寄り

target: #808080
A: #FF8080 red tint
B: #80FF80 green tint
C: #8080FF blue tint
候補RGB距離 / 点OKLab距離 / 点CIEDE2000 / 点
A #FF80800.4980 / 710.2120 / 7928.21 / 75
B #80FF800.4980 / 710.3609 / 6439.82 / 64
C #8080FF0.4980 / 710.1937 / 8127.95 / 75
方式勝者
RGB距離A/B/C 同点
OKLab距離C #8080FF
CIEDE2000C #8080FF

RGBでは、どれも1チャンネルだけ同じ量ずれているので同点。しかし、OKLabとCIEDE2000では緑方向のズレがより大きく扱われる。

この例は、実際のゲームでも起こりやすい。親がグレーっぽい色を作り、子が少し赤寄り・緑寄り・青寄りに外すようなケースである。

なお、この例ではCIEDE2000のAとCがどちらも75点になる。しかしraw distanceではCの方がわずかに近い。

つまり、表示点数は同じでも内部順位は違う、というUI上の注意点も見える。

ケース4: OKLabとCIEDE2000が強く反転する

target: #FF00FF
A: #000000 black
B: #00E040 green-ish
候補RGB距離 / 点OKLab距離 / 点CIEDE2000 / 点
A #0000001.4142 / 180.7722 / 2356.71 / 49
B #00E0401.5273 / 120.5761 / 42106.20 / 5
方式勝者
RGB距離A #000000
OKLab距離B #00E040
CIEDE2000A #000000

このケースはかなり面白い。

OKLab距離では、Bの方がマゼンタに近い。CIEDE2000では、Bはかなり遠く、黒の方が近い。

つまり、OKLab距離とCIEDE2000は、どちらも知覚寄りではあるが、同じものではない。

OKLab距離は、OKLab空間上のシンプルな直線距離。CIEDE2000は、Lab系の色差に複数の補正を入れた式。

特に高彩度色や補色に近い組み合わせでは、順位が変わりやすい。

ケース5: 3方式がすべて別の勝者を選ぶ

target: #E649C3
A: #298F04 dark green
B: #65EF3F bright green
C: #072A2E dark cyan
候補RGB距離 / 点OKLab距離 / 点CIEDE2000 / 点
A #298F041.0889 / 370.4183 / 5887.08 / 22
B #65EF3F0.9735 / 440.4984 / 5095.83 / 14
C #072A2E1.0588 / 390.4774 / 5242.54 / 62
方式勝者
RGB距離B #65EF3F
OKLab距離A #298F04
CIEDE2000C #072A2E

これは、判定方式の違いを見せる教材としてかなり良い。

同じtarget、同じanswersでも、3方式で勝者が全部違う。

RGB距離は、RGB成分の差が小さいBを選ぶ。OKLab距離は、OKLab空間上で近いAを選ぶ。CIEDE2000は、色差式上で近いCを選ぶ。

このような例を見ると、色距離は単なる技術選定ではなく、ゲームルールそのものだと分かる。

ケース6: RGBでは遠いが、CIEDE2000では近い

target: #4D9433
A: #771764 purple
B: #EF303B red
C: #E3FABC pale yellow-green
候補RGB距離 / 点OKLab距離 / 点CIEDE2000 / 点
A #7717640.5517 / 680.3598 / 6475.65 / 32
B #EF303B0.7472 / 570.3169 / 6867.55 / 39
C #E3FABC0.8914 / 490.3613 / 6430.88 / 72
方式勝者
RGB距離A #771764
OKLab距離B #EF303B
CIEDE2000C #E3FABC

CはRGB距離では一番遠い。しかしCIEDE2000では一番近い。

targetは緑系で、Cは明るい黄緑系。RGB成分だけ見ると大きく離れているが、色味の系統としては近い。

これは、RGB数値上は遠いが、見た目・色味としては近い、という例として使いやすい。

エッジケースをテストデータとして残す

実装のテストやデバッグには、こういうケースをそのまま使える。

const edgeCases = [
  {
    name: "RGB tie on white, perceptual methods prefer cyan",
    target: "#FFFFFF",
    answers: [
      { id: "A", color: "#FFFF00" },
      { id: "B", color: "#00FFFF" },
      { id: "C", color: "#FF00FF" },
    ],
    expected: {
      rgb: ["A", "B", "C"],
      oklab: ["B"],
      ciede2000: ["B"],
    },
  },
  {
    name: "RGB tie on black, perceptual methods prefer blue",
    target: "#000000",
    answers: [
      { id: "A", color: "#FF0000" },
      { id: "B", color: "#00FF00" },
      { id: "C", color: "#0000FF" },
    ],
    expected: {
      rgb: ["A", "B", "C"],
      oklab: ["C"],
      ciede2000: ["C"],
    },
  },
  {
    name: "RGB tie on gray, perceptual methods penalize green tint",
    target: "#808080",
    answers: [
      { id: "A", color: "#FF8080" },
      { id: "B", color: "#80FF80" },
      { id: "C", color: "#8080FF" },
    ],
    expected: {
      rgb: ["A", "B", "C"],
      oklab: ["C"],
      ciede2000: ["C"],
    },
  },
  {
    name: "OKLab and CIEDE2000 disagree strongly",
    target: "#FF00FF",
    answers: [
      { id: "A", color: "#000000" },
      { id: "B", color: "#00E040" },
    ],
    expected: {
      rgb: ["A"],
      oklab: ["B"],
      ciede2000: ["A"],
    },
  },
  {
    name: "All three methods choose different winners",
    target: "#E649C3",
    answers: [
      { id: "A", color: "#298F04" },
      { id: "B", color: "#65EF3F" },
      { id: "C", color: "#072A2E" },
    ],
    expected: {
      rgb: ["B"],
      oklab: ["A"],
      ciede2000: ["C"],
    },
  },
  {
    name: "RGB says purple, OKLab says red, CIEDE2000 says pale yellow-green",
    target: "#4D9433",
    answers: [
      { id: "A", color: "#771764" },
      { id: "B", color: "#EF303B" },
      { id: "C", color: "#E3FABC" },
    ],
    expected: {
      rgb: ["A"],
      oklab: ["B"],
      ciede2000: ["C"],
    },
  },
];

こういうテストケースを持っておくと、後から実装を変えたときに確認しやすい。

  • 意図した判定方式で順位が出ているか
  • RGB距離に戻ってしまっていないか
  • 丸めたスコアで順位を決めていないか

OKLab距離をデフォルトにした理由

最終的に、デフォルト判定はOKLab距離にした。

理由は、CIEDE2000が悪いからではない。むしろ、CIEDE2000は色差評価としてはかなり有力である。

ただ、このゲームは工業製品の色差検査ではない。

必要なのは、次のバランスである。

  • プレイヤーが見て納得しやすい
  • 説明しやすい
  • 実装しやすい
  • 保守しやすい

その観点では、OKLab距離がちょうどよい。

観点OKLab距離
RGB距離より見た目に寄るか寄る
CIEDE2000より説明しやすいか説明しやすい
実装しやすいかculoriで簡単
計算コストは問題か問題にならない
ゲームルールとして説明できるかできる
学習用に比較できるかできる

プレイヤー向けには、これで十分だと思う。

このゲームでは、RGBの数字の差ではなく、人間の見た目に近くなるよう設計されたOKLab色空間で、正解色との距離が一番小さい回答を勝ちにしています。

開発者向けには、こう。

アプリ内のRGB値をculoriのrgb形式に変換し、differenceEuclidean("oklab") で距離を計算する。ランキングはraw distanceで決め、scoreは表示用に100点化する。

実装上の注意点

透明色は扱わない

このゲームでは、alphaは扱わない方がよい。

半透明色は背景によって見た目が変わる。

rgba(255, 0, 0, 0.5)

これは、白背景では薄い赤に見えるし、黒背景では暗い赤に見える。

つまり、透明色を許すと「色そのものの距離」ではなく「背景と合成した後の色の距離」を測る必要が出てくる。

ゲームルールが複雑になるので、まずは不透明色だけでよい。

距離の数値スケールを混ぜない

RGB距離、OKLab距離、CIEDE2000は、数値のスケールが違う。

方式スケール感
RGB距離最大 sqrt(3) 前後
OKLab距離今回は 1 を基準にスコア化
CIEDE2000100前後まで大きくなる

そのため、OKLab距離 0.3CIEDE2000 30 を見て、どちらが「より近い方式」なのかを直接比較してはいけない。

比較するなら、同じ方式の中で比較する。

  • OKLab距離でAとBを比較する
  • CIEDE2000でAとBを比較する

という形にする。

今回の結論

このゲームで気にしていたことは、単なる色差計算ではなかった。

本当に気にしていたのは、次のことだった。

  • 勝敗判定として納得できるか
  • プレイヤーに説明できるか
  • 実装として安全か
  • 後から読み返して理解できるか

RGB距離は簡単だが、人間の見た目とはズレやすい。CIEDE2000は標準的な色差として有力だが、ゲームルールとしては少し重い。OKLab距離は、その中間のちょうどよい場所にある。

だから、今回の最終方針はこう。

  1. 入力はアプリ内RGB値
  2. culori用のrgb形式に変換
  3. デフォルトは differenceEuclidean("oklab")
  4. ランキングはraw distanceで決める
  5. scoreは表示用に100点化する
  6. RGB距離とCIEDE2000は学習・比較用に残す

実装としてはこれで十分シンプル。でも、切り替えて比べると、色距離の考え方の違いがちゃんと見える。

このゲームは、色を当てるゲームであると同時に、

色の近さとは何か?

を体験できる小さな教材にもなった。

参考資料

資料参照した内容
W3C CSS Color Module Level 4#RRGGBB がsRGB成分を指定するHEX記法であること、CSSにおける色空間の扱い。
culori API ReferencedifferenceEuclidean()differenceCiede2000() などの色差関数の使い方。
Björn Ottosson, “A perceptual color space for image processing”OKLabの設計目的、知覚的な明るさ・彩度・色相を扱うための色空間という位置づけ。
CIE, “Colorimetry - Part 6: CIEDE2000 Colour-Difference Formula”CIEDE2000の位置づけ、CIE 142-2001に基づく色差式であること。