はじめに
クレジットカード番号の読み取りにcard-io/card.io-iOS-SDKというライブラリを使っていたのですが 2016 年くらいにアーカイブされています。iPhone 12 Pro Max だとピントが合わないという事象もあるみたいです(これと同じ?)。
Vision.framework を使うと文字認識ができるみたいなので自作に挑戦してみようと思います
こんな感じです(手書きなのでちょっと精度下がりました)。
ソース全体
レイアウトはこんな感じです。previewView
は superview
の上下左右に制約つけてます(Safe Area ではなく)。青が maskView
でその中の白が numberRegionView
です。
ソース全体
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
につけます。
手元のカードを確認しながら番号の位置に合うように numberRegionView
に制約をつけます。
下記を追加します。
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 上の座標系から変換するために上記処理が必要になります。
これで 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
に表示しています。
これで完成です
課題
手元にあったいくつかのカードと iPhone 12 mini でしか試せていないので他のカードや端末だとうまくいかない可能性があります(試した限りはそこそこの精度で読み取れてました)。
エンボス加工ありとかなしでも精度が変わりそうですし実務レベルにするにはもうちょっと色々なカードや端末でテストをする必要があると思います。
おわりに
Vision.framework を使うと思ったよりかんたんに文字認識ができました。これで色々遊べそうです。
クレジットカードはナンバーレスのものも増えてきてますし将来的にはカメラでの番号読み取りはなくなるのかも??