LoginSignup
63
43

More than 1 year has passed since last update.

[Swift]Firebase MLKitを使って動画の顔認識をさせる

Last updated at Posted at 2019-09-23

前書き

少し前ですがFirebaseのハッカソンに参加してきました。

Firebaseは普段からごりごり業務で使っていて、使うことにそこまでは抵抗はない人って多いとは思いますが、、、
たくさん機能がある中でも、実際にサービスで使う機能って特定のものに限られますよね、、?
(Analytics, Crashlytics, RemoteConfig, ABTesting, Messaging, Predictions ...etc)

、、、と思っていて、せっかくハッカソンだし普段使わない機能を使いたい!というtryをしてみた結果、、
FirebaseのMLKitを使ってなんかしたい!にたどり着きました。笑

今回はその過程で学んだものをアウトプットしたいと思います。

実現したこと

内容

①MLKitを使って顔認識させ、UI上に結果を反映

具体的には、顔に連動して左上のいらすとやも変わるようになっています!

1日(8時間)というハッカソンの限られた時間だったので、3パターンまでしか用意できませんでしたが、、
「通常」 「寝ている」 「笑っている」の3パターンを検知しています。

mlkit.gif
※このイケメンは残念ながら僕ではありません。

たまに感度が悪い時もありますが、、、
かなり速い速度で顔を認識してくれます。

②認識させたデータをグラフにして可視化

MLKitで取得したデータをアプリで加工し、Web上でグラフとして可視化しています。
今回は「笑っている度合い」「右目の空き具合」「左目の空き具合」の指数をデータとして送っています。

Image from iOS (1).png

Image from iOS.png

データをわかりやすくするために1~100になるように丸め込んでいます。
横は時間軸になっており、縦はその時の数値になります。

数値の意味は以下になります。
- 笑顔の指数:100に近ければ笑っており、0に近ければ笑ってない
- 目の指数:100に近ければ開いており、0に近ければ閉じている

仕組み

行っていることは至ってシンプルです。

Artboard@3x.png

①カメラを通してMLKitで顔認識
②認識したデータをRealtime Databaseに送る
③Web上でグラフ化する

実装

準備

①Firebaseのセットアップ

MLKitを行うため用に、firebaseのコンソールからプロジェクトの作成

スクリーンショット 2019-09-19 3.09.48.png

作成後、設定(歯車アイコン)からダウンロードできる「GoogleService-Info.plist」をアプリに入れる

スクリーンショット 2019-09-19 3.23.25.png

雑になりましたが、詳しくはこの辺のセットアップ解説を参考にしてください

②Firebaseをアプリにインポート

MLkitとRealtime Databaseを使用するために、以下の対応するFirebaseライブラリをインストールしてください。
(Podじゃなくてもよいですが、自分でバイナリを用意するのは面倒かと思うので)

pod 'Firebase'
pod 'Firebase/Core'
pod 'Firebase/MLVision'
pod 'Firebase/MLVisionFaceModel'
pod 'Firebase/Database'

その後、AppDelegateFirebaseApp.configure()を追加するのを忘れないでください。
参考として、公式のリファレンスをみてください。

実装

今回は実装に必要なものを洗い出した結果、3つ必要だったので管理クラスもそれぞれ分けて実装しました。
互いの責務が別れるようになっています。

Connection.png

①動画の読み取り

ここが調査に手間がかかりました、、。
「MLKitで顔認識やってみた」系の記事は、画像の認識しかやっている人がおらず、、
結論から言うと、動画データを細切れの画像データとして取得して、それを顔認識させます。

では、さっそく動画のデータを取得できるようにします。
今回はCaptureVideoManagerというシングルトンクラスで管理できるように切り出しました。

1.結果の分岐実装

動画を読み込んだ際の結果をenumで定義しておきます。

CaptureVideoResult.swift
enum CaptureVideoResult {
    case success(_ manager: CaptureVideoManager)
    case failure
}

2.管理クラスの作成

CaptureVideoManager.swift
import AVKit

final class CaptureVideoManager: NSObject {

