QRコードの読み取り値をそのまま使ってはいけない ── 誤り訂正が通っても値は保証されない

· 更新日: · · QRコード, バーコード, 誤り訂正, 入力検証, データ品質, 業務システム, C#, 設計, 現場運用

更新履歴(3件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
検証の条件(対象シンボル、生成ツール、デコーダ2種、損傷の与え方、試行回数、判定基準)を表で明示しました。4つの失敗パターンそれぞれに、業務ではどう現れるかの具体例を追加し、文字コードの前提を先に決める注意と検査の順序を独立させました。末尾に自分のQRで試す3ステップを追加しています。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21639632)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「QRコードの読み取り値をそのまま使ってはいけない ── 誤り訂正が通っても値は保証されない」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21639632 https://staging.comcomponent.com/blog/qr-decoded-value-validation/

DOI(最新版)
10.5281/zenodo.21639632
DOI(この版)
10.5281/zenodo.21733175

倉庫の検品端末が「ピッ」と鳴る。伝票のQRコードが読めた合図です。読めた文字列はそのまま在庫システムに渡り、伝票が引き当てられ、出荷指示が確定します。QRコードには誤り訂正があるのだから、多少汚れていても正しい値が返ってくる ── この前提でできているシステムは、決して珍しくありません。

前半は正しい話です。QRコードは汚れや破損があってもデータを復元できるよう設計されており、誤り訂正レベルLからHまで、コードワードの約7%から約30%を復元できます。1 問題は後半、「だから返ってきた値は正しい」という推論のほうです。

この記事では、実際に生成したQRコードのサンプル画像と、2種類のデコーダによる実測をもとに、誤り訂正が通っても値が保証されない理由と、業務システム側で何を検証すべきかを整理します。記事に載せたQRコードはすべて実物です。読み取れるものは手元のQRリーダーでそのまま確かめられます(「読めないこと」を示すための見本も含まれており、その箇所では明記します)。ブラウザ上で jsQR と OpenCV.js を切り替えて試せるQRコード読み取り比較ツールも用意しました。この記事の実測は、そちらで再現できます。

1. まず結論

  • 誤り訂正は「復元」であって「検証」ではありません。規格自身が、汚れたモジュールは「明らかに妥当だが異なる符号語として誤復号する」と書いています。2
  • ランダムな汚れなら、まず「読めない」に倒れます。実測9,700回で、誤った値が返ったケースは0件でした。
  • しかし損傷が一か所に偏ると、確実に別の値になります。26符号語のうち7個が化けるだけで 004873104873 として読め、独立した2つのデコーダがそろって同じ誤りを返しました。
  • 誤り訂正の外側には、もっと簡単な落とし穴があります。分割QR、文字コード、画面内の別のコード。いずれもエラーが出ないまま起こりえます。デコーダによっては警告や例外が出ることもありますが、出方は実装しだいで、出ないほうに倒れる組み合わせが実在します。
  • だから、読めた値は未検証の入力として扱います。形式検査 → チェックディジット → 業務検証の3段で受けるのが基本形です。

この記事の知識マップ

QRコードはリード・ソロモン符号による誤り訂正を備えるが、これは印刷パターンからの復元であって値の正しさの保証ではなく、損傷が符号語の近傍に偏って入り込むと規格が明記する通り別の妥当な符号語として誤って復元されることがある。さらに誤り訂正の外側にも分割QR(Structured Append)や文字コード指定(ECI)の不備といった、エラーを出さずに誤った値を返す原因が存在する。これらに対し業務システム側は形式検査・チェックディジット・マスタ照合をこの順で重ねる多段検証で受け止める必要があり、チェックディジットは1文字の誤りを確実に検出できる一方で複数文字の変化は取りこぼすため、実在確認だけでなく今処理すべき対象との一致まで見るマスタ照合が最後の砦になる。QRの下にGS1のHRI(人間が読める表記)を併記しておくことも、誤読が疑われた際の発見手段として有効である。

QRコードの誤り訂正と値検証の知識マップQRコードの誤り訂正が訂正であって検証ではないこと、誤り訂正・分割QR・ECIがそれぞれ誤読を引き起こしうること、チェックディジットと業務検証がどう誤読を防ぐかを示す図利用する原因になり得る利用する原因になり得る前提とする原因になり得る軽減するより先に行うべきより先に行うべき軽減する推奨される対応利用する利用するで確認できる利用する軽減するQRコードリード・ソロモン誤り訂正QRコードの誤読分割QR(Structured Append)ECI(拡張チャネル解釈)チェックディジット(検査用数字)入力値の検証(バリデーション)マスタ照合(業務検証)GS1のHRI(人間が読める表記)

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全16件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

2. サンプル ── ほとんど同じに見える2枚のQRコード

まず現物を見てください。次の2枚は、どちらもエラーなく読み取れます。

上が正常なQRコード、下は縦一本の帯状の損傷が入ったQRコード。見た目はほとんど同じ

試す前に一言: iOSの標準カメラでは反応しないことがあります。読めなかったらQRリーダーアプリで試してください(理由はこの下の注記に書きました)。

  • A(上)NO:20260725-004873
  • B(下)NO:20260725-104873

