【Swift】MLKitを使用してQRコードを読み取ってみた

2022.06.05

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

DevelopersIOの自分の著者ページURLをQRコードにして、それを読み取った後にページを表示させたくなったのでQRコード読み取りについて調べてみました

作ったもの

環境

  • Xcode 13.2.1

処理の流れ

ざっくりの説明になりますが、

  1. カメラでビデオ撮影
  2. ビデオ撮影したものからフレームをキャプチャし、イメージを切り出す
  3. 切り出したイメージ内にQRコードがある場合は、そのQRコードからURLを取得する
  4. URLが取得出来たら、SafariViewControllerで当該ページを表示する

といった流れになっています。

今回はQRコード読み取りにMLKitを使用するのでインストールします。

MLKitのインストール

バーコードスキャンAPIは元々Firebaseの一部でありましたが、現在はFirebaseの有無に関わらず使用できるMLKit SDKに移行されております。 なので、今回はMLKit SDKを使用していきたいと思います。

こちらを見ると、まだSPMの対応がされてなさそうだったので今回はCocoaPodsを使用します。

下記をPod Fileに追記し、pod installを行います。

pod 'GoogleMLKit/BarcodeScanning', '2.6.0'

pod install完了後に.xcworkspaceが出来るのでそちらを開いて作業をしていきます。

Camera Usage Descriptionを設定

カメラを使用する為に、Info.plistにPrivacy - Camera Usage Descriptionを追加します。

カメラからの出力を取得する

まずはカメラからの出力をキャプチャする為のクラスVideoCaptureを作成します。

import AVFoundation

protocol VideoCaptureDelegate: AnyObject {
    func videoCapture(_ videoCapture: VideoCapture, didSet previewLayer: AVCaptureVideoPreviewLayer)
    func videoCapture(_ videoCapture: VideoCapture, didOutput sampleBuffer: CMSampleBuffer)
}

class VideoCapture: NSObject {

    weak var delegate: VideoCaptureDelegate?

    // AVの出入力のキャプチャを管理するセッションオブジェクト
    private let captureSession = AVCaptureSession()

    // ビデオを記録し、処理を行うためにビデオフレームへのアクセスを提供するoutput
    private let videoOutput = AVCaptureVideoDataOutput()

    // カメラセットアップとフレームキャプチャを処理する為のDispathQueue
    private let sessionQueue = DispatchQueue(label: "video-capture-queue")

    // キャプチャの開始
    func startCapturing() {

        // capture deviceのメディアタイプを決め、
        // 何からインプットするかを決める
        guard let captureDevice = AVCaptureDevice.default(for: .video),
              let deviceInput = try? AVCaptureDeviceInput(device: captureDevice)
        else { return }

        // captureSessionにdeviceInputを入力値として入れる
        captureSession.addInput(deviceInput)

        // キャプチャセッションの開始
        captureSession.startRunning()

        // ビデオフレームの更新ごとに呼ばれるデリゲートをセット
        videoOutput.setSampleBufferDelegate(self, queue: sessionQueue)

        // captureSessionから出力を取得するためにdataOutputをセット
        captureSession.addOutput(videoOutput)

        // captureSessionをUIに描画するためにPreviewLayerにsessionを追加
        let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        delegate?.videoCapture(self, didSet: previewLayer)
    }

    // キャプチャの再開
    func restartCapturingIfNeeded() {
        if !captureSession.isRunning {
            captureSession.startRunning()
        }
    }

    // キャプチャの停止
    func stopCapturing() {
        // キャプチャセッションの停止
        captureSession.stopRunning()
    }
}

extension VideoCapture: AVCaptureVideoDataOutputSampleBufferDelegate {

    func captureOutput(_ output: AVCaptureOutput,
                       didOutput sampleBuffer: CMSampleBuffer,
                       from connection: AVCaptureConnection) {

        delegate?.videoCapture(self, didOutput: sampleBuffer)
    }
}

以下に詳細を説明します。

プロパティ

Delegate

weak var delegate: VideoCaptureDelegate?

AVCaptureSession

// AVの出入力のキャプチャを管理するセッションオブジェクト
private let captureSession = AVCaptureSession()

AVCaptureVideoDataOutput()

// ビデオを記録し、処理を行うためにビデオフレームへのアクセスを提供するoutput
private let videoOutput = AVCaptureVideoDataOutput()

SessionQueue

// カメラセットアップとフレームキャプチャを処理する為のDispathQueue
private let sessionQueue = DispatchQueue(label: "video-capture-queue")

startCapturing()