    static let shared = CaptureVideoManager()
    private override init() {}

    weak var delegate: CaptureVideoManagerDelegate?
    lazy var videoLayer: AVCaptureVideoPreviewLayer = {
        return AVCaptureVideoPreviewLayer(session: captureSession)
    }()
    private let captureSession = AVCaptureSession()
    private let videoDevice = AVCaptureDevice.default(for: .video)

    private lazy var videoOutput: AVCaptureVideoDataOutput = {
        let output = AVCaptureVideoDataOutput()
        let queue = DispatchQueue(label: "videoOutput", attributes: .concurrent)
        output.setSampleBufferDelegate(self, queue: queue)
        output.alwaysDiscardsLateVideoFrames = true
        return output
    }()
}

動画を画面上に表示するために必要なプロパティを定義しました。
今回は動画の映像を分析するため、オーディオ設定はしていないので、音を拾いたい場合は別途実装が必要です。
AVCaptureVideoPreviewLayerは、スマホの画面上に撮影している動画を表示するために使用します。

他に必要なメソッドを追加していきます。

CaptureVideoManager.swift
extension CaptureVideoManager {

    // カメラを使用するための許諾
    func requestPermission(completion: @escaping (CaptureVideoResult) -> Void) {
        AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
            guard let self = self else { return }
            completion((granted ? .success(self) : .failure))
        }
    }

    // カメラ映像を接続できるようにセットアップ
    func initialSession() {
        guard let vDevice = videoDevice, let vInput = try? AVCaptureDeviceInput(device: vDevice) else {
            debugPrint("error: non videoDevice")
            return
        }
        captureSession.addInput(vInput)
        captureSession.addOutput(videoOutput)
    }

    // カメラ映像の接続開始
    func startRecording() {
        captureSession.startRunning()
    }

    // カメラ映像の接続停止
    func stopRecording() {
        captureSession.stopRunning()
    }
}

今回は最低限の実装しかしていませんが、フレームレートの設定など動画の設定は、initialSession内で行なっているプロパティ操作部分で行うことができます。

3.動画データの受け取り

受け取った動画データを呼び出し元に返すためのDelegateを作成します。

CaptureVideoManagerDelegate.swift
protocol CaptureVideoManagerDelegate: class {
    func captureOutput(didOutput buffer: CMSampleBuffer)
}

CMSampleBufferというのがMLKitで分析するために必要な型になります。

AVCaptureVideoDataOutputSampleBufferDelegateを使用して動画のデータを受け取ります。

extension CaptureVideoManager: AVCaptureVideoDataOutputSampleBufferDelegate {

    func captureOutput(_ output: AVCaptureOutput,
                       didOutput sampleBuffer: CMSampleBuffer,
                       from connection: AVCaptureConnection) {

        delegate?.captureOutput(didOutput: sampleBuffer)
    }
}

先ほど作成したDelegateを呼んであげることで、呼びだし元にデータを渡すことができます。
AVCaptureOutputAVCaptureConnectionは今回は必要ないので一旦渡していません。

②顔認識させる

MLKitを使って顔を認識させます。
では、コードを記載していきます。
こちらもシングルトンのコードで管理クラスとして独立したものを作っていきます。

FacialDetector.swift

import AVFoundation
import FirebaseMLVision

final class FacialDetector {

    static let shared = FacialDetector()
    private init() {}

    private lazy var vision = Vision.vision()
    private let detectorOptions: VisionFaceDetectorOptions = {
        /// 検出できるデータの設定を行う
        let options = VisionFaceDetectorOptions()
        options.performanceMode = .accurate
        options.landmarkMode = .all
        options.contourMode = .none
        options.classificationMode = .all
        options.isTrackingEnabled = true
        return options
    }()
}

MLKitのVisionを使用していることに注意してください。
(元からあるVision.frameworkのAPIではないということ)

必要なメソッドを追加していきます。

FacialDetector.swift

extension FacialDetector {

