初心者がYOLOをCore MLに変換してリアルタイムでオブジェクトカウンティングに挑戦してみた

## はじめに
この記事は[NTTテクノクロス Advent Calendar 2024](https://qiita.com/advent-calendar/2024/ntt-tx)のシリーズ1、21日目の記事です。
はじめまして。NTTテクノクロスの大谷です。
iOSアプリ開発に携わって1年弱の初心者エンジニアです🔰

今回はYOLOをCore MLに変換して、スマホのカメラを使ってリアルタイムオブジェクトカウンティングに挑戦してみました。初心者目線でつまづいた箇所を重点的に解説できればと思います。

まずは今回登場する用語についてご説明します。

### YOLOとは
物体検出・画像分割ライブラリです。`You Only Look Once`の略称の通り、その高速性と精度の高さが特長です。うまい（高精度）・安い（OSS）・早い（高速）の三拍子そろった画像認識界の牛丼のような存在です。

### Core MLとは
Appleのデバイスで機械学習やAIモデルを実行するためのフレームワークです。
モデル実行がデバイス上で完結するため、高セキュリティと応答性を実現しています。
TensorFlow、PyTorchなど様々なライブラリをCore MLに変換して利用することができて汎用性も高くとても便利です。

## 今回の完成形
![プレゼンテーション2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942155/04dd568a-99a1-3d2a-d9c9-bf4739b77804.gif)
今回は画面に映った物体について検出・カウントを表示させることを目指します。
実用的なオブジェクトカウンティングを実装するにはトラッキングなども行わないといけないのですが、ちょっと沼が深そうだったので一旦ここをゴールにします。（初心者並感）


## YOLOをインストールする
何はともあれYOLOを入れないとお話にならないのでインストールします。
詳細は公式の[クイックスタート](https://docs.ultralytics.com/ja/quickstart/)をご覧ください。

今回はUbuntu上のpython環境で進めます。パッケージマネージャーはpipを利用しています。

```CLI:CLI
$ pip install ultralytics
```
これで使えるようになったらしいので試しに実行してみます。
以下のコマンドで、最新版のYOLO11（`yolo11n.pt`）のモデルを利用した画像の物体検知を行うことができます。
カレントディレクトリに`runs/detect/predict/bus.jpg`が生成されれば成功です。

```CLI:CLI
$ yolo detect predict model=yolo11n.pt source='https://ultralytics.com/images/bus.jpg'
```

:::note info
コマンドの実行中追加でパッケージのインストールが発生することがあります。
プロキシ環境下などでインストールが失敗してしまう場合は、別途該当パッケージをpip等で入れてあげると上手くいきます。
:::

### YOLOのオブジェクトカウンティング
ついでにYOLO本家がどんな感じでオブジェクトカウンティングしてくれるかも見ておきましょう。
詳細は公式の[オブジェクトカウンティング](https://docs.ultralytics.com/ja/guides/object-counting/)をご覧ください。

以下のコマンドを実行すると、実行例を生成してくれます。(`runs/solutions/exp`配下)
```CLI:CLI
$ yolo solutions count show=True
```

![プレゼンテーション1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942155/b7c5e41f-19bf-7517-670e-ba93b28b3b1d.gif)
なるほど、中央の四角形の中に入った数・出た数をカウントしてくれるらしい。🤔
これがコマンド一発でできるとは……YOLO恐るべし。

## モデルをCore MLに変換してみる
早速YOLO11のモデルをCore MLモデルに変換してみましょう。Core ML公式モデルにもYOLOv3がありますが、せっかくならより高性能な方がいいですからね。
推論タスクの種類によって使われるモデルが異なりますが、今回は物体検出ができればいいので`yolo11n.pt`を変換していきます。

以下のコマンドを実行します。
```CLI:CLI
$ yolo export model=yolo11n.pt format=coreml nms=True
```
成功するとカレントディレクトリ配下に`yolo11n.mlpackage`が生成されます。
とっても簡単で拍子抜けしました。

::: note info
`nms=True`を付けることで学習済みのクラスのラベルも一緒に出力してくれるのでさらに楽ちんです。
:::

## Core MLを使ってアプリを作ってみる
それでは本題に移ります。
今回アプリケーションを作成するにあたって、以下の記事とプロジェクトが大変参考になりました。

「Yolov8をCoreMLに変換してiPhoneで使う」
- https://qiita.com/john-rocky/items/0817bbd16667df183807
- https://github.com/john-rocky/Yolov8-RealTime-iOS

### 1. 準備
まずは以下のような設定でプロジェクトを作成しましょう。特に`Interface`は`Storyboard`がおすすめです。情報が多いので。
![project_setting.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942155/df1238e6-4b03-d77d-64cb-e63500eb27e0.jpeg)

作成したプロジェクトに先程の`yolo11n.mlpackage`をドラッグ＆ドロップでインポートします。
このようなプロジェクト構成になります。
![add_yolomodel.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942155/754d393e-eebe-3023-0915-ca33ca8d9768.jpeg)

### 2. UIの作成
`Main.storyboard`でUIを作成します。
`CameraView`は`UIImageView`、`FooterView`は`UIView`です。
`CameraView`の`Content Mode`は`Scale To Fill`にしておくのがミソです。
初心者あるあるの「画像ちっちゃいんだけど！？」を防ぐことができます。
![Storyboard2.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942155/a128232f-3a73-944b-ddc5-4d4105c6d451.jpeg)

### 3. ViewControllerの準備
中身の処理は`ViewController.swift`に書いていきます。
カメラの処理には`AVFoundation`フレームワークを使用するので、忘れずにインポートしておきましょう。
まずはカメラを動かせるように`ViewController`クラスの準備を整えます。

```Swift:ViewController.swift
import UIKit
import AVFoundation
import CoreML
import Vision

class ViewController: UIViewController, AVCaptureVideoDataOutputSampleBufferDelegate {
    @IBOutlet weak var cameraView: UIImageView! // カメラのプレビュー用のビュー
    @IBOutlet weak var footerView: UIView! // カウンティング結果表示用のビュー
    private var textView: UILabel! // カウンティング結果表示用のラベル

    var frameInterval = 3 // フレーム間隔
    var frameCounter = 0 // フレームカウンター

    var captureSession: AVCaptureSession! // カメラのセッション
    var ciContext: CIContext! // CoreImageのコンテキスト
    // カメラの向きを調整するクラス（今回はコメントアウト（後述））
//    var rotationCoordinator: AVCaptureDevice.RotationCoordinator! 

    var yoloModel: VNCoreMLModel! // YOLOのモデル

    var mainCounter : [String: Int] = [:] // カウンターの初期配列

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // カウンティング結果表示用のラベルの初期化
        textView = UILabel()
        textView.numberOfLines = 0
        textView.translatesAutoresizingMaskIntoConstraints = false
        footerView.addSubview(textView)
        NSLayoutConstraint.activate([
            textView.topAnchor.constraint(equalTo: footerView.topAnchor, constant: 10),
            textView.bottomAnchor.constraint(equalTo: footerView.bottomAnchor, constant: -10),
            textView.leadingAnchor.constraint(equalTo: footerView.leadingAnchor),
            textView.trailingAnchor.constraint(equalTo: footerView.trailingAnchor)
        ])
        
        // カメラのセットアップ
        setupCamera()

        ciContext = CIContext()
    }
}
```
### 4. カメラを使えるようにする
まずはカメラへのアクセス権限を求めるポップアップを追加します。
`Info.plist`に`Privacy - Camera Usage Description`を追加しましょう。
`Value`はデフォルトでアプリのバージョンになっています。任意の値を入れることでポップアップに表示する文言を変更できます。
![Info_plist.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942155/b13c1980-7e79-8dec-1028-4daa39fd0967.jpeg)

初回起動時に以下の画面が出ます。
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942155/b993a859-e9cc-76d0-b11a-508b6b88733c.jpeg">


続いてカメラのセットアップを行います。ここら辺はほぼお約束……🤫
```Swift:ViewController.swift - setupCamera()
/*!
 * @brief カメラのセットアップを行う
 */
func setupCamera() {
    captureSession = AVCaptureSession()

    // カメラのデバイスを取得
    guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { return }

    // rotationCoordinatorの初期化（後述）
//    rotationCoordinator = AVCaptureDevice.RotationCoordinator(device: videoCaptureDevice, previewLayer: self.cameraView.layer)

    // ビデオ入力を設定
    let videoInput: AVCaptureDeviceInput

    do {
        videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
    } catch {
      return
    }

    if (captureSession.canAddInput(videoInput)) {
        captureSession.addInput(videoInput)
    } else {
        return
    }

    // ビデオ出力を設定
    let videoOutput = AVCaptureVideoDataOutput()
    videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "videoQueue"))
    if (captureSession.canAddOutput(videoOutput)) {
        captureSession.addOutput(videoOutput)
    } else {
        return
    }
    
    // セッションの開始
    captureSession.startRunning()
}
```

### 5. Core MLで画像を処理する
#### 5. 1. モデル初期化
今回のメインどころ、Core MLを利用した画像処理を実装します。
`viewDidLoad()`メソッドに以下を追記してモデルの初期化を行います。
```Swift:ViewController.swift - viewDidLoad() 
do {
    // モデルの初期化
    yoloModel = try VNCoreMLModel(for: yolo11n().model)
} catch {
    print("Model initialization error")
    return
}
```

#### 5. 2. 物体検出
いよいよ物体検出を行います。
Core MLの画像分析は、
①初期化したモデルを使ってリクエストの作成
↓
②リクエストをリクエストハンドラーで実行
↓
③実行結果の処理
の3ステップからなります。

まずは実行結果を扱いやすくするように各実行結果を格納する構造体を設定しておきます。
```Swift:ViewController.swift
struct DetectedObject {
    let observation: VNRecognizedObjectObservation // 検出結果オブジェクト
    let label: String // ラベル
    let confidence: Float // 信頼度
    let box: CGRect // オブジェクトの境界
}
```
`VNRecognizedObjectObservation`は検出したオブジェクトそのものを示します。
このクラスには検出した物体のラベルが格納されており、親クラスの`VNDetectedObjectObservation`には検出した物体の境界（`boundingBox`）が含まれます。
`VNDetectedObjectObservation`を継承したクラスは複数あり、モデルの種類によって使い分けられます。

それでは物体検出を実行してみましょう。
```Swift:ViewController.swift - detectObject()
/* !
 * @brief 画像からオブジェクトを検出する
 * 
 * @param [in] pixelBuffer 入力画像のピクセルバッファ
 * 
 * @return [DetectedObject] 検出されたオブジェクトの配列
 */
func detectObject(pixelBuffer: CVPixelBuffer) -> [DetectedObject]? {
    // ①リクエストを作成する
    let request = VNCoreMLRequest(model: yoloModel) { (request, error) in
        guard request.results is [VNRecognizedObjectObservation] else { return }
    }

    // リクエストハンドラーの作成
    let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer)
    do {
        // ②リクエストを実行する
        try handler.perform([request])
        guard let results = request.results as? [VNRecognizedObjectObservation] else { return nil }

        // ③実行結果を処理する
        var detectedObjects: [DetectedObject] = []

        // 画像サイズの取得
        let size = CGSize(width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
        
        for observation in results {
            // 信頼度が高いものだけ出力する（画面ビカビカ抑止のため目視で調整）
            if observation.confidence > 0.4 {
                guard let label = observation.labels.first?.identifier else { return nil }

/*
 * VNDetectedObjectObservationで取得できるboundingBoxは座標が左下原点で(0.0, 1.0)に正規化されています。
 * ここでは左上を原点とし、画像座標に変換しています。
 * 
 * VNImageRectForNormalizedRect()：正規化座標→画像座標に変換するメソッド。
 * sizeは座標の変換先である入力画像のサイズを示しています。
 * 以下参考：https://github.com/john-rocky/Yolov8-RealTime-iOS/blob/5226255dd1ea70243a1d0565515d7e3f0597e983/Yolov8-RealTime-iOS/ViewController.swift#L93
 */
                let vnBox = CGRect(x: observation.boundingBox.minX,
                                        y: 1 - observation.boundingBox.maxY, 
                                        width: observation.boundingBox.width,
                                        height: observation.boundingBox.height)
                let imageBox = VNImageRectForNormalizedRect(vnBox, Int(size.width), Int(size.height))

                // オブジェクトを配列に追加
                let object = DetectedObject(
                    observation: observation,
                    label: label,
                    confidence: observation.confidence,
                    box: imageBox)
                detectedObjects.append(object)
                print(object.label, observation.confidence)
            }
        }
        
        return detectedObjects
    } catch {
        print("Detection error")
        return nil
    }
}
```

### 6. 結果を集計する

検出結果が揃ったので結果を集計していきます。
検出結果の配列からカウント結果のラベルを生成するメソッドを作成します。

やっていることは単純にオブジェクトのラベルごとの数を集計しているだけです。
辞書型は要素の順序を保持しないため、結果を受け取るたびにfor文でテキストを生成すると同じ内容の辞書でも違うテキストになることがあります。
それをそのまま出力すると画面がビカビカしてしまうので、テキストを返すのは内容に変化があった時だけにしています。

```Swift: ViewController.swift - countObject()
/*!
 * @brief 検出されたオブジェクトを集計する
 *
 * @param [in] objects 検出されたオブジェクトの配列
 * 
 * @return 集計結果のテキスト
 */
func countObject(objects: [DetectedObject]) -> String? {
    var text = ""
    if objects.count > 0 {
        var objectCounter: [String: Int] = [:] // カウンターを初期化
        for object in objects {
            if objectCounter[object.label] != nil {
                objectCounter[object.label]! += 1
            } else {
                objectCounter[object.label] = 1
            }
        }

        // カウンターの値が変わった時だけ更新後のテキストを返す
        if mainCounter != objectCounter {
            mainCounter = objectCounter
            // カウンターをソートしてテキストに整形
            let sorted_counter = objectCounter.sorted(by: { $0.1 > $1.1 })
            for (label, count) in sorted_counter {
                text += "\(label): \(count)    "
                // person: 1  といった具合に表示される
            }
            return text
        }
    }
    return nil
}
```

### 7. 結果を出力する
いよいよ今までの成果を目に見える形にする時が来ました。
画面にはカメラ映像に検出した物体を囲うボックスと、オブジェクトカウントの結果を出力します。

#### 7. 1. 検出したオブジェクトのボックスを描画する

先ほど検出したオブジェクトの境界線を画像に書き込んでいきます。
 `CVPixelBuffer`で受け取った画像を`CIImage`→`CGImage`に変換して加工していきます。
```Swift: ViewController.swift - drawBox()
/*!
 * @brief 検出したオブジェクトのボックスを描画する
 * 
 * @param [in] pixelBuffer 入力画像のピクセルバッファ
 * @param [in] objects 検出されたオブジェクトの配列
 * 
 * @return UIImage ボックス描画後の画像
 */
func drawBox(pixelBuffer: CVPixelBuffer, objects: [DetectedObject]) -> UIImage? {
        // 画像を変換
        let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
        let cgImage = self.ciContext.createCGImage(ciImage, from: ciImage.extent)!

        let size = ciImage.extent.size

        // CGContextを設定
        guard let cgContext = CGContext(data: nil,
                                        width: Int(size.width),
                                        height: Int(size.height),
                                        bitsPerComponent: 8,
                                        bytesPerRow: 4 * Int(size.width),
                                        space: CGColorSpaceCreateDeviceRGB(),
                                        bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
        cgContext.draw(cgImage, in: CGRect(origin: .zero, size: size))

        for object in objects {
            // CGImageに合わせてY座標を反転
            let invertedBox = CGRect(x: object.box.minX,
                                     y: size.height - object.box.maxY,
                                     width: object.box.width,
                                     height: object.box.height)

            // 線の設定と描画
            cgContext.setStrokeColor(UIColor.red.cgColor)
            cgContext.setLineWidth(5)
            cgContext.stroke(invertedBox)


        }

        guard let image = cgContext.makeImage() else { return nil }

        // 端末の向きに合わせて画像を回転させる
        var orientation: UIImage.Orientation!
        switch UIDevice.current.orientation {
            case .portrait:
                orientation = .right
            case .landscapeRight:
                orientation = .down
            case .landscapeLeft:
                orientation = .up
            default:
                orientation = .up
        }
        return UIImage(cgImage: image, scale: 1.0, orientation: orientation)
    }
```

特に鬼門なのが画面回転についてです。
どうやら`AVCaptureVideoDataOutput`で取得できるデータは横向き（カメラが左側に来る向き、またの名をLandscapeLeft）がデフォルトらしく、そのまま縦画面で表示すると画像が横向きになってしまいます。
今までは`AVCaptureConnection.videoOrientation`で指定することが多かったのですが、これがiOS17から非推奨になってしまいました😢
（もちろんiOS17より前のバージョンを対象にする場合は上記の手法で問題ありません。
2024年12月現在、この置き換えに関する情報が少なかったため、あえて頭を絞ることにしました）

この記事では画像に加工を加える関係上、表示する`UIImage`を端末の向きによって回転させるという力技でなんとかしています。他にいい方法があればコメントください。

:::note info
#### 余談
ところどころ`（後述）`と記載されながらも一向に説明がない変数にお気づきの方もいるかもしれません。
そう、`rotationCoordinator`です。
Apple Developerでは`videoOrientation`の代わりに`videoRotationAngle`を利用するよう案内されます。
それを提供してくれるのが`AVCaptureDevice.RotationCoordinator`です。
ただ映像をプレビューするだけであれば、各フレームの`AVCaptureConnection.videoRotationAngle`を設定してあげるだけで端末の向きに合わせて画面を回転することができます。

ただ今回のように上から描画したりエフェクトをかけるとそれが回転後の座標から置いてけぼりになってしまうため、今回は別の手段で画面回転を行なっています。
具体的な使用例は……後ほど出てきます。
:::

#### 7. 2. プレビューを表示する

ここまで実装してきた関数・メソッドを各フレームに適用し、画面に表示していきます。
`AVCaptureVideoDataOutputSampleBufferDelegate`のデリゲートメソッドである`captureOutput()`に記述していきます。
`AVCaptureVideoDataOutputSampleBufferDelegate`はビデオデータ出力のサンプルバッファを受信してその状態を監視しています。`captureOutput()`はフレームが書き出されるごとに呼び出されます。

画面ビカビカを抑制するために、最初に宣言しておいた`frameInterval`と`frameCounter`を利用して、3フレームごとに画像の処理・出力を行なっています。

```Swift: ViewController.swift - captureOutput()
/*!
 * @brief カメラの映像をキャプチャする
 * @param [in] output キャプチャした映像
 * @param [in] sampleBuffer サンプルバッファ
 * @param [in] connection キャプチャの接続
 */
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
    // frameInterval(3フレーム)ごとに処理を行う
    frameCounter += 1
    if frameCounter == frameInterval {
        frameCounter = 0

/* 
 * カメラの向きを縦向きに調整
 * videoRotationAngleForHorizonLevelCaptureを設定することで、撮影した画像が重力に対して水平になります。
 * フレーム出力ごとに設定しているので、端末を回転しても追従します。　
 */
//        connection.videoRotationAngle = rotationCoordinator.videoRotationAngleForHorizonLevelCapture

        // サンプルバッファからピクセルバッファを取得
        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }

        // 物体検出と画像の描画を実行
        var text: String!
        var image: UIImage!
        if let objects = detectObject(pixelBuffer: pixelBuffer) {
            text = countObject(objects: objects)
            image = drawBox(pixelBuffer: pixelBuffer, objects: objects)
        }

        // UIの更新はメインキューで行う
        DispatchQueue.main.async {
            // テキスト更新
            if text != nil {
                self.textView.text = text
            }

            // 画像更新
            if image != nil {
                self.cameraView.image = image
            }
        }
    }
}
```

以上で実装は完了です。お疲れ様でした！🥳
ぜひ実際に動かしてみてください。（カメラを用いたアプリのため実機必須です！）


## おわりに
長々となりましたがお読みいただきありがとうございました。
ありふれた内容になってしまいましたが、私と同じような初学者の皆様のお役に立てれば幸いです。

今後はトラッキングなども活用して本格的なオブジェクトカウンティングに拡張できればいいなぁと思っています。
また何かのご縁があればお会いしましょう👋

最後に今回実装したViewController.swift全体のコードを載せておきます。

<details><summary>ViewController.swift</summary>

```Swift: ViewController.swift
//
//  ViewController.swift
//  RealtimeObjectCounting
//

import UIKit
import AVFoundation
import CoreML
import Vision

class ViewController: UIViewController, AVCaptureVideoDataOutputSampleBufferDelegate {

    @IBOutlet weak var cameraView: UIImageView! // カメラのプレビュー用のビュー
    @IBOutlet weak var footerView: UIView! // カウンティング結果表示用のビュー
    private var textView: UILabel! // カウンティング結果表示用のラベル

    var frameInterval = 3 // フレーム間隔
    var frameCounter = 0 // フレームカウンター

    var captureSession: AVCaptureSession! // カメラのセッション
    var ciContext: CIContext! // CoreImageのコンテキスト
//    var rotationCoordinator: AVCaptureDevice.RotationCoordinator! // カメラの向きを調整するクラス

    var yoloModel: VNCoreMLModel! // YOLOのモデル

    var mainCounter : [String: Int] = [:] // カウンターの初期配列

    override func viewDidLoad() {
        super.viewDidLoad()

        // カウンティング結果表示用のラベルの初期化
        textView = UILabel()
        textView.numberOfLines = 0
        textView.translatesAutoresizingMaskIntoConstraints = false
        footerView.addSubview(textView)
        NSLayoutConstraint.activate([
            textView.topAnchor.constraint(equalTo: footerView.topAnchor, constant: 10),
            textView.bottomAnchor.constraint(equalTo: footerView.bottomAnchor, constant: -10),
            textView.leadingAnchor.constraint(equalTo: footerView.leadingAnchor),
            textView.trailingAnchor.constraint(equalTo: footerView.trailingAnchor)
        ])

        do {
            // モデルの初期化
            yoloModel = try VNCoreMLModel(for: yolo11n().model)
        } catch {
            print("Model initialization error")
            return
        }

        // カメラのセットアップ
        setupCamera()

        ciContext = CIContext()
    }

    /*!
     * @brief カメラのセットアップを行う
     */
    func setupCamera() {
        captureSession = AVCaptureSession()

        // カメラのデバイスを取得
        guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { return }

        // rotationCoordinatorの初期化。
//        rotationCoordinator = AVCaptureDevice.RotationCoordinator(device: videoCaptureDevice, previewLayer: self.cameraView.layer)

        // カメラの入力を設定
        let videoInput: AVCaptureDeviceInput

        do {
            videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
        } catch {
            return
        }

        if (captureSession.canAddInput(videoInput)) {
            captureSession.addInput(videoInput)
        } else {
            return
        }

        // ビデオ出力を設定
        let videoOutput = AVCaptureVideoDataOutput()
        videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "videoQueue"))
        if (captureSession.canAddOutput(videoOutput)) {
            captureSession.addOutput(videoOutput)
        } else {
            return
        }

        // セッションの開始
        captureSession.startRunning()
    }

    /* !
     * @brief 画像からオブジェクトを検出する
     *
     * @param [in] pixelBuffer 入力画像のピクセルバッファ
     *
     * @return [DetectedObject] 検出されたオブジェクトの配列
     */
    func detectObject(pixelBuffer: CVPixelBuffer) -> [DetectedObject]? {
         // ①リクエストを作成する
        let request = VNCoreMLRequest(model: yoloModel) { (request, error) in
            guard request.results is [VNRecognizedObjectObservation] else { return }
        }

        let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer)
        do {
            // ②リクエストを実行する
            try handler.perform([request])
            guard let results = request.results as? [VNRecognizedObjectObservation] else { return nil }

            // ③実行結果を処理する
            var detectedObjects: [DetectedObject] = []

            // 画像サイズの取得
            let size = CGSize(width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))

            for observation in results {
                // 信頼度が高いものだけ出力する
                if observation.confidence > 0.4 {
                    guard let label = observation.labels.first?.identifier else { return nil }

                    let vnBox = CGRect(x: observation.boundingBox.minX,
                                            y: 1 - observation.boundingBox.maxY,
                                            width: observation.boundingBox.width,
                                            height: observation.boundingBox.height)
                    let imageBox = VNImageRectForNormalizedRect(vnBox, Int(size.width), Int(size.height))
                    let object = DetectedObject(
                        observation: observation,
                        label: label,
                        confidence: observation.confidence,
                        box: imageBox)
                    detectedObjects.append(object)
                    print(object.label, observation.confidence)
                }
            }

            return detectedObjects
        } catch {
            print("Detection error")
            return nil
        }
    }

    /*!
     * @brief 検出されたオブジェクトを集計する
     *
     * @param [in] objects 検出されたオブジェクトの配列
     *
     * @return 集計結果のテキスト
     */
    func countObject(objects: [DetectedObject]) -> String? {
        var text = ""
        if objects.count > 0 {
            var objectCounter: [String: Int] = [:] // カウンターを初期化
            for object in objects {
                if objectCounter[object.label] != nil {
                    objectCounter[object.label]! += 1
                } else {
                    objectCounter[object.label] = 1
                }
            }

            // カウンターの値が変わった時だけ更新後のテキストを返す
            if mainCounter != objectCounter {
                mainCounter = objectCounter
                // カウンターをソートしてテキストに整形
                let sorted_counter = objectCounter.sorted(by: { $0.1 > $1.1 })
                for (label, count) in sorted_counter {
                    text += "\(label): \(count)    "
                }
                return text
            }
        }
        return nil
    }

    /*!
     * @brief 検出したオブジェクトのボックスを描画する
     *
     * @param [in] pixelBuffer 入力画像のピクセルバッファ
     * @param [in] objects 検出されたオブジェクトの配列
     *
     * @return UIImage ボックス描画後の画像
     */
    func drawBox(pixelBuffer: CVPixelBuffer, objects: [DetectedObject]) -> UIImage? {
        // 画像を変換
        let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
        let cgImage = self.ciContext.createCGImage(ciImage, from: ciImage.extent)!

        // CGContextを設定
        let size = ciImage.extent.size
        guard let cgContext = CGContext(data: nil,
                                        width: Int(size.width),
                                        height: Int(size.height),
                                        bitsPerComponent: 8,
                                        bytesPerRow: 4 * Int(size.width),
                                        space: CGColorSpaceCreateDeviceRGB(),
                                        bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
        cgContext.draw(cgImage, in: CGRect(origin: .zero, size: size))

        for object in objects {
            // CGImageに合わせてY座標を反転
            let invertedBox = CGRect(x: object.box.minX,
                                     y: size.height - object.box.maxY,
                                     width: object.box.width,
                                     height: object.box.height)

            // 線の設定と描画
            cgContext.setStrokeColor(UIColor.red.cgColor)
            cgContext.setLineWidth(5)
            cgContext.stroke(invertedBox)
        }

        guard let image = cgContext.makeImage() else { return nil }

        // 端末の向きに合わせて画像を回転させる
        var orientation: UIImage.Orientation!
        switch UIDevice.current.orientation {
            case .portrait:
                orientation = .right
            case .landscapeRight:
                orientation = .down
            case .landscapeLeft:
                orientation = .up
            default:
                orientation = .up
        }
        return UIImage(cgImage: image, scale: 1.0, orientation: orientation)
    }


    /*!
     * @brief カメラの映像をキャプチャする
     * @param [in] output キャプチャした映像
     * @param [in] sampleBuffer サンプルバッファ
     * @param [in] connection キャプチャの接続
     */
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        // frameInterval(3フレーム)ごとに処理を行う
        frameCounter += 1
        if frameCounter == frameInterval {
            frameCounter = 0

            // カメラの向きを縦向きに調整
//            connection.videoRotationAngle = rotationCoordinator.videoRotationAngleForHorizonLevelCapture

            // サンプルバッファからピクセルバッファを取得
            guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }

            var text: String!
            var image: UIImage!
            if let objects = detectObject(pixelBuffer: pixelBuffer) {
                text = countObject(objects: objects)
                image = drawBox(pixelBuffer: pixelBuffer, objects: objects)
            }

            DispatchQueue.main.async {
                // テキスト更新
                if text != nil {
                    self.textView.text = text
                }

                // 画像更新
                if image != nil {
                    self.cameraView.image = image
                }
            }
        }
    }
}

struct DetectedObject {
    let observation: VNRecognizedObjectObservation // 検出結果オブジェクト
    let label: String // ラベル
    let confidence: Float // 信頼度
    let box: CGRect // オブジェクトの境界
}
```
</details>

## 参考
- 公式ドキュメント
    - [Ultralyticsホームページ](https://docs.ultralytics.com/ja)
    - [Apple Developer - Core ML](https://developer.apple.com/jp/documentation/coreml/)
    - [Apple Developer - AVFoundation](https://developer.apple.com/documentation/avfoundation)
    - [Apple Developer - Vision](https://developer.apple.com/documentation/vision)

- 外部記事
    - [Yolov8をCoreMLに変換してiPhoneで使う](https://qiita.com/john-rocky/items/0817bbd16667df183807)
    - [CoreMLでの推論結果に使われるVNObservationクラスについて](https://zenn.dev/ryosism/articles/cbf031456faed0)
    - [iOSでの動画処理における「回転」「向き」の取り扱いでもう混乱したくない](https://qiita.com/shu223/items/057351d41229861251af)
    - [【AVFoundation】リアルタイムキャプチャの基本](https://qiita.com/imchino/items/c916fd3a1d4057b1ff40)