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

![Xcode-14.1](https://img.shields.io/badge/Xcode-14.1-brightgreen) ![iOS-16.0](https://img.shields.io/badge/iOS-16.0-brightgreen) 

## はじめに
クレジットカード番号の読み取りに[card-io/card.io-iOS-SDK](https://github.com/card-io/card.io-iOS-SDK)というライブラリを使っていたのですが 2016 年くらいにアーカイブされています。iPhone 12 Pro Max だとピントが合わないという事象もあるみたいです（[これ](https://sbapp.net/appnews/app/upinfo/ios16/pintogaawanai-139826)と同じ？）。

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

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

![demo](https://user-images.githubusercontent.com/34936885/206319924-2499f94b-a430-48aa-b983-badde8e1185a.gif)

## ソース全体
レイアウトはこんな感じです。`previewView` は `superview` の上下左右に制約つけてます（Safe Area ではなく）。青が `maskView` でその中の白が `numberRegionView` です。

<img width="491" alt="layout" src="https://user-images.githubusercontent.com/34936885/206315914-72a63625-c8f9-4c81-a960-9c8f23737e79.png">

<details><summary>ソース全体</summary><div>

```swift
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
        }
    }
}
```
</div></details>

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

```swift
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 を使って文字認識をするため下記を追加します。

```swift
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` を設定しないと向きが合わないみたいです。

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

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

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

クレジットカードのサイズは縦 53.98 mm × 横 85.60 mm らしい（[参考](https://www.rakuten-card.co.jp/minna-money/credit-card/knowledge/article_2206_90071/#:~:text=%E3%82%AF%E3%83%AC%E3%82%B8%E3%83%83%E3%83%88%E3%82%AB%E3%83%BC%E3%83%89%E3%81%AE%E3%82%B5%E3%82%A4%E3%82%BA%E3%81%AF%E7%B8%A653.98mm%C3%97%E6%A8%AA,%E5%A4%A7%E3%81%8D%E3%81%95%E3%81%AF%E5%90%8C%E3%81%98%E3%81%A7%E3%81%99%E3%80%82)）ので 85.6:53.98 の制約を `maskView` につけます。

<img width="355" alt="ratio" src="https://user-images.githubusercontent.com/34936885/206315992-60a2041b-b338-4025-ac42-9019c9207730.png">

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


<img width="500" alt="layout1" src="https://user-images.githubusercontent.com/34936885/206316018-5a04a54d-8ecb-406a-868d-8259146d8731.png">

<img width="500" alt="layout2" src="https://user-images.githubusercontent.com/34936885/206316089-a38a0675-01f9-4d2c-9c04-6b95087e98a3.png">

下記を追加します。

```swift
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
```

ポイントはここです。

```swift
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 ドキュメント](https://developer.apple.com/documentation/vision/vnimagebasedrequest/2877482-regionofinterest)

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

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

```swift
// 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 を使うと思ったよりかんたんに文字認識ができました。これで色々遊べそうです。

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

## 参考
* [【iOS13】Vision.frameworkの文字認識(OCR)で遊んでみる](https://qiita.com/orimomo/items/a60d981ecaba5ce70293)
* [[iOS]画像解析フレームワークVision FrameworkとUIKitの座標計算](https://blog.personal-factory.com/2020/05/17/convert-uikit-coordinate-from-vision/)
* [【Swift】簡単なリアルタイムに画像を分類するアプリを作ってみた](https://dev.classmethod.jp/articles/objectdetection/)
* [【Swift】Visionフレームワークを用いた動画からリアルタイムにボールの軌道を検出するアプリ](https://qiita.com/viendfig/items/66946618988e08592f4c)