LoginSignup
10
7

Vision.frameworkでクレジットカード番号を読取る(Swift)

Last updated at Posted at 2022-12-14

Xcode-14.1 iOS-16.0

はじめに

クレジットカード番号の読み取りにcard-io/card.io-iOS-SDKというライブラリを使っていたのですが 2016 年くらいにアーカイブされています。iPhone 12 Pro Max だとピントが合わないという事象もあるみたいです(これと同じ?)。

Vision.framework を使うと文字認識ができるみたいなので自作に挑戦してみようと思います:muscle:

こんな感じです(手書きなのでちょっと精度下がりました:see_no_evil:)。

demo

ソース全体

レイアウトはこんな感じです。previewViewsuperview の上下左右に制約つけてます(Safe Area ではなく)。青が maskView でその中の白が numberRegionView です。

layout
ソース全体
import UIKit
import AVFoundation
import Vision

final class ViewController: UIViewController {

    @IBOutlet private weak var previewView: UIView!
    @IBOutlet private weak var numberRegionView: UIView!
    @IBOutlet private weak var maskView: UIView!
    @IBOutlet private weak var resultLabel: UILabel!
    private let session = AVCaptureSession()
    private var regionOfInterest: CGRect = .zero

    override func viewDidLoad() {
        super.viewDidLoad()
        guard let device = AVCaptureDevice.default(for: .video),
              let input = try? AVCaptureDeviceInput(device: device) else {
            return
        }
        if session.canAddInput(input) {
            session.addInput(input)
        }
        let queue = DispatchQueue(label: "buffer queue")
        let output = AVCaptureVideoDataOutput()
        output.setSampleBufferDelegate(self, queue: queue)
        if session.canAddOutput(output) {
            session.addOutput(output)
        }
        let previewLayer = AVCaptureVideoPreviewLayer(session: session)
        previewLayer.videoGravity = .resizeAspectFill
        previewLayer.frame = UIScreen.main.bounds
        previewView.layer.addSublayer(previewLayer)

        maskView.backgroundColor = .clear
        maskView.layer.borderColor = UIColor.white.cgColor
        maskView.layer.borderWidth = 1

        numberRegionView.backgroundColor = .clear
        numberRegionView.layer.borderColor = UIColor.white.cgColor
        numberRegionView.layer.borderWidth = 1

        session.startRunning()
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        let frame = maskView.convert(numberRegionView.frame, to: previewView)
        regionOfInterest = .init(x: frame.origin.x / previewView.frame.size.width,
                                 y: 1 - (frame.origin.y / previewView.frame.size.height) - frame.size.height / previewView.frame.size.height,
                                 width: frame.size.width / previewView.frame.size.width,
                                 height: frame.size.height / previewView.frame.size.height)
    }
}

extension ViewController : AVCaptureVideoDataOutputSampleBufferDelegate{

    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
            return
        }

        let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .right)
        let request = VNRecognizeTextRequest { [weak self] request, error in
            guard let results = request.results else {
                return
            }
            let texts = results.compactMap { $0 as? VNRecognizedTextObservation }.compactMap { $0.topCandidates(1).first?.string }
            if !texts.isEmpty {
                texts.forEach {
                    if let number = self?.checkCardNumber($0) {
                        DispatchQueue.main.async {
                            self?.resultLabel.text = number
                        }
                    }
                }
            }
        }
        request.preferBackgroundProcessing = true
        request.recognitionLanguages = ["en_US"]
        request.usesLanguageCorrection = true
        request.regionOfInterest = regionOfInterest
        try? handler.perform([request])
    }

    private func checkCardNumber(_ text: String) -> String? {
        var target = text.replacingOccurrences(of: " ", with: "")
        target = target.replacingOccurrences(of: " ", with: "")
        if target.range(of: "[^0-9]+", options: .regularExpression) == nil,
           target.count == 16 {
            return target
        } else {
            return  nil
        }
    }
}

動画表示

info.plist に「Privacy - Camera Usage Description」を追加して下記実装をするだけで動画表示ができます。

import UIKit
import AVFoundation

final class ViewController: UIViewController {

    private let session = AVCaptureSession()

    override func viewDidLoad() {
        super.viewDidLoad()
        guard let device = AVCaptureDevice.default(for: .video),
              let input = try? AVCaptureDeviceInput(device: device) else {
            return
        }
        if session.canAddInput(input) {
            session.addInput(input)
        }
        let output = AVCaptureVideoDataOutput()
        if session.canAddOutput(output) {
            session.addOutput(output)
        }
        let previewLayer = AVCaptureVideoPreviewLayer(session: session)
        previewLayer.videoGravity = .resizeAspectFill
        previewLayer.frame = UIScreen.main.bounds
        view.layer.addSublayer(previewLayer)

        session.startRunning()
    }
}

