目次
対象リポジトリ
何をするか
- カメラを使いたい場合にカメラの映像をハンドリングする方法をまとめる
- iPhone,iPadでカメラの出力内容の癖があるのでそれをまとめる
処理の流れ
- カメラセッションの準備
- Inputデバイスの設定と紐付け
- Inputデバイスからの出力先(Output先)の設定と紐付け
- カメラセッションの準備完了
- カメラセッション開始
- カメラ映像の処理
- プレビューの表示
ファイル一覧
- カメラセッションの準備や処理をするファイル(処理の流れ: 1 ~ 5)
- カメラから取得したフレーム画像の処理をするファイル(処理の流れ: 6)
- 画面に描画するためのUIKitの生成をするファイル(処理の流れ: 7)
処理の詳細
まず重要なのは、iPhone,iPadともにカメラが2種類以上あるという点です。
FrontカメラとBackカメラです。
2種類以上と書いたのは、「iPadOSでは外付けカメラも使うことができる」ようになっているためです。
まずはBackカメラ、つまりはデバイスの外側に向いているカメラを対象にプログラムを作成していきます。
1. カメラの使用準備と設定
ここでは、CameraService.swiftを作成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108
| import AVFoundation import Combine import CoreImage import SwiftUI
protocol CameraServiceDelegate: AnyObject { func cameraService(_ service: CameraService, didOutput pixelBuffer: CVPixelBuffer) }
final class CameraService: NSObject, ObservableObject { let session = AVCaptureSession()
weak var cameraServiceDelegate: CameraServiceDelegate?
private let cameraSessionQueue = DispatchQueue(label: "camera-session")
override init() { super.init()
cameraSessionQueue.async { [weak self] in self?.configureSession() self?.session.startRunning() } }
private func configureSession() { session.beginConfiguration()
session.sessionPreset = .hd1920x1080
guard let device = AVCaptureDevice.default( .builtInWideAngleCamera, for: .video, position: .back ), let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { session.commitConfiguration() return }
session.addInput(input)
let output = AVCaptureVideoDataOutput() output.alwaysDiscardsLateVideoFrames = true output.videoSettings = [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, ]
let outputQueue = DispatchQueue(label: "video-output")
output.setSampleBufferDelegate(self, queue: outputQueue)
guard session.canAddOutput(output) else { session.commitConfiguration() return }
session.addOutput(output)
session.commitConfiguration() } }
extension CameraService: AVCaptureVideoDataOutputSampleBufferDelegate { func captureOutput( _: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection ) { if UIDevice.current.userInterfaceIdiom == .phone { connection.videoRotationAngle = 90 }
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
cameraServiceDelegate?.cameraService(self, didOutput: pixelBuffer) } }
|
画像を表示するPreviewLayerは後からPreview側のコードでsessionに追加するのでとりあえず気にしなくて大丈夫です。
ここでは、主に以下の機能を作成しています。
- 解像度の指定
- 使用するデバイスの指定
- 出力先の生成と指定
- 映像が来た時にPixelBufferだけを取り出して外部に処理を委託
ここで重要なのは、NSObjectを継承するクラスをここに留めることです。
なので少しまわりくどいですが、AVCaptureVideoDataOutputSampleBufferDelegateをハンドリングして、ピクセルバッファのみ取り出して、結果をデリゲートで処理ピクセルバッファを伝達するように作成しています。
重要なのは、以下の部分です。
別スレッドでsession処理、映像ハンドリング処理をする
AVCaptureSessionの処理は重い可能性やハードウェア(カメラ)などの応答を待つことがある都合上、メインスレッドでは「行っていけない」ことになっています。
なので、何も考えず処理を実行すると思わぬエラーになります。
そこで、DispatchQueueを使うことで別スレッドに処理を任せることをしています。
これは、AVCaptureVideoDataOutputでも同じでコードを見るとDelegateの紐付け時に渡しているところでも同じ意味があります。
カメラデバイスからくる映像は回転している可能性がある
まず前提として、このアプリでは、以下の縛りをしています
- iPhone : 縦画面固定
- iPad : 横画面固定(充電口が右側に来るように)
captureOutput関数の処理を見ると、iPhoneの時だけ映像の角度を90度回転させていることがわかると思います。
これは、データが横で流れてきてしまうためです。
これは、センサーの向きに関係しているのですが、調べてもあまり明確な内容はありませんでした。
(現状はこのような仕様ですがバージョンが上がったら変化する可能性があります。)
以下の画像の右下が「生の画像の向き」です。
一番大きく出ている画像がAVCaptureVideoPreviewLayerで表示した映像になります。
どちらのデバイスでもデバイスの向きは「縦」にしています。
- iPhone