キャプチャの開始にはstartCapturing()メソッドを使用します。

func startCapturing() {

    // capture deviceのメディアタイプを決め、
    // 何からインプットするかを決める
    guard let captureDevice = AVCaptureDevice.default(for: .video),
          let deviceInput = try? AVCaptureDeviceInput(device: captureDevice)
    else { return }

    // captureSessionにdeviceInputを入力値として入れる
    captureSession.addInput(deviceInput)

    // キャプチャセッションの開始
    captureSession.startRunning()

    // ビデオフレームの更新ごとに呼ばれるデリゲートをセット
    videoOutput.setSampleBufferDelegate(self, queue: sessionQueue)

    // captureSessionから出力を取得するためにdataOutputをセット
    captureSession.addOutput(videoOutput)

    // captureSessionをUIに描画するためにPreviewLayerにsessionを追加
    let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
    delegate?.videoCapture(self, didSet: previewLayer)
}

まず最初に、captureDeviceとして、AVCaptureDevice.default(for: .video)を設定しています。

AVCaptureDevice.default(for:)では様々なAVMediaTypeを指定できますが、今回はvideoからキャプチャしたので.videoにしています。

captrureDeviceからcaptureSessionへ取得データを提供する為に、AVCaptureDeviceInput(device: captureDevice)を準備しておきます。

addInput(_ input)

準備したdeviceInputcaptureSessionに追加します。

startRunning()

インプットデータも決まったので、captureSession.startRunning()captureSessionの実行をスタートさせます。

setSampleBufferDelegate(_:queue:)

カメラのビデオをキャプチャし、そのフレームが更新される度に画像の分類やViewへの描画を行いたいので、setSampleBufferDelegate(_:queue:)をセットしてデリゲートメソッドを使えるようにしておきます。

addOutput(_ output)

captureSession.addOutput(_ output)captureSessionの出力にvideoOutputを追加します

AVCaptureVideoPreviewLayer

AVCaptureVideoPreviewLayerは、キャプチャされたビデオを表示するレイヤーで、今回はパラメーターにカメラからのキャプチャSessionとしてcaptureSessionを渡し、プレビューレイヤーをイニシャライズしています。

let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)

previewLayerを定義したら、そのpreviewLayerVideoCaptureDelegateメソッドのvideoCapture(_ videoCapture: VideoCapture, didSet previewLayer: AVCaptureVideoPreviewLayer)に渡しています。

キャプチャの再開

キャプチャを停止後に、再開する時に使用します。

func restartCapturingIfNeeded() {
    if !captureSession.isRunning {
        captureSession.startRunning()
    }
}

キャプチャの停止

stopRunnningcaptureSessionを停止します。

func stopCapturing() {
    // キャプチャセッションの停止
    captureSession.stopRunning()
}

AVCaptureVideoDataOutputSampleBufferDelegate

ビデオデータ出力からサンプルバッファを受信し、そのステータスを監視できるメソッドで、その中のcaptureOutput(_ output: , didOutput sampleBuffer: , from connection:)で新しいビデオフレームが書き込まれたことを検知することができます。

extension VideoCapture: AVCaptureVideoDataOutputSampleBufferDelegate {

    func captureOutput(_ output: AVCaptureOutput,
                       didOutput sampleBuffer: CMSampleBuffer,
                       from connection: AVCaptureConnection) {

        delegate?.videoCapture(self, didOutput: sampleBuffer)
    }
}

このパラメーターCMSampleBufferには、ビデオフレームのデータやフレームに関する情報が含まれています。

そして、sampleBufferVideoCaptureDelegateメソッドのvideoCapture(_ videoCapture: VideoCapture, didOutput sampleBuffer: CMSampleBuffer)に渡しています。

カメラ部分は完成したので、次はQRコードリーダー部分を作成します。

QRコードの読み取り

QRコードを読み取り、URLに変換するクラスQRCodeReaderを作成します。

import MLKit
import AVFoundation

protocol QRCodeReaderDelegate: AnyObject {
    func qrCodeReader(_ qrCodeReader: QRCodeReader, didScan url: URL)
}

class QRCodeReader: NSObject {

    weak var delegate: QRCodeReaderDelegate?

    // バーコードスキャナー
    private let barcodeScanner: BarcodeScanner = {
        let barcodeOptions = BarcodeScannerOptions(formats: .qrCode)
        let barcodeScanner = BarcodeScanner.barcodeScanner(options: barcodeOptions)
        return barcodeScanner
    }()

