11
9

More than 1 year has passed since last update.

Swiftで指パッチンカメラを作ってみた

Posted at

はじめに

:christmas_tree:
この記事は、SLP KBIT AdventCalendar2021 25日目の記事です。
気づいたらもう最終日になってました。2日目7日目も担当しているのでよかったらご覧ください。

ということで、最後は指パッチンカメラを作ったので紹介しようと思います。
その名の通り、指パッチンでカメラのシャッターを切ることができるアプリです。
GitHubで公開しています。

iPhone向けにSwiftで作ったのでmacのXcodeでないとビルドできません。ご注意くださいませ。

完成したアプリの動作

こんな感じで指をパッチン!と鳴らせば撮れてしまうのです!
もちろんボタンでも動作します。

IMG_0023_MOV_AdobeCreativeCloudExpress.gif

他に、内側のレンズへの切り替えやフラッシュ、タイマー機能といったカメラとしての最低限の機能を実装しています。

アプリについて

指パッチンの検出にはSound Analysisを使っています。
データセットを用意して学習させてもよかったのですが、延々と指パッチンするのは辛いので今回はBuilt-in Sound Classifierを利用しました。

カメラアプリのガワはSwiftUIで制作しました。

Swiftでアプリを作るのは初めてなので認識が甘いところや間違っている部分が多々あるかと思います。おかしなところはバンバン指摘していただければ幸いです。

Sound Analysisとは

音声解析のためのSwift用フレームワークです。デバイス上でリアルタイムに音を認識してくれます。

以下の公式ドキュメントで概要や簡単な使い方が説明されています。

Built-in Sound Classifierとは

Sound Analysisで用意されている組み込みの音声分類器です。今年のWWDCで発表されました。
楽器の音、拍手、雨の音や話し声など300種類以上の基本的なサウンドが分類できます。

以前は自分でデータセットを用意しCreate MLを使って学習させる必要がありましたが、
その手間がかからない分、手軽にアプリに組み込めるようになりました。

開発環境

  • macOS 12.1(Monterey)
  • Xcode 13.2.1
  • Swift 5.5.2
  • iOS 15.2

開発の流れ

特定のサウンドを検知するアプリとカメラアプリをそれぞれ制作し、指パッチン検出をカメラアプリの方に組み込みました。
デバイスのカメラとマイク、保存のためにカメラロールにアクセスする必要があるのでInfo.plistにプライバシー設定を追加しておきましょう。
以下では完成したアプリの実装について簡単に紹介します。

指パッチンを検知する

指パッチンを検知するためにはBuilt-in Sound Classifierfinger_snappingラベルがついたデータを指定し、SoundAnalysisで解析を行います。

準備

音を解析するためのクラスを作ります。

  • audioEngineでマイクからのストリーミングオーディオをキャプチャします。
  • analyzerでキャプチャーしたオーディオを解析します。
  • inputFormatでオーディオの形式を保持します。
  • detectionObserverで解析結果を受け取り、指パッチンが行われたどうかを監視します。
  • cameraは後述するカメラモデルを参照するために宣言しています。

detectionObserverには指パッチンを監視させるよう指定し、PassthroughSubjectを渡しています。

SystemAudioClassifier
import Foundation
import AVFoundation
import SoundAnalysis
import Combine

class SystemAudioClassifier: NSObject {

    // 指パッチン検知を通知するサブジェクト
    let subject = PassthroughSubject<Bool, Never>()
    var detectionCancellable: AnyCancellable?

    // アナライザー関連
    let audioEngine = AVAudioEngine()
    var inputFormat: AVAudioFormat!
    var analyzer: SNAudioStreamAnalyzer!

    // オブザーバー
    var detectionObserver: DetectionObserver!

    var camera: CameraModel!

    init(_ camera: CameraModel) {
        detectionObserver = DetectionObserver(label: "finger_snapping", subject: subject)
        self.camera = camera
        super.init()
    }

    // 省略 //

}

以下の関数はこのクラス内に記述していきます。

オーディオセッションの設定を変更する

デフォルトの状態では、カメラのアクティブな方向を正面として、マイクの音をカーディオイドに変換してしまうので解析には向いていません。そんなお節介機能はいらないのでAVAudioSessionsetCategoryで変更します。mode.measurementにすることで、プライマリマイクで拾った音をそのまま流してくれます。

setUpAudioSession
func setUpAudioSession() {
    // セッションの設定
    do {
        let session = AVAudioSession.sharedInstance()
        try session.setCategory(.record, mode: .measurement)
        try session.setActive(true)
    } catch {
        fatalError("Failed to configure and activate session.")
    }
}

AudioEngineをスタートさせる

audioEngineには暗黙の入力ノードがあるのでこれのオーディオフォーマットをinputFormatで受け取り、オーディオエンジンを開始します。

