0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

QRコードにロゴを埋め込むときの誤り訂正レベル設計と、読み取り性を落とさない実装

0
Posted at

はじめに

QRコード生成ライブラリを使えば、中央にロゴを重ねること自体はそれほど難しくありません。

ところが実際にロゴを載せてみると、ロゴなしでは読めていたQRコードの読み取り率が急に落ちることがあります。

私も無料のQRコード生成ツールを作る過程で、この問題にぶつかりました。

ブラウザ上では読み取れているのに、印刷すると不安定になる。ロゴを少し大きくすると、端末によって反応しなくなる。そんな現象です。

多くの解説では「ロゴを入れるなら誤り訂正レベルをHにする」「ロゴは小さめにする」と説明されています。

それ自体は正しいのですが、実装する側としては なぜHなのか、どこまでロゴを大きくしてよいのか を判断できたほうが設計しやすくなります。

この記事では、QRコードへのロゴ埋め込みを 「誤り訂正能力の予算を使う処理」 と捉え、実際のJavaScript実装をもとに設計方針を整理します。

なお、掲載しているコードは公開中のツール(ロゴ埋め込み・色の編集・フレーム付き生成)から該当箇所を抜き出したもので、それぞれ別のページの実装です。


ロゴ埋め込みは「意図的なデータ欠損」と考える

QRコードの規格(JIS X 0510 / ISO/IEC 18004)には、Reed-Solomon符号による誤り訂正が組み込まれています。

レベル 復元可能な欠損率
L (Low) 約 7%
M (Medium) 約 15%
Q (Quartile) 約 25%
H (High) 約 30%

中央にロゴを重ねると、その部分にあったQRコードの情報は読み取れなくなります。

つまり実装上は、符号語の一部を意図的に欠損させているのと同じ状態です。その欠損をデコーダが誤り訂正によって復元することで、ロゴが載っていてもQRコードを読み取れます。

この考え方をすると、ロゴのサイズを単なるデザイン上の指定ではなく、誤り訂正能力の予算をどれだけ消費するかという問題として扱えるようになります。


レベルHでも、ロゴに30%を使ってはいけない

レベルHの復元能力は約30%です。

それなら「ロゴも30%まで載せられる」と考えたくなりますが、実運用ではこの設計は危険です。

誤り訂正能力は、ロゴだけのために存在するわけではありません。印刷物や実際の読み取り環境では、次のような劣化にも同じ予算が使われます。

  • 印刷時のドットゲイン、かすれ
  • 媒体の折れ、汚れ、退色
  • 斜め方向からの読み取り(透視歪み)
  • 低照度環境、モーションブラー、フォーカス外れ

ロゴだけで誤り訂正の予算を使い切ると、現実の劣化に対する耐性が残りません。

ディスプレイ上では読めるのに、紙へ印刷すると読めなくなることがあるのも、この差によるものです。

ディスプレイは自発光でコントラストが高く、紙よりも読み取り条件が良いため、限界に近いQRコードでも通ってしまいます。

そのため、設計上は次のように余裕を残します。

ロゴは面積比 20〜25% に抑え、誤り訂正の残余を現実の劣化に割り当てる。

レベルHの「約30%」は、ロゴ専用に使える領域ではありません。
ロゴを限界まで大きくするのではなく、印刷や撮影条件の悪化を吸収する余裕を残しておく必要があります。


実装では、ロゴの有無で誤り訂正レベルを切り替える

実装には qr-code-styling を使用しました。

ロゴ画像だけでなく、ドット形状やコーナー形状もオプションで制御できます。

ここでやりたいのは、ロゴがある場合だけ誤り訂正レベルをHへ引き上げることです。

常にHにしてしまうとデータ容量が減るため、長いURLではQRコードのバージョンが不必要に上がり、セル数が増えてしまいます。

次のコードでは、selected にロゴが存在するときだけ errorCorrectionLevelH にしています。

function options(size) {
  const opt = {
    width: size,
    height: size,
    type: 'canvas',
    data: elContent.value.trim() || ' ',
    margin: 8,
    // ロゴがあるときだけ H に引き上げる
    qrOptions: { errorCorrectionLevel: selected ? 'H' : 'M' },
    dotsOptions:          { color: elFg.value, type: 'square' },
    cornersSquareOptions: { color: elFg.value, type: 'square' },
    backgroundOptions:    { color: '#ffffff' },
  };

  if (selected) {
    opt.image = iconDataUri(selected, elFg.value);
    opt.imageOptions = {
      crossOrigin: 'anonymous',
      imageSize: 0.28,          // 面積比ではなく辺の比率
      margin: 6,                // ロゴ周囲の余白
      hideBackgroundDots: true, // ロゴ下のドットを描画しない
    };
  }
  return opt;
}

