OpenCVでQRコードを複数同時検出する方法 [detectAndDecodeMulti]

OPTiM TECH BLOG Advent Calendar 2020 12/15 の記事です。

R&Dの加藤です。最近のマイブームは市場で魚を買う事です。(切り落としやテールですが)マグロも数百円で買えます。
今回は、タイトルにもある通り、OpenCVでQRコードを複数同時検出する方法をコードや動画を交えて紹介します。

前置き

覚えている方も多いかもしれませんが、MLKitの紹介をした時に以下のように説明しました。

OpenCVのQRコード検出のみ現状でマルチ検出に対応していません。
しかし、マルチ検出のプルリクエストが出ているので、気長に待ちましょう。

実はOpenCV 4.3からQRコード*1の複数同時検出に対応ました*2

という事で、OpenCV 4.3以降であれば簡単にQRコードの複数同時検出が可能になりました。
今までは複数のQRコードがある場合にどちらも検出できなかった(またはどちらか一方ランダムに検出)ため、非常に便利になりました。

デモ

QRコードの複数同時検出を動画にしたのでご覧ください

コード

実際に使用したコードは以下の通りです。

#!/usr/bin/env python3
# -*-coding: utf-8 -*-
# pylint: disable=invalid-name
import argparse
from pathlib import Path

import cv2
import numpy as np


def command():
    parser = argparse.ArgumentParser(description='QRコードの複数同時検出デモ')
    parser.add_argument(
        '--video', default=0,
        help='Video入力 [default: 0]'
    )
    parser.add_argument(
        '--wait_time', type=int, default=2,
        help='cv2.waitKey() [default: 2]'
    )
    parser.add_argument(
        '--out_dir', default=Path().cwd() / 'out' / 'video',
        help='frame保存場所 [default: ./out/video]'
    )
    parser.add_argument(
        '--save_frame', action='store_true',
        help='frame保存モード'
    )
    args = parser.parse_args()
    return args


class QRCodeReader(object):

    def __init__(self):
        self.qr = cv2.QRCodeDetector()
        self._img = None

    def __call__(self, img, thresh=80, max_val=255):
        # 二値化して、QRコードの検出
        binary = self._cvt_bgr2binary(img, thresh)
        ret, *data = self.qr.detectAndDecodeMulti(binary)
        # 見やすくするためにnormalizeして、描画用の画像を準備する
        cv2.normalize(binary, binary, 100, max_val, cv2.NORM_MINMAX)
        self._img = cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR)
        self._write_txt(f'thresh: {thresh:3}', (10, 30))

        # QRコードの未検出
        if not ret:
            return self

        # QRコードの描画
        diff = max_val // len(data[0])
        for i, (txt, pts, straight_qrcode) in enumerate(zip(*data)):
            color = self._apply_color_map(diff * i)
            rslt = self._draw_qr(txt, pts, straight_qrcode.shape[0], color)
            print(i, rslt)

        return self

    @property
    def img(self):
        return self._img

    def _cvt_bgr2binary(self, img, thresh, max_val=255, flg=cv2.THRESH_BINARY):
        if thresh < 0:
            thresh = 0
        elif thresh > max_val:
            thresh = max_val

        img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        img = cv2.threshold(img, thresh, max_val, flg)[1]
        return img

    def _apply_color_map(self, val):
        bk = np.zeros((1, 1), dtype=np.uint8)
        bk.fill(val)
        return tuple([
            int(c) for c in cv2.split(cv2.applyColorMap(bk, cv2.COLORMAP_JET))
        ])

    def _write_txt(
        self, txt, pts, color=(0, 0, 0),
        font_face=cv2.FONT_HERSHEY_PLAIN,
        font_scale=2, font_thick=2, line_type=cv2.LINE_8
    ):
        cv2.putText(
            self._img, txt, pts,
            font_face, font_scale, color, font_thick, line_type
        )

    def _draw_qr(
        self, txt, pts, option='', color=(0, 0, 255),
        line_thick=5, line_type=cv2.LINE_8, line_shift=0
    ):
        pts = pts.astype(np.int32)
        cv2.polylines(
            self._img, [pts], True, color,
            thickness=line_thick, lineType=line_type, shift=line_shift
        )

        txt = txt.replace('https://', '')
        out_txt = 'txt not found' if txt == '' else f'{txt[:20]}({option})'
        self._write_txt(out_txt, (pts[0][0], pts[0][1] - 10), color)
        return f'{out_txt},{color}'


def main(args):
    print(args)
    # カメラ設定
    cap = cv2.VideoCapture(args.video)
    w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) * 0.5)
    h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) * 0.5)
    print(
        f'Display size ({w}, {h})\n',
        'Key\n',
        '  [q]: Exit\n',
        '  [a]: Thresh += 5\n',
        '  [z]: Thresh -= 5'
    )

    # 保存ディレクトリの作成
    if not args.out_dir.exists():
        args.out_dir.mkdir(parents=True)

    # カメラ読込み開始
    qr = QRCodeReader()
    cnt = 0
    thresh = 80
    while True:
        # ビデオキャプチャ
        ret, frame = cap.read()
        if not ret:
            print('[Error] camera frame not found')
            break

        # QRコードを検出して、検出結果を描画する
        dst = qr(frame, thresh).img
        # frameに検出結果を連結する
        frame = cv2.resize(np.hstack([frame, dst]), (w * 2, h))
        # 可視化
        cv2.imshow('Camera frame', frame)
        # キーボード入力受付
        key = cv2.waitKey(100 if ret else args.wait_time)
        if key == ord('q'):
            break
        elif key == ord('a'):
            thresh += 5
            print(f'Thresh: {thresh}')
        elif key == ord('z'):
            thresh -= 5
            print(f'Thresh: {thresh}')

        # 保存
        if args.save_frame:
            cnt += 1
            save_path = args.out_dir / f'frame_{cnt:04}.png'
            cv2.imwrite(save_path.as_posix(), frame)

    cap.release()
    return 0


if __name__ == '__main__':
    exit(main(command()))

今までのQRコード検出(とデコード)はdetectAndDecodeでしたが、detectAndDecodeMultiに変えるだけです。

detectAndDecode

retval, points, straight_qrcode = cv.QRCodeDetector.detectAndDecode( img[, points[, straight_qrcode]] )

detectAndDecodeMulti

retval, decoded_info, points, straight_qrcode = cv.QRCodeDetector.detectAndDecodeMulti( img[, points[, straight_qrcode]] )

detectAndDecoderetvaldetectAndDecodeMultidecoded_infoに相当します。retvalは素直にTrue、Falseを返すようになります。

注意点

OpenCVのバグなのか切り分けできていませんが、まれにQRコード検出部で処理が止まることがありました。
放置していれば復帰しますが、使用する際はその点に注意してください。

最後に

OpenCVでQRコード検出をするなら、とりあえずdetectAndDecodeMultiを使いましょう。

OPTiMでは、業種職種問わずチャレンジャーなエンジニアを募集しています。

*1:QRコードはデンソーウェーブの登録商標です

*2:なぜかリリースノートには書かれていませんでした