LoginSignup
22
17

More than 5 years have passed since last update.

AVFoundationを使ったカメラの実装:画面タップでフォーカス当てる実装

Last updated at Posted at 2018-08-29

この記事ではiOS標準のカメラにもある 画面をタップしてフォーカスを当てる機能 をAVFoundationを使って実装する方法を紹介します。
また、よりiOS標準のカメラの機能に近づけるために、フォーカスを当てた後に端末を動かし、フォーカスが外れる機能を実装する方法も紹介します。

確認した環境

iOS11

前提知識:フォーカスと露出

画面をタップした際にカメラはフォーカスと露出を調整しています。
露出とは明るさを調整するものです。
暗い場所をタップすると多くの光を取り入れようとしたり、明るい場所をタップすると取り入れる光の量を少なくします。

本記事ではフォーカスと露出の制御について紹介していきます。

フォーカスと露出のモード

    // フォーカス
    public enum FocusMode : Int {        
        case locked
        case autoFocus
        case continuousAutoFocus
    }
    // 露出
    public enum ExposureMode : Int {   
        case locked
        case autoExpose
        case continuousAutoExposure
        @available(iOS 8.0, *)
        case custom
    }

AVFoundationで用意されているフォーカスと露出のモードはこの通りです。
フォーカスと露出には、locked auto continuous の3つのモードがあることがわかります。

locked:フォーカスと露出を現在の状態から変えないモードです。
auto:指定の位置に適切なフォーカスと露出を一度設定するモードです。一度設定した後は locked になります。
continuous:必要に応じて継続的に適切なフォーカスと露出を設定するモードです。

露出の custom はユーザが明るさを任意に調整する時に使用するモードです。

iOS標準のカメラとフォーカスと露出のモード

フォーカスと露出のモードを理解した上で、iOS標準のカメラの機能を確認します。
まず、画面をタップすると auto になり、一度だけその位置に適切なフォーカスと露出が設定されます。
その後端末を動かすと continuous になり、継続的に適切なフォーカスと露出が設定されます。
また、 auto で適切な設定がされた直後に端末を動かしても、直ぐに continuous にならないことも確認できます。一定時間後に continuous になります。
露出の custom は画面をタップしたあとに表示されるViewで明るさを調整するときに使うモードだと考えられます。(今回実装しなかったため考えられます。という表現にとどめています)

状態遷移図

カメラの状態遷移図.png

これまでの内容をもとに状態遷移図を作成しました。
この状態遷移図では、カメラの状態ではなく実装に必要な状態で書いています。

ポイントは2つです。

  • auto から locked に自動で変わるため autoAndLocked 状態にしました
  • auto から continuous に直ぐにならないことから locked 状態を2つ用意しました。一定時間経過前で continuous になれない force と一定時間後に continuous になれる unlockable です。

実装の紹介

ステートマシン

import RxCocoa
import UIKit

class CameraFocusExposeStateMachine {
    private let subject = BehaviorRelay<State>(value: .continuousAuto)
    private var isMoved = false

    var stateStream: Driver<State> { get { return subject.asDriver() }}

    func onAction(_ action: Action) {

        if case .moved = action {
            isMoved = true
        }

        let currentState = subject.value
        let newState: State

        switch (currentState, action) {
        case (_, .showedView):
            newState = .continuousAuto

        case (_, .tappedView(let point)):
            newState = .autoAndForceLocked(tappedPoint: point)

        case (.autoAndForceLocked, .elapsedForceLockedTime):
            if isMoved {
                newState = .continuousAuto
            } else {
                newState = .unlockableLocked
            }

        case (.unlockableLocked, .moved):
            newState = .continuousAuto

        default:
            return
        }

        subject.accept(newState)
        isMoved = false
    }

    enum State {
        case continuousAuto, autoAndForceLocked(tappedPoint: CGPoint), unlockableLocked
    }

    enum Action {
        case tappedView(point: CGPoint), elapsedForceLockedTime, moved, showedView
    }
}

状態は continuousAuto autoAndForceLocked unlockableLocked の3つを用意しました。

continuousAuto :AVFoundationの continuous と同じです。
autoAndForceLocked :AVFoundationの auto と その後の continuous にはならないロック状態です。
unlockableLockedcontinuous になれるロック状態です。

