はじめに
メリークリスマス
この記事は、SLP KBIT AdventCalendar2021 25日目の記事です。
気づいたらもう最終日になってました。2日目と7日目も担当しているのでよかったらご覧ください。
ということで、最後は指パッチンカメラを作ったので紹介しようと思います。
その名の通り、指パッチンでカメラのシャッターを切ることができるアプリです。
GitHubで公開しています。
iPhone向けにSwiftで作ったのでmacのXcodeでないとビルドできません。ご注意くださいませ。
完成したアプリの動作
こんな感じで指をパッチン!と鳴らせば撮れてしまうのです!
もちろんボタンでも動作します。
他に、内側のレンズへの切り替えやフラッシュ、タイマー機能といったカメラとしての最低限の機能を実装しています。
アプリについて
指パッチンの検出には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 Classifierのfinger_snapping
ラベルがついたデータを指定し、SoundAnalysis
で解析を行います。
準備
音を解析するためのクラスを作ります。
-
audioEngine
でマイクからのストリーミングオーディオをキャプチャします。 -
analyzer
でキャプチャーしたオーディオを解析します。 -
inputFormat
でオーディオの形式を保持します。 -
detectionObserver
で解析結果を受け取り、指パッチンが行われたどうかを監視します。 -
camera
は後述するカメラモデルを参照するために宣言しています。
detectionObserver
には指パッチンを監視させるよう指定し、PassthroughSubject
を渡しています。
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()
}
// 省略 //
}
以下の関数はこのクラス内に記述していきます。
オーディオセッションの設定を変更する
デフォルトの状態では、カメラのアクティブな方向を正面として、マイクの音をカーディオイドに変換してしまうので解析には向いていません。そんなお節介機能はいらないのでAVAudioSession
のsetCategory
で変更します。mode
を.measurement
にすることで、プライマリマイクで拾った音をそのまま流してくれます。
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
で受け取り、オーディオエンジンを開始します。
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
へ追加します。
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を開始
タップをインストールすることで、audioEngine
のinputNodeの出力をanalyzer
に渡します。
あとは勝手に解析が行われるので、オブザーバーが指パッチンの検知を通知したタイミングでカメラの撮影が開始されるようにしています。
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
クラスのオブジェクトに通知します。
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)
}
}
}
}
起動時の動作
クラスのインスタンスが生成された時にマイクの使用権限をチェックし、問題がなければ先ほどのメソッドを順番に実行します。
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に変換し、本体のカメラロールに保存しています。
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を作成します。
これに関しての詳しい説明は以下が参考になるかと思います。
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
に入れ、またそれぞれのパーツの外縁部の大きさを揃えることでシャッターボタンが中央に来る様にしています。
ボタンを押すことででカメラモデルの状態が変化し、写真を撮るなどの処理が行われます。
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
が呼ばれるのでカメラのセットアップや指パッチン検出器の起動を行います。
写真の撮影に関しては、
- 検出器で指パッチンを検出あるいはボタンを押した場合に
camera
のwillTake
をトグル - その変化を
onChange
で拾い、それがtrueの時に撮影用のメソッドを呼ぶ
といった流れになっているので、CameraViwe
内に記述しています。
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()
}
}
}
}
所感
実用性としてはどうなんでしょう。集合写真や離れたところからの自撮りに使うくらいですかねえ。
あとは周りの目を気にしない心が大事ですね。
まあ指パッチンで何かを操作するのはロマンってやつですよ。はい。
ちなみにSiriKitを使って指パッチンからSiriを起動するアプリとかも作りたかったのですが...有料の開発者でなければできないようです...残念
あとそもそもSwiftという言語を知らない人も多いと思うので、それについての記事もいつか書けたらいいなあ。
おわりに
指パッチンを検知したら撮影してくれるアプリを作ってみました。
ビルトインの音声分類器は高精度な音声分類を手軽に行えるので、他にも色々と活用できそうです。
ということで今年のSLPアドカレは無事完結です!
また来年会いましょう!
良いお年を!(パチン!)
参考
- https://developer.apple.com/documentation/soundanalysis
- https://developer.apple.com/videos/play/wwdc2021/10036/
- https://heartbeat.comet.ml/sound-classification-using-core-ml-3-and-create-ml-fc73ca20aff5
- https://ichi.pro/swiftui-de-kameraapuri-o-kochikusuru-hoho-237113513468798
- https://software.small-desk.com/development/2020/08/10/swiftui-avfoundation-avcapturevideopreviewlayer/