株式会社Berry
🤖

オフィス探検アプリを作る~簡単な物体認識のはじめ方~

2024/12/06に公開

はじめに

こんにちは、Berryへ新卒入社し、2年目のKyoです。主にモバイルアプリ関連の開発に携わっております。最近、Berryのオフィスでは外国籍の社員が増え、職場の雰囲気がますます国際的になってきました。異なるバックグラウンドを持つメンバーが増えたことで、新たな視点やアイデアが日々生まれており、非常に刺激的な環境が広がっています。

その一方で、オフィス内には「これは何だろう?」と気になるアイテムがいくつか見受けられます。例えば、机の上に置かれたユニークな形の置物や、共有スペースに置かれた見慣れないツールなど。これらが装飾的なアイテムなのか、それとも特定の用途を持つものなのか、興味をそそられる場面が増えています。

しかし、外国人としてこれらの「謎なグッズ」について直接尋ねるのは、タイミングや言葉選びに少し迷ってしまうこともあります。

ちょうどこれまでiOSのプロジェクトに携わってきた経験を活かして、写真を撮るだけで物体を認識し、その情報を提供するアプリを作成する構想を立てています。

今回のプロジェクトでは、写真を撮るだけで物体を認識するアプリを作成します。まずは完成したデモをご覧ください。

このアプリは、以下の環境で開発を進めています:

iOSバージョン: iOS 17

kaiyuanjiang@Kyos-MacBook ~ % xcodebuild -version
Xcode 16.0
Build version 16A242d
kaiyuanjiang@Kyos-MacBook ~ % swift --version
swift-driver version: 1.115 Apple Swift version 6.0 (swiftlang-6.0.0.9.10 clang-1600.0.26.2)
Target: arm64-apple-macosx14.0

これらの環境が整っていれば、同じ手順を試していただけます。

作り始め

今回は、シンプルなプロジェクトを作成します。以下の手順に沿って進めていきます:

1. カメラ構成

Xcodeで新規プロジェクトを作成し、カメラ機能を追加するための基本設定を行います。

2. カメラビュー作成

カメラのプレビューを表示するビューを実装します。

3. CoreMLをカメラと連携する

カメラから取得したフレームをCoreMLモデルに入力し、認識結果をリアルタイムで取得します。

これから各ステップの詳細な実装方法を説明していきます。

カメラ構成

まずは、カメラを構成するための Camera.swift を作成し、以下のコードを記述します。

class Camera {
    private let session = AVCaptureSession()
    private let videoOutput = AVCaptureVideoDataOutput()
    private let sessionQueue = DispatchQueue(label: "com.camera.sessionQueue")
    let videoOutputQueue = DispatchQueue(label: "com.camera.videoQueue", qos: .userInitiated, attributes: [], autoreleaseFrequency: .workItem)
    @Published var currentImage: UIImage?
    private let context = CIContext()
}

続いて、カメラを構成するための手順を進めていきます。
手順は以下の3つです:
カメラのアクセス権限確認->デバイスの入力設定->カメラの起動
まずは、カメラのアクセス権限を確認するメソッドを実装します。
Cameraクラスに以下のprivate func checkPermissions()を追加し、このメソッドでカメラのアクセス権限を確認し、状態に応じて適切な処理を行います。

class Camera {
//...先のコード
private func checkPermissions() {
   switch AVCaptureDevice.authorizationStatus(for: .video) {
   case .notDetermined:
       sessionQueue.suspend()
       AVCaptureDevice.requestAccess(for: .video) { authorized in
           if !authorized {
               print("認証されました")
           }
           self.sessionQueue.resume()
       }
   case .restricted:
       print("制限されています")
   case .denied:
       print("拒否されました") 
   case .authorized:
       break
   @unknown default:
       print("不明なステータス")
   }
}
//...

これでシンプルな権限管理が可能になります。
次に、カメラ構成のためにデバイスの入力を設定するメソッドを追加します。
Cameraクラスにprivate func configureCaptureSession()を実装し、このメソッドでカメラセッションを構成します。具体的には、カメラの入力とビデオの出力を設定します。

private func configureCaptureSession() {
    session.beginConfiguration()
    defer { session.commitConfiguration() }

    guard let camera = AVCaptureDevice.default(
        .builtInWideAngleCamera,
        for: .video,
        position: .back
    ) else {
        print("カメラが利用できません")
        return
    }

    do {
        let cameraInput = try AVCaptureDeviceInput(device: camera)
        if session.canAddInput(cameraInput) {
            session.addInput(cameraInput)
        } else {
            print("カメラ入力を追加できません")
            return
        }
    } catch {
        print("カメラ入力の作成に失敗しました")
        return
    }

    if session.canAddOutput(videoOutput) {
        session.addOutput(videoOutput)
        videoOutput.videoSettings = [
            kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
        ]
        videoOutput.connection(with: .video)?.videoRotationAngle = 90
        videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue.main)
    } else {
        print("ビデオ出力を追加できません")
        return
    }
}