実物なので、手元のリーダーでそのまま試せます。この伝票番号は「受注日8桁 + 連番5桁 + チェックディジット1桁」という体系なので、変わったのは連番の部分 ── 0048710487 になり、1万件ずれた別の伝票を指しています。

iOSの標準カメラでは反応しないことがあります。標準カメラはURLのように「開く」操作ができる内容を優先する作りで、この記事のような文字列だけのQRでは何も出ないことがあります。QRリーダーアプリを使えば読めます。同じ画像なのに読み取り側の実装で挙動が変わる ── この記事の主題そのものが、最初のサンプルで体験できてしまう形です。

BがAと違うのは、左から10〜14列目、縦一本の帯の中にある31モジュールだけです。符号語領域208モジュールのうち15%にあたります。

Bのうち、Aから変化した31モジュールを赤枠で示した図。縦方向に細長く分布している

そして肝心なのは、デコーダがどちらについてもエラーを返さないことです。「訂正しました」という通知すらありません。アプリから見れば、両方とも同じように成功した読み取りです。

ひとつ断っておくと、この損傷は狙って構成したもので、たまたまこうなる確率は高くありません。作り方と、現実の汚れとの距離は第4節で扱います。

3. なぜ起きるのか ── 誤り訂正の中身

QRコードの誤り訂正はリード・ソロモン符号で、符号語(8ビット単位)ごとに働きます。今回使ったバージョン1・誤り訂正レベルM(21×21モジュール)の内訳はこうです。2

総符号語数 データ符号語 誤り訂正符号語 規格上の訂正能力
26 16 10 4符号語

目を引くのは最後の列です。誤り訂正符号語が10個あれば、リード・ソロモン符号としては5個まで訂正できます。ところが規格が定める訂正能力は4です。差の1個ぶんは、誤読防止用符号語 p として意図的に取り置かれています(バージョン1-Mでは p = 2)。規格の表13にも脚注で「誤読の確率を下げるため、訂正能力を誤り訂正符号語数の半分未満にしている」と明記されています。2

つまり、規格そのものが「誤り訂正は誤った値を出しうる」という前提で設計されているわけです。同じ節にはこうも書かれています。

QRコードはマトリックスシンボルであるため、モジュールを暗から明へ(あるいはその逆へ)変える欠陥は、該当するシンボルキャラクタが明らかに妥当だが異なる符号語として誤復号する結果を招く。2

理由は訂正の原理そのものにあります。リード・ソロモン復号がやっているのは、受け取ったパターンから一定の距離(訂正能力)の内側に符号語があるか探すことです。見つかればそれを答えとして返し、見つからなければ「読めない」で終わります。どんなに壊れていても一番近い符号語を探し出す、という動作ではありません。

この性質から、結果は2つに分かれます。ランダムな損傷はどの符号語からも遠いところに散るので、たいていは「見つからない=読めない」に倒れます。危ないのは、損傷がたまたま別の符号語の近傍に入り込んだときです。そのときデコーダは、その符号語こそ正解だと判断して返します。返ってきた符号語はそれ自体として完全に整合しているので、誤りだとは分かりません。

4. どこまで訂正され、どこから危ないか

ここからが実測です。結論を先に言うと、危険なのは汚れの「量」ではなく「当たりどころ」でした。

数字を読む前に、この章の実験条件をまとめておきます。手元で追試する場合の前提です。

項目 内容
対象シンボル バージョン1-M / 21×21(26符号語 = データ16 + 誤り訂正10)
生成 segno 1.6.6 / Python 3.11
デコーダ1 OpenCV 5.0.0 cv2.QRCodeDetector(Python 3.11)
デコーダ2 jsQR 1.4.0(Node.js 22)
損傷の与え方(4.1節) 符号語領域208モジュールからランダムに選んで反転/符号語単位でランダムに破壊/シンボル全体へ一様なぼかし・ノイズ・コントラスト低下
損傷の与え方(4.2節) 目標値Bへ寄せる符号語の組み合わせを全数試行
試行回数 モジュール反転3,900回(各水準300回)、符号語破壊1,800回(各水準200回)+訂正能力超えの追試4,000回、画質劣化1,000回(各条件200回)。4.2節は792通り・495通りの全数
判定 デコーダの戻り値を、期待値と一致すれば「正しく読めた」、値が得られなければ「読めない」、空でない別の値が返れば「誤った値」に分類

第5節のサンプルを含めた記事全体の環境は、記事末の「検証環境」にまとめてあります。

4.1. ランダムな汚れは「読めない」に倒れる

バージョン1-Mのシンボルに対し、符号語領域208モジュールのうち何個かをランダムに反転させ、結果を分類しました(各水準300回、計3,900回)。

6モジュール反転と9モジュール反転のQRコードの比較。上は読み取れ、下は読み取れない

上が6個、下が9個を反転させたものです。上は正しく読め、下はまったく読めません。3モジュールの差は人の目にはほとんど分かりません。境目は見た目に現れないところにあります。

反転したモジュール数 正しく読めた 読めない 誤った値
0〜5 1,799 1 0
6 131 169 0
7 32 268 0
8 11 289 0
9〜12 0 1,200 0

