プログラミング / 言語 / Python

QRコードの読取箇所を制御する
— 右上・左上・任意領域指定

1. なぜ読取箇所を制御するのか

QR コードが常に同じ位置に印刷されている書類(伝票・帳票・証明書など)では、ページ全体をスキャンするよりも QR コードが存在する領域だけを切り出して読み取るほうが多くのメリットがあります。

領域絞り込みのメリット:
誤検出を防ぐ:ページ内に複数の QR コードや装飾パターンがあっても、目的の QR コードだけを確実に読み取れる
処理速度が向上する:解析対象のピクセル数が減るため、デコード処理が速くなる
小さな QR コードの検出率が上がる:周囲の不要な情報を排除することで、細かいパターンが際立つ

2. 画像座標の基本

Pillow(PIL)の座標系は 左上が原点(0, 0)で、右方向が X 軸正方向、下方向が Y 軸正方向です。

Pillow の座標系
(0, 0) ─────────────────── (width, 0)
  │                              │
  │        画像の領域             │
  │                              │
(0, height) ────────── (width, height)

crop(box) の box 形式: (left, upper, right, lower)
  left  : 切り出し開始 X 座標
  upper : 切り出し開始 Y 座標
  right : 切り出し終了 X 座標
  lower : 切り出し終了 Y 座標

たとえば幅 1000px・高さ 1400px の画像の右上 1/4 を切り出すには:

Python
from PIL import Image

img = Image.open("sample.png")
w, h = img.size   # (1000, 1400)