func configureを作成し、権限確認、セッション設定、起動の3つの処理がシンプルにまとまり、非同期処理で効率的に動作します。

func configure() {
    checkPermissions()
    sessionQueue.async {
        self.configureCaptureSession()
        self.session.startRunning()
    }
}

これで、カメラセッションの基本構成が完了し、初期設定と起動が効率的に行えます!
ビデオフレームのデータを取得するために、AVCaptureVideoDataOutputSampleBufferDelegate を追加します。このプロトコルは Objective-C プロトコルであるため、クラスがこれに準拠するには NSObject を継承する必要があります。そのため、NSObject もクラスに追加します。

class Camera: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
//...

captureOutputを追加します。

func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }

        let ciImage = CIImage(cvPixelBuffer: pixelBuffer)

        videoOutputQueue.async { [weak self] in
            guard let self = self else { return }

            if let cgImage = self.context.createCGImage(ciImage, from: ciImage.extent) {
                let image = UIImage(cgImage: cgImage)
                DispatchQueue.main.async {
                    self.currentImage = image

                }
            }
        }
    }

カメラビュー作成

SwiftUI の View でカメラクラスを使いたいため、Camera クラスに ObservableObject プロトコルを追加します。このプロトコルを適用することで、@Published プロパティを使ってデータの変更を監視し、UI にリアルタイムで反映できます。

class Camera: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, ObservableObject {
//...

このコードは、カメラの映像をリアルタイムで表示する SwiftUI ビューです。@StateObject を使って Camera クラスのインスタンスを管理し、Viewの状態としてカメラの状態を追跡します。

import SwiftUI

struct CameraView: View {
    @StateObject private var camera = Camera() // StateObjectを明確にprivateで管理
    var body: some View {
        GeometryReader { geo in
            frameView()
                .frame(width: geo.size.width, height: geo.size.height) // レイアウトを調整
        }
        .onAppear {
            camera.configure() // ビューの表示と同時にカメラを構成
        }
    }

    @ViewBuilder
    private func frameView() -> some View {
        if let uiImage = camera.currentImage {
            Image(uiImage: uiImage)
                .resizable()
                .scaledToFill()
                .clipped()
        } else {
            Text("No Frame")
                .foregroundColor(.gray)
        }
    }
}

このコードは、カメラ映像のプレビューを簡単に表示するための基本的な構成になっています。

試し開始前、Info.plistにプライバシー設定を忘れずに

アプリを開いてみましょう!

アプリを起動すると、カメラの使用許可を求められる画面が表示されます。「Allow」 をタップして、カメラが正しく動作するかどうかを確認しましょう!

CoreMLをカメラと連携する

これから物体認識用のモデルを作成します。まずは、事前に学習用データを準備しましょう。
次に、Create MLで新しいプロジェクトを作成します。

OfficeExplorerの名前をつけ、プロジェクトを立ち上げましょう。

先ほど作成したフォルダーtrainning_dataをドロップし、学習開始。

具体的なtrainning_dataの作成方法については、こちらをご参照ください。

/trainning_data
├── 3Dプリントしたウサギ
│   ├── IMG_2156.JPG
│   ├── ...
├── Kyoの相棒
│   ├── IMG_2172.JPG
│   ├── ...
├── ノギス
│   ├── IMG_2118.JPG
│   ├── ...
├── 穴あけパンチ
│   ├── IMG_2214.JPG
│   ├── ...
├── 名越さんの肘マット
│   ├── IMG_2203.JPG
│   ├── ...
├── ベビーバンド
│   ├── IMG_2137.JPG
│   ├── ..
├── 中野さんが作った花瓶
│   ├── IMG_2124.JPG
│   ├── ...
└── 名越さんが作ったペン立て
    ├── IMG_2193.JPG
    ├── ...

OutputからモデルをExportして保存する。
Xcodeのプロジェクトに出力したモデルを入れる。
CoreMLモデルを使用するために、ImageClassifier.swiftを追加

import Foundation
import Vision
import UIKit

class ImageClassifier {
    struct Prediction {
        let classification: String
        let confidencePercentage: String
    }