読めるかどうかは5個から6個の間で崩れ、9個以上は全滅します。そして誤った値は1件も出ていません。訂正しきれない損傷は「読めない」に倒れる、というのが素直な良いニュースです。jsQRでも数字はほぼ同じでした(0〜5は1,800件すべて正解、6以降はOpenCVと1件差以内)。

同じことを符号語単位で見ると、境目がもっとはっきりします(各水準200回、計1,800回)。

破壊した符号語数 正しく読めた 読めない 誤った値
0〜5 1,194 6 0
6〜8 0 600 0

5符号語まで訂正されています。前節のとおり規格上の訂正能力は4で、残りは「訂正せずに検出だけする」ための枠でした。jsQRは0〜5の1,200件すべてを正しく読んでおり、2つの実装がどちらもその枠を訂正に使い切っています。規格が誤読対策として置いたマージンは、実装レベルでは当てにできません。

訂正能力を明確に超える破壊(6符号語と8符号語)も各2,000回ずつ追試しましたが、誤った値はやはり0件、すべて「読めない」で終わりました。

カメラ由来の劣化も同じ傾向です。シンボル全体に一様なぼかし・ノイズ・コントラスト低下をかけた結果(各200回、計1,000回)。

ぼけ σ ノイズ σ コントラスト 正しく読めた 読めない 誤った値
0 0 1.00 200 0 0
1.5 10 0.90 183 17 0
3.0 20 0.70 1 199 0
4.5 30 0.50 0 200 0
6.0 40 0.35 0 200 0

結果は「正しく読める」か「読めない」かのどちらかで、中間はありません。劣化が進むと正解率が落ちますが、減ったぶんはすべて読み取り失敗に変わります。

ただしこれは一様な劣化での話です。実際の手ぶれには方向があり、斜めからの撮影や照明のムラは像の一部だけを崩します。次に見るとおり危ないのは損傷が偏ることなので、「画質の問題なら誤読しない」と一般化しないでください。

4.2. 当たりどころが悪いと、確実に別の値になる

第2節のサンプルBは、その「偏った損傷」を狙って構成したものです。

NO:20260725-004873(A)と NO:20260725-104873(B)の26符号語を並べると、差があるのは12か所でした。データ符号語が2つと、それに引きずられた誤り訂正符号語10個です。

この12か所のうち7か所をBの値に寄せると、できあがったパターンはAから7符号語ぶん離れ、Bからは5符号語ぶんの距離に来ます。訂正能力が5なら、デコーダはこれを「Bが5か所汚れたもの」と解釈し、Bへ訂正します

条件 試した組み合わせ Bとして誤読した数
7符号語をBへ寄せる(Bとの距離5) 792通り 792通り(100%)
8符号語をBへ寄せる(Bとの距離4) 495通り 495通り(100%)

どの組み合わせでも例外なく誤読しました。下段はBとの距離が4、つまり規格が定める訂正能力の内側です。誤読防止用符号語 p を尊重する実装であっても、損傷が1符号語ぶん増えれば同じ結果になります。p は確率を下げるだけで、防いではいません。

第2節に載せたBは、このうち損傷が縦一本にまとまる組み合わせ(31モジュール)です。最小化すれば23モジュールまで減らせました。OpenCV 5.0.0とjsQR 1.4.0という無関係な2実装が、どちらも NO:20260725-104873 を返します。

4.3. この差をどう受け取るか

正直に書くと、この損傷はランダムには起きにくいものです。でたらめな汚れでは9,700回試して誤読0件でした。「明日にも起きる」という話ではありません。

それでも無視できない理由が3つあります。

  • 現実の損傷はランダムではありません。折り目は直線に走り、搬送の擦れは同じ辺に集中し、印字ヘッドの目詰まりは縦筋になります。今回の帯状の損傷は、こうした「位置に偏る損傷」の一例です。ただしBは白→黒17モジュール・黒→白14モジュールと両方向の変化を含むため、インクが減るだけの不良では再現できません。両方向が同時に起きるのは、折り目の陰影で二値化の判定がずれる、汚れとかすれが重なる、上から別のシールが部分的に貼られる、といった場合です。
  • スキャン回数が桁違いです。1回あたりでは無視できる確率でも、1日に数万回読む現場では話が変わります。しかも誤読はエラーを出さないので記録に残らず、原因不明の棚卸し差異として処理されて終わります。
  • 意図的に作れるということは、他人にも作れるということです。今回のパターンは、狙った値を決めてから機械的に構成しました。値札やクーポンのように書き換えの動機があるQRでは、これは攻撃手法になります。

5. 誤り訂正と関係のない「読めたのに違う」

実務でより頻繁に踏むのはこちらです。誤り訂正が完璧に働いていても起き、確率の問題ですらありません。

5.1. 分割QRの1枚目だけ読む

QRコードには、長いデータを複数のシンボルに分けて読み取り側で連結する仕組み(Structured Append)があります。次は NO:20260725-004873/LOT:AB-77/QTY:120/EXP:20270131 を3枚に分割した、その1枚目です。

分割QR3枚のうちの1枚目。単独で読むと末尾が欠けた伝票番号が返る

