Vision.frameworkでカメラ画像のテキスト認識を行う

[前回の記事](https://qiita.com/beckyJPN/items/4bc46a8e6a000b711de6)では、 `Vision.framework` をつかって顔認識を行いました。
今度はテキスト認識をやってみます。
ちなみに、テキストの文字認識はiOS13からの機能みたいです。

# 概要
カメラ画像からテキストを検出し、テキスト部分に矩形を表示。
さらにその部分に検出したテキストを出力します。

現在のところ、対応言語が英語のみのようです。
また、今回のサンプルでは端末を横にしないと、文字をうまく認識しません。

# 試した環境
Xcode 11.3
iOS 13.2
swift 5

# 実行サンプル
[![youtube link](http://img.youtube.com/vi/1sll_scS0UI/0.jpg)](http://www.youtube.com/watch?v=1sll_scS0UI)
https://www.youtube.com/watch?v=1sll_scS0UI

動画なので速度を出すため、検証精度を落として確認しています。
画面をアップにするとそこそこの精度は出ていそうです。

検証に使ったサイトのURLは以下です。
https://en.wikipedia.org/wiki/Apple

# コード説明
手順的には、顔認証とほぼ同じで、リクエストを `VNDetectFaceRectanglesRequest` から `VNRecognizeTextRequest` に変更します。
`VNRecognizeTextRequest` で画像から検出した文字情報を `[VNRecognizedTextObservation]` として受け取ります。
ちなみに、 `VNDetectTextRectanglesRequest` でも文字の矩形取得はできるのですが、こちらの場合文字情報を取得することができません。
 
また、リクエストにプロパティを設定することで、文字取得条件を変更できます。


`recognitionLevel` = 文字の取得制度設定。 `fast` と `accurate` があり、動画で検出する場合は `fast` が良いみたい
`recognitionLanguages` = 認識する言語。 現在は英語のみ。 
`usesLanguageCorrection` = 認識した文字を自動修正する機能。 スペルミス防止などにつかえそう？
 
```swift
    /// 文字認識情報の配列取得 (非同期)
    private func getTextObservations(pixelBuffer: CVPixelBuffer, completion: @escaping (([VNRecognizedTextObservation])->())) {
        let request = VNRecognizeTextRequest { (request, error) in
            guard let results = request.results as? [VNRecognizedTextObservation] else {
                completion([])
                return
            }
            completion(results)
        }

        request.recognitionLevel = recognitionLevel
        request.recognitionLanguages = supportedRecognitionLanguages
        request.usesLanguageCorrection = true

        let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:])
        try? handler.perform([request])
    }
```

サポートしている言語一覧は以下手続きで取得。

```swift
    /// サポートしている言語リストを取得 （現在は英語のみ）
    private lazy var supportedRecognitionLanguages : [String] = {
        return (try? VNRecognizeTextRequest.supportedRecognitionLanguages(
        for: recognitionLevel,
        revision: VNRecognizeTextRequestRevision1)) ?? []
    }()
```
文字取得とは直接関係ありませんが、画面上に取得した文字を表示するため、
`CGContext` に文字を書き込んでいます。
普通に書き込むと座標系の関係で文字列が逆転してしまうみたいで、
何気にここが一番面倒くさい処理となりました・・・

```swift
   /// コンテキストに矩形を描画
    private func drawRect(_ rect: CGRect, text: String, context: CGContext) {

        context.setLineWidth(4.0)
        context.setStrokeColor(UIColor.green.cgColor)
        context.stroke(rect)
        context.setFillColor(UIColor.black.withAlphaComponent(0.6).cgColor)
        context.fill(rect)
        
        drawText(text, rect: rect, context: context)
 
    }

    /// コンテキストにテキストを描画　 (そのまま描画すると文字が反転するので、反転させる必要あり）
    private func drawText(_ text: String, rect: CGRect, context: CGContext) {
        
        context.saveGState()
        defer {
            context.restoreGState()
        }

        let transform = CGAffineTransform(scaleX: 1, y: 1)
        context.concatenate(transform)
        
        guard let textStyle = NSMutableParagraphStyle.default.mutableCopy() as? NSMutableParagraphStyle else {
            return
        }
        let font = UIFont.boldSystemFont(ofSize: 20)
        let textFontAttributes = [
            NSAttributedString.Key.font: font,
            NSAttributedString.Key.foregroundColor: UIColor.white,
            NSAttributedString.Key.paragraphStyle: textStyle
        ]
        
        let astr = NSAttributedString(string: text, attributes: textFontAttributes)
        let setter = CTFramesetterCreateWithAttributedString(astr)
        let path = CGPath(rect: rect, transform: nil)
        let frame = CTFramesetterCreateFrame(setter, CFRange(), path, nil)

        context.textMatrix = CGAffineTransform.identity
        CTFrameDraw(frame, context)

    }
```

`VNRecognizedTextObservation` から文字や検出範囲を取得する手続きは以下部分です。
`topCandidates` に検出文字候補が評価が高い順に入ってるみたいなので、一番最初の物を決め打ちで取るようにしています。

```swift
        textObservations.forEach{
            let rect = getUnfoldRect(normalizedRect: $0.boundingBox, targetSize: imageSize)
            let text = $0.topCandidates(1).first?.string ?? "" // topCandidates に文字列候補配列が含まれている
            self.drawRect(rect, text: text, context: newContext)
        }
```


# コード全体
```swift
import UIKit
import AVFoundation
import Vision

class TextObservationViewController: UIViewController {

    @IBOutlet weak var previewImageView: UIImageView!

    private let avCaptureSession = AVCaptureSession()
    
    /// 認識制度を設定。 リアルタイム処理なので fastで
    private let recognitionLevel : VNRequestTextRecognitionLevel = .fast
    
    /// サポートしている言語リストを取得 （現在は英語のみ）
    private lazy var supportedRecognitionLanguages : [String] = {
        return (try? VNRecognizeTextRequest.supportedRecognitionLanguages(
        for: recognitionLevel,
        revision: VNRecognizeTextRequestRevision1)) ?? []
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupCamera()
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        avCaptureSession.stopRunning()
    }

    /// カメラのセットアップ
    private func setupCamera() {
        avCaptureSession.sessionPreset = .photo

        let device = AVCaptureDevice.default(for: .video)
        let input = try! AVCaptureDeviceInput(device: device!)
        avCaptureSession.addInput(input)

        let videoDataOutput = AVCaptureVideoDataOutput()
        videoDataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String : Int(kCVPixelFormatType_32BGRA)]
        videoDataOutput.alwaysDiscardsLateVideoFrames = true
        videoDataOutput.setSampleBufferDelegate(self, queue: .global())

        avCaptureSession.addOutput(videoDataOutput)
        avCaptureSession.startRunning()
    }

    /// コンテキストに矩形を描画
    private func drawRect(_ rect: CGRect, text: String, context: CGContext) {

        context.setLineWidth(4.0)
        context.setStrokeColor(UIColor.green.cgColor)
        context.stroke(rect)
        context.setFillColor(UIColor.black.withAlphaComponent(0.6).cgColor)
        context.fill(rect)
        
        drawText(text, rect: rect, context: context)
 
    }

    /// コンテキストにテキストを描画　 (そのまま描画すると文字が反転するので、反転させる必要あり）
    private func drawText(_ text: String, rect: CGRect, context: CGContext) {
        
        context.saveGState()
        defer {
            context.restoreGState()
        }

        let transform = CGAffineTransform(scaleX: 1, y: 1)
        context.concatenate(transform)
        
        guard let textStyle = NSMutableParagraphStyle.default.mutableCopy() as? NSMutableParagraphStyle else {
            return
        }
        let font = UIFont.boldSystemFont(ofSize: 20)
        let textFontAttributes = [
            NSAttributedString.Key.font: font,
            NSAttributedString.Key.foregroundColor: UIColor.white,
            NSAttributedString.Key.paragraphStyle: textStyle
        ]
        
        let astr = NSAttributedString(string: text, attributes: textFontAttributes)
        let setter = CTFramesetterCreateWithAttributedString(astr)
        let path = CGPath(rect: rect, transform: nil)
        let frame = CTFramesetterCreateFrame(setter, CFRange(), path, nil)

        context.textMatrix = CGAffineTransform.identity
        CTFrameDraw(frame, context)

    }
    
    /// 文字認識情報の配列取得 (非同期)
    private func getTextObservations(pixelBuffer: CVPixelBuffer, completion: @escaping (([VNRecognizedTextObservation])->())) {
        let request = VNRecognizeTextRequest { (request, error) in
            guard let results = request.results as? [VNRecognizedTextObservation] else {
                completion([])
                return
            }
            completion(results)
        }

        request.recognitionLevel = recognitionLevel
        request.recognitionLanguages = supportedRecognitionLanguages
        request.usesLanguageCorrection = true

        let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:])
        try? handler.perform([request])
    }

    /// 正規化された矩形位置を指定領域に展開
    private func getUnfoldRect(normalizedRect: CGRect, targetSize: CGSize) -> CGRect {
        return CGRect(
            x: normalizedRect.minX * targetSize.width,
            y: normalizedRect.minY * targetSize.height,
            width: normalizedRect.width * targetSize.width,
            height: normalizedRect.height * targetSize.height
        )
    }

    /// 文字検出位置に矩形を描画した image を取得
    private func getTextRectsImage(sampleBuffer :CMSampleBuffer, textObservations: [VNRecognizedTextObservation]) -> UIImage? {

        guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
            return nil
        }

        CVPixelBufferLockBaseAddress(imageBuffer, CVPixelBufferLockFlags(rawValue: 0))

        guard let pixelBufferBaseAddres = CVPixelBufferGetBaseAddressOfPlane(imageBuffer, 0) else {
            CVPixelBufferUnlockBaseAddress(imageBuffer, CVPixelBufferLockFlags(rawValue: 0))
            return nil
        }

        let width = CVPixelBufferGetWidth(imageBuffer)
        let height = CVPixelBufferGetHeight(imageBuffer)
        let bitmapInfo = CGBitmapInfo(rawValue:
            (CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue)
        )

        guard let newContext = CGContext(
            data: pixelBufferBaseAddres,
            width: width,
            height: height,
            bitsPerComponent: 8,
            bytesPerRow: CVPixelBufferGetBytesPerRow(imageBuffer),
            space: CGColorSpaceCreateDeviceRGB(),
            bitmapInfo: bitmapInfo.rawValue
            ) else
        {
            CVPixelBufferUnlockBaseAddress(imageBuffer, CVPixelBufferLockFlags(rawValue: 0))
            return nil
        }

        let imageSize = CGSize(width: width, height: height)

        textObservations.forEach{
            let rect = getUnfoldRect(normalizedRect: $0.boundingBox, targetSize: imageSize)
            let text = $0.topCandidates(1).first?.string ?? "" // topCandidates に文字列候補配列が含まれている
            self.drawRect(rect, text: text, context: newContext)
        }

        CVPixelBufferUnlockBaseAddress(imageBuffer, CVPixelBufferLockFlags(rawValue: 0))

        guard let imageRef = newContext.makeImage() else {
            return nil
        }
        let image = UIImage(cgImage: imageRef, scale: 1.0, orientation: UIImage.Orientation.right)

        return image
    }
}


extension TextObservationViewController : AVCaptureVideoDataOutputSampleBufferDelegate{

    /// カメラからの映像取得デリゲート
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
            return
        }
        getTextObservations(pixelBuffer: pixelBuffer) { [weak self] textObservations in
            guard let self = self else { return }
            let image = self.getTextRectsImage(sampleBuffer: sampleBuffer, textObservations: textObservations)
            DispatchQueue.main.async { [weak self] in
                self?.previewImageView.image = image
            }
        }
    }
}
```
# github
becky3/text_observation: Vision.frameworkでカメラ画像のテキスト認識を行う
https://github.com/becky3/text_observation

# 参考サイト
- iOS13から標準サポートされる文字認識  
https://qiita.com/KenNagami/items/a75b2bc282ad05a6dcde
- Core Text で縦書き - 錯綜  
https://hrt1ro.hatenablog.com/entry/2018/09/27/132803
- 【Swift】Vision.frameworkでカメラ画像の顔認識を行う【iOS】 - Qiita  
https://qiita.com/beckyJPN/items/4bc46a8e6a000b711de6