受け取るアクションは tappedView elapsedForceLockedTime moved showedView の4つを用意しました。

tappedView :画面をタップするアクションです。
elapsedForceLockedTimecontinuous にはならないロック状態から一定時間経過したアクションです。
moved :端末が動いたアクションです。
showedView :カメラ画面が表示されたアクションです。

state の変更は RxCocoaDriver を使って、状態の変更を監視できるようにしています。

tappedViewelapsedForceLockedTime アクション

画面がタップされると tappedView を発行し、合わせて elapsedForceLockedTime を発行するtimerを設定しています。

    private var focusExposeLockTimer: Timer? {
        willSet {
            guard let oldValue = focusExposeLockTimer else {
                return
            }
            if oldValue.isValid {
                oldValue.invalidate()
            }
        }
    }

    func focus(tappedPoint: CGPoint) {
        guard setMode(point: tappedPoint, focusMode: .autoFocus, exposureMode: .autoExpose) else {
            return
        }

        focusExposeStateMachine.onAction(.tappedView(point: tappedPoint))

        focusExposeLockTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
            self?.focusExposeStateMachine.onAction(.elapsedForceLockedTime)
        }
    }

moved アクション

端末が動いたことの検知には加速度センサーを使いました。

import CoreMotion

    private let motionManager = CMMotionManager()

    func startToObserve() {
        guard motionManager.isAccelerometerAvailable else {
            return
        }
        motionManager.accelerometerUpdateInterval = 1 / 10

        guard let queue = OperationQueue.current else {
            return
        }
        motionManager.startAccelerometerUpdates(to: queue) { [weak self] data, error in
            guard let wself = self else {
                return
            }

            guard let acceleration = data?.acceleration else {
                return
            }

            let displacement = abs(acceleration.x) + abs(acceleration.y) + abs(acceleration.z)
            if displacement > 1.5 {
                wself.focusExposeStateMachine.onAction(.moved)
            }
        }
    }

    func stopToObserve() {
        if motionManager.isAccelerometerAvailable {
            motionManager.stopAccelerometerUpdates()
        }
    }

showedView アクション

カメラ画面表示時に発行します。

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        cameraManager.focusExposeStateMachine.onAction(.showedView)
    }

フォーカスと露出の設定

    private func setMode(point: CGPoint, focusMode: AVCaptureDevice.FocusMode, exposureMode: AVCaptureDevice.ExposureMode) -> Bool {
        guard let device = cameraDevice else {
            return false
        }

        do {
            try device.lockForConfiguration()
        } catch {
            return false
        }

        let devicePoint = cameraPreviewLayer.captureDevicePointConverted(fromLayerPoint: point)

        if device.isFocusPointOfInterestSupported && device.isFocusModeSupported(focusMode) {
            device.focusPointOfInterest = devicePoint
            device.focusMode = focusMode
        }

        if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) {
            device.exposurePointOfInterest = devicePoint
            device.exposureMode = exposureMode
        }

        device.unlockForConfiguration()
        return true

    }

ポイントは3つです。

  • lockForConfigurationunlockForConfiguration を呼ぶ
  • UIKitのCGPointではなく captureDevicePointConverted で変換した0~1の値の設定する
  • フォーカスと露出のモードが対応しているモードか確認してから設定する

レイアウトの実装

import UIKit

private let maxSquareSize = CGSize(width: 187.5, height: 187.5)
private let centerSquareSize = CGSize(width: 112.5, height: 112.5)
private let squareSize = CGSize(width: 75, height: 75)

class CameraFocusView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }

    private func setup() {
        backgroundColor = .clear
        layer.borderColor = UIColor.white.cgColor
        layer.borderWidth = 2
    }

    func showCenter(at centerPoint: CGPoint) {
        show(centerPoint: centerPoint, isCenter: true)
    }

    func show(at centerPoint: CGPoint) {
        show(centerPoint: centerPoint, isCenter: false)
    }

    func weaken() {
        alpha = 0.5
    }

    private func show(centerPoint: CGPoint, isCenter: Bool) {
        frame.size = maxSquareSize
        center = centerPoint
        alpha = 0

        let animations: ()->() = { [weak self] in
            guard let wself = self else {
                return
            }

            wself.frame.size = isCenter ? centerSquareSize : squareSize
            wself.center = centerPoint
            wself.alpha = 1
        }

        let completion: (Bool)->() = { [weak self] _ in
            guard let wself = self else {
                return
            }
            if isCenter {
                UIView.animate(withDuration: 0.5, delay: 2, animations: {
                    wself.alpha = 0
                })
            }
        }

        UIView.animate(withDuration: 0.5, animations: animations, completion: completion)
    }
}

