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

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文字数誤り訂正型番余白込みモジュール数読めた最小の幅
短い(トップページ)20L23384
短い(トップページ)20M23384
短い(トップページ)20Q23380
短い(トップページ)20H33788
UTM3つ125L649120
UTM3つ125M857140
UTM3つ125Q961148
UTM3つ125H1169None
UTM4つ+ref181L857160
UTM4つ+ref181M1065160
UTM4つ+ref181Q1273172
UTM4つ+ref181H1481200

右端の None は、200ピクセルまで広げても安定しなかった組み合わせです。理由は次の節に書きます。

目を引いたのは、minW をモジュール数で割った値がほぼ一定だったことです。計算できる11件で、1モジュールあたり2.36〜2.81ピクセル、2.4台が6件でした。この見本では、モジュールを2.5ピクセル近くで撮れないと読めなくなります。

固定幅で見ると、差がはっきりします。幅96、120、160ピクセルのときの、20回中の成功数です。

URL誤り訂正幅96幅120幅160
短い(トップページ)L202020
短い(トップページ)M202020
短い(トップページ)Q202020
短い(トップページ)H202020
UTM3つL02020
UTM3つM0020
UTM3つQ0020
UTM3つH0020
UTM4つ+refL0020
UTM4つ+refM0020
UTM4つ+refQ000
UTM4つ+refH000

短い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コード全体が占めるピクセル数

出典

この記事をシェア