startAudioEngine
func startAudioEngine() {

    // 入力形式を取得
    inputFormat = audioEngine.inputNode.inputFormat(forBus: 0)

    // オーディオエンジンを開始
    do{
        try audioEngine.start()
    }catch( _){
        print("error in starting the Audio Engin")
    }
}

Analyzerをセットアップする

キャプチャしたオーディオはリアルタイムで解析するため、SNAudioStreamAnalyzerを利用します。今回はビルトインの音声分類器を使用するので引数をclassifierIdentifier: .version1とします。(version2は今のところ無さそう?)
また、指パッチンは鳴っている時間が短いので、解析する1区間あたりの時間を0.5秒(設定できる最小値)にしています。
聞き逃さないようにoverlapFactorも0.9まで引き上げています...まあ本来は0.5もあれば十分だと思いますが...

最後に、上記の設定と解析結果を受け取るためのオブザーバーをanalyzerへ追加します。

setUpAnalyzer
func setUpAnalyzer() {

    // マイクストリームから音を入力
    analyzer = SNAudioStreamAnalyzer(format: inputFormat)

    do {
        // ビルトインの音声分類器を使用
        let request = try SNClassifySoundRequest(classifierIdentifier: .version1)

        // ウインドウの時間的な長さ
        request.windowDuration = CMTimeMakeWithSeconds(0.5, preferredTimescale: 48_000)

        // ウインドウの重なり度合い
        request.overlapFactor = 0.9

        // アナライザーに検出リクエストを追加
        try analyzer.add(request, withObserver: detectionObserver)
    } catch {
        print("Unable to prepare request: \(error.localizedDescription)")
        return
    }
}

Analyzeを開始

タップをインストールすることで、audioEngineinputNodeの出力をanalyzerに渡します。
あとは勝手に解析が行われるので、オブザーバーが指パッチンの検知を通知したタイミングでカメラの撮影が開始されるようにしています。

startAnalyze
func startAnalyze() {

    // 監視用のオーディオタップをインストール
    audioEngine.inputNode.installTap(onBus: 0, bufferSize: 8000, format: inputFormat) { buffer, time in
        DispatchQueue.global().async {
            self.analyzer.analyze(buffer, atAudioFramePosition: time.sampleTime)
        }
    }

    // detectionObserverから送られる通知を処理
    detectionCancellable = subject
        .receive(on: DispatchQueue.main)
        .sink{

            // 指パッチンが検出されたらカメラの撮影トリガーを切り替える
            if $0 && self.camera.isSaved && self.camera.canUse {
                self.camera.isSaved = false
                self.camera.willTake = true
            }
    }
}

オブザーバーについて

解析結果を受け取り、指パッチンの検出を通知するクラスを作ります。
SNResultsObservingクラスを継承し、func request(_ request: SNRequest, didProduce result: SNResult)で結果を受け取ります。

解析結果における指パッチンの確度が5割を超えていれば、指パッチンが行われたと判断します。
PassthroughSubjectというPublisherを通じて真偽をSystemAudioClassifierクラスのオブジェクトに通知します。

DetectionObserver
import Foundation
import SoundAnalysis
import Combine

class DetectionObserver: NSObject, SNResultsObserving {
    private let subject: PassthroughSubject<Bool, Never>
    private var label: String

    init(label: String, subject: PassthroughSubject<Bool, Never>) {
        self.subject = subject
        self.label = label
    }

    // 検知用の関数
    func request(_ request: SNRequest, didProduce result: SNResult) {

        // 指パッチンが検出され、その信頼度が50%以上ならtrueを通知
        if let result = result as? SNClassificationResult,
           let classification = result.classification(forIdentifier: label) {

            if classification.confidence > 0.5 {
                subject.send(true)
            } else {
                subject.send(false)
            }
        }
    }
}

起動時の動作

クラスのインスタンスが生成された時にマイクの使用権限をチェックし、問題がなければ先ほどのメソッドを順番に実行します。

check
switch AVCaptureDevice.authorizationStatus(for: .audio) {
    case .authorized:
        setUpAudioSession()
        startAudioEngine()
        setUpAnalyzer()
        startAnalyze()

    //  省略  //
}

カメラの中身を作る

カメラの動作を実装します。UIImagePickerControllerでは指パッチンのタイミングでシャッターを切れないのでAVCaptureSessionを使って自分で記述します。

以下の公式ドキュメントやこちらを参考に作りました。

実装

カメラの実装に関しては最低限の要点だけを簡単に説明します。

セットアップ

カメラの状態の変化に応じて画面を更新したいのでObservableObjectを継承します。また、AVCapturePhotoCaptureDelegateでphotoメソッドを使えるようにします。
まずはAVCaptureSessionクラスのインスタンスを生成し、各種設定をしておきます。