# 右上 1/4 を切り出す
# left  = w//2 = 500  (水平方向の中央から右)
# upper = 0           (上端から)
# right = w   = 1000  (右端まで)
# lower = h//2 = 700  (垂直方向の中央まで)
top_right = img.crop((w // 2, 0, w, h // 2))
print(f"切り出し後サイズ: {top_right.size}")  # (500, 700)

3. Pillow でトリミング(crop)する基本

Image.crop(box) メソッドは (left, upper, right, lower) の 4 要素タプルを受け取ります。元の画像は変更されず、新しい Image オブジェクトが返されます。

Python
from PIL import Image

img = Image.open("sample.png")
w, h = img.size

# crop(box) の box = (left, upper, right, lower)
# 例:左上 300x300 の領域を切り出す
region = img.crop((0, 0, 300, 300))
print(f"元画像: {img.size}")        # (1000, 1400)
print(f"切り出し後: {region.size}") # (300, 300)

# 確認用に保存
region.save("region_top_left.png")

4. プリセット領域(右上・左上・中央など)

よく使う 9 つの定位置を関数として定義しておくと、帳票処理の実装がシンプルになります。

9 分割グリッドのイメージ

左上
top_left
上中
top_center
右上
top_right
左中
middle_left
中央
center
右中
middle_right
左下
bottom_left
下中
bottom_center
右下
bottom_right

プリセット領域を返すユーティリティ関数

Python
from PIL import Image
from typing import Literal

# 対応するプリセット名の一覧
RegionName = Literal[
    "top_left",    "top_center",    "top_right",
    "middle_left", "center",        "middle_right",
    "bottom_left", "bottom_center", "bottom_right"
]

def get_region(
    img: Image.Image,
    region: RegionName,
    margin_ratio: float = 0.0   # 切り出し後にさらにマージンを取る場合(0〜0.5)
) -> Image.Image:
    """
    画像を 3x3 に 9 分割し、指定した位置の領域を返す

    Args:
        img:          元の Pillow Image
        region:       取得したい領域名
        margin_ratio: 追加マージン比率(0.05 なら全辺から 5% 内側に絞る)
    Returns:
        PIL.Image.Image(切り出した領域)
    """
    w, h = img.size
    # 3 分割のピクセル境界
    x1, x2 = w // 3, (w * 2) // 3
    y1, y2 = h // 3, (h * 2) // 3

    # 各領域の (left, upper, right, lower)
    region_map = {
        "top_left":      (0,  0,  x1, y1),
        "top_center":    (x1, 0,  x2, y1),
        "top_right":     (x2, 0,  w,  y1),
        "middle_left":   (0,  y1, x1, y2),
        "center":        (x1, y1, x2, y2),
        "middle_right":  (x2, y1, w,  y2),
        "bottom_left":   (0,  y2, x1, h),
        "bottom_center": (x1, y2, x2, h),
        "bottom_right":  (x2, y2, w,  h),
    }

    box = region_map[region]

    # マージン処理(内側に縮める)
    if margin_ratio > 0:
        rw = box[2] - box[0]
        rh = box[3] - box[1]
        mx = int(rw * margin_ratio)
        my = int(rh * margin_ratio)
        box = (box[0] + mx, box[1] + my, box[2] - mx, box[3] - my)

    return img.crop(box)


# 使用例
img = Image.open("sample.png")

# 右上の領域を切り出す
top_right = get_region(img, "top_right")
print(f"右上領域サイズ: {top_right.size}")

# 左上の領域を切り出す
top_left = get_region(img, "top_left")
print(f"左上領域サイズ: {top_left.size}")

5. 比率指定で領域を切り出す

帳票によっては「右端 20%、上端 15% の範囲」のように比率で QR コードの位置が決まっている場合があります。画像サイズに依存しない汎用的な切り出しが可能です。

Python
from PIL import Image

def crop_by_ratio(
    img: Image.Image,
    left_ratio: float,
    upper_ratio: float,
    right_ratio: float,
    lower_ratio: float
) -> Image.Image:
    """
    画像を比率で切り出す

    Args:
        img:          元の Pillow Image
        left_ratio:   左端の位置(0.0〜1.0)
        upper_ratio:  上端の位置(0.0〜1.0)
        right_ratio:  右端の位置(0.0〜1.0)
        lower_ratio:  下端の位置(0.0〜1.0)

    例: 右端 25%、上端 20% の範囲 →
        crop_by_ratio(img, 0.75, 0.0, 1.0, 0.2)
    """
    w, h = img.size
    box = (
        int(w * left_ratio),
        int(h * upper_ratio),
        int(w * right_ratio),
        int(h * lower_ratio),
    )
    return img.crop(box)


# 使用例
img = Image.open("sample.png")

# 右端 25%、上端 20% の範囲(右上コーナーに QR コードがある帳票)
qr_area = crop_by_ratio(img, 0.75, 0.0, 1.0, 0.2)
print(f"切り出し領域サイズ: {qr_area.size}")

# 左端 25%、上端 15% の範囲(左上コーナーに QR コードがある帳票)
qr_area_left = crop_by_ratio(img, 0.0, 0.0, 0.25, 0.15)
print(f"左上領域サイズ: {qr_area_left.size}")
比率のチューニング方法: QR コードの位置を特定するには、まず全体 scan(PART 10 参照)を実行して obj.rect の値を確認し、その座標値を画像サイズで割れば比率が得られます。

6. ピクセル指定で領域を切り出す

ピクセル単位で厳密に位置が決まっているケース(高解像度スキャナーで常に同じ DPI でスキャンした書類など)では、絶対座標指定が最も確実です。

Python
from PIL import Image

def crop_by_pixel(
    img: Image.Image,
    left: int,
    upper: int,
    right: int,
    lower: int,
    padding: int = 10   # 余白を追加してQRコードの端が切れるのを防ぐ
) -> Image.Image:
    """
    絶対ピクセル座標で領域を切り出す(余白付き)

    Args:
        left / upper / right / lower: ピクセル座標
        padding: 切り出し範囲を周囲に広げるピクセル数
    """
    w, h = img.size
    box = (
        max(0,     left  - padding),
        max(0,     upper - padding),
        min(w,     right + padding),
        min(h,     lower + padding),
    )
    return img.crop(box)


# 使用例
# DPI=200 の A4 画像(1654 x 2339 px)で右上に QR コードが
# (1400, 60) から (1600, 260) の位置にある場合
img = Image.open("sample.png")
qr_area = crop_by_pixel(img, left=1400, upper=60, right=1600, lower=260, padding=20)
print(f"切り出し後サイズ: {qr_area.size}")
DPI と座標の関係: DPI が変わると画像サイズが変わり、絶対座標も変わります。PDF を dpi=200 で変換した場合と dpi=300 で変換した場合では座標が 1.5 倍異なります。絶対座標を使う場合は必ず DPI を固定してください。比率指定 crop_by_ratio なら DPI の影響を受けません。

7. 切り出した領域から QR コードを読み取る

Pillow でトリミングした領域を、そのまま pyzbar や OpenCV に渡せます。

pyzbar で読み取る

Python
from PIL import Image
from pyzbar.pyzbar import decode


def read_qr_from_region(
    img: Image.Image,
    left_ratio: float = 0.75,
    upper_ratio: float = 0.0,
    right_ratio: float = 1.0,
    lower_ratio: float = 0.2
) -> list[str]:
    """
    指定した比率領域内の QR コードの値をリストで返す(pyzbar 使用)

    Returns:
        list[str] — 見つかった QR コードの値
    """
    w, h = img.size
    box = (
        int(w * left_ratio),
        int(h * upper_ratio),
        int(w * right_ratio),
        int(h * lower_ratio),
    )
    cropped = img.crop(box)

    results = []
    for obj in decode(cropped):
        if obj.type == "QRCODE":
            try:
                results.append(obj.data.decode("utf-8"))
            except UnicodeDecodeError:
                results.append(obj.data.decode("shift_jis", errors="replace"))
    return results


# 使用例
img = Image.open("sample.png")

# 右上 25%×20% の領域
values = read_qr_from_region(img, 0.75, 0.0, 1.0, 0.2)
print(f"右上領域の QR コード: {values}")

# 左上 25%×20% の領域
values_left = read_qr_from_region(img, 0.0, 0.0, 0.25, 0.2)
print(f"左上領域の QR コード: {values_left}")

OpenCV で読み取る

Python
import cv2
import numpy as np
from PIL import Image


def read_qr_opencv_region(
    img: Image.Image,
    left_ratio: float = 0.75,
    upper_ratio: float = 0.0,
    right_ratio: float = 1.0,
    lower_ratio: float = 0.2
) -> list[str]:
    """
    指定した比率領域内の QR コードの値をリストで返す(OpenCV 使用)
    """
    w, h = img.size
    box = (
        int(w * left_ratio),
        int(h * upper_ratio),
        int(w * right_ratio),
        int(h * lower_ratio),
    )
    cropped = img.crop(box)

    # Pillow → NumPy → BGR に変換
    img_np = np.array(cropped.convert("RGB"))
    img_cv = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)

    detector = cv2.QRCodeDetector()
    retval, decoded_info, points, _ = detector.detectAndDecodeMulti(img_cv)

    if not retval:
        return []
    return [v for v in decoded_info if v]


# 使用例
img = Image.open("sample.png")
values = read_qr_opencv_region(img, 0.75, 0.0, 1.0, 0.2)
print(f"右上領域の QR コード: {values}")

8. PDF ページの特定領域から QR コードを読み取る

PART 10 で学んだ PDF→画像変換と、本記事の領域絞り込みを組み合わせます。

Python
import io
import fitz
from PIL import Image
from pyzbar.pyzbar import decode


def read_qr_from_pdf_region(
    pdf_path: str,
    page_number: int = 0,
    dpi: int = 200,
    left_ratio: float = 0.75,
    upper_ratio: float = 0.0,
    right_ratio: float = 1.0,
    lower_ratio: float = 0.2
) -> list[str]:
    """
    PDF の指定ページの指定領域から QR コードの値を読み取る

    Args:
        pdf_path:    PDF ファイルパス
        page_number: ページ番号(0 始まり)
        dpi:         変換解像度
        left_ratio〜lower_ratio: 読取領域(比率)

    Returns:
        list[str] — 検出された QR コードの値リスト
    """
    # 1. PDF ページ → Pillow Image
    doc = fitz.open(pdf_path)
    page = doc[page_number]
    zoom = dpi / 72
    pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
    img = Image.open(io.BytesIO(pix.tobytes("png")))
    doc.close()

    # 2. 領域を切り出す
    w, h = img.size
    box = (
        int(w * left_ratio),
        int(h * upper_ratio),
        int(w * right_ratio),
        int(h * lower_ratio),
    )
    cropped = img.crop(box)

    # 3. QR コードを読み取る
    results = []
    for obj in decode(cropped):
        if obj.type == "QRCODE":
            try:
                results.append(obj.data.decode("utf-8"))
            except UnicodeDecodeError:
                results.append(obj.data.decode("shift_jis", errors="replace"))

    return results


# ── 使用例 ──────────────────────────────────────────────────────────
# 1 ページ目(index=0)の右上 25%×20% から QR コードを読む
values = read_qr_from_pdf_region(
    pdf_path="invoice.pdf",
    page_number=0,
    dpi=200,
    left_ratio=0.75, upper_ratio=0.0,
    right_ratio=1.0, lower_ratio=0.2
)
print(f"右上の QR コード: {values}")

# 左上 25%×15% から読む
values_tl = read_qr_from_pdf_region(
    pdf_path="invoice.pdf",
    page_number=0,
    dpi=200,
    left_ratio=0.0, upper_ratio=0.0,
    right_ratio=0.25, lower_ratio=0.15
)
print(f"左上の QR コード: {values_tl}")
出力例
右上の QR コード: ['https://example.com/invoice/12345'] 左上の QR コード: []

9. 複数領域を並列スキャンする

ページによって QR コードの位置が異なる場合(右上または左上のどちらかにある、など)、複数の候補領域を一度にスキャンするアプローチが便利です。

Python
from PIL import Image
from pyzbar.pyzbar import decode


# 候補領域の定義(名前: (left_ratio, upper_ratio, right_ratio, lower_ratio))
CANDIDATE_REGIONS = {
    "top_right":  (0.75, 0.00, 1.00, 0.20),
    "top_left":   (0.00, 0.00, 0.25, 0.20),
    "top_center": (0.35, 0.00, 0.65, 0.20),
    "bottom_right": (0.75, 0.80, 1.00, 1.00),
}


def scan_multiple_regions(
    img: Image.Image,
    regions: dict = None,
    stop_on_first_hit: bool = True
) -> dict:
    """
    複数の候補領域をスキャンして QR コードを探す

    Args:
        img:               スキャン対象の Pillow Image
        regions:           候補領域の辞書(省略時は CANDIDATE_REGIONS を使用)
        stop_on_first_hit: True の場合、最初に QR コードが見つかった時点で終了

    Returns:
        {
            'found_region': str または None,
            'values': list[str],
            'all_hits': {region_name: [value, ...], ...}
        }
    """
    if regions is None:
        regions = CANDIDATE_REGIONS

    w, h = img.size
    all_hits = {}
    found_region = None
    first_values = []

    for name, (lr, ur, rr, lo) in regions.items():
        box = (int(w * lr), int(h * ur), int(w * rr), int(h * lo))
        cropped = img.crop(box)

        values = []
        for obj in decode(cropped):
            if obj.type == "QRCODE":
                try:
                    values.append(obj.data.decode("utf-8"))
                except UnicodeDecodeError:
                    values.append(obj.data.decode("shift_jis", errors="replace"))

        if values:
            all_hits[name] = values
            if found_region is None:
                found_region = name
                first_values = values
            if stop_on_first_hit:
                break

    return {
        'found_region': found_region,
        'values':       first_values,
        'all_hits':     all_hits
    }


# 使用例
img = Image.open("sample.png")
result = scan_multiple_regions(img, stop_on_first_hit=True)

if result['found_region']:
    print(f"検出領域: {result['found_region']}")
    print(f"QR コード: {result['values']}")
else:
    print("QR コードが見つかりませんでした")

10. 実践スクリプト — 全体まとめ

PDF の全ページを処理し、各ページの指定領域から QR コードを読み取って CSV に出力する実践スクリプトです。

Python — qr_region_scanner.py
"""
qr_region_scanner.py
PDF の各ページの指定領域から QR コードを読み取り CSV に出力するスクリプト

使い方:
    python qr_region_scanner.py  [--region top_right|top_left|...] [--dpi 200]

例:
    python qr_region_scanner.py invoice.pdf --region top_right --dpi 200
"""

import io
import csv
import sys
import argparse
import fitz
from PIL import Image
from pyzbar.pyzbar import decode


# ── 定義済み領域プリセット ─────────────────────────────────────────────
PRESET_REGIONS = {
    "top_left":      (0.00, 0.00, 0.25, 0.20),
    "top_center":    (0.35, 0.00, 0.65, 0.20),
    "top_right":     (0.75, 0.00, 1.00, 0.20),
    "middle_left":   (0.00, 0.40, 0.25, 0.60),
    "center":        (0.35, 0.40, 0.65, 0.60),
    "middle_right":  (0.75, 0.40, 1.00, 0.60),
    "bottom_left":   (0.00, 0.80, 0.25, 1.00),
    "bottom_center": (0.35, 0.80, 0.65, 1.00),
    "bottom_right":  (0.75, 0.80, 1.00, 1.00),
    "full":          (0.00, 0.00, 1.00, 1.00),  # ページ全体
}


# ── PDF ページ → Pillow Image ────────────────────────────────────────
def page_to_image(doc: fitz.Document, page_num: int, dpi: int) -> Image.Image:
    page = doc[page_num]
    zoom = dpi / 72
    pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
    return Image.open(io.BytesIO(pix.tobytes("png")))


# ── 領域から QR コードを読み取る ─────────────────────────────────────
def read_qr(img: Image.Image, box_ratios: tuple) -> list[str]:
    w, h = img.size
    lr, ur, rr, lo = box_ratios
    cropped = img.crop((int(w * lr), int(h * ur), int(w * rr), int(h * lo)))
    results = []
    for obj in decode(cropped):
        if obj.type != "QRCODE":
            continue
        try:
            results.append(obj.data.decode("utf-8"))
        except UnicodeDecodeError:
            results.append(obj.data.decode("shift_jis", errors="replace"))
    return results


# ── メイン処理 ────────────────────────────────────────────────────────
def main():
    parser = argparse.ArgumentParser(description="PDF の特定領域から QR コードを読み取る")
    parser.add_argument("pdf_path",  help="対象 PDF ファイルのパス")
    parser.add_argument("--region",  default="top_right",
                        choices=list(PRESET_REGIONS.keys()),
                        help="QR コードを読み取る領域(デフォルト: top_right)")
    parser.add_argument("--dpi",     type=int, default=200,
                        help="PDF→画像変換の解像度(デフォルト: 200)")
    parser.add_argument("--output",  default="qr_results.csv",
                        help="出力 CSV ファイル名(デフォルト: qr_results.csv)")
    args = parser.parse_args()

    box = PRESET_REGIONS[args.region]
    doc = fitz.open(args.pdf_path)
    total = len(doc)
    rows = []

    print(f"PDF: {args.pdf_path}  ページ数: {total}  領域: {args.region}  DPI: {args.dpi}")

    for page_num in range(total):
        img = page_to_image(doc, page_num, args.dpi)
        values = read_qr(img, box)

        if values:
            for v in values:
                rows.append({'page': page_num + 1, 'region': args.region, 'value': v})
            print(f"  p.{page_num+1:>3}  {len(values)} 件: {values}")
        else:
            print(f"  p.{page_num+1:>3}  QR コードなし")

    doc.close()

    # CSV 出力
    with open(args.output, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=["page", "region", "value"])
        writer.writeheader()
        writer.writerows(rows)

    print(f"\n合計 {len(rows)} 件を {args.output} に書き出しました")


if __name__ == "__main__":
    main()

実行例

bash
# 右上領域から読み取る(デフォルト)
python qr_region_scanner.py invoice.pdf

# 左上領域から読み取る
python qr_region_scanner.py invoice.pdf --region top_left

# 全体をスキャンする
python qr_region_scanner.py invoice.pdf --region full --dpi 300

# 解像度と出力ファイル名を指定
python qr_region_scanner.py invoice.pdf --region top_right --dpi 300 --output result.csv
実行例の出力
PDF: invoice.pdf ページ数: 5 領域: top_right DPI: 200 p. 1 1 件: ['https://example.com/invoice/001'] p. 2 1 件: ['https://example.com/invoice/002'] p. 3 QR コードなし p. 4 1 件: ['https://example.com/invoice/004'] p. 5 QR コードなし 合計 3 件を qr_results.csv に書き出しました
QR コードが読み取れないときのチェックリスト:
□ DPI を上げる(dpi=300 または dpi=400 を試す)
□ 領域を少し広めに取る(margin を追加する)
□ pyzbar と OpenCV 両方を試す
□ 切り出した領域を cropped.save("debug.png") で保存して目視確認する
□ QR コードが画像として埋め込まれているか確認(PDF 内でベクターの場合は別途処理が必要)
□ QR コードが傾いていたり小さすぎないか確認