    // サンプルバッファをスキャンする
    func scan(sampleBuffer: CMSampleBuffer) {
        // サンプルバッファをVisionImageに変換
        let image = VisionImage(buffer: sampleBuffer)
        // 画像の向きを指定
        image.orientation = imageOrientation(
            deviceOrientation: UIDevice.current.orientation,
            cameraPosition: .back)

        // バーコードスキャンの処理を開始
        barcodeScanner.process(image) { [weak self] barcodes, error in
            guard let self = self,
                  error == nil,
                  let barcodes = barcodes,
                  !barcodes.isEmpty else { return }

            var scannedUrl: URL?
            // バーコードの中にURL情報がある場合は取得する
            for barcode in barcodes {
                guard let urlString = barcode.url?.url else { continue }

                scannedUrl = URL(string: urlString)
                break
            }

            guard let url = scannedUrl else { return }

            // URLが取得出来たのでDelegateメソッドにURLを渡す
            self.delegate?.qrCodeReader(self, didScan: url)
        }
    }

    // 画像データの向きを指定する
    private func imageOrientation(deviceOrientation: UIDeviceOrientation,
                                  cameraPosition: AVCaptureDevice.Position) -> UIImage.Orientation {
        switch deviceOrientation {
        case .portrait:
            return cameraPosition == .front ? .leftMirrored : .right
        case .landscapeLeft:
            return cameraPosition == .front ? .downMirrored : .up
        case .portraitUpsideDown:
            return cameraPosition == .front ? .rightMirrored : .left
        case .landscapeRight:
            return cameraPosition == .front ? .upMirrored : .down
        case .faceDown, .faceUp, .unknown:
            return .up
        @unknown default:
            fatalError()
        }
    }
}

プロパティ

Delegate

weak var delegate: QRCodeReaderDelegate?

バーコードスキャナー

private let barcodeScanner: BarcodeScanner = {
    let barcodeOptions = BarcodeScannerOptions(formats: .qrCode)
    let barcodeScanner = BarcodeScanner.barcodeScanner(options: barcodeOptions)
    return barcodeScanner
}()

今回はQRコードリーダーとして使用するので、バーコードスキャナーのオプションは、.qrCodeを指定していますが、他にも下記のフォーマットをサポートしています。

  • code128
  • code39
  • code93
  • codaBar
  • dataMatrix
  • EAN13
  • EAN8
  • ITF
  • qrCode
  • UPCA
  • UPCE
  • PDF417
  • aztec

scan(sampleBuffer: CMSampleBuffer)

このscan(sampleBuffer:)VideoCaptureが出力したsampleBufferからバーコード情報を取得します。

スキャン実装部分は、Google公式ドキュメントiOSでMLキットを使用してバーコードをスキャンするに丁寧に書いてありました。

func scan(sampleBuffer: CMSampleBuffer) {
    // サンプルバッファをVisionImageに変換
    let image = VisionImage(buffer: sampleBuffer)
    // 画像の向きを指定
    image.orientation = imageOrientation(
        deviceOrientation: UIDevice.current.orientation,
        cameraPosition: .back)

    // バーコードスキャンの処理を開始
    barcodeScanner.process(image) { [weak self] barcodes, error in
        guard let self = self,
              error == nil,
              let barcodes = barcodes,
              !barcodes.isEmpty else { return }

        var scannedUrl: URL?
        // バーコードの中からURL情報がある場合は取得する
        for barcode in barcodes {
            guard let urlString = barcode.url?.url else { continue }

            scannedUrl = URL(string: urlString)
            break
        }

        guard let url = scannedUrl else { return }

        // URLが取得出来たのでDelegateメソッドにURLを渡す
        self.delegate?.qrCodeReader(self, didScan: url)
    }
}

まずは、サンプルバッファをVisionImageに変更して、画像の向きをimageOrientation(deviceOrientation:,cameraPosition:)で指定しています。

barcodeScanner.process(_ image:, completion:)

barcodeScanner.process(_ image:, completion:)に画像を渡して、その中にあるバーコードを情報を取得します。

エラーがない、またbarcodesnilでない場合は、バーコードの中からURL取得を行ってます。今回は、URLしか取得していないですが、下記の例のようにbarcode.valueTypeをみて、タイプによって処理を変更することも可能です。

// 例
let valueType = barcode.valueType
switch valueType {
case .wiFi:
    let ssid = barcode.wifi?.ssid
    let password = barcode.wifi?.password
    let encryptionType = barcode.wifi?.type
case .URL:
    let title = barcode.url!.title
    let url = barcode.url!.url
case .text
    let text = barcode.displayValue
default:
    print("process something")
}