カスタムViewを用意しました。
unlockableLocked になると色が薄くなるので weak メソッドを用意しています。

// ViewController
    override func viewDidLoad() {
        super.viewDidLoad()

        cameraManager.focusExposeStateMachine.stateStream.drive(onNext: { [weak self] state in
            guard let wself = self else {
                return
            }

            switch state {
            case .continuousAuto:
                let centerPoint = wself.cameraFoundationView.center
                wself.focusView.showCenter(at: centerPoint)
                wself.cameraManager.continuousAutoFocus(point: centerPoint)
            case .autoAndForceLocked(let tappedPoint):
                wself.focusView.show(at: tappedPoint)
            case .unlockableLocked:
                wself.focusView.weaken()
            }

        }).disposed(by: disposeBag)
    }

ステートマシンの状態を監視して、Viewの状態を変えています。

コード全容

コードの断片を紹介してきたので、最後にコードの全容を記載します。
今回の記事に関係がある部分のみ記載しているため、コピペして直ぐに動くものではないではありません。

import UIKit
import RxSwift

class CameraViewController: UIViewController {
    fileprivate let disposeBag = DisposeBag()
    fileprivate let cameraManager = CameraManager()

    override func viewDidLoad() {
        super.viewDidLoad()

        cameraManager.focusExposeStateMachine.stateStream.drive(onNext: { [weak self] state in
            guard let wself = self else {
                return
            }

            switch state {
            case .continuousAuto:
                let centerPoint = wself.cameraFoundationView.center
                wself.focusView.showCenter(at: centerPoint)
                wself.cameraManager.continuousAutoFocus(point: centerPoint)
            case .autoAndForceLocked(let tappedPoint):
                wself.focusView.show(at: tappedPoint)
            case .unlockableLocked:
                wself.focusView.weaken()
            }

        }).disposed(by: disposeBag)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        cameraManager.startToObserve()
        cameraManager.focusExposeStateMachine.onAction(.showedView)
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        cameraManager.stopToObserve()
    }

    @objc private func tappedCameraFoundationView(gestureRecognizer: UITapGestureRecognizer) {
        let tappedPoint = gestureRecognizer.location(in: gestureRecognizer.view)
        cameraManager.focus(tappedPoint: tappedPoint)
    }

}

import AVFoundation
import CoreMotion

class CameraManager {

    private lazy var cameraPreviewLayer : AVCaptureVideoPreviewLayer = {
        let cameraPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        cameraPreviewLayer.videoGravity = .resizeAspectFill
        return cameraPreviewLayer
    }()

    private lazy var captureSession: AVCaptureSession = {
        let session = AVCaptureSession()
        session.sessionPreset = .photo
        return session
    }()

    fileprivate lazy var cameraDevice: AVCaptureDevice? = {

        let session = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .back)
        let devices = session.devices

        for device in devices {
            if device.position == AVCaptureDevice.Position.back {
                return device
            }
        }
        return nil
    }()

    fileprivate let photoOutput = AVCapturePhotoOutput()
    private let motionManager = CMMotionManager()

    let focusExposeStateMachine = CameraFocusExposeStateMachine()
    private var focusExposeLockTimer: Timer? {
        willSet {
            guard let oldValue = focusExposeLockTimer else {
                return
            }
            if oldValue.isValid {
                oldValue.invalidate()
            }
        }
    }

    func startToObserve() {
        guard motionManager.isAccelerometerAvailable else {
            return
        }
        motionManager.accelerometerUpdateInterval = 1 / 10

        guard let queue = OperationQueue.current else {
            return
        }
        motionManager.startAccelerometerUpdates(to: queue) { [weak self] data, error in
            guard let wself = self else {
                return
            }

            guard let acceleration = data?.acceleration else {
                return
            }

            let displacement = abs(acceleration.x) + abs(acceleration.y) + abs(acceleration.z)
            if displacement > 1.5 {
                wself.focusExposeStateMachine.onAction(.moved)
            }
        }
    }

    func stopToObserve() {
        if motionManager.isAccelerometerAvailable {
            motionManager.stopAccelerometerUpdates()
        }
    }
}