    // 顔を認識させる
    func detectFaces(buffer: CMSampleBuffer,
                     orientation: UIDeviceOrientation,
                     position: AVCaptureDevice.Position) -> [VisionFace]? {

        let metadata = VisionImageMetadata()
        metadata.orientation = imageOrientation(orientation: orientation, position: position)

        let visionImage = VisionImage(buffer: buffer)
        visionImage.metadata = metadata

        let faceDetector = vision.faceDetector(options: detectorOptions)

        guard let faces = try? faceDetector.results(in: visionImage), !faces.isEmpty else {
            debugPrint("Failed to detect faces")
            return nil
        }
        return faces
    }

    // 端末の向きから画像の向きを返す
    func imageOrientation(orientation: UIDeviceOrientation,
                                  position: AVCaptureDevice.Position) -> VisionDetectorImageOrientation {

        switch orientation {
        case .portrait:
            return position == .front ? .leftTop : .rightTop
        case .landscapeLeft:
            return position == .front ? .bottomLeft : .topLeft
        case .portraitUpsideDown:
            return position == .front ? .rightBottom : .leftBottom
        case .landscapeRight:
            return position == .front ? .topRight : .bottomRight
        case .faceDown, .faceUp, .unknown:
            return .leftTop
        }
    }

    // 顔のデータから必要な情報を取り出して配列として返す
    func analysis(from faces: [VisionFace]) -> [String: Any] {
        var params = [String: Int]()

        for face in faces {            
            if face.hasSmilingProbability {
                let smileProb = face.smilingProbability * 100
                params["smileProb"] = Int(face.smilingProbability * 100)
            }
            if face.hasLeftEyeOpenProbability {
                let leftEyeOpenProb = face.leftEyeOpenProbability * 100
                params["leftEyeOpenProb"] = Int(leftEyeOpenProb)
            }
            if face.hasRightEyeOpenProbability {
                let rightEyeOpenProb = face.rightEyeOpenProbability * 100
                params["rightEyeOpenProb"] = Int(rightEyeOpenProb)
            }
            if face.hasTrackingID {
                let trackingId = face.trackingID
                params["trackingId"] = trackingId
            }
        }
        return params
    }
}

先ほども出てきたCMSampleBufferという聞いたこともない型に最初は戸惑っていましたが、、
MLKitを使用する際に、このCMSampleBuffer対応していた点は幸運でした。
そのおかげで、画像や動画のコンバートコードをかかずにすみました。

端末の向きから画像の向きを返すimageOrientationですが、こちらドキュメントのコードをそのまま使用すると横にした時にうまく動かず、、
ハッカソンで時間がなかったのでこちらの中身は編集していません。現状の上記のコードだと縦の画像(縦のカメラ)しかうまく認識しないので注意してください、

③Realtime Databaseへデータを書き込む

Realtime Databaseへのアクセスを一元管理するため、シングルトンのマネージャークラスを作成しています。
また、拡張性を持たせるためにKeyをenumで管理するようにしています。
Key(DBの子要素の名前)を指定することで、Realtime Database上の操作するデータを指定できます。

ConnectionManager.swift

import Firebase

final class ConnectionManager {

    enum Keys: String, CaseIterable {
        case faces
    }

    static let shared = ConnectionManager()
    private init() {}

    private let reference = Database.database().reference()

    /// Realtime Database へ書き込み
    func write(by key: Keys, params: [String: Any]) {
        let userReference = reference.child(key.rawValue)
        userReference.childByAutoId().setValue(params)
    }

    /// Realtime Database の中身をリセット
    func remove(by key: Keys) {
        let userReference = reference.child(key.rawValue)
        userReference.removeValue()
    }

    /// Realtime Database を監視する
    func connect(by key: Keys) {
        let userReference = reference.child(key.rawValue)
        userReference.observe(.childAdded) { snap in
            // do something
        }
    }

    /// Realtime Database の監視を解除
    func disconnect(by key: Keys) {
        let userReference = reference.child(key.rawValue)
        userReference.removeAllObservers()
    }
}