バーコードからURLを取得出来たら、QRCodeReaderDelegateqrCodeReader(_ qrCodeReader: QRCodeReader, didScan url: URL)に渡しています。

VideoCaptureとQRコード読み取りを組み合わせる

作成したクラスを使用して、キャプチャ出力からQRコード内のURLを取得し、その当該ページに遷移させます。

import UIKit
import AVFoundation
import SafariServices

class QRReadableCameraViewController: UIViewController {

    @IBOutlet private weak var previewView: UIView!
    private let videoCapture = VideoCapture()
    private let qrCodeReader = QRCodeReader()

    override func viewDidLoad() {
        super.viewDidLoad()

        qrCodeReader.delegate = self
        videoCapture.delegate = self
        videoCapture.startCapturing()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        videoCapture.restartCapturingIfNeeded()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        videoCapture.stopCapturing()
    }
}

extension QRReadableCameraViewController: VideoCaptureDelegate {

    // previewLayerがセットされた時に呼ばれる
    func videoCapture(_ videoCapture: VideoCapture, didSet previewLayer: AVCaptureVideoPreviewLayer) {
        previewView.layer.addSublayer(previewLayer)
        previewLayer.frame = previewView.frame
    }

    // サンプルバッファーが出力される度に呼ばれる
    func videoCapture(_ videoCapture: VideoCapture, didOutput sampleBuffer: CMSampleBuffer) {
        qrCodeReader.scan(sampleBuffer: sampleBuffer)
    }
}

extension QRReadableCameraViewController: QRCodeReaderDelegate {

    // QRからURLがスキャンされた時に呼ばれる
    func qrCodeReader(_ qrCodeReader: QRCodeReader, didScan url: URL) {
        let safariViewController = SFSafariViewController(url: url)
        present(safariViewController, animated: true)
    }
}

プロパティ

@IBOutlet private weak var previewView: UIView!
private let videoCapture = VideoCapture()
private let qrCodeReader = QRCodeReader()

カメラの出力を画面に反映する為のpreviewView: UIViewIBOutletで用意しておきます。また、用意した2つのクラスもインスタンス化します。

viewDidLoad

override func viewDidLoad() {
    super.viewDidLoad()

    qrCodeReader.delegate = self
    videoCapture.delegate = self
    videoCapture.startCapturing()
}

読み込み時には、各Delegateにselfを渡して、ビデオのキャプチャを開始しています。

viewWillAppear

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    videoCapture.restartCapturingIfNeeded()
}

遷移後の画面から戻ってきた際にキャプチャを再開できるようにrestartCapturingIfNeeded()を記述しています。

viewWillDisappear

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    videoCapture.stopCapturing()
}

画面が見えなくなる際は、キャプチャを停止しています。

VideoCaptureDelegate

didSet

func videoCapture(_ videoCapture: VideoCapture, didSet previewLayer: AVCaptureVideoPreviewLayer) {
    previewView.layer.addSublayer(previewLayer)
    previewLayer.frame = previewView.frame
}

previewLayerがセットされたら、画面反映用のpreviewViewのレイヤーとして追加し、frameprevieView.frameと合わせています。

didOutput

func videoCapture(_ videoCapture: VideoCapture, didOutput sampleBuffer: CMSampleBuffer) {
    qrCodeReader.scan(sampleBuffer: sampleBuffer)
}

VideoCaptureから出力としてサンプルバッファを受け取ったら、そのサンプルバッファをスキャンします。

QRCodeReaderDelegate

didScan

func qrCodeReader(_ qrCodeReader: QRCodeReader, didScan url: URL) {
    let safariViewController = SFSafariViewController(url: url)
    present(safariViewController, animated: true)
}

QRコードのスキャンでURLが取得したら、今回はそのURLを使ってSafariViewControllerを表示させています。

今回のQRコード

今回は、このURLhttps://dev.classmethod.jp/author/littleossa/をQRコードとして作成したので、クラスメソッドの私の著者ページが出ると完成です!

おわりに

MLKitを使用すると、本当に簡単にQRコードを読み取るアプリを作ることが出来ました。今回はQRコードでしたが、他にも認識できるものは沢山あるので他のものでも試したくなりました!SwiftUIでも試してみたいですね。

モバイルアプリ開発のチームメンバー絶賛募集中!

モバイル事業部では事業会社様と一緒に、数年間にわたり長期でモバイルアプリをグロースさせています。

そんなモバイルアプリ開発のチームメンバーを絶賛募集中です!

もちろんモバイルアプリ開発以外のエンジニアも募集中です!

クラスメソッド採用サイト

参考