ロゴなしではM、ロゴありではH。

こうすることで、通常時のデータ容量を維持しつつ、ロゴによって発生する欠損だけを補強できます。

imageSize は面積比ではない

ここで注意したいのが imageSize: 0.28 の意味です。

これは面積比ではなく、QRコードに対するロゴの辺の比率です。

正方形として単純計算すると 0.28² ≒ 7.8% になります。

ただし実際には hideBackgroundDots: true によってロゴの下だけでなく周辺のドットも消え、さらに margin の領域も加わります。そのため、実効的な欠損は7.8%より大きくなります。

この部分は数式だけで安全値を決めるよりも、実機でスキャンして検証するほうが現実的でした。今回の実装では、複数端末で読み取りを確認した結果として 0.28 を採用しています。

hideBackgroundDots を有効にする理由

hideBackgroundDotsfalse にすると、ロゴの下にもQRコードのドットが描画されます。

ロゴとドットが重なって視認性が悪くなり、デコーダにとってもノイズになるため、明示的に消す方針にしました。


位置検出パターンとクワイエットゾーンは削らない

ロゴサイズだけでなく、どこを隠すかも重要です。

QRコードにはデータ以外にも、読み取りに必要な機能要素があります。

領域 役割
位置検出パターン(三隅の大きな四角) シンボルの検出・向きの判定
タイミングパターン セル座標の同期
クワイエットゾーン(外周4セル分の余白) 背景との分離

三隅の大きな四角は、カメラがQRコードの位置や向きを判断するための領域です。

ここをロゴで隠すと、データ復元以前の段階でQRコード自体を検出できなくなる可能性があります。

中央配置が定番なのは、単に見た目のバランスがよいからではありません。これらの機能要素を比較的避けやすい位置でもあるためです。

前掲のコードで margin: 8 を指定しているのは、このクワイエットゾーンを確保するためです。

CSSレイアウトの都合でQRコードの周囲をぎりぎりまで詰めたくなることがありますが、余白も読み取り機能の一部と考え、削らない方針にしています。


配色はWCAG相対輝度を使って機械的に判定する

ロゴサイズと並んで、読み取り事故につながりやすかったのが配色です。

QRコードのデコーダは色相そのものを見るのではなく、主に輝度差を使ってモジュールを二値化します。

そのため、見た目にはきれいでも、次の2パターンでは読み取りが不安定になります。

  1. コントラスト不足 — 前景と背景の輝度差が小さい
  2. 明暗の反転 — 前景が背景より明るい

利用者に「濃い色を使ってください」と書くだけでは防ぎきれないため、ツール側で色を判定し、問題がある場合は警告を表示するようにしました。

まず、WCAG 2.x の相対輝度の定義を使って色の明るさを計算します。

