iOSのVision Frameworkで画像から文字列を取得、文字列の位置を画像上に矩形表示する

# 環境
- iOS16
- Xcode14.1
- シミュレーターにて動作を確認


# やりたいこと
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/570572/572cc746-93fc-5c2e-873c-49a2c3260628.png">

- UIImageViewの画像の文字認識を行う
- 取得した文字列を下部のUILabelに表示
- UIImageView上に赤い矩形で認識した文字列の位置を表示

# Vision Framework
iOSで画像認識を行うことができるフレームワーク。今回は文字列の認識機能を利用した。
下記のチュートリアルの通りに進めることで文字列認識が行えた。

[https://developer.apple.com/documentation/vision/recognizing_text_in_images](https://developer.apple.com/documentation/vision/recognizing_text_in_images)

文字列認識処理は下記で実行できた。UIはStoryboardで配置している。

```swift
import UIKit
import Vision
import AVFoundation

class ViewController: UIViewController {
    let image = UIImage(named: "sample_image")!

    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var label: UILabel!

    @IBAction func didPushButton(_ sender: Any) {
        run()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        imageView.image = image
    }

    // 文字列認識実行
    private func run() {
        guard let cgImage = image.cgImage else { return }
        let requestHandler = VNImageRequestHandler(cgImage: cgImage)
        let request = VNRecognizeTextRequest(completionHandler: recognizeTextHandler)
        // 日本語に設定
        request.recognitionLanguages = ["ja-JP"]
        do {
            try requestHandler.perform([request])
        } catch {
            print("Unable to perform the requests: \(error).")
        }
    }
    
    // 文字列認識結果
    func recognizeTextHandler(request: VNRequest, error: Error?) {
        guard let observations =
                request.results as? [VNRecognizedTextObservation] else {
            return
        }
        
        let recognizedStrings = observations.compactMap { observation in
            return observation.topCandidates(1).first?.string
        }
        // 取得できた文字列配列を表示
        label.text = recognizedStrings.debugDescription

        // ** 矩形を表示(後述する) ** //
        showBoundingRect(observations)
    }
```

# 文字列認識した位置を画像上に表示する
ここがハマりポイントだった。
上記のチュートリアルでも矩形の位置を取得する方法が紹介されている。VNImageRectForNormalizedRectを用いて、画像上のどの位置かが取得できた。
しかし、今回はUIImageViewのContentModeをAspectFitに設定しているのでアスペクト比を維持したまま、画像が縮小されて表示されている。またx,y座標もUIImageViewの中央に移動している。

1. Visionで取れる矩形の位置はy座標は反対向きになっているので反転させる必要があった。
    * https://qiita.com/john-rocky/items/86ddab067fb1179976d0 こちらの記事で紹介されているようにy軸方向に反転する計算を行うことで対応できた。
     
2. 画像は元画像よりサイズを端末サイズに合わせて縮小されているので、その縮小率に合わせて矩形の位置を調整する必要があった。
    * UIImageView内の縮小されたimageのCGRectを取得したい。AVFoundationのメソッドを利用することで取得できた。(https://qiita.com/fr0g_fr0g/items/35339e0b9d977a404a22)。 取得できたCGRect.sizeと元画像のCGSizeから縮小率を計算できた。
    
3. 画像はUIImageViewの真ん中に表示されるのでその分平行移動させる必要があった。
    * 2で取得できたx,yの座標を足して、並行移動することで対応できた。


```swift

    // 文字列認識できた位置に赤い矩形を表示
    private func showBoundingRect(_ observations: [VNRecognizedTextObservation]) {
        // ①　画像のRectを取得。メソッド内にてy座標の反転を行う。
        let boundingRects = getBoundingRects(observations: observations)
        // ② 画像のUIImageView内でのRectを取得。
        let imageRectInView = imageRect()
        // ② 画像の縮小率を取得。
        let ratio = imageRectInView.width / image.size.width
        
        boundingRects.forEach { rect in
            let view = UIView()
            view.layer.borderColor = UIColor.red.cgColor
            view.layer.borderWidth = 1
            let resizedRect = CGRect(x: rect.minX * ratio + imageRectInView.minX, // ③ 画像を並行移動
                                     y: rect.minY * ratio + imageRectInView.minY, // ③ 画像を並行移動
                                     width: rect.width * ratio,
                                     height: rect.height * ratio)
            view.frame = resizedRect
            imageView.addSubview(view)
        }
    }
    
    // ② UIImageView内のimageのCGRectを取得
    func imageRect() -> CGRect {
        return AVMakeRect(aspectRatio: image.size, insideRect: imageView.bounds)
    }
    
    func getBoundingRects(observations: [VNRecognizedTextObservation]) -> [CGRect] {
        let boundingRects: [CGRect] = observations.compactMap { observation in
            guard let candidate = observation.topCandidates(1).first else { return .zero }
            let stringRange = candidate.string.startIndex..<candidate.string.endIndex
            let boxObservation = try? candidate.boundingBox(for: stringRange)
            let boundingBox = boxObservation?.boundingBox ?? .zero
            let normalizedRect = VNImageRectForNormalizedRect(boundingBox,
                                                              Int(image.size.width),
                                                              Int(image.size.height))
            // ① Visionのy座標はUIKitとは逆向きなので反転する必要がある
            let yInImage = image.size.height - normalizedRect.minY - normalizedRect.height
            return CGRect(x: normalizedRect.minX,
                          y: yInImage,
                          width: normalizedRect.width,
                          height: normalizedRect.height)
        }
        return boundingRects
    }
```