【iOS】カメラ周りの機能の実装方法

【iOS】カメラ周りの機能の実装方法

目次

対象リポジトリ

何をするか

  • カメラを使いたい場合にカメラの映像をハンドリングする方法をまとめる
  • iPhone,iPadでカメラの出力内容の癖があるのでそれをまとめる

処理の流れ

  1. カメラセッションの準備
  2. Inputデバイスの設定と紐付け
  3. Inputデバイスからの出力先(Output先)の設定と紐付け
  4. カメラセッションの準備完了
  5. カメラセッション開始
  6. カメラ映像の処理
  7. プレビューの表示

ファイル一覧

  • カメラセッションの準備や処理をするファイル(処理の流れ: 1 ~ 5)
    • CameraService.swift
  • カメラから取得したフレーム画像の処理をするファイル(処理の流れ: 6)
    • CameraViewModel.swift
  • 画面に描画するためのUIKitの生成をするファイル(処理の流れ: 7)
    • CameraPreview.swift

処理の詳細

まず重要なのは、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 // UIDeviceでデバイス判定のためにインポート

// カメラから取得した名前のピクセル情報を渡す
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

// ビデオデバイス(ハード)の指定と取得
// AVCaptureDeviceInputの生成
// 入力として使えるかチェック
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 = [
// ピクセルバッファの色の順番をBGRAに指定
kCVPixelBufferPixelFormatTypeKey as String:
kCVPixelFormatType_32BGRA,
]

// ビデオ出力用のキューを生成
let outputQueue = DispatchQueue(label: "video-output")

// サンプルバッファの処理キューの紐づけ
output.setSampleBufferDelegate(self, queue: outputQueue)

// 生成した出力先にアウトプットが設定できるかチェック
guard session.canAddOutput(output) else {
session.commitConfiguration()
return
}

// outputをセッションに登録
session.addOutput(output)

// 設定終了(反映)
session.commitConfiguration()
}
}

extension CameraService: AVCaptureVideoDataOutputSampleBufferDelegate {
func captureOutput(
_: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
// フレーム画像を使った処理
// iPadは横画面(充電ポートが右)のフレームがくるのでそのまま
// iPhoneは縦画面のフレームだが、横画面(充電ポートが右)に回転したフレームがくるので90度回転して縦にする
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
//  iPadは充電器側を右側にした横画面にするので、AVCaptureVideoPreviewLayerで自動補正しないように明示的に指定
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) {
// CIImageの依存はCoreImage
let ciImage = CIImage(cvPixelBuffer: pixcelBuffer)

// 生の向きのまま CGImageにする
let rect = ciImage.extent
guard let cgImage = CIContext().createCGImage(ciImage, from: rect) else {
return
}

// UIをいじるのでメインスレッドで動かす
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

// iPadは充電器側を右側にした横画面にするので、AVCaptureVideoPreviewLayerで自動補正しないように明示的に指定
if UIDevice.current.userInterfaceIdiom == .pad {
videoPreviewLayer.connection?.videoRotationAngle = 0
}
}
}

struct CameraPreview: UIViewRepresentable {
// 外部からセッションを受け取る
let session: AVCaptureSession

// Viewオブジェクトを作成、初期状態の構成
// 1回だけ呼ばれる
func makeUIView(context _: Context) -> some UIView {
let view = PreviewView()
view.videoPreviewLayer.session = session
view.videoPreviewLayer.videoGravity = .resizeAspect
// .resizeAspect : 黒帯付きで全部表示(クロップなし)
// .resizeAspectFill : はみ出す部分はクロップして画面を埋める
// .resize : 伸び縮みさせてピッタリ(歪む)

return view
}

// Viewの状態を更新する
func updateUIView(_: UIViewType, context _: Context) {}
}

ここの処理は、AVCaptureSessionから流れてきた映像をAVCaptureVideoPreviewLayerview.videoPreviewLayer.session = sessionで流し込むことで、自動で、connectionの設定などをよしなにしてくれます。

逆にそのお手軽さゆえに、こちらが望んでいない変換もしてしまうので以下コードの部分のように回転をしないように強制する必要があるなど、少しこだわりのある動作をさせようとすると逆に設定が増えたりしてしまいます。

1
2
3
4
5
6
7
8
9
10
override func layoutSubviews() {
super.layoutSubviews()

videoPreviewLayer.frame = bounds

// iPadは充電器側を右側にした横画面にするので、AVCaptureVideoPreviewLayerで自動補正しないように明示的に指定
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ランタイムから呼び出さ

Author

Daiki Iijima

Posted on

2026-01-07

Updated on

2026-01-07

Licensed under