撮影プリセットを.photoにすることで3:2の高画質なキャプチャになります。
また、デバイスのどのカメラを使用するかなどを定義し、セッションに追加します。

class CameraModel: NSObject, ObservableObject, AVCapturePhotoCaptureDelegate {

    // カメラのステータス
    @Published var willTake = false
    @Published var isTaking = false
    @Published var isSaved = true
    @Published var canUse = false

    @Published var session = AVCaptureSession()
    var inputDevice: AVCaptureDeviceInput!

    // 写真データを取得するためのアウトプット
    @Published var output = AVCapturePhotoOutput()

    // プレビュー
    @Published var preview: AVCaptureVideoPreviewLayer!

    // 撮影データ
    @AppStorage("last_pic") var picData = Data(count: 0)

    // 省略 //

    func setUp() {

        // カメラの初期設定を行う

        do{
            // 設定変更を開始
            self.session.beginConfiguration()

            // プリセットに写真用のものを選択
            self.session.sessionPreset = .photo

            // インプット元のデバイスなどを設定(機種によって変更する必要有るかも)
            let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back)

            inputDevice = try AVCaptureDeviceInput(device: device!)

            // セッションに追加できるかを確認し、インプット元を追加
            if self.session.canAddInput(inputDevice) {
                self.session.addInput(inputDevice)
            }

            // 同様にアウトプット先を追加
            if self.session.canAddOutput(self.output) {
                self.session.addOutput(self.output)
            }

            // キャプチャを高解像度に設定
            self.output.isHighResolutionCaptureEnabled = true
            // 写真は品質を優先するように設定
            self.output.maxPhotoQualityPrioritization = .quality


            // 設定変更をコミット
            self.session.commitConfiguration()

            DispatchQueue.main.async {
                self.canUse = true
            }
        } catch{
            print(error.localizedDescription)
        }
    }

    // 省略 //
}

写真撮影を行う

写真を撮る際はtakePicを呼び出します。AVCapturePhotoSettingsで撮影におけるフラッシュの有無や撮影の形式を設定できます。

output.capturePhotoで撮影プロセスを実行しています。ここではデリゲート先をオブジェクト自身に設定し、撮影中および撮影後の処理も同じクラス内に記述しています。

func takePic() {

    DispatchQueue.global(qos: .userInteractive).async {

        // 撮影時の設定を行う
        let photoSettings = AVCapturePhotoSettings()
        // フラッシュを焚くかどうか
        if self.inputDevice.device.isFlashAvailable {
            photoSettings.flashMode = self.flash
        }

        // 撮影を高解像度で行う
        photoSettings.isHighResolutionPhotoEnabled = true
        // 撮影を品質優先で行う
        photoSettings.photoQualityPrioritization = .quality

        // 撮影を実行
        self.output.capturePhoto(with: photoSettings, delegate: self)

        DispatchQueue.main.async {
            print("pic taken")
            self.willTake = false
        }
    }
}

撮影したデータを処理

撮影プロセスの進行中および完了後にはこちらのデリゲートメソッドに通知が行われます。撮影プロセスが完了後の処理を以下のコードように定義します。

送られてくる撮影データをUIImageに変換し、本体のカメラロールに保存しています。

photoOutput
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {

    if error != nil{
        return
    }

    // 撮影データを生成
    guard let imageData = photo.fileDataRepresentation() else {return}
    self.picData = imageData

    // UIImageに変換
    let image = UIImage(data: self.picData)!

    // イメージをアルバムに保存
    UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)

    // セーブ完了
    DispatchQueue.main.async {
        self.isSaved = true
    }

    print("saved Successfully")
}

Viewを作る

今回はSwiftUIを使って画面表示を作りました。純正のカメラアプリの見た目に少し寄せています。
なお、こちらもフラッシュやタイマー機能の説明は省略しています。

カメラのプレビュー

カメラモデルのクラスでもちょろっと出てきていたAVCaptureVideoPreviewLayerクラスを利用します。
これにカメラのセッションを渡すことで処理のプレビューを取得できます。

もちろんCALayerはSwiftUIで直接扱えないので、UIViewRepresentableでラップしたUIViewを作成します。
これに関しての詳しい説明は以下が参考になるかと思います。

CameraPreview
import SwiftUI
import AVFoundation

struct CameraPreview: UIViewRepresentable {

    @ObservedObject var camera: CameraModel