    private static let imageClassifier: VNCoreMLModel = {
        guard let wrapper = try? FastViTT8F16(configuration: MLModelConfiguration()),
              let visionModel = try? VNCoreMLModel(for: wrapper.model) else {
            fatalError("Failed to create VNCoreMLModel")
        }
        return visionModel
    }()

    private var predictionHandlers = [VNRequest: (([Prediction]?) -> Void)]()

    func makePredictions(for photo: UIImage, completionHandler: @escaping ([Prediction]?) -> Void) throws {
        guard let cgImage = photo.cgImage else {
            fatalError("Photo doesn't have underlying CGImage")
        }

        let request = VNCoreMLRequest(model: Self.imageClassifier) { [weak self] request, error in
            guard let self = self,
                  let handler = self.predictionHandlers.removeValue(forKey: request) else { return }

            if let error = error {
                print("Vision error: \(error.localizedDescription)")
                handler(nil)
                return
            }

            let predictions = (request.results as? [VNClassificationObservation])?.map {
                Prediction(classification: $0.identifier,
                          confidencePercentage: $0.confidencePercentage)
            }
            handler(predictions)
        }
        
        request.imageCropAndScaleOption = .centerCrop
        predictionHandlers[request] = completionHandler

        try VNImageRequestHandler(cgImage: cgImage,
                                orientation: CGImagePropertyOrientation(photo.imageOrientation))
            .perform([request])
    }
}

private extension VNClassificationObservation {
    var confidencePercentage: String {
        let percentage = confidence * 100
        switch percentage {
        case 100.0...: return "100%"
        case 1.0..<100.0: return String(format: "%2.1f", percentage)
        default: return String(format: "%1.2f", percentage)
        }
    }
}

private extension CGImagePropertyOrientation {
    init(_ orientation: UIImage.Orientation) {
        switch orientation {
        case .up: self = .up
        case .down: self = .down
        case .left: self = .left
        case .right: self = .right
        case .upMirrored: self = .upMirrored
        case .downMirrored: self = .downMirrored
        case .leftMirrored: self = .leftMirrored
        case .rightMirrored: self = .rightMirrored
        @unknown default: self = .up
        }
    }
}

Cameraクラスに必要な関数とプロパティを追加していきましょう。

class Camera {
//...
private let itemClassifier = ImageClassifier()
    @Published var detectedObjects: [String] = []
//...
}
extension Camera {

    func classifyImage(_ image: UIImage) {
        do {
            try self.itemClassifier.makePredictions(for: image,
                                                    completionHandler: imagePredictionHandler)
        } catch {
            print("Vision was unable to make a prediction...\n\n\(error.localizedDescription)")
        }
    }

    func imagePredictionHandler(_ predictions: [ImageClassifier.Prediction]?) {
        guard let predictions = predictions else {
            detectedObjects = ["Nothing Detected..."]
            return
        }

        detectedObjects = formatPredictions(predictions)

    }

    private func formatPredictions(_ predictions: [ImageClassifier.Prediction]) -> [String] {
        // Vision sorts the classifications in descending confidence order.
        let predictionsToShow = 1

        let topPredictions: [String] = predictions.prefix(predictionsToShow).map { prediction in
            var name = prediction.classification

            if let firstComma = name.firstIndex(of: ",") {
                name = String(name.prefix(upTo: firstComma))
            }
//            "\(name) - \(prediction.confidencePercentage)%"
            return "\(name)"
        }

        return topPredictions
    }

}

Viewで検出結果見えるように、overlayTextを追加

import SwiftUI

struct CameraView: View {
    @StateObject var camera = Camera()
    @State private var showDetection: Bool = false
    var body: some View {
        GeometryReader { geo in
            frameView()
                .overlay(alignment: .center) {
                    Text(camera.detectedObjects.description)
                        .font(.title)
                        .foregroundStyle(.white)
                }
        }
        .onAppear {
            camera.configure()
        }
    }

以上で基本的な実装は完了となります

現段階ではUIにまだ改善の余地がありますが、次回の記事でより洗練されたUIの実装します。ぜひ次回もお楽しみください!

参考文献

https://developer.apple.com/documentation/coreml
https://developer.apple.com/documentation/avfoundation/
https://developer.apple.com/documentation/createml/

株式会社Berry
株式会社Berry

Discussion