赤と黒のQRコードが読めないのはなぜか — 2値化と相対輝度をJSで計算する

QRコードにブランドカラーを当てたら読めなくなった、という話は繰り返し出てくる。原因を「色を変えたから」で終わらせると次も同じことをするので、読み取り機が実際に何を見ているのかまで降りておきたい。

結論を先に書くと、読み取りが見ているのは色相ではなく明るさである。だから赤と黒のように、目には強い対比に見える組み合わせでも読めなくなる。ハシトシステムのQRコード生成ツールで前景色と背景色を選ぶときに何を基準にすればよいか、その基準を計算で出す。

読み取りの最初の工程は2値化である

QRコードのデコードは、おおまかに次の順で進む。

  1. 撮影した画像を明るいセルと暗いセルの2値に分ける(2値化)
  2. 3隅の位置検出パターンを見つけ、傾きと歪みを補正する
  3. モジュールを読み出し、マスクを外す
  4. 誤り訂正で欠損を復元し、データを取り出す

注目すべきは順番である。誤り訂正は4番目にしかいない。つまり1番目で明暗が分かれなければ、誤り訂正は一度も働かない。汚れやかすれが復元できるのは2値化を通過したあとの話であって、色の失敗はその手前で起きている。

自分のコードがどう2値化されるかを確かめる一番早い方法は、画像をグレースケールに変換してみることである。グレーにして模様が判別できなければ、読み取り機にも判別できない。

sRGB から相対輝度を出す

「明るさ」を数値にするには、色空間の話が少し要る。CSSで指定する #ff0000 のような値は sRGB のガンマ補正がかかった値で、そのまま平均を取っても人が感じる明るさにはならない。

W3C のアクセシビリティ指針(WCAG)が定義している相対輝度の式が、この用途にそのまま使える。手順は2段階である。

まず各チャンネルを 0〜1 に正規化し、ガンマを外して線形値に戻す。

1
2
3
4
function toLinear(c8) {
const c = c8 / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}

次に、人の目の感度に合わせた係数で足し合わせる。緑の係数が大きいのは、同じ強度なら緑が最も明るく見えるためである。

1
2
3
4
5
6
7
8
9
function relativeLuminance(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(hex);
if (!m) throw new Error(`色の指定が不正: ${hex}`);
const n = parseInt(m[1], 16);
const r = toLinear((n >> 16) & 0xff);
const g = toLinear((n >> 8) & 0xff);
const b = toLinear(n & 0xff);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

コントラスト比は、明るいほうと暗いほうの相対輝度から出す。分子分母に 0.05 を足すのは、真っ黒(輝度0)で比が発散しないようにするためである。

1
2
3
4
5
6
function contrastRatio(hexA, hexB) {
const a = relativeLuminance(hexA);
const b = relativeLuminance(hexB);
const [hi, lo] = a >= b ? [a, b] : [b, a];
return (hi + 0.05) / (lo + 0.05);
}

赤と黒を実際に計算する

用意した関数で、よく使われる組み合わせを並べてみる。

1
2
3
4
5
6
7
8
9
10
const pairs = [
["#000000", "#ffffff"], // 黒 / 白(標準)
["#000000", "#ff0000"], // 黒 / 赤
["#000000", "#ffff00"], // 黒 / 黄
["#000000", "#0000ff"], // 黒 / 青
["#1a1a2e", "#f5f5f5"], // 濃紺 / 薄いグレー
];
for (const [fg, bg] of pairs) {
console.log(fg, bg, contrastRatio(fg, bg).toFixed(2));
}

出力は次のようになる。

1
2
3
4
5
#000000 #ffffff 21.00
#000000 #ff0000 5.25
#000000 #ffff00 19.56
#000000 #0000ff 2.44
#1a1a2e #f5f5f5 16.60

黒と白の 21.00 が理論上の上限である。ここで見たいのは黒と青の 2.44 で、目には「濃い青と黒」で十分に違って見えるのに、輝度で見るとほとんど差がない。青の係数が 0.0722 と最も小さいので、青をいくら濃くしても輝度はさほど上がらないためである。

黒と赤の 5.25 は微妙な位置にある。明るい室内なら通ることが多いが、照明が落ちた場所や斜めからの撮影では失敗が増える。一方で黒と黄の 19.56 は白に近く、色を付けても安全な例である。黄色は赤と緑の合成なので、係数の大きい緑を含むぶん輝度が高い。

実務上の目安としては、4:1 を下回ったら採用しない、3:1 未満は論外、という線で運用すると事故が減る。文字の可読性に使う WCAG の 4.5:1 とおおむね同じ水準で考えておけばよい。

明暗の向きは比では表せない

コントラスト比は絶対値なので、どちらが明るいかの情報を持たない。黒地に白のコード(反転コード)は、白地に黒のコードと同じ 21.00 を返す。

しかしQRコードの規格は、明るい背景に暗いセルを前提にしている。反転コードを読める実装は実際にあるが、それは実装ごとの好意であって保証ではない。したがって判定は2つ要る。

1
2
3
4
5
6
7
8
9
10
function isSafeQrColors(fgHex, bgHex) {
const ratio = contrastRatio(fgHex, bgHex);
const inverted = relativeLuminance(fgHex) > relativeLuminance(bgHex);
return { ratio, inverted, ok: ratio >= 4 && !inverted };
}

console.log(isSafeQrColors("#ffffff", "#000000"));
// { ratio: 21, inverted: true, ok: false }
console.log(isSafeQrColors("#c62828", "#ffffff"));
// { ratio: 5.36..., inverted: false, ok: true }

比だけを見て通すと反転コードが素通りするので、inverted の判定を別に持つ必要がある。

背景が一様でない場合はこの計算が効かない

ここまでの計算は、前景と背景がそれぞれ単色であることを前提にしている。写真の上にコードを重ねる、セルにグラデーションをかける、金や銀の光沢インクを使う、といった構成では背景の輝度が場所ごとに変わるため、単一のしきい値が決まらない。

この場合の対処は計算ではなく構成の側にある。コードの下に無地の下地を敷き、その下地をクワイエットゾーン(周囲4モジュール分の余白)の外側まで広げる。余白の内側だけ白くして外が写真のままだと、境界の検出が不安定になる。

なお、光沢インクは角度によって明暗が反転するので、静止画で計算しても意味がない。読める角度と読めない角度が生まれることを前提に、印刷前に実物で確かめる以外にない。

まとめ

QRコードの色の失敗は、誤り訂正より手前の2値化で起きている。判断に使えるのは相対輝度とコントラスト比で、どちらもブラウザだけで数行の関数として書ける。比が 4:1 以上あること、明るい側が背景であること、背景が単色であることの3つを満たせば、ブランドカラーを当てても読み取りはおおむね安定する。

色を決めたら、実際にその色でコードを作って確かめておきたい。QRコード生成ツールで前景色と背景色を指定でき、できあがったコードの構造はQRコード解析ツールで読み解ける。うまく読めなくなったコードの原因を切り分けるなら写真のQRコード復元ツールが使える。


赤と黒のQRコードが読めないのはなぜか — 2値化と相対輝度をJSで計算する
https://blog.hashito.biz/2026/08/13/qr-color-relative-luminance-binarization/
著者
hashito
作成日
2026年8月13日
著作権