// 相対輝度(WCAG)。sRGB のガンマを線形化してから輝度を求める
function relLuminance(hex) {
  const m = /^#?([0-9a-f]{6})$/i.exec(hex);
  if (!m) return 0;
  const n = parseInt(m[1], 16);
  const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map(v => {
    v /= 255;
    return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
}

sRGBの値をそのまま比較するのではなく、一度線形化してから相対輝度を求めています。

これによって、人間の明るさの知覚に近い尺度で前景色と背景色を比較できます。

次に、この値からコントラスト比と明暗の向きを同時に判定します。

// 低コントラスト、または明暗の反転を検出する
// (実際の実装では前景色・背景色を入力要素から直接読んでいますが、
//   ここでは引数として渡す形で示します)
function isUnreadable(fg, bg) {
  const Lf = relLuminance(fg);
  const Lb = relLuminance(bg);
  const ratio = (Math.max(Lf, Lb) + 0.05) / (Math.min(Lf, Lb) + 0.05);
  return ratio < 3 || Lf > Lb;   // 低コントラスト or 反転(前景が背景より明るい)
}

ratio < 3 でコントラスト不足を、Lf > Lb で明暗の反転を検出しています。

コントラスト比だけを確認するのでは不十分です。

コントラスト比は絶対値の比なので、明暗が入れ替わっていても同じ値になります。 たとえば白背景に明るい黄色を置いた場合、比の条件だけでは通過してしまうことがあります。

そのため、前景が背景より暗いことも別に確認する必要があります。

閾値の 3 は、WCAGにおける大きいテキストの基準を借りた値です。QRコード規格で定められた閾値ではありませんが、実用上の下限として機能しています。

WCAGの相対輝度を使っているのは、QRコードがWCAG準拠を必要とするからではありません。
前景色と背景色の明るさを機械的に比較するための指標として利用しています。


プレビューとダウンロードの解像度を分ける

画面上のプレビューは260pxで十分ですが、その画像をそのまま拡大して印刷用途に使うと問題が起きます。

画像の拡大時に補間が入ると、QRコードのドット境界がぼやけるからです。

そこでプレビュー用とは別に、ダウンロード時に1024pxでQRコードを生成し直しています。

const PREVIEW = 260;
const HIRES   = 1024;

btnPng.addEventListener('click', () => {
  // 印刷用途を想定し、表示とは別に高解像度で生成し直す
  new QRCodeStyling(options(HIRES)).download({ name: 'qrcode', extension: 'png' });
});

プレビューのcanvasを単純に拡大するのではなく、同じオプションから高解像度版を再生成するため、セル境界をシャープなまま保持できます。

表示用と出力用を分離するのは小さな実装ですが、印刷物を想定する場合は効果が大きい部分でした。

印刷時の物理サイズにも注意が必要です。小さく印刷する場合は、2cm角程度を下限の目安としています。それより小さくすると、印刷時にドットが潰れやすくなります。


本番環境でだけ画像が消えた原因はCSPだった

QRコード生成そのものとは別ですが、本番環境へデプロイした際にも一度ハマりました。

Cloudflare Pagesへ公開したところ、QRコード自体は表示されるのに、フレームを合成した画像だけが表示されません。 ローカル環境では正常に動作しています。

原因は Content-Security-Policy でした。

img-src 'self' data: https://www.googletagmanager.com ...

この設定では blob: が許可されていません。

そのため、URL.createObjectURL() で生成したblob URLを img に設定すると、ブラウザによって読み込みがブロックされます。

CSP自体を緩めるのではなく、画像の生成経路を data: URLへ変更しました。

// blob: は CSP の img-src で不許可のため、data: URL で読み込む
function blobToImage(blob) {
  return new Promise((res, rej) => {
    const fr = new FileReader();
    fr.onload = () => {
      const im = new Image();
      im.onload = () => res(im);
      im.onerror = rej;
      im.src = fr.result;
    };
    fr.onerror = rej;
    fr.readAsDataURL(blob);
  });
}

FileReader.readAsDataURL() を使えば、既存の img-src に含まれている data: の範囲で画像を読み込めます。

CSPを広げる必要がないため、この方法を採用しました。

この問題が厄介なのは、ローカルの file:// ではCSPが適用されないことです。

そのため、ローカルでは正常でも、デプロイ後に初めて問題が見える場合があります。

CSPを設定しているフロントエンドでは、画像を生成するときに blob:data:objectURL のどの経路を使っているかを意識しておくと、原因を追いやすくなります。


まとめ

QRコードへのロゴ埋め込みは、単に画像を中央に重ねる処理ではありません。

意図的にデータを欠損させ、その欠損を誤り訂正で吸収する設計として考えると、各パラメータの意味が整理しやすくなります。

今回の実装では、次の方針にしました。

  • ロゴ埋め込みは誤り訂正の予算を消費する行為として扱う
  • レベルHの約30%を使い切らず、ロゴは20〜25%、残りは現実の劣化に割り当てる
  • 誤り訂正レベルはロゴの有無で動的に切り替える
  • 位置検出パターンとクワイエットゾーンを侵さない
  • 配色はWCAG相対輝度でコントラスト比と明暗の向きを検証する
  • プレビューとダウンロードの解像度を分離する
  • CSP環境では、blob:data: など画像の読み込み経路にも注意する

ライブラリのAPIは変わっても、「どの機能がどの程度の誤り訂正能力を消費しているか」という考え方はそのまま使えます。

数値の根拠を持って設計しておくと、ライブラリが変わっても判断できます。


作ったもの・参考資料

実際に動くものはこちらです。登録不要・無料で、処理はすべてブラウザ内で完結します。入力内容はサーバーへ送信しません。

ソースは公開しています。指摘や改善案があればぜひ教えてください。

参考


思想・アイディア・校閲・校正:cmalu ractu
文章生成:Claude(Anthropic)/ChatGPT(OpenAI)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?