    // UIKitを使用してUIViewを作成
    func makeUIView(context: Context) -> UIView {

        // viewのサイズを指定して生成
        let height = UIScreen.main.bounds.width * 4 / 3
        let y = UIScreen.main.bounds.height / 2 - height / 2 
        let rect = CGRect(x: 0, y: y, width: UIScreen.main.bounds.width, height: height)
        let view = UIView(frame: rect)

        camera.preview = AVCaptureVideoPreviewLayer(session: camera.session)
        camera.preview.frame = view.frame

        // レイヤーへのプレビューの表示形式(枠に収まるようにアス比を維持してリサイズ)
        camera.preview.videoGravity = .resizeAspect
        view.layer.addSublayer(camera.preview)

        // カメラのセッションを開始
        camera.session.startRunning()

        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) { return }
}

画面下にあるパーツ群

画面の下側にはシャッターボタン、内カメ外カメ切り替えボタン、直前に撮影した写真を表示する枠を設置します。
まあこれは見たまんまですね。

横に並べるのでHStackに入れ、またそれぞれのパーツの外縁部の大きさを揃えることでシャッターボタンが中央に来る様にしています。
ボタンを押すことででカメラモデルの状態が変化し、写真を撮るなどの処理が行われます。

LowerPart
import SwiftUI

struct LowerPart: View {

    @StateObject var camera: CameraModel

    var body: some View {
        HStack {

            // 直近の撮影した写真を表示するパーツ
            Button(action: {
                // タッチでカメラロールに遷移
                if let url = URL(string: "photos-redirect:") {
                    UIApplication.shared.open(url)
                }
            }) {
                if let image = UIImage(data: camera.picData) {
                    Image(uiImage: image)
                        .resizable()
                        .scaledToFill()
                        .frame(width: 60, height: 60)
                        .clipShape(RoundedRectangle(cornerRadius: 10))
                } else {
                    Image(systemName: "photo.on.rectangle")
                        .foregroundColor(.black)
                        .frame(width: 60, height: 60)
                        .background(.white.opacity(0.8))
                        .clipShape(RoundedRectangle(cornerRadius: 10))
                }
            }

            Spacer()

            // シャッターボタン
            Button(action: {
                if camera.canUse && camera.isSaved {
                    camera.isSaved = false
                    camera.willTake = true
                }
            }) {
                ZStack {
                    Circle()
                        .fill(Color.white)
                        .frame(width: 60, height: 60)

                    Circle()
                        .stroke(Color.white, lineWidth: 4)
                        .frame(width: 68, height: 68)
                }
            }

            Spacer()

            // 外カメラと内カメラを切り替えるボタン
            Button(action: {
                if camera.canUse {
                    camera.front.toggle()
                    camera.changeCam()
                }
            }) {
                Image(systemName: "arrow.triangle.2.circlepath.camera")
                    .foregroundColor(.black)
                    .frame(width: 45, height: 45)
                    .background(.white.opacity(0.8))
                    .clipShape(Circle())
            }
            .frame(width: 60.0, height: 60.0)
        }
        .frame(height: 100)
        .padding(.horizontal, 25)
    }
}

最終レイアウト

カメラのプレビューやボタン類などのViewを定義したのでそれらを配置して最終的な画面を作ります。
プレビューを奥に、ボタン類は手前に設置します。

アプリの起動時にonAppearが呼ばれるのでカメラのセットアップや指パッチン検出器の起動を行います。

写真の撮影に関しては、
1. 検出器で指パッチンを検出あるいはボタンを押した場合にcamerawillTakeをトグル
2. その変化をonChangeで拾い、それがtrueの時に撮影用のメソッドを呼ぶ

といった流れになっているので、CameraViwe内に記述しています。

CameraView
import SwiftUI

struct CameraView: View {

    @StateObject var camera = CameraModel()

    var body: some View {
        ZStack {

            PreviewField(camera: camera)

            VStack {

                UpperPart(camera: camera)

                Spacer()

                LowerPart(camera: camera)
            }
        }
        .onAppear(perform: {
            camera.check()
            camera.detector?.check()
        })
        .onChange(of: camera.willTake) { state in
            if state {
                camera.camSequence()
            }
        }
    }
}

最終的なUIはこのようになります。
image.png

所感

実用性としてはどうなんでしょう。集合写真や離れたところからの自撮りに使うくらいですかねえ。
あとは周りの目を気にしない心が大事ですね。
まあ指パッチンで何かを操作するのはロマンってやつですよ。はい。

ちなみにSiriKitを使って指パッチンからSiriを起動するアプリとかも作りたかったのですが...有料の開発者でなければできないようです...残念

あとそもそもSwiftという言語を知らない人も多いと思うので、それについての記事もいつか書けたらいいなあ。

おわりに

指パッチンを検知したら撮影してくれるアプリを作ってみました。
ビルトインの音声分類器は高精度な音声分類を手軽に行えるので、他にも色々と活用できそうです。

ということで今年のSLPアドカレは無事完結です!
また来年会いましょう!
良いお年を!(パチン!:point_right:)

参考

11
9
0

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
11
9