🎨

SwiftUIでカメラから色を取得してみる

2023/12/15に公開

こんなのを作ります

はじめに

今回はSwiftUIでAVFoundationを使用し、カメラから色を取得するアプリを作っていきます。

本記事のポイント

  • SwiftUIでAVFoundationを使ってカメラ画面の表示
  • カメラの映像から色を取得

使用するフレームワーク

  • AVFoundation
  • SwiftUI
  • UIKit

AVFondationでカメラの表示

アーキテクチャ

今回はVIPERアーキテクチャでカメラ機能を実装していきたいと思います。

名称 責務
View Presenterから伝えられた情報の表示、ユーザーからの画面操作処理
Presenter Viewに表示するためのデータ生成し、Viwからのユーザイベントの受け取り、Iteratorへデータを要求し、そのデータが返ってきたらViewコンテンツを準備し、Viewに伝える
Interactor Entityに関するビジネスロジックが責務
Entity アプリケーションのデータモデル
Router 画面遷移と新しい画面のセットアップ

VIPERのざっくりとした機能説明は以上になります。
AVFoundationでカメラ画面を描画するだけなので、EntityとRouterは使用せずに実装していきます。

カメラへのアクセス許可

カメラから映像を取得するためにはアクセス許可が必要です。
Info.plistにNSCameraUsageDescriptionを追加し、カメラ許諾ダイアログが表示されるようにしておきましょう。これがないとカメラ使用時にアプリがクラッシュしてしまいます。

Viewの実装

CALayerをSwiftUIで使用できるように変換

AVFoundationでカメラから取得してきた映像を反映させるCALayerをSwiftUIでも使用可能にするため、UIViewControllerRepresentable型に変換していきます。
CALayer自体はPresenterから受け取るので、CALayerView初期化時に受け取れるようにしておきます。

import SwiftUI

struct CALayerView: UIViewControllerRepresentable {
    typealias UIViewControllerType = UIViewController
    var caLayer: CALayer

    func makeUIViewController(context: Context) -> UIViewController {
        let viewController = UIViewController()
        viewController.view.layer.addSublayer(caLayer)
        caLayer.frame = viewController.view.layer.frame
        return viewController
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
        caLayer.frame = uiViewController.view.layer.frame
    }
}

先ほど変換したCALayerViewをSwiftUIで表示します。

import SwiftUI

struct ColorCaptureView: View {
    @ObservedObject var presenter: ColorCapturePresenter

    var body: some View {
	ZStack {
	    CALayerView(caLayer: presenter.previewLayer)
	}
	.onAppear {
	        // View表示時
	    self.presenter.apply(inputs: .onAppear)
	}
	.onDisappear {
	    // View非表示時
            self.presenter.apply(inputs: .onDisappear)
	}
    }
}

Presenterの実装

PresenterではInteractorへカメラセッションの設定と開始と停止、Interactorから返ってきたCALayerをViewからPresenter経由で参照できるようにします。

import SwiftUI
import Combine

final class ColorCapturePresenter: ObservableObject {
    private let interactor = ColorCaptureInteractor()

        // これをViewから参照する
    var previewLayer: CALayer {
        return interactor.previewLayer!
    }

    enum Inputs {
        case onAppear
        case onDisappear
    }

    init() {
            // カメラの設定
        interactor.setupAVCaptureSession()
    }

    func apply(inputs: Inputs) {
        switch inputs {
        case .onAppear:
	    // カメラ開始
            interactor.startSettion()
        case .onDisappear:
	        // カメラ停止
            interactor.stopSettion()
        }
    }
}

Interactorの実装

Interactorではカメラの映像を画面に反映させるために、キャプチャセッションを作成していきます。
キャプチャセッション作成の流れとしては以下になります。

  1. カメラを使用するデバイス情報の生成
  2. デバイス情報を使ってInputs(入力ソース)をキャプチャセッションにセット
  3. Outputs(出力ソース)をキャプチャセッションにセット
  4. キャプチャセッションからPreviewLayer(画面に表示する映像)を生成

またセッションの開始・停止処理もここで実装いたします。
これでカメラの映像をアプリ上に表示することが出来るようになりました。

import AVFoundation
import UIKit

final class ColorCaptureInteractor: NSObject, ObservableObject {
    private let captureSession = AVCaptureSession()
    private var captureDevice: AVCaptureDevice?
    @Published var previewLayer: AVCaptureVideoPreviewLayer?
    
    func setupAVCaptureSession() {
            // 出力する映像の品質を設定
        captureSession.sessionPreset = .photo
	
	// 1. カメラを使用するデバイス情報の生成
        if let availableDevice = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .back).devices.first {
            captureDevice = availableDevice
        }

        //  2. デバイス情報を使ってInputs(入力ソース)をキャプチャセッションにセット
        do {
            let captureDeviceInput = try AVCaptureDeviceInput(device: captureDevice!)
            captureSession.addInput(captureDeviceInput)
        } catch let error {
            print(error)
        }
        
	// 3. Outputs(出力ソース)をキャプチャセッションにセット
        let dataOutput = AVCaptureVideoDataOutput()
        dataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA]

        if captureSession.canAddOutput(dataOutput) {
            captureSession.addOutput(dataOutput)
        }

        // 4. キャプチャセッションからPreviewLayer(画面に表示する映像)を生成
        let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        previewLayer.name = "CameraPreview"
        previewLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
        self.previewLayer = previewLayer
        
        captureSession.commitConfiguration()
    }

    // セッションの開始
    func startSettion() {
        if captureSession.isRunning { return }
        Task {
            await MainActor.run {
                captureSession.startRunning()
            }
        }
    }

        // セッションの停止
    func stopSettion() {
        if !captureSession.isRunning { return }
        captureSession.stopRunning()
    }
}

カメラの映像から色を取得

カメラの映像から色を取得するために、以下のような流れで実装していきます。

  1. カメラ映像のフレーム毎の画像データを取得
  2. 画像データから特定の座標の色情報を取得

カメラ映像のフレーム毎の画像データを取得

カメラ映像から画像データを取得するためには、キャプチャセッションの出力にデリゲートを設定することで、毎フレーム事のCMSampleBufferを受け取れるようにし、そのCMSampleBufferをUIImageに変換することで実現できます。
CMSampleBufferとは主にビデオやオーディオなどのメディアデータを表現するために用いられる型になります。

Outputsへのデリゲートの設定

先ほどのキャプチャセッション設定の処理にデリゲートの設定処理を追加します。

let dataOutput = AVCaptureVideoDataOutput()
dataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA]
// デリゲートを設定
dataOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "videoQueue"))

CMSampleBufferの取得・UIImageへの変換

Interactorのextentionとしてデリゲート関連の処理を実装していきます。
まずはcaptureOutputで毎フレームsampleBufferを受け取ります。

extension ColorCaptureInteractor: AVCaptureVideoDataOutputSampleBufferDelegate {
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        let image = imageFromSampleBuffer(sampleBuffer: sampleBuffer)
    }
}

次にsampleBufferからUIImageに変換していきます。

  1. sampleBufferからCMSampleBufferGetImageBufferでCVPixelBufferを取得します。
    CVPixelBufferはビデオフレームの画像データを保持するバッファです。
let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)!
  1. CVPixelBufferLockBaseAddressは、CVPixelBufferのメモリアドレスをロックします。これにより、後続の処理で安全にメモリアクセスができるようになります。
CVPixelBufferLockBaseAddress(imageBuffer, CVPixelBufferLockFlags(rawValue: 0))
  1. ビットマップコンテキストを作成します。
    ビデオフレームのデータが格納されているCVPixelBufferから、ビットマップコンテキストを構築します。
    以下引数毎の説明になります。
  • data:CVPixelBufferGetBaseAddressOfPlaneはCVPixelBufferの指定された平面(plane)のメモリアドレスを取得します。
  • widthおよびheight: ビデオフレームの幅と高さを指定します。
  • bitsPerComponent: 各ピクセルの各色成分のビット数を指定します(ここでは8ビット)。
  • bytesPerRow: 1行あたりのバイト数を指定します。
  • space: 使用する色空間を指定します(ここではデバイスRGB)。
  • bitmapInfo: ビットマップ画像の構成情報を指定します。
let newContext = CGContext(
    data: CVPixelBufferGetBaseAddressOfPlane(imageBuffer, 0)!,
    width: Int(UInt(CVPixelBufferGetWidth(imageBuffer))),
    height: Int(UInt(CVPixelBufferGetHeight(imageBuffer))),
    bitsPerComponent: 8,
    bytesPerRow: Int(UInt(CVPixelBufferGetBytesPerRow(imageBuffer))),
    space: CGColorSpaceCreateDeviceRGB(),
    bitmapInfo: CGBitmapInfo(rawValue: (CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue) as UInt32).rawValue
)! as CGContext
  1. CVPixelBufferUnlockBaseAddressは、先程ロックしたメモリアドレスを解放します。これにより、他の部分でのメモリアクセスが可能になります。
CVPixelBufferUnlockBaseAddress(imageBuffer, CVPixelBufferLockFlags(rawValue: 0))
  1. ビットマップコンテキストからCGImageを作成し、それを使用してUIImageを生成しています。scaleは画像のスケールを指定し、orientationは画像の向きを指定しています。ここではUIImage.Orientation.upとしています
UIImage(cgImage: newContext.makeImage()!, scale: 1.0, orientation: UIImage.Orientation.up)

ここまでのCMSampleBufferからUIImageへの変換処理を以下にまとめておきます。

private func imageFromSampleBuffer(sampleBuffer: CMSampleBuffer) -> UIImage {
    let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)!

    // イメージバッファのロック
    CVPixelBufferLockBaseAddress(imageBuffer, CVPixelBufferLockFlags(rawValue: 0))

    // ビットマップコンテキスト作成
    let newContext = CGContext(data: CVPixelBufferGetBaseAddressOfPlane(imageBuffer, 0)!,
                               width: Int(UInt(CVPixelBufferGetWidth(imageBuffer))),
                               height: Int(UInt(CVPixelBufferGetHeight(imageBuffer))),
                               bitsPerComponent: 8, // コンポーネントあたりのビット数
                               bytesPerRow: Int(UInt(CVPixelBufferGetBytesPerRow(imageBuffer))),
                               space: CGColorSpaceCreateDeviceRGB(),
                               bitmapInfo: CGBitmapInfo(rawValue: (CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue) as UInt32).rawValue
    )! as CGContext

    // イメージバッファのアンロック
    CVPixelBufferUnlockBaseAddress(imageBuffer, CVPixelBufferLockFlags(rawValue: 0))
    return UIImage(cgImage: newContext.makeImage()!, scale: 1.0, orientation: UIImage.Orientation.up)
}

画像データから特定の座標の色情報を取得

最後にUIImageから色を取得していきます。
以下のコードは与えられたUIImageView内の指定された位置(UITapGestureRecognizerによるタップまたは指定された座標)から、対応するピクセルの色を取得する処理です。
順に説明していきます。

  1. data: CGImageのデータプロバイダーから、ピクセルデータへのポインタを取得しています。
  2. bytesPerPixel: 1ピクセルあたりのバイト数を計算しています。bitsPerPixelは1ピクセルあたりのビット数なので、8で割ることでバイト数に変換しています。(1バイト = 8ビット)
  3. bytesPerRow: 1ラインあたりのバイト数を取得しています。ピクセルデータは通常、メモリ上で1行ごとに格納されています。
  4. pixelAd: 指定された座標 (capturePoint) からピクセルのアドレスを計算しています。
    ピクセルのアドレスは画像のデータバッファ内の位置を表しており、実際に求めているpixedlAdにあたるアドレスは画像データ内の指定された位置に対応するピクセルの先頭アドレスになります。
    以下指定された座標で取得する場合の処理の意味を詳しく説明します。(タップした座標の場合もほぼ同じです)
  • capturePoint!.y: 指定された座標の y 座標。
  • Int(capturePoint!.y) * bytesPerRow: y 座標に対応する行の先頭アドレスまでのバイト数。
  • capturePoint!.x: 指定された座標の x 座標。
  • Int(capturePoint!.x) * bytesPerPixel: x 座標に対応するピクセルの先頭アドレスまでのバイト数。
    上記の2つのアドレスを合算して、指定された座標に対応するピクセルの先頭アドレスを計算します。
  1. UIColor: pixedlAdを使い、dataから色情報を取得していきます。

これで画像から色の取得ができました。
後はPresenterを通してViewに色を伝えれば実装完了です。

static func getColorFromImage(sender: UITapGestureRecognizer?, capturePoint: CGPoint?, imageView: UIImageView, completion: (UIColor) -> Void) {
    let cgImage = imageView.image?.cgImage!
    let data: UnsafePointer = CFDataGetBytePtr(cgImage?.dataProvider!.data)
    // 1ピクセルのバイト数
    let bytesPerPixel = (cgImage?.bitsPerPixel)! / 8
    // 1ラインのバイト数
    let bytesPerRow = (cgImage?.bytesPerRow)!
    var pixelAd: Int = 0
    if sender != nil {
        // タップした座標の取得
        let tapPoint = sender!.location(in: imageView)
        // タップした位置の座標にあたるアドレスを算出
        pixelAd = Int(tapPoint.y) * bytesPerRow + Int(tapPoint.x) * bytesPerPixel
    } else {
        // 指定された座標での取得
        pixelAd = Int(capturePoint!.y) * bytesPerRow + Int(capturePoint!.x) * bytesPerPixel
    }
    completion(UIColor(red: CGFloat(data[pixelAd + 2]) / 255, green: CGFloat(data[pixelAd + 1]) / 255, blue: CGFloat(data[pixelAd]) / 255, alpha: 1.0))
}

おまけ

せっかく色が取得できたので、UIColorから以下に変換する方法を紹介していきます。

  • カラーコード
  • RGBA
  • CMYK

UIColor→カラーコード

func toHexString() -> String {
    guard let components = self.cgColor.components else {
        return "#000000"
    }
    
    let red = Float(components[0])
    let green = Float(components[1])
    let blue = Float(components[2])
    
    // 16進数に変換
    let hexString = String(format: "#%02lX%02lX%02lX", lroundf(red * 255), lroundf(green * 255), lroundf(blue * 255))
    return hexString
}

UIColor→RGBA

func getRGBA() -> [Any] {
    var red: CGFloat     = 1.0
    var green: CGFloat   = 1.0
    var blue: CGFloat    = 1.0
    var alpha: CGFloat   = 1.0
    var rgb = [Any]()
    // UIColorからRGB成分を取得
    self.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
    rgb.append(NSString(format: "%.0f", (red * 100) / 100 * 255) as String)
    rgb.append(NSString(format: "%.0f", (green * 100) / 100 * 255) as String)
    rgb.append(NSString(format: "%.0f", (blue * 100) / 100 * 255) as String)
    rgb.append(Float(alpha))
    return rgb
}

UIColor→CMYK

func getCMYK() -> [Any] {
    var cyan: CGFloat = 0
    var magenta: CGFloat = 0
    var yellow: CGFloat = 0
    var alpha: CGFloat = 0

    var red: CGFloat = 0
    var green: CGFloat = 0
    var blue: CGFloat = 0
    // UIColorからRGB成分を取得
    getRed(&red, green: &green, blue: &blue, alpha: &alpha)

    // RGBからCMYに変換
    cyan = 1.0 - red
    magenta = 1.0 - green
    yellow = 1.0 - blue

    // CMYからCMYKに変換
    let k = min(cyan, magenta, yellow)
    if k < 1.0 {
        cyan = (cyan - k) / (1.0 - k)
        magenta = (magenta - k) / (1.0 - k)
        yellow = (yellow - k) / (1.0 - k)
    }
    
    let format: (CGFloat) -> String = { String(format: "%.1f", $0 * 100) }
    var cmyk = [Any]()
    cmyk.append(format(cyan))
    cmyk.append(format(magenta))
    cmyk.append(format(yellow))
    cmyk.append(format(k))
    cmyk.append(alpha)
    return cmyk
}

取得した情報を表示したらこんな感じです。

Discussion