データが書き込まれることによって、それをWEBが検知してグラフ化しています。
今回はRealtime Databaseの監視まわりは必要ないですが、必要に応じて作りたい物がある人は拡張してください。

RealtimeDatabaseの中身

配下は、実際に送られている物になります。
先のコードにもありましたが、facesをkeyとして指定していたので、その下にデータが作られる様になっています。
faces配下の謎の文字列キーは、firebase側が勝手につけているユニークなものです。

スクリーンショット 2019-09-19 1.22.16.png

グラフ化するにあたり、timestampとcounterはこちらで付与しています。
動画を細切れにした画像で送っているため、順番をつけないとグラフの横軸を作れないためです。
(コード上には、timestampとcounterを付与する部分は省いています。)

timestampはDateFormatterのextensionを作成して現在の時間を取得しています。

timestamp.swift
extension DateFormatter {

    var timestamp: String {
        let timeInterval = NSDate().timeIntervalSince1970
        let myTimeInterval = TimeInterval(timeInterval)
        let time = NSDate(timeIntervalSince1970: TimeInterval(myTimeInterval))
        dateFormat = "yyyyMMddHHmmss"
        return self.string(from: time as Date)
    }
}

MLKitではtrakingIDというIDによって、顔の識別に使用しています。
複数の顔を読み込み可能です。
実際複数の顔を読みこめましたが、画面から顔が出て再び入ると新しいIDになってしまうなどの問題があります、、。

④動作

ここまで

① 動画データの読み込み(CaptureVideoManager)
② 顔を認識させる(FacialDetector)
③ RealtimeDatabaseへの書き込み(ConnectionManager)

の3つを作成してきました。

最後に呼び出し元のコントローラを記載しておきます。

MonitorViewController.swift
import UIKit
import AVKit

final class MonitorViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        initialView()
        requestPermission()
    }
}

extension MonitorViewController: CaptureVideoManagerDelegate {

    /// 動画データを受け取り、顔を認識させる処理に渡す
    func captureOutput(didOutput buffer: CMSampleBuffer) {
        detectFaces(from: buffer)
    }
}

private extension MonitorViewController {

    /// 動画の画面をiOS上に描画
    func initialView() {
        let videoLayer = CaptureVideoManager.shared.videoLayer
        videoLayer.frame = self.view.bounds
        videoLayer.videoGravity = .resizeAspectFill
        view.layer.addSublayer(videoLayer)
    }

    /// 動画の許諾を確認。成功ならdelegateをセットして動画接続の開始。
    func requestPermission() {
        CaptureVideoManager.shared.requestPermission { result in
            switch result {
            case .success(let manager):
                manager.delegate = self
                manager.initialSession()
                manager.startRecording()

            case .failure:
                // do some error handling
                break
            }
        }
    }

    /// MLKitで顔認識とRealtimeDatabaseへの書き込み
    func detectFaces(from buffer: CMSampleBuffer) {
        let result = FacialDetector.shared.detectFaces(
            buffer: buffer,
            orientation: UIDevice.current.orientation,
            position: AVCaptureDevice.Position.back
        )

        guard let faces = result else {
            debugPrint("----- error detectFaces ----")
            return
        }

        let params = FacialDetector.shared.analysis(from: faces)
        guard !params.isEmpty else {
            debugPrint("----- error params is empty ----")
            return
        }

        ConnectionManager.shared.write(by: .faces, params: params)
    }
}

責務を切り出しているので、コントローラはだいぶスッキリしていると思います。
一部コードを省略しているので、動かない場合や不具合があった場合は下記にあるリポジトリから動作するコードを見ていただけると幸いです。

後書き

MLKit、、正直感動しましたね笑
特に顔認識ソフトは何千万とかで販売されてたりするので、本来なら機械学習が必要な部分を無償提供してくれていると思うと、本当にすごいことだと思います。

その他

リポジトリ

こちらに実際に動くものを設置しておきます。
注意点として、自身の「GoogleService-Info.plist」をアプリに入れるのを忘れないでください
じゃないと動作しません

参考文献

関連

63
43
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
63
43