- iPad
ここで重要なのは、プレビューの映像と、処理する対象の画像の向きが合っていることです。
合っていないと処理してから回転させたり座標を計算したりすると変な位置に出てしまいます。
AVCaptureVideoPreviewLayerの優しいけど理解しにくい部分について
「一つ前のカメラデバイスからくる映像は回転している可能性がある」で話しましたが、AVCaptureVideoPreviewLayerは「映像を縦画面」で表示するようによしなに回転させて表示しているようです。
その結果以下のような表示になります。
- iPhone

- iPad
実際にカメラを動かすとわかりますが、画像の表示は縦画面っぽいのにカメラを横に動かすとプレビュー映像は縦に動いてしまうような無理矢理な変換をした結果の映像になってしまいます。
なので、これを補正するために、プレビュー時のiPadだけの処理として以下のコードで回転を勝手にしないように強制しています。
1 2 3 4
| if UIDevice.current.userInterfaceIdiom == .pad { videoPreviewLayer.connection?.videoRotationAngle = 0 }
|
3. UIとロジックの結合のためのViewModelの作成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| import Combine import SwiftUI
final class CameraViewModel: ObservableObject, CameraServiceDelegate { @Published var debugImage: CGImage?
let service = CameraService()
private let context = CIContext()
init() { service.cameraServiceDelegate = self }
func cameraService(_: CameraService, didOutput pixcelBuffer: CVPixelBuffer) { let ciImage = CIImage(cvPixelBuffer: pixcelBuffer)
let rect = ciImage.extent guard let cgImage = CIContext().createCGImage(ciImage, from: rect) else { return }
DispatchQueue.main.async { [weak self] in self?.debugImage = cgImage } } }
|
ここは比較的シンプルで、一番最初に作成したCameraServiceでカメラの映像がきたら発火するようにしたイベントを購読して新しい映像をそのままUIに表示するという処理を行なっています。
注意点とすれば、SwiftUIの変更を伴うdebugImageに対して画像を設定する部分ではメインスレッドで処理する必要があるのでそこに注意が必要なところぐらいです。
2. プレビュー画面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| import AVFoundation import SwiftUI
final class PreviewView: UIView { override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
override func layoutSubviews() { super.layoutSubviews()
videoPreviewLayer.frame = bounds
if UIDevice.current.userInterfaceIdiom == .pad { videoPreviewLayer.connection?.videoRotationAngle = 0 } } }
struct CameraPreview: UIViewRepresentable { let session: AVCaptureSession
func makeUIView(context _: Context) -> some UIView { let view = PreviewView() view.videoPreviewLayer.session = session view.videoPreviewLayer.videoGravity = .resizeAspect
return view }
func updateUIView(_: UIViewType, context _: Context) {} }
|
ここの処理は、AVCaptureSessionから流れてきた映像をAVCaptureVideoPreviewLayerにview.videoPreviewLayer.session = sessionで流し込むことで、自動で、connectionの設定などをよしなにしてくれます。
逆にそのお手軽さゆえに、こちらが望んでいない変換もしてしまうので以下コードの部分のように回転をしないように強制する必要があるなど、少しこだわりのある動作をさせようとすると逆に設定が増えたりしてしまいます。
1 2 3 4 5 6 7 8 9 10
| override func layoutSubviews() { super.layoutSubviews()
videoPreviewLayer.frame = bounds
if UIDevice.current.userInterfaceIdiom == .pad { videoPreviewLayer.connection?.videoRotationAngle = 0 } }
|
UIKitとSwiftUIの繋ぎ込みの調査をしていると時間がかかってしまうのでまた今度
各クラスについて(メモ程度)
AVCaptureSession
入力映像と出力先を繋げる分配器のようなクラス
このクラスに各設定を記述して繋げることでカメラからの映像をハンドリングできる。
このクラスは、複数のconnection(後述)に同じデータを流すことができるので、表示用(AVCaptureVideoPrevviewLayer)や処理用(AVCaptureVideoDataOutput)に映像データを流すことができる
脱線話(実質学んだこと)
classにfinalをつける理由
CameraService.swiftではfinal拡張子をクラスに付与しています。
別に付与しなくても動作に問題はありませんが、付与することで以下の意図をコードに含めることができます。
- このコードが行う処理は後から拡張するものではない
- この処理はプロジェクト内でこの一箇所だけで行うことを意図している
finalという機能自体は、単純でその拡張子をつけられたクラスを継承して新しいクラスを作れないことを強制しています。
また、コンパイラ的な視点に立つと、
コメントで「このクラスは継承しないでね」と書くよりも、コードの仕様としてコンテキストを含めることができるので以後finalを見たら「この機能は唯一無二で重要なことをしているんだな」と思うようにします。
NSObjectについて
NSObjectはObejct-Cで作成されたクラスとSwiftでやり取りをするときに継承する必要があります。
昔からある機能や低レイヤーな機能では未だにObject-Cでコントロールされている機能があります。
これらを扱うクラスには実装が必須になります。
AVFoundationはガッツリObject-Cがバックで動いているので、以下のような流れでNSObjectを継承した効果が発揮されてSwiftで書いたコードがObject-Cランタイムから呼び出さ