見た目は普通のQRコードで、3枚のうちの1枚だという手がかりはありません。これを単独で読むと、OpenCVはこう返します。

NO:20260725-00487

エラーなし、警告なし。末尾のチェックディジット 3 が落ちただけの、完全にそれらしい伝票番号です。2枚目・3枚目はそれぞれ 3/LOT:AB-77/QTY: 120/EXP:20270131 になります。同じ画像をjsQRに渡すと空文字が返りました。分割QRを想定していないアプリがたまたま1枚目をスキャンしたとき何が起きるかは、デコーダしだいで変わります。

業務ではこう現れます。伝票番号を前方一致や LIKE で検索している画面だと、末尾1桁が落ちた NO:20260725-00487 がそのまま元の伝票にヒットしてしまい、「読めたし、伝票も出た」ので誰も異常に気づきません。桁数を固定で検査していない限り、この断片は最後まで正常な読み取りとして流れていきます。

5.2. 文字コードとECI

次は 部品番号 東-004873 をShift_JISで、ECI指定なしに入れたQRコードです。

Shift_JISでECI指定なしに生成したQRコード。読み取ると文字化けする

これも実物です。手元のリーダーで読むと、部品番号 東-004873 と出るか、文字化けした文字列が出るか、何も返らないか ── お使いのリーダーがどの解釈をしているかが分かります。

QRコード読み取り比較ツールでこの画像を読ませると、jsQRが取り出した生バイト列と、それをUTF-8 / Shift_JIS / EUC-JPなどで解釈し直した結果が並びます。同じバイト列が文字コードしだいで別物になる様子を、その場で確認できます。

同じ内容を、文字コードとECI指定を変えて生成し、2つのデコーダに読ませた結果がこちらです。この4条件のシンボルはすべてツールのサンプルに入れてあります。ただし表の値はPythonの cv2 で測ったもので、3行目だけはブラウザ版と結果が違います(この表の直後で詳しく述べます)。ツールで確認できるのはブラウザ版の挙動なので、3行目の「例外で落ちる」を再現するにはPythonの cv2 が要ります。

生成条件 OpenCV 5.0.0 jsQR 1.4.0
Shift_JIS / ECIなし 文字化け文字列を成功として返す 空文字
UTF-8 / ECIなし 部品番号 東-004873 部品番号 東-004873
Shift_JIS / ECIあり 警告を出して復号に失敗 空文字
UTF-8 / ECIあり 部品番号 東-004873 部品番号 東-004873

1行目が最悪です。OpenCVはバイト列をLatin-1として解釈し、\x95\x94\x95i... という壊れた文字列をエラーなしで返しました。アプリから見れば正常な読み取りで、そのままデータベースに入れば文字化けしたレコードが1件できあがります。

業務ではこう現れます。入庫実績の品名欄に化けた文字列が入って登録され、翌日「その品番で検索しても実績が出てこない」という問い合わせになります。同じラベルを読み直しても同じ値が入るので、現場は「システムの検索がおかしい」と報告し、原因が読み取り側の文字コードだと分かるまで往復が続きます。

これはOpenCVのバグではなく、現行規格が定める既定の解釈(ISO/IEC 8859-1)どおりの動作です。3 規格から外れているのは、Shift_JISをECIなしで入れた側です。

3行目も見逃せません。ECI(文字コードを明示する仕組み)を正しく指定したのに、OpenCVは QR: ECI is not supported properly という警告を出し、戻り値をUTF-8として解釈できずに例外で落ちました。規格に忠実に作ったQRのほうが読めないという逆転が実際に起きます。

さらに、この3行目は同じOpenCVでも言語バインディングで結果が変わります。上の表はPythonの cv2 での結果ですが、ブラウザ版(opencv.js 5.0.0)に同じ画像を渡すと例外は出ず、���i��� ��-004873 という置換文字だらけの文字列を成功として返します。Emscriptenが std::string をUTF-8として変換する際、不正なバイトを例外ではなく U+FFFD に落とすためです。バージョンも画像も同じで、変わったのは呼び出し側の言語だけ。例外で気づけるPythonのほうがまだましで、ブラウザ版は「読めた」と言いながら壊れた値を返します。ツールのサンプルで実際に確認できます。

5.3. 画面内に複数のQRがある

伝票に複数のQRが印刷されている、隣の箱のラベルが視野に入る ── ありふれた状況です。3つのQRを横に並べて試しました。

3つのQRコードを横に並べた画像。左から NO:、ITEM:、LOT: の順

まず、この画像ではOpenCVの単一読み取りAPIは何も返しませんでした。ただしこれは「複数あったら弾いてくれる」という保証ではありません。単一読み取りAPIは1つのQRを検出して復号するものと文書化されているだけで、複数枚を拒否するとは書かれておらず、配置によってはどれか1つを返すこともあり得ます。複数コードの検出を、単一読み取りAPIの戻り値の有無で代用しないでください。

では複数読み取りAPIならどうか ── ここが厄介でした。

上の図(左から NO: / ITEM: / LOT:)をそのまま使い、画素数だけを変えて読ませた結果です。実際のスキャナで、対象との距離やカメラの解像度が変わる状況に相当します。

画像の幅 戻り順
1,001px NO: / LOT: / ITEM:
1,502px 何も返らない
2,002px NO: / LOT: / ITEM:
3,003px NO: / ITEM: / LOT:
4,004px LOT: / NO: / ITEM:

同じ画像なのに、解像度が変わるだけで順序が変わります。左から順でも、大きい順でもありません。読めない解像度すらあります。順序は検出アルゴリズムの内部事情で決まるもので、規定されていない以上こうなります。

つまり「1つ目が伝票番号のはず」とインデックス0を使うコードは、今日たまたま動いていても、明日カメラを寄せただけで別のコードを掴みます。再現しにくく、原因も追いにくい種類の不具合です。

業務ではこう現れます。検品台の上で、目的の伝票と隣の箱のラベルが同時に視野へ入る。インデックス0を採用しているアプリは隣の伝票を引き当て、出荷指示がそちらで確定します。作業者は正しいラベルにカメラを向けたつもりなので、出荷後に「違う商品が届いた」と言われるまで誰も気づきません。

受け取り側は、順序ではなく中身で選ぶ必要があります。複数読み取りAPIで全部拾い、接頭辞と形式が一致するものだけを採用し、該当が0個または2個以上ならエラーにする ── これが安全な書き方です。

5.4. そもそも中身が正しいとは限らない

画像処理では原理的に検出できない層があります。印刷元のデータが間違っている、貼り替え前の古いラベルが箱に残っている、別の取引先のラベルが混入している、ラベルがコピーされている。

QRコードは「そこに何が書かれているか」しか教えてくれません。「それが正しいか」「自社が発行したものか」は、受け取った側が確かめるしかありません。

業務ではこう現れます。再利用した箱の側面に前回のラベルが残っていて、天面の新しいラベルではなくそちらを読む。値としては完全に正しいQRなので、形式検査もチェックディジットもマスタ照合も全部通り、前回の出荷先へ荷物が向かいます。この層だけは、読み取り値の検査では一切捕まりません。

6. 受け取った値をどう扱うか

対策は、誤り訂正の外側に自前の検証を積むことに尽きます。

検証内容 捕まえられるもの
1. 形式検査 長さ・文字種・区切り・接頭辞の完全一致 別のコードの読み取り、分割QRの断片、文字化け
2. 自己検証 チェックディジット 1文字の変化は確実に検出。複数文字は取りこぼしあり
3. 業務検証 マスタ照合と、いま処理すべき対象と一致するか 古いラベル、他社ラベル、別の伝票の取り違え

サンプルの伝票番号は NO: + 受注日8桁 + 連番5桁 + チェックディジット1桁で、末尾はGS1と同じモジュラス10・ウェイト3方式です。第2節の誤読 NO:20260725-104873 はここで止まります。2026072510487 の正しいチェックディジットは 0 で、ラベル上の 3 と一致しないためです。

コードを書く前に、文字コードの前提を先に決めてください。下の実装は、伝票番号がASCIIの数字だけで構成されることを前提に、チェックディジットを「文字 − '0'」で計算しています。全角数字や、5.2節で見た文字化けしたバイト列がこの計算まで届くと、結果は意味を持ちません。そのため形式検査の正規表現は \d ではなく [0-9] で書き、ASCII以外の数字をチェックディジット計算に到達させない順序にしてあります。前提を決めるのが先、それを形式検査で担保するのが次、計算はその後です。この順番が崩れると、後段の検査はすべて空回りします。

using System.Linq;
using System.Text.RegularExpressions;

public sealed record ScanOutcome(bool Accepted, string? SlipNo, string Reason);

public static class SlipScanValidator
{
    // NO: + 受注日8桁 + '-' + 連番5桁 + チェックディジット1桁
    // 終端は $ ではなく \z。.NET の $ は末尾の改行の直前にもマッチするため、
    // "NO:20260725-004873\n" を通してしまう。
    // 数字は \d ではなく [0-9]。.NET の \d は全角数字などUnicodeの数字全般に
    // マッチするが、後段のチェックディジット計算はASCIIを前提にしている
    private static readonly Regex Format =
        new(@"\ANO:(?<date>[0-9]{8})-(?<seq>[0-9]{5})(?<cd>[0-9])\z", RegexOptions.Compiled);

    public static ScanOutcome Validate(string? raw, ISlipRepository repo)
    {
        // 1. 「読めなかった」を「空の成功」にしない。
        //    デコーダは失敗を null / 空文字 / 例外のどれで返すか実装ごとに違う
        if (string.IsNullOrEmpty(raw))
            return new(false, null, "読み取れませんでした。もう一度スキャンしてください");

        // 2. 形式検査。前方一致ではなく、長さまで含めた完全一致で見る。
        //    分割QRの断片 "NO:20260725-00487" はここで落ちる
        var m = Format.Match(raw);
        if (!m.Success)
            return new(false, null, $"伝票QRの形式ではありません({Describe(raw)})");

        // 3. 自己検証。誤訂正で数字が化けていればここで捕まる
        var body = m.Groups["date"].Value + m.Groups["seq"].Value;
        if (Modulus10Weight3(body) != m.Groups["cd"].Value[0] - '0')
            return new(false, null, "チェックディジットが一致しません。ラベルの汚れを確認してください");

        // 4. 業務検証。実在するか、そしていま処理してよい状態か
        var slip = repo.Find(raw);
        if (slip is null)
            return new(false, null, "該当する伝票がありません");
        if (slip.Status != SlipStatus.WaitingForShipment)
            return new(false, null, $"この伝票は「{slip.Status}」です。出荷対象ではありません");

        return new(true, raw, "OK");
    }

    // GS1と同じモジュラス10・ウェイト3。右端から3,1,3,1... の重みを掛ける
    private static int Modulus10Weight3(string body)
    {
        var sum = 0;
        for (var i = 0; i < body.Length; i++)
        {
            var weight = (body.Length - i) % 2 == 1 ? 3 : 1;
            sum += (body[i] - '0') * weight;
        }
        return (10 - sum % 10) % 10;
    }

    // 読み取り値は外部入力。画面やログに出す前に、印字可能なASCIIだけを残す。
    // char.IsControl はUnicodeのControl(Cc)しか落とさず、U+202E(右横書きへの
    // 上書き)などのFormat(Cf)を通してしまう。除外リストではなく許可リストで書く
    private static string Describe(string raw)
    {
        var kept = raw.Where(c => c >= ' ' && c <= '~').Take(40).ToArray();
        if (kept.Length == 0) return "(表示できない文字列)";
        var safe = new string(kept);
        return kept.Length < raw.Length ? safe + "…(一部の文字を除去)" : safe;
    }
}

チェックディジットの設計そのものは、業務システムのコード設計とチェックディジットで算式と選び方を整理しています。この記事の文脈での価値は、その検査が人の打ち間違いだけでなく機械の誤読にも効く点にあります。

この3段が守れないもの

3段そろえても抜ける穴が3つあります。どれも「検証の設計を一段深くする」ことで塞げるので、あわせて押さえてください。

チェックディジットは複数文字の変化を取りこぼします。モジュラス10・ウェイト3が確実に捕まえるのは1文字の誤りです。実際、20260725004872026072517487 はどちらもチェックディジットが 3 になるため、NO:20260725-174873 は2段目を素通りします。誤訂正が変えるのは1文字とは限らないので、3段目を省けません。

マスタ照合を「存在確認」で終わらせないでください。上のコードの repo.Find() と状態チェックは、「使える伝票がどこかに存在する」ことしか確かめていません。作業者が隣の箱のラベルを読んだ場合、そのラベルも形式・チェックディジット・出荷待ち状態をすべて満たすので、そのまま通ります。読み取った値は、いま処理しているはずの対象と突き合わせる必要があります ── ピッキングリストの次の1件と一致するか、スキャン済みのコンテナIDに紐づいているか、出荷先が作業中の便と同じか。何と突き合わせるかは業務ごとに違うので、ここだけは汎用のコードになりません。

二重処理は検証では防げません。2台の端末が同じラベルをほぼ同時に読むと、どちらも「出荷待ち」を確認してから状態を更新するため、両方が通過します。防ぐのは実行側の仕事で、UPDATE ... WHERE status = '出荷待ち' のような条件付きの状態遷移を1回の原子的な操作にするか、冪等キーで再実行を吸収します。検証は入口の判定であって、排他制御の代わりにはなりません。

「事故」と「攻撃」は別問題

ここまでの3段が守るのは事故です。汚れによる誤読、分割QRの読み落とし、文字化け、古いラベルの混入 ── 悪意のない誤りは、これで止まります。

一方、書き換えの動機がある用途(値札、クーポン、入場券、決済)では防御になりません。攻撃者は形式を満たし、チェックディジットを計算し直し、実在する別の番号を指すQRを自由に作れます。マスタ照合は存在しか見ないので素通りします。

QRの所持が価値や権限を意味するなら、値そのものに真正性を持たせる必要があります。サーバーが発行する推測不能なトークン(十分な長さの乱数)にして番号から番号を推測できないようにするか、ペイロードに鍵付きMACや電子署名を付けて受け取り側が鍵で検証する。どちらの場合も、使用済み状態をサーバー側で管理して複製の二重使用を防ぎます。

ただし真正性だけでは「すり替え」は止まりません。安い商品から正規のQRを剥がして高い商品に貼れば、トークンも署名も本物のままです。貼り替えられる値札のように媒体が再利用される場面では、使用済み判定も効きません。ここでも効くのは3段目と同じ発想で、そのQRが目の前の対象のものかを値とは別の経路で確かめることです ── 商品側の識別を別手段で取って突き合わせる、取引の文脈(レジの明細、入場の時間帯)と照合する、剥がすと破れるラベルで物理的に紐づける、といった方法があります。

チェックディジットもマスタ照合も、真正性については何も保証しません。そして真正性そのものも、その値が目の前の対象のものであることまでは保証しません。「誤読対策」「偽造対策」「すり替え対策」を同じ仕組みで賄おうとしないことが肝心です。

7. 運用側で決めておくこと

コードだけでは塞ぎきれない部分があります。

  • QRの下に人が読める文字列を必ず併記する。GS1が定めるHRI(Human Readable Interpretation)の考え方と同じです。4 誤読が疑われたとき、人が突き合わせられる手段が残ります。実運用では、これが唯一の発見手段になることも珍しくありません。バーコード全般の現場運用はGS1バーコード規格の基本と現場運用の注意点にまとめています。
  • 「読めない」ときの手順を決めておく。再スキャン回数の上限、手入力へのフォールバック、その承認者。ここが曖昧だと、現場は「読めるまで角度を変えて何度も試す」という、誤読の確率を上げる方向に動きます。
  • 弾いた値をログに残す。特定のラベルや端末に偏っていれば、印刷機やスキャナの不調を早期に発見できます。ただし生の文字列を行指向のログにそのまま書かないでください。改行や制御文字を含む値は、ログの行を偽装したり表示を壊したりします。原本は長さを区切ってエスケープし構造化ログのフィールドやデータベースの列に保存する、人が読む行には無害化した表現だけを出す ── この分離を最初から入れておきます。可能なら読み取り画像も残します。
  • 分割QRを使わない。データが入り切らないなら、バージョンを上げるか、QRには識別子だけを入れて残りはマスタから引きます。後者はラベルを小さくでき、内容の訂正がラベル再発行なしでできる利点もあります。
  • 文字コードは「入れない」で解決する。業務用途のQRはASCIIの範囲に収めます。バイトモードはECI指定がなければ文字コードを宣言しておらず、既定の解釈は規格の版によって変わっています。3 UTF-8で書くだけでは相互運用性は得られず、別の解釈をするスキャナでは文字化けします。どうしても非ASCIIを入れるならUTF-8をECI指定つきで宣言するのが規格上の正解ですが、5.2節のとおりECIの扱いが怪しい実装も現実に存在するため、想定機種での実機確認からは逃れられません。
  • 不可逆な操作の前に確認を挟む。出荷確定・在庫引き落とし・入金消し込みのように取り消しが重い操作では、読み取った値から引いた品名や金額を画面に出して人に見せる。誤読は値としては妥当に見えても、業務文脈では不自然に見えることが多いためです。

検証をどこまでやるかは、間違えたときの損害で決まります。

用途 形式検査 チェックディジット マスタ照合 人による確認
社内の場所・棚番号 必須 任意 推奨 不要
入出庫・棚卸し 必須 推奨 必須 不要
出荷確定・在庫引き落とし 必須 必須 必須 推奨
請求・入金消し込み 必須 必須 必須 必須
医薬品・危険物の取り違え防止 必須 必須 必須 必須

8. まとめ

QRコードの誤り訂正は、印刷されたパターンから元の符号語を復元するための仕組みです。この仕事はきちんとこなします。実測でも、ランダムな汚れや一様な画質劣化に対しては、訂正できる範囲では正しく読み、できない範囲では素直に読み取りを諦めました。

しかしそれは、アプリが受け取った文字列が業務的に正しいこととは別の話です。損傷の当たりどころによっては、訂正が働いた結果として別の妥当な値が出てきます。分割QRや文字コード、画面内の別コードに至っては、誤り訂正の外側の問題で、確率ですらありません。

規格自身が「明らかに妥当だが異なる符号語として誤復号する」と書き、誤読防止用の符号語をわざわざ確保していることが、この構造を端的に表しています。そこまでしても確率を下げられるだけ ── それが規格の到達点です。残りを埋められるのは、値を受け取ったアプリケーションだけです。

読み取れた値は、外部から来た未検証の入力です。キーボードから打ち込まれた文字列と同じ扱いをする ── それが、QRコードとの正しい付き合い方だと考えています。

自分のQRを試す3ステップ

ここまでの話は、自社のラベルでそのまま確かめられます。

  1. 生成する。実際に運用している書式の値を1つ、手元のQR生成ツールでQRにします(この記事のサンプルは segno 1.6.6 で生成しました)。まずは無傷の状態で読めることを確認します。
  2. 傷を付ける。画像編集ソフトで、折り目や印字ヘッドの目詰まりに見立てた縦一本の帯を引きます。重要なのは量ではなく偏りなので、全体に薄くノイズを散らすのではなく、狭い範囲に固めてください。
  3. 2つのデコーダで比べる。QRコード読み取り比較ツールに読ませ、jsQR と OpenCV.js の結果を見比べます。片方だけが値を返す、両方が同じ値を返すのに元の値と違う、といった挙動がその場で確認できます。

結果が2つのデコーダで割れたら、その条件は自社の検証設計で必ず面倒を見るべき条件です。「うちの現場のスキャナは大丈夫か」を、机の上で1回試しておく価値があります。


検証環境

誤り訂正の挙動を見る第2〜4節はバージョン1-M(21×21モジュール)で統一しています。第5節のサンプルはデータ量に応じてバージョンが変わるため、節ごとに記載します。

項目 内容
デコーダ1 OpenCV 5.0.0 cv2.QRCodeDetector
デコーダ2 jsQR 1.4.0(Node.js 22)
生成 segno 1.6.6 / Python 3.11
第2〜4節のシンボル バージョン1-M / 21×21(26符号語 = データ16 + 誤り訂正10)
5.1節(分割QR) 3枚ともバージョン1-M / 21×21
5.2節(文字コードとECI) Shift_JISはバージョン2-Q、UTF-8はバージョン2-M(いずれも25×25)。日本語18〜23バイトがバージョン1-Mのデータ16符号語に収まらないため
5.3節(複数コード) NO:ITEM: はバージョン1-M、LOT:AB-77 はデータが短くsegnoが誤り訂正レベルを引き上げるためバージョン1-H(いずれも21×21)
  1. 株式会社デンソーウェーブ, 誤り訂正機能について|QRコードドットコム 

  2. ISO/IEC 18004 Information technology — Automatic identification and data capture techniques — QR code bar code symbology specification. 誤り訂正能力の式 e + 2t ≦ d - p、誤読防止用符号語 p の値、および「明らかに妥当だが異なる符号語として誤復号する」という記述は 8.5.1 Error correction capacity に、バージョン1-Mの (26,16,4) と脚注「誤読の確率を下げるため訂正能力を誤り訂正符号語数の半分未満にしている」は Table 13 にあります(引用は2000年版に基づく)。現行版はISO/IEC 18004:2024。  2 3 4

  3. バイトモードでECI指定がないときの既定の解釈は、規格の版で変わっています。ISO/IEC 18004:2000 の 8.3.1 は「The default interpretation for QR Code is ECI 000020 representing the JIS8 and Shift JIS character sets」と定めていましたが、2006年版(QR Code 2005)以降は ECI 000003、すなわち ISO/IEC 8859-1 が既定です。宣言されていない既定に依存すると、規格の版が変わるだけで解釈が変わりうるということでもあります。  2

  4. GS1, GS1 General Specifications 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

誤り訂正レベルをHにすれば誤読は防げますか?
防げません。レベルを上げると訂正できる汚れの量は増えますが、「訂正が働いて別の値になる」という現象そのものはなくなりません。リード・ソロモン復号は「受け取ったパターンから訂正能力の内側に符号語があるか探す」動作なので、損傷が別の符号語の近傍に入り込むと、そちらを正解として返します(遠く外れた損傷は「読めない」で終わります)。ISO/IEC 18004はこれを「明らかに妥当だが異なる符号語として誤復号する」と明記し、誤読防止用の符号語を別枠で確保していますが、これも確率を下げる措置であって保証ではありません。誤読を捕まえられるのは、値を受け取ったアプリ側だけです。
実際のところ、QRの誤読はどのくらい起きるものですか?
ランダムな汚れであれば、まず起きません。本記事の実測では、モジュールをランダムに反転させた3,900回と、符号語をランダムに壊した5,800回のすべてで、誤った値が返ったケースは0件でした。訂正しきれない損傷は「読めない」に倒れます。ただし現実の損傷はランダムではなく、折り目・擦れ・印字ヘッドの目詰まりのように位置に偏りを持ちます。さらに、分割QRの読み落としや複数コードの取り違えは確率の問題ですらなく、条件がそろえば毎回起きます。
QRには誤り訂正があるのに、チェックディジットも必要ですか?
必要です。守っている層が違います。誤り訂正が扱うのはシンボル内部の一貫性で、しかも復元を保証するのは訂正能力の範囲内までです。それを超える損傷では、別の妥当な符号語へ「復元」してしまうことがあります。チェックディジットのほうは「アプリが受け取った文字列が、コード体系として成立しているか」を検査します。1文字の変化は確実に捕まえられますが、複数文字が変わった場合は取りこぼしがあります。モジュラス10の検査値は10通りしかなく、本記事でも2桁の変化でチェックディジットが一致する例を挙げています。そのため、チェックディジットは誤読の大半を止める層と考え、最終的な判断はマスタ照合と業務的な突き合わせに委ねてください。手入力・転記・別ルートからの取り込みも同じ検査で守れる点は利点です。
スマホのカメラアプリで読めたのだから、値は正しいのでは?
「読めた」が意味するのは「デコーダが空でない文字列を返した」ことだけで、その内容が正しいかどうかは何も語りません。失敗の返し方も実装ごとに違い、例外・空文字・nullのいずれもあり得るため、例外が出なかったことを成功の判定に使うのも危険です。本記事の実測では、同じ画像に対してOpenCVとjsQRが異なる結果を返す例が複数ありました。Shift_JISで日本語を入れたQRを、一方は文字化けした文字列を成功として返し、もう一方は空文字を返しています。分割QRの1枚目でも結果が割れました。読めたかどうかは、値の正しさについて何も語りません。
分割QR(Structured Append)は業務で使わないほうがよいですか?
特別な理由がなければ避けるのが無難です。分割QRは複数のシンボルに分けたデータを、読み取り側が集めて連結する仕組みですが、対応していないデコーダに1枚目だけを読ませたときの挙動は実装しだいです。本記事の実測では、OpenCVが末尾の欠けた伝票番号をエラーなしで返した一方、jsQRは空文字を返しました。断片をそれらしい値として通してしまう実装がある以上、分割QRを想定しないなら不完全な結果を弾く検証が要ります。データが入り切らないなら、QRのバージョンを上げるか、コードを短くしてマスタ参照に寄せるほうが安全です。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る