文字認識

Vision.framework を使って文字認識をするため下記を追加します。

let queue = DispatchQueue(label: "buffer queue")
let output = AVCaptureVideoDataOutput()
output.setSampleBufferDelegate(self, queue: queue)

extension ViewController : AVCaptureVideoDataOutputSampleBufferDelegate{

    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
            return
        }

        let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .right)
        let request = VNRecognizeTextRequest { request, error in
            guard let results = request.results else {
                return
            }
            let texts = results.compactMap { $0 as? VNRecognizedTextObservation }.compactMap { $0.topCandidates(1).first?.string }
            texts.forEach {
                print($0) // 認識した文字列
            }
        }
        request.preferBackgroundProcessing = true
        request.recognitionLanguages = ["en_US"]
        request.usesLanguageCorrection = true
        try? handler.perform([request])
    }
}

ここで .right を設定しないと向きが合わないみたいです。

let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .right)

これでとりあえず文字認識ができます。

指定範囲だけ解析

このままだとカメラに写った文字をすべて解析するので指定範囲だけ解析するようにしていきます。

クレジットカードのサイズは縦 53.98 mm × 横 85.60 mm らしい(参考)ので 85.6:53.98 の制約を maskView につけます。

ratio

手元のカードを確認しながら番号の位置に合うように numberRegionView に制約をつけます。

layout1 layout2

下記を追加します。

private var regionOfInterest: CGRect = .zero

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    let frame = maskView.convert(numberRegionView.frame, to: previewView)
    regionOfInterest = .init(x: frame.origin.x / previewView.frame.size.width,
                             y: 1 - (frame.origin.y / previewView.frame.size.height) - frame.size.height / previewView.frame.size.height,
                             width: frame.size.width / previewView.frame.size.width,
                             height: frame.size.height / previewView.frame.size.height)
}

/// captureOutputメソッド内のVNRecognizeTextRequestに設定
request.regionOfInterest = regionOfInterest

ポイントはここです。

let frame = maskView.convert(numberRegionView.frame, to: previewView)
regionOfInterest = .init(x: frame.origin.x / previewView.frame.size.width,
                         y: 1 - (frame.origin.y / previewView.frame.size.height) - frame.size.height / previewView.frame.size.height,
                         width: frame.size.width / previewView.frame.size.width,
                         height: frame.size.height / previewView.frame.size.height)

regionOfInterest は左下を原点として 0 ~ 1 で設定するらしいので UIKit 上の座標系から変換するために上記処理が必要になります。

regionOfInterest ドキュメント

これで numberRegionView の範囲のみ解析するようになりました。

カード番号の判定

認識した文字列がカード番号かどうか判定する処理をいれます。

// captureOutputメソッド内の処理
let texts = results.compactMap { $0 as? VNRecognizedTextObservation }.compactMap { $0.topCandidates(1).first?.string }
texts.forEach {
    // ここ追加
    if let number = self?.checkCardNumber($0) {
        DispatchQueue.main.async {
            self?.resultLabel.text = number
        }
    }
}

private func checkCardNumber(_ text: String) -> String? {
    var target = text.replacingOccurrences(of: " ", with: "")
    target = target.replacingOccurrences(of: " ", with: "")
    if target.range(of: "[^0-9]+", options: .regularExpression) == nil,
       target.count == 16 {
        return target
    } else {
        return  nil
    }
}

captureOutput メソッド内の処理で返ってくる文字列は「0000 0000 0000 0000」のような文字列が返ってきます。checkCardNumber メソッドで空白を削除し数値のみの 16 桁であることを確認し当てはまれば UILabel に表示しています。

これで完成です:tada:

課題

手元にあったいくつかのカードと iPhone 12 mini でしか試せていないので他のカードや端末だとうまくいかない可能性があります(試した限りはそこそこの精度で読み取れてました)。

エンボス加工ありとかなしでも精度が変わりそうですし実務レベルにするにはもうちょっと色々なカードや端末でテストをする必要があると思います。

おわりに

Vision.framework を使うと思ったよりかんたんに文字認識ができました。これで色々遊べそうです。

クレジットカードはナンバーレスのものも増えてきてますし将来的にはカメラでの番号読み取りはなくなるのかも??

参考

10
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
7