extension CameraManager {

    func focus(tappedPoint: CGPoint) {
        guard setMode(point: tappedPoint, focusMode: .autoFocus, exposureMode: .autoExpose) else {
            return
        }

        focusExposeStateMachine.onAction(.tappedView(point: tappedPoint))

        focusExposeLockTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
            self?.focusExposeStateMachine.onAction(.elapsedForceLockedTime)
        }
    }

    func continuousAutoFocus(point: CGPoint) {
        _ = setMode(point: point, focusMode: .continuousAutoFocus, exposureMode: .continuousAutoExposure)
    }

    private func setMode(point: CGPoint, focusMode: AVCaptureDevice.FocusMode, exposureMode: AVCaptureDevice.ExposureMode) -> Bool {
        guard let device = cameraDevice else {
            return false
        }

        do {
            try device.lockForConfiguration()
        } catch {
            return false
        }

        let devicePoint = cameraPreviewLayer.captureDevicePointConverted(fromLayerPoint: point)

        if device.isFocusPointOfInterestSupported && device.isFocusModeSupported(focusMode) {
            device.focusPointOfInterest = devicePoint
            device.focusMode = focusMode
        }

        if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) {
            device.exposurePointOfInterest = devicePoint
            device.exposureMode = exposureMode
        }

        device.unlockForConfiguration()
        return true

    }

}

import RxCocoa
import UIKit

class CameraFocusExposeStateMachine {
    private let subject = BehaviorRelay<State>(value: .continuousAuto)
    private var isMoved = false

    var stateStream: Driver<State> { get { return subject.asDriver() }}

    func onAction(_ action: Action) {

        if case .moved = action {
            isMoved = true
        }

        let currentState = subject.value
        let newState: State

        switch (currentState, action) {
        case (_, .showedView):
            newState = .continuousAuto

        case (_, .tappedView(let point)):
            newState = .autoAndForceLocked(tappedPoint: point)

        case (.autoAndForceLocked, .elapsedForceLockedTime):
            if isMoved {
                newState = .continuousAuto
            } else {
                newState = .unlockableLocked
            }

        case (.unlockableLocked, .moved):
            newState = .continuousAuto

        default:
            return
        }

        subject.accept(newState)
        isMoved = false
    }

    enum State {
        case continuousAuto, autoAndForceLocked(tappedPoint: CGPoint), unlockableLocked
    }

    enum Action {
        case tappedView(point: CGPoint), elapsedForceLockedTime, moved, showedView
    }
}

import UIKit

private let maxSquareSize = CGSize(width: 187.5, height: 187.5)
private let centerSquareSize = CGSize(width: 112.5, height: 112.5)
private let squareSize = CGSize(width: 75, height: 75)

class CameraFocusView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }

    private func setup() {
        backgroundColor = .clear
        layer.borderColor = UIColor.white.cgColor
        layer.borderWidth = 2
    }

    func showCenter(at centerPoint: CGPoint) {
        show(centerPoint: centerPoint, isCenter: true)
    }

    func show(at centerPoint: CGPoint) {
        show(centerPoint: centerPoint, isCenter: false)
    }

    func weaken() {
        alpha = 0.5
    }

    private func show(centerPoint: CGPoint, isCenter: Bool) {
        frame.size = maxSquareSize
        center = centerPoint
        alpha = 0

        let animations: ()->() = { [weak self] in
            guard let wself = self else {
                return
            }

            wself.frame.size = isCenter ? centerSquareSize : squareSize
            wself.center = centerPoint
            wself.alpha = 1
        }

        let completion: (Bool)->() = { [weak self] _ in
            guard let wself = self else {
                return
            }
            if isCenter {
                UIView.animate(withDuration: 0.5, delay: 2, animations: {
                    wself.alpha = 0
                })
            }
        }

        UIView.animate(withDuration: 0.5, animations: animations, completion: completion)
    }
}



22
17
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
22
17