QRコードにUTMを付けたら、小さく写すと読めなくなった。誤り訂正を上げると、さらに読めなかった

同じページへ飛ぶQRコードを12枚作って、小さな画像で読み取らせました。同じURLなら、読むのに広い幅が要ったのは、いつも誤り訂正が一番上のHでした。強いはずの設定が、いちばん厳しい条件になったのです。
名刺、チラシ、手渡しの小さなカード。どれも隅にQRコードを置いて、購入ページや無料の配布ページへ案内できます。ここに計測用の utm_source などを足すと、URLは20文字から125文字に伸びました。
今回の検査は、すべて合成した見本です。実在の商品や販売実績の話ではありません。検査スクリプトはAIエージェントに書かせ、同じ環境で走らせました。
試した条件
URLは3種類です。トップページだけの短いもの、UTMを3つ付けたもの、UTMを4つと注文番号らしき ref まで付けたものです。注文番号の値は、見本として私が書いた架空の文字列です。
誤り訂正レベルはL、M、Q、Hの4段階です。QRコードは汚れや欠けがあっても読めるように、データとは別に訂正用の情報を持ちます。復元できる割合の目安は、Lが約7%、Mが約15%、Qが約25%、Hが約30%です。
読み取りは、スマホで撮った写真の代わりに画像で模しました。QRコード全体(余白込み)をカメラ画像の中で幅Wピクセルに縮め、ぼかしとノイズを足して、zxing-cppで読みます。Wを200から36まで4ずつ減らし、各幅で20回ずつ試しました。
実機のスマホと実際の印刷は使っていません。ぼかしは標準偏差1.0ピクセル、ノイズは標準偏差6です。この2つの数字は私が決めた仮定で、実測ではありません。
環境を作ります。ディレクトリは一時的なもので構いません。
python3 -m venv qrenv
./qrenv/bin/pip install -q segno zxing-cpp pillow numpy
./qrenv/bin/pip list | grep -i -E "segno|zxing|pillow|numpy"
1行目と2行目は出力がありません。3行目の出力は、次のとおりでした。Pythonは3.13.5です。
numpy 2.5.4
pillow 13.0.0
segno 1.6.6
zxing-cpp 3.1.1
qrtest.py の全文(62行)
import json, random
import numpy as np, segno, zxingcpp
from PIL import Image, ImageFilter
URLS = {
"short": "https://hibanas.net/",
"utm": "https://hibanas.net/entry/ai-cover-image-provenance-check/?utm_source=thankyou_card&utm_medium=print&utm_campaign=autumn_2026",
"long": "https://hibanas.net/entry/ai-cover-image-provenance-check/?utm_source=thankyou_card&utm_medium=print&utm_campaign=autumn_2026&utm_content=checklist_bundle_v1&ref=order_20261030_0001",
}
ECS = ["L","M","Q","H"]
WIDTHS = list(range(200, 35, -4)) # QR全体(余白込み)がカメラ画像で占める幅(px)
SIGMA = 1.0 # ガウスぼかし(px)
NOISE = 6 # ガウスノイズ(0-255)
TRIALS = 20
def matrix_img(qr, border=4):
m = qr.matrix
n = len(m)+2*border
a = np.full((n,n),255,np.uint8)
for y,row in enumerate(m):
for x,v in enumerate(row):
if v: a[y+border,x+border]=0
return Image.fromarray(a)
def trial(img, W, seed):
big = img.resize((img.width*8,img.height*8), Image.NEAREST)
pad = 24
canvas = Image.new("L",(big.width+2*pad, big.height+2*pad),255)
canvas.paste(big,(pad,pad))
frame = int(round(W*canvas.width/big.width))
cam = canvas.resize((frame,frame), Image.BILINEAR).filter(ImageFilter.GaussianBlur(SIGMA))
arr = np.asarray(cam).astype(np.float32)
arr += np.random.default_rng(seed).normal(0,NOISE,arr.shape)
arr = np.clip(arr,0,255).astype(np.uint8)
return zxingcpp.read_barcodes(Image.fromarray(arr), formats=zxingcpp.BarcodeFormat.QRCode)
out = {}
for uname,url in URLS.items():
for ec in ECS:
qr = segno.make(url, error=ec, micro=False, boost_error=False)
img = matrix_img(qr)
mods_total = img.width
rates = {}
for W in WIDTHS:
ok = 0
for t in range(TRIALS):
r = trial(img, W, 1000*W+t)
if r and r[0].text == url: ok += 1
rates[W] = ok
# smallest W such that every tested width >= W reads 20/20
minW = None
for W in sorted(WIDTHS):
if all(rates[w]==TRIALS for w in WIDTHS if w>=W):
minW = W; break
out[f"{uname}-{ec}"] = dict(version=qr.version, modules=qr.symbol_size(border=0)[0],
total=mods_total, bytes=len(url), minW=minW, rates=rates)
json.dump(out, open("result.json","w"), indent=1)
print("url ec ver mods total bytes minW(px) px/module")
for k,v in out.items():
u,e = k.split("-")
ppm = round(v['minW']/v['total'],2) if v['minW'] else None
print(f"{u:<6}{e} {v['version']:>2} {v['modules']:>3} {v['total']:>3} {v['bytes']:>4} {v['minW']} {ppm}")
qrtest.py として保存し、実行します。
./qrenv/bin/python qrtest.py
出力は次の表でした。ver はQRコードの型番、mods は余白を除いた一辺のモジュール数、total は余白(4モジュール×2)を足した数です。minW は、それ以上の幅ならどれも20回中20回読めた最小の幅です。
url ec ver mods total bytes minW(px) px/module
short L 2 25 33 20 84 2.55
short M 2 25 33 20 84 2.55
short Q 2 25 33 20 80 2.42
short H 3 29 37 20 88 2.38
utm L 6 41 49 125 120 2.45
utm M 8 49 57 125 140 2.46
utm Q 9 53 61 125 148 2.43
utm H 11 61 69 125 None None
long L 8 49 57 181 160 2.81
long M 10 57 65 181 160 2.46
long Q 12 65 73 181 172 2.36
long H 14 73 81 181 200 2.47
読めた最小の幅
出力を読みやすく並べ直します。
| URL | 文字数 | 誤り訂正 | 型番 | 余白込みモジュール数 | 読めた最小の幅 |
|---|---|---|---|---|---|
| 短い(トップページ) | 20 | L | 2 | 33 | 84 |
| 短い(トップページ) | 20 | M | 2 | 33 | 84 |
| 短い(トップページ) | 20 | Q | 2 | 33 | 80 |
| 短い(トップページ) | 20 | H | 3 | 37 | 88 |
| UTM3つ | 125 | L | 6 | 49 | 120 |
| UTM3つ | 125 | M | 8 | 57 | 140 |
| UTM3つ | 125 | Q | 9 | 61 | 148 |
| UTM3つ | 125 | H | 11 | 69 | None |
| UTM4つ+ref | 181 | L | 8 | 57 | 160 |
| UTM4つ+ref | 181 | M | 10 | 65 | 160 |
| UTM4つ+ref | 181 | Q | 12 | 73 | 172 |
| UTM4つ+ref | 181 | H | 14 | 81 | 200 |
右端の None は、200ピクセルまで広げても安定しなかった組み合わせです。理由は次の節に書きます。
目を引いたのは、minW をモジュール数で割った値がほぼ一定だったことです。計算できる11件で、1モジュールあたり2.36〜2.81ピクセル、2.4台が6件でした。この見本では、モジュールを2.5ピクセル近くで撮れないと読めなくなります。
固定幅で見ると、差がはっきりします。幅96、120、160ピクセルのときの、20回中の成功数です。
| URL | 誤り訂正 | 幅96 | 幅120 | 幅160 |
|---|---|---|---|---|
| 短い(トップページ) | L | 20 | 20 | 20 |
| 短い(トップページ) | M | 20 | 20 | 20 |
| 短い(トップページ) | Q | 20 | 20 | 20 |
| 短い(トップページ) | H | 20 | 20 | 20 |
| UTM3つ | L | 0 | 20 | 20 |
| UTM3つ | M | 0 | 0 | 20 |
| UTM3つ | Q | 0 | 0 | 20 |
| UTM3つ | H | 0 | 0 | 20 |
| UTM4つ+ref | L | 0 | 0 | 20 |
| UTM4つ+ref | M | 0 | 0 | 20 |
| UTM4つ+ref | Q | 0 | 0 | 0 |
| UTM4つ+ref | H | 0 | 0 | 0 |
短いURLは、どの訂正レベルでも幅96で全部読めました。UTM付きだと、同じ幅96でゼロ回です。
Hにしたら悪化した
誤り訂正を上げれば安全になる。QRコードの説明では、こうよく書かれます。汚れや傷への強さについては、たぶんその通りです。ただ、今回調べたのはそこではありません。小さく撮ったときに読めるかどうかです。
訂正レベルを上げると、同じURLでも型番が上がります。UTM付きURLなら、Lで型番6(41モジュール)、Hで型番11(61モジュール)です。モジュールが増えれば、1つあたりのピクセルが減ります。
UTM付きのHは、200ピクセルでも20回中16回しか読めませんでした。160ピクセルでは20回、156ピクセルでは19回、152ピクセルでは4回と、増えたり減ったりしています。幅を広げれば読めるようになる、とは限りませんでした。
正直に言うと、この揺れの原因は分かっていません。幅が1モジュールの整数倍からずれたときの縮小の癖かもしれませんが、確かめていません。扱うには、ある幅で1回読めただけで安心せず、前後の幅も試す必要があります。
最初のスクリプトを書き直した
最初に走らせた版は、幅を160から52まで6段階で比べました。読み取りの前に、画像を切り取る位置を乱数でずらす処理も入れていました。ところが、ずらしの量に0を掛けてしまっていて、まったく効いていませんでした。
20回の試行の違いは、ノイズの乱数だけです。「UTM付きは小さいと読めない」という大まかな傾向は、この版でも出ました。ただ、位置ずれに強いかどうかは調べられていません。ずらしの処理を消し、条件を正直に書いた上で、幅を細かく刻んで最小幅を探す形にしたのが、上のスクリプトです。
この結果から決めたこと
短い入口URLを作り、計測用のパラメータは転送先で付ける。これが今回の結論です。QRコードのほうは、20文字ほどのURLなら型番2か3、誤り訂正はMのままで済みます。幅84ピクセル前後から読めました。
ただし、この転送の仕組みは今回作っていません。次の作業です。転送を挟むと、入口URLが止まったときに全部のカードが使えなくなります。そこは別に考える必要があります。
もうひとつ、汚れや傷への強さは見ていません。屋外に貼るポスターのように汚れる場所なら、Hを選ぶ理由は残ります。印刷サイズを大きく取れるなら、なおさらです。
用語
| 用語 | この記事での意味 |
|---|---|
| 誤り訂正レベル | QRコードが汚れや欠けから復元できる割合の段階。L、M、Q、Hの4つ |
| 型番 | QRコードの大きさの段階。数字が大きいほどモジュールが多い |
| モジュール | QRコードを作る小さな黒または白の四角 |
| 余白 | コードの周りに空ける白い枠。規格では4モジュール分 |
| UTMパラメータ | URLの末尾に付ける、流入元を測るための utm_source などの目印 |
| 幅 | カメラ画像の中でQRコード全体が占めるピクセル数 |


