iOS에서 커스텀 카메라 만들기

원문

우리는 iOS에서 어떤 형태로든 커스텀 카메라를 봐왔습니다만, 어떻게 직접 카메라를 커스텀할 수 있을까요?

이 튜토리얼에서는 기본 사항을 다루면서 동시에 고급 구현 및 옵션에 대해 설명합니다. 곧 알게 되겠지만 iOS 기기에서 오디오/비디오 하드웨어 상호 작용에 관한 옵션은 많습니다! 항상 그렇듯이 저는 복사-붙여넣기를 위한 코드를 제공하는 것보다 우리가 하고 있는 일에 대한 직관력(intuition)을 발전시키는 것을 목표로 합니다.

iOS에서 카메라 앱을 만드는 방법을 이미 알거나 더 많은 도전을 원하나요? 필터 구현에 대한 고급 튜토리얼 (영문)를 확인하세요.

 

시작 코드

예제 코드에서 코드를 클론하거나 다운로드한 뒤 Start 폴더의 프로젝트를 엽니다.

이 포스트에 다루지 않은 곁다리 코드들이 있으므로 반드시 예제 프로젝트에서 시작해야 모든 동작이 정상 작동합니다.

(모든 내용은 실제 기기에서만 실행됩니다.) 실행해보면 거의 진행되지 않습니다. 모든 로직은 ViewController.swift에서 이루어집니다. 캡처 버튼, 카메라 전환 버튼, 마지막으로 찍은 사진을 담을 View만 있습니다. 카메라 액세스 요청도 포함시켰습니다. 거부하면 앱이 중단됩니다 😁. 뷰 설정 및 카메라 인증 확인은 관련성이 낮은 코드로 작업 공간을 어지럽히지 않기 위해 ViewController+Extras.swift 별도 파일의 클래스 확장 아래에 있습니다.

 

표준 커스텀 카메라 설정

이제 AVFoundation 프레임워크를 사용하여 카메라 피드를 캡처하고 표시하여 사용자가 UIImagePickerViewController(*앱 내에서 사용 가능한 기본 카메라를 제공)를 사용하지 않고도 앱 내에서 사진을 찍을 수 있는 방법에 대해 알아보겠습니다.

AVFoundation은 iOS의 모든 오디오/비주얼에 대한 높은 추상화 레벨의 프레임워크입니다. 그러나 과소 평가하지 마세요. (합리적인 범위 내에서) 매우 강력하며 원하는 모든 유연성을 제공합니다.

우리의 관심은 카메라 및 미디어 캡처입니다.

AVFoundation 캡처 하위 시스템은 iOS 및 macOS에서 비디오, 사진 및 오디오 캡처 서비스를 위한 높은 추상화 레벨의 아키텍처를 제공합니다. 다음과 같은 경우 이 시스템을 사용합니다.

– 사진 또는 비디오 촬영을 앱의 사용자 경험에 통합하는 커스텀 카메라 UI를 구축

– 사용자가 초점, 노출 및 안정화 옵션과 같은 사진 및 비디오 캡처를 보다 직접적으로 제어

– RAW 형식 사진, 심도 맵 또는 사용자 정의 시간 메타데이터가 있는 비디오와 같이 시스템 카메라 UI와 다른 결과를 생성

캡처 장치에서 직접 픽셀 또는 오디오 데이터 스트리밍에 실시간으로 액세스

이 부분에서 우리는 첫 번째 포인트를 달성할 것입니다.

그렇다면 이 “캡처 서브시스템(capture subsytem)”은 무엇이며 어떻게 작동할까요? 하드웨어에서 소프트웨어로 이어지는 파이프라인이라고 생각할 수 있습니다. 입출력이 있는 중앙 AVCaptureSession이 있습니다. 둘 사이의 데이터를 중재합니다. 입력(input)은 iOS 장치의 다양한 오디오/비디오 하드웨어 구성 요소의 소프트웨어 표현인 AVCaptureDevices에서 가져옵니다. AVCaptureOutputs는 캡처 세션에 공급되는 항목에서 데이터를 추출하는 오브젝트 또는 다른 방법입니다.

 

섹션 1: AVCaptureSession 설정

가장 먼저 해야 할 일은 AVFoundation 프레임워크를 파일로 가져오는 것입니다. 그런 다음 세션을 만들고 해당 세션에 대한 참조를 저장할 수 있습니다.

그러나 세션에서 모든 작업을 수행하려면 구성(configuration)을 시작하고 beginConfiguration()commitConfiguration()을 각각 사용하여 변경 내용을 커밋하도록 지시해야 합니다.

우리는 왜 이것을 하나요? 좋은 방법이니까요! 이렇게 하면 캡처 세션에 수행하는 모든 작업이 원자적(atomic)으로 적용되므로 모든 작업이 한 번에 수행됩니다.

우리가 이걸 왜 원해야 되나요? 초기 설정에 필요한 것은 아니지만 카메라를 전환할 때 변경 사항을 대기열(queue)에 추가하면(한 입력을 제거하고 다른 입력을 추가) 최종 사용자가 보다 원활하게 전환할 수 있습니다.

모든 구성을 완료한 후 세션을 시작하려고 합니다.

import UIKit
import AVFoundation

class ViewController: UIViewController {
    // MARK: - Vars
    var captureSession: AVCaptureSession!
    
    ...
    
    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        checkPermissions()
        setupAndStartCaptureSession()
    }
    
    // MARK: - Camera Setup
    func setupAndStartCaptureSession() {
        DispatchQueue.global(qos: .userInitiated).async { [unowned self] in
            // 세션 초기화
            captureSession = AVCaptureSession()
            // 구성(configuration) 시작
            captureSession.beginConfiguration()
            
            // ... do some configuration? ...
            
            // commit configuration: 단일 atomic 업데이트에서 실행 중인 캡처 세션의 구성에 대한 하나 이상의 변경 사항을 커밋합니다.
            self.captureSession.commitConfiguration()
            // 캡처 세션 실행
            captureSession.startRunning()
        }
    }
}

 

왜 백그라운드 스레드에서 setupAndStartCaptureSession() 본문을 실행할까요? 이는 startRunning()이 차단 호출(block call)이기 때문입니다. 즉, 캡처 세션이 실제로 시작되거나 실패할 때까지 해당 라인에서 앱 실행이 중지됩니다. 실패 여부는 NSNotification을 구독(subscribe)하여 알 수 있습니다.

이제 실제로 세션을 구성하는 방법과 의미는 무엇일까요? 여러분이 생각하고 있는 것처럼 입력과 출력을 추가하는 작업은 당연하지만 사실 그 이상의 일을 할 수 있습니다.

AVCaptureSession에 대한 설명서를 살펴보면 수행할 수 있는 여러 작업이 있습니다. 모두 중요하지만 실무에 실제로 필요한 것만 언급하겠습니다.

  • 입력 및 출력 관리 — 다음 섹션에서 이에 대해 설명합니다.
  • 실행 상태 관리 — 프로덕션 애플리케이션에서 중요하며 캡처 세션에서 진행되는 작업을 추적할 수 있기를 원합니다.
  • 연결 관리 — 데이터 파이프라인에 대한 보다 미세한 조정(fine tuning)을 제공합니다. 입력과 출력을 캡처 세션에 연결할 때 AVCaptureConnection 개체를 통해 암시적으로 또는 명시적으로 연결됩니다. 이 내용은 나중에 다룰 것입니다.
  • 색 공간(Color Space) 관리 — 이것은 더 넓은 색 영역(가능한 경우)을 사용할 수 있는 기능을 제공합니다. iPhone 7s 이상에는 P3 색 영역이 있는 반면 구형 기기에는 sRGB만 있으므로 이를 활용할 수 있습니다.

 

이 섹션에서는 출력의 품질 수준(quality level)인 프리셋에 대해 다룹니다. 캡처 세션은 입력과 출력 사이의 중재자이므로 이러한 것 중 일부를 제어할 수 있습니다. AVCaptureSession.Presets는 기기에서 나오는 품질을 미세 조정하는 더 높은 추상화 방법을 제공합니다. 다음 섹션에서 볼 수 있듯이 기기 레벨에서 훨씬 더 깊이 들어갈 수 있습니다. 우리는 카메라를 만들고 있기 때문에 사진 프리셋 설정이 가장 적합합니다. 이 프리셋은 최고 품질의 이미지를 반환하는 구성을 사용하도록 연결된 장치(카메라)에 지시합니다.

우리는 품질을 중요하게 생각하기 때문에 더 넓은 색 영역에 접근할 수 있다면 더 넓은 색 공간을 활성화하여 사용해야 합니다.

func setupAndStartCaptureSession() {
    DispatchQueue.global(qos: .userInitiated).async { [unowned self] in
        // 세션 초기화
        captureSession = AVCaptureSession()
        // 구성(configuration) 시작
        captureSession.beginConfiguration()
        
        // session specific configuration
        // 세션 프리셋을 설정하기 전에 지원 여부를 확인해야 합니다.
        if captureSession.canSetSessionPreset(.photo) {
            captureSession.sessionPreset = .photo
        }
        
        // 사용 가능한 경우 세션이 자동으로 광역 색상을 사용해야 하는지 여부를 지정합니다.
        captureSession.automaticallyConfiguresCaptureDeviceForWideColor = true
        
        // commit configuration: 단일 atomic 업데이트에서 실행 중인 캡처 세션의 구성에 대한 하나 이상의 변경 사항을 커밋합니다.
        self.captureSession.commitConfiguration()
        // 캡처 세션 실행
        captureSession.startRunning()
    }
}

이제 입력을 설정할 준비가 되었습니다 ☺️.

 

섹션 2: 입력 설정

AVCaptureDevice
캡처 세션에 대한 입력(예: 오디오 또는 비디오)을 제공하고 하드웨어별 캡처 기능에 대한 컨트롤을 제공하는 장치입니다.

이 오브젝트는 “캡처 장치”를 나타냅니다. 캡처 장치는 카메라나 마이크와 같은 하드웨어입니다. 데이터를 피드할 수 있도록 캡처 세션에 연결합니다.

이를 위해 전면 카메라와 후면 카메라 두 개의 장치가 필요합니다.

 

옵션 1:
func default(for mediaType: AVMediaType) -> AVCaptureDevice?

이것은 AVMediaType을 사용하며 많은 타입이 있습니다… 🥴

  • audio
  • closedCaption
  • depthData
  • metadata
  • metadataObject
  • muxed
  • subtitle
  • text
  • timecode
  • video

 

보시다시피 이 구조체(struct)는 AVCaptureDevices만을 위한 것이 아니라 자막 및 텍스트와 같은 항목을 포함합니다. 참고로 저희가 신경쓰는 부분은 영상인데 여기에서는 전면 카메라인지 후면 카메라인지 지정할 방법이 없기 때문에 이 옵션은 진행하지 않겠습니다.

우리가 비디오에 관심이 있다고 말했지만 왜 비디오일까요? 사진 옵션이 없는 이유는 무엇일까요? 카메라(및 일반적으로 디지털 카메라)는 사진을 찍으려고 할 때 켜지지 않고 그 전에 미리 켜집니다. 사진을 찍지 않는 동안 그들은 비디오 캡처 장치로 작동하지만 사진을 찍는다는 것은 실제로 이미 제작 중인 비디오에서 프레임을 가져오는 것입니다.

 

옵션 2:
func default(_ deviceType: AVCaptureDevice.DeviceType, 
            for mediaType: AVMediaType?, 
                 position: AVCaptureDevice.Position
            ) -> AVCaptureDevice?

이 메서드는 카메라 위치(앞, 뒤 또는 미지정) 및 기기 타입(deviceType)이라는 2개의 새로운 매개변수를 도입합니다.

AVCaptureDevice.DeviceType 아래를 살펴보면 다음과 같은 옵션의 양에 놀랄 수 있습니다.

  • builtInDualCamera
  • builtInDualWideCamera
  • buildInTripleCamera
  • builtInWideAngleCamera
  • builtInUltraWideCamera
  • builtInTelephotoCamera
  • builtInTrueDepthCamera
  • builtInMicrophone
  • externalUnkown

마이크는 하나뿐이므로 바로 알기 쉽지만, 나머지 카메라 종류들은 헷갈릴 수 있습니다. 하지만 일단 구글링은 잠시 접어두세요.

운 좋게도 모든 장치에는 전면과 후면 모두에 builtInWideAngleCamera가 있습니다. 이 카메라 유형을 고수하는 데 아무런 문제가 없으며 단순함을 위해 이것을 사용할 것입니다.. 실제 응용 프로그램에서는 사용자 장치에 있을 수 있는 다른 더 나은 카메라 옵션을 활용하고 싶을 수 있습니다.

이제 우리가 찾고 있는 것이 무엇인지 알았으므로 그것을 가져오고 후면 카메라를 캡처 세션에 연결하겠습니다(일반적으로 카메라 앱은 후면 카메라에 열리기 때문에).

class ViewController: UIViewController {
    // MARK: - Vars
    var captureSession: AVCaptureSession!
    
    var backCamera: AVCaptureDevice!
    var frontCamera: AVCaptureDevice!
    var backInput: AVCaptureInput!
    var frontInput: AVCaptureInput!
    
    ...
    
    // MARK: - Camera Setup
    func setupAndStartCaptureSession() {
        DispatchQueue.global(qos: .userInitiated).async { [unowned self] in
            // 세션 초기화
            captureSession = AVCaptureSession()
            // 구성(configuration) 시작
            captureSession.beginConfiguration()
            
            // session specific configuration
            // 세션 프리셋을 설정하기 전에 지원 여부를 확인해야 합니다.
            if captureSession.canSetSessionPreset(.photo) {
                captureSession.sessionPreset = .photo
            }
            
            // 사용 가능한 경우 세션이 자동으로 광역 색상을 사용해야 하는지 여부를 지정합니다.
            captureSession.automaticallyConfiguresCaptureDeviceForWideColor = true
            
            // Setup inputs
            setupInputs()
            
            // commit configuration: 단일 atomic 업데이트에서 실행 중인 캡처 세션의 구성에 대한 하나 이상의 변경 사항을 커밋합니다.
            self.captureSession.commitConfiguration()
            // 캡처 세션 실행
            captureSession.startRunning()
        }
    }
    
    func setupInputs() {
        // 후면(back) 및 전면(front) 카메라
        if let backCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
           let frontCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) {
            self.backCamera = backCamera
            self.frontCamera = frontCamera
        } else {
            fatalError("No cameras.")
        }
        
        // 이제 기기로부터 입력 오브젝트를 만들어야 합니다.
        guard let backInput = try? AVCaptureDeviceInput(device: self.backCamera) else {
            fatalError("could not create input device from back camera")
        }
        self.backInput = backInput
        if !captureSession.canAddInput(self.backInput) {
            fatalError("could not add back camera input to capture session")
        }
        
        guard let frontInput = try? AVCaptureDeviceInput(device: self.frontCamera) else {
            fatalError("could not create input device from front camera")
        }
        self.frontInput = frontInput
        if !captureSession.canAddInput(self.frontInput) {
            fatalError("could not add front camera input to capture session")
        }

        // **후면 카메라 입력을 세션에 연결합니다.
        captureSession.addInput(backInput)
    }
    
    ...

}

setupAndStartCaptureSession() 함수 내에서 setupInputs()를 호출합니다. 이제 입력이 있습니다 😁. 이제 앞에서 장치를 캡처 세션에 연결한다고 말했지만 사실이지만 장치가 연결되는 방식은 장치를 입력 오브젝트인 AVCaptureInput으로 바꾸는 것입니다.

AVCaptureInput
캡처 세션에 입력 데이터를 제공하는 객체의 추상 슈퍼클래스

여러 데이터 스트림을 전송하는 장치의 포트로 이동하려는 경우에 유용합니다.

AVCaptureDevices 구성

이전 섹션에서 몇 가지 기본 옵션으로 캡처 세션 구성에 대해 논의했으며 캡처 장치에 대해 훨씬 더 자세히 알아볼 수 있다고 언급했습니다. 다음은 제공되는 옵션입니다.

  • Formats (형식) —해상도(resolution), 종횡비(aspect ratio), 주사율(refresh rate) 등
  • Image Exposure (이미지 노출)
  • Depth data (깊이 데이터)
  • Zoom (줌)
  • Focus (포커스)
  • Flash (플래시)
  • Torch — 특히 플래시라이트 모드에서
  • Framerate(프레임레이트)
  • Transport — things like playback speed
  • Lens position (렌즈 위치)
  • White balance (화이트 밸런스)
  • ISO — 이미지 센서의 감광도 (sensitivity)
  • HDR
  • Color spaces (색 공간)
  • Geometric distortion correction
  • Device calibration
  • Tone Mapping

그리고 이들 중 대부분은 구성 옵션을 사용할 수 있는지 확인하는 일종의 function과 함께 제공됩니다. 다른 “카메라”에는 다른 구성 옵션이 있습니다. 그리고 “카메라”는 “내 iPhone 11 Pro의 후면 카메라”와 같은 하나의 획일적인(monolithic) 개체가 아닙니다. iOS 기기, 특히 최신 기기에는 각각 다른 기능을 가진 카메라 장치에 대한 여러 카메라 표현이 있습니다.

이러한 옵션은 확실히 유용하지만 이 튜토리얼에서는 다루지 않습니다. 그러나 걱정하지 마세요. 구성하는 것은 어렵지 않습니다. Apple은 여기 설명서에서 좋은 예시를 제공합니다.

 

섹션 3: 카메라 피드 표시

이제 입력이 있습니다. 즉, 캡처 세션이 현재 카메라에서 비디오 스트림을 수신하고 있지만 화면에서는 아무 것도 볼 수 없습니다!

고맙게도 AVFoundation 프레임워크는 AVCaptureVideoPreviewLayer라는 비디오 피드를 표시하는 매우 간단한 방법을 제공합니다.

이것은 매우 간단합니다. 미리보기 레이어는 캡처 세션에서 생성할 수 있는 CALayer일 뿐이며 View에 하위 레이어로 추가할 수 있습니다. 레이어가 하는 일은 캡처 세션을 통해 실행되는 비디오를 제공하는 것 뿐입니다.

class ViewController: UIViewController {
    // MARK: - Vars
    var previewLayer: AVCaptureVideoPreviewLayer!
    
    ...

    // MARK: - Camera Setup
    func setupAndStartCaptureSession() {
        DispatchQueue.global(qos: .userInitiated).async { [unowned self] in
            // 세션 초기화
            captureSession = AVCaptureSession()
            // 구성(configuration) 시작
            captureSession.beginConfiguration()
            
            // session specific configuration
            // 세션 프리셋을 설정하기 전에 지원 여부를 확인해야 합니다.
            if captureSession.canSetSessionPreset(.photo) {
                captureSession.sessionPreset = .photo
            }
            
            // 사용 가능한 경우 세션이 자동으로 광역 색상을 사용해야 하는지 여부를 지정합니다.
            captureSession.automaticallyConfiguresCaptureDeviceForWideColor = true
            
            // Setup inputs
            setupInputs()
            
            // UI 관련 부분은 메인 스레드에서 실행되어야 합니다.
            DispatchQueue.main.async {
                // 미리보기 레이어 셋업
                self.setupPreviewLayer()
            }
            
            // commit configuration: 단일 atomic 업데이트에서 실행 중인 캡처 세션의 구성에 대한 하나 이상의 변경 사항을 커밋합니다.
            self.captureSession.commitConfiguration()
            // 캡처 세션 실행
            captureSession.startRunning()
        }
    }
    
    ...
    
    func setupPreviewLayer() {
        previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        view.layer.insertSublayer(previewLayer, below: switchCameraButton.layer)
        previewLayer.frame = self.view.frame
    }
    
    ...
    

}

마침내 앱을 실행하고 무언가를 볼 수 있습니다!

 

이제 크기 조정 및 종횡비에 대해 알아봅시다. 구성이 다르면 치수가 달라집니다. 예를 들어 사진 캡처 세션 사전 설정을 사용하여 iPhone X에서 코드를 실행하는 경우 후면 카메라의 활성 형식 크기는 4032×3024입니다. 이는 구성 옵션에 따라 변경됩니다. 예를 들어 iPhone X의 후면 카메라에 대해 최대 프레임 속도 옵션을 사용하기로 선택한 경우 240FPS를 얻을 수 있지만 훨씬 덜 인상적인 1280×720 해상도로 변경됩니다.

곧 보게 될 전면 카메라의 크기도 다를 수 있습니다. 예를 들어 사진 캡처 세션 사전 설정이 있는 iPhone X에서 실행 중인 경우 전면 해상도는 3088×2320입니다. 이는 후면 카메라와 거의 동일한 종횡비이므로 사용자가 크기 변화를 알아차리지 못할 것입니다. 구성에 따라 종횡비가 모든 곳에 있을 수 있습니다. UI는 결과 미리보기가 제공할 수 있는 모든 종횡비에서 작동해야 합니다.

프레임이 미리보기 레이어를 채우는 방법을 알고 싶다면 videoGravity 속성을 살펴보세요.

 

섹션 4: 출력 설정 및 사진 찍기

출력(output)은 캡처 세션에서 데이터를 가져올 수 있도록 캡처 세션에 연결하는 것입니다. 이전 섹션에서 내장된 미리보기 레이어를 탐색했습니다. 그 오브젝트는 정의상 출력이기도 합니다.

여기에는 두 가지 옵션이 있으며 둘 다 AVCaptureOutputs입니다.

캡처 세션에서 기록된 미디어를 출력하는 오브젝트입니다.

즉, 캡처 세션이 입력 장치에서 중재하는 데이터를 제공합니다.

 

옵션 1:

이것은 AVCapturePhotoOutput 이라는 매우 간단한 옵션입니다. 해야 할 일은 오브젝트를 생성하고 세션에 연결하는 것입니다. 사용자가 캡처 버튼을 누르면 그것에 대해 capturePhoto(with:delegate:)를 호출하면 조작/저장할 수 있는 사진 오브젝트를 다시 받게 됩니다. 이것은 강력한 클래스입니다. Live Photos를 찍을 수 있고, 사진 자체를 찍고 원하는 표현을 위한 수많은 옵션을 정의할 수 있습니다. 자신만의 앱에 맞는 커스텀 UI의 일반 카메라를 원한다면 이 클래스는 완벽합니다.

 

옵션 2:

이 옵션은 raw 비디오 프레임을 반환합니다. 이것은 구현하기 쉬우면서도 이 옵션을 사용하여 사용자 정의 카메라를 모든 방향으로 가져갈 수 있으므로 계속 진행할 것입니다.

AVCaptureVideoDataOutput
비디오를 녹화하고 처리를 위해 비디오 프레임에 대한 액세스를 제공하는 캡처 출력.

오브젝트의 이름에 주의를 기울이면 이전 옵션과 같이 사진이 아닌 비디오를 참조하는 것을 알 수 있습니다. 이는 카메라에서 모든 단일 프레임을 가져오기 때문입니다. 해당 프레임으로 수행할 작업을 결정할 수 있습니다. 즉, 사용자가 카메라 버튼을 누를 때 들어오는 다음 프레임을 뽑기(pluck)만 하면 됩니다. 또한 우리가 사용하고 있는 미리보기 레이어를 버릴 수 있다는 의미이기도 합니다.

항상 그렇듯이 이 출력 오브젝트에 대한 많은 구성 옵션이 있지만 이 튜토리얼과는 관련되지 않습니다. 우리의 관심은 다음과 같습니다.

func setSampleBufferDelegate(_ sampleBufferDelegate: AVCaptureVideoDataOutputSampleBufferDelegate?, 
                    queue sampleBufferCallbackQueue: DispatchQueue?)

첫 번째 파라미터는 프레임과 함께 콜백할 대리자(delegate)이고 두 번째 파라미터는 콜백이 호출되는 queue입니다. 카메라의 프레임 속도로 호출되며(큐가 사용 중이 아닌 경우) 해당 콜백 데이터를 처리할 것으로 예상되므로 기본(UI) 스레드에서 실행되지 않는 것이 사용성에 있어 중요합니다.

새 프레임을 사용할 수 있을 때 실행 중인 대기열이 사용 중인 경우 alwaysDiscardsLateVideoFrames 속성에 따라 “늦은” 프레임을 삭제합니다.

class ViewController: UIViewController {
    // MARK: - Vars
    ...
    
    var videoOutput: AVCaptureVideoDataOutput!
    
    ...
    
    // MARK: - Camera Setup
    func setupAndStartCaptureSession() {
        DispatchQueue.global(qos: .userInitiated).async { [unowned self] in
            // 세션 초기화
            captureSession = AVCaptureSession()
            // 구성(configuration) 시작
            captureSession.beginConfiguration()
            
            // session specific configuration
            // 세션 프리셋을 설정하기 전에 지원 여부를 확인해야 합니다.
            if captureSession.canSetSessionPreset(.photo) {
                captureSession.sessionPreset = .photo
            }
            
            // 사용 가능한 경우 세션이 자동으로 광역 색상을 사용해야 하는지 여부를 지정합니다.
            captureSession.automaticallyConfiguresCaptureDeviceForWideColor = true
            
            // Setup inputs
            setupInputs()
            
            // UI 관련 부분은 메인 스레드에서 실행되어야 합니다.
            DispatchQueue.main.async {
                // 미리보기 레이어 셋업
                self.setupPreviewLayer()
            }
            
            // Setup output
            setupOutput()
            
            // commit configuration: 단일 atomic 업데이트에서 실행 중인 캡처 세션의 구성에 대한 하나 이상의 변경 사항을 커밋합니다.
            self.captureSession.commitConfiguration()
            // 캡처 세션 실행
            captureSession.startRunning()
        }
    }
    
    ...
    
    func setupOutput() {
        videoOutput = AVCaptureVideoDataOutput()
        let videoQueue = DispatchQueue(label: "videoQueue", qos: .userInteractive)
        videoOutput.setSampleBufferDelegate(self, queue: videoQueue)
        
        if captureSession.canAddOutput(videoOutput) {
            captureSession.addOutput(videoOutput)
        } else {
            fatalError("could not add video output")
        }
    }
    
    ...
}

extension ViewController: AVCaptureVideoDataOutputSampleBufferDelegate {
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        
    }
}

보시다시피 출력 설정은 다소 쉬웠습니다. 이제 대리자 함수 captureOutput과 해당 3개의 파라미터에 초점을 맞춥니다.

출력은 이것이 나온 출력 장치를 지정합니다(동일한 대리자로 여러 AVCaptureOutput 오브젝트를 중재하는 경우). 샘플 버퍼에는 비디오 프레임 데이터가 있습니다. 연결(connection)은 데이터가 전달된 연결 오브젝트를 지정합니다. 우리는 아직 연결을 건드리지 않았고 하나의 출력 오브젝트만 있으므로 우리가 신경 쓰는 유일한 것은 샘플 버퍼입니다.

 

CMSampleBuffer
0개 또는 1개 이상의 미디어 파이프라인을 통해 미디어 샘플 데이터를 이동하는 데 사용되는 특정 미디어 유형(오디오, 비디오, muxed 등)의 압축(또는 압축되지 않은) 샘플을 포함하는 오브젝트

이 오브젝트의 전체 범위는 다소 복잡해집니다. 우리가 신경쓰는 것은 이것을 이미지로 표현하는 것입니다. 코코아 애플리케이션에서 이미지를 어떻게 표현할까요? 이미지의 서로 다른 수준을 나타내는 서로 다른 프레임워크의 각 부분인 3가지 주요 유형이 있습니다.

  • UIImage(UIKit) — 최고 추상화 레벨의 이미지 컨테이너, 다양한 이미지 표현에서 UIImage를 생성할 수 있으며 이는 우리 모두에게 친숙한 것입니다.
  • CGImage(Core Graphics) — 이미지의 비트맵 표현
  • CIImage(Core Image) — Core Image 프레임워크를 사용하여 효율적으로 처리할 수 있는 이미지 레시피입니다.

 

CMSampleBuffer로 돌아옵니다. 본질적으로 다양한 데이터 유형의 전체 배열(array)을 포함할 수 있으며, 우리가 원하는 것은 이미지 버퍼입니다. 다양한 것을 표현할 수 있기 때문에 Core Media 프레임워크는 다양한 표현(representation)을 시도하고 검색할 수 있는 많은 기능을 제공합니다. 사용 가능한 것은 CMSampleBufferGetImageBuffer()입니다. 이것은 다시 한 번 또 다른 특이한 유형인 CVImageBuffer 를 반환합니다. 이제 이 이미지 버퍼에서 CIImage를 가져올 수 있습니다. CIImage는 이미지의 재료(recipe)일 뿐이므로 여기에서 UIImage를 만들 수 있습니다.

class ViewController: UIViewController {
    // MARK: - Vars
    ...

    var isTakePicture = false
    
    ...
    
    // MARK: - Actions
    @objc func captureImage(_ sender: UIButton?){
        isTakePicture = true
    }
    
    @objc func switchCamera(_ sender: UIButton?){
        
    }
    
}

extension ViewController: AVCaptureVideoDataOutputSampleBufferDelegate {
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        if !isTakePicture {
            return // 이미지 버퍼로 할 일이 없습니다.
        }
        
        // 샘플 버퍼에서 CVImageBuffer를 가져오기
        guard let cvBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
            return
        }
        
        // CVImageBuffer에서 CIImage를 가져오기
        let ciImage = CIImage(cvImageBuffer: cvBuffer)
        
        // CIIImage를 UIImage로 변환
        let uiImage = UIImage(ciImage: ciImage)
        
        // 이미지 표시 (UI 영역)
        DispatchQueue.main.async {
            self.capturedImageView.image = uiImage
            self.isTakePicture = false
        }
    }
}

보시다시피 반환된 샘플 버퍼를 사용해야 하는지 여부를 결정하기 위해 Bool 플래그 isTakePicture를 추가했습니다. 실행하면 첫 번째 사진이 오른쪽 하단 영역에 나타나게 됩니다 🎉

불행히도 방향이 잘못되었습니다. 비디오 미리보기 레이어는 자동으로 올바른 방향을 표시하지만 AVCaptureVideoDataOutput 오브젝트를 통해 들어오는 데이터는 그렇지 않습니다. 연결 자체(즉, 출력 오브젝트와 세션 간의 연결) 또는 CIImage에서 UIImage를 생성할 때 두 곳에서 이 문제를 수정할 수 있습니다. 연결 자체에서 이를 변경할 것입니다.

AVCaptureConnection
캡처 세션에서 캡처 입력과 캡처 출력 오브젝트의 특정 쌍 간의 연결입니다.

앞에서 입력과 출력을 연결할 때 캡처 세션을 통해 연결이 형성된다고 언급했습니다. 이러한 연결은 오브젝트 자체이며 캡처 세션에서 addInput()addOutput()을 통해 암시적으로 생성했습니다. 연결 오브젝트는 데이터 파이프라인(입력, 캡처 세션 및 출력) 전체에서 어디에서나 액세스할 수 있습니다. 비디오 구성 관리에서 출력 연결에 videoOrientation을 설정하는 옵션이 있습니다.

func setupOutput() {
    ...
    
    // 방향을 포트레이트(세로)로 설정
    videoOutput.connections.first?.videoOrientation = .portrait
}

 

이제 사진을 찍을 때 올바른 비디오 방향을 갖게 되었습니다.

 

섹션 5: 카메라 전환

좋습니다🐟. 이미지를 표시하고 촬영할 수 있는 전체 캡처 파이프라인을 설정했습니다. 하지만 전/후면 카메라를 어떻게 변경해야 할까요?

생각해 보면 캡처 세션은 입력을 출력으로 중재합니다. 우리는 방금 출력(사진 촬영을 위한)을 다루었고 그 전에 입력 장치를 가져와 입력 오브젝트를 구성했습니다. 후면 카메라와 전면 카메라 모두에 대해 2개의 참조를 저장했기 때문에(backInput, frontInput) 세션 오브젝트를 재구성하기만 하면 됩니다.

class ViewController: UIViewController {
    // MARK: - Vars
    ...
    var isBackCameraOn = true
    
    ...
    
    func switchCameraInput() {
        // 스위치되는 동안 사용자가 버튼을 스팸처럼 연타하지 못하도록 합니다.
        // 사용자에게는 재미가 있지만 성능에는 재미가 없습니다.
        switchCameraButton.isUserInteractionEnabled = false
        
        // input 재설정
        captureSession.beginConfiguration()
        
        if isBackCameraOn {
            captureSession.removeInput(backInput)
            captureSession.addInput(frontInput)
            isBackCameraOn = false
        } else {
            captureSession.removeInput(frontInput)
            captureSession.addInput(backInput)
            isBackCameraOn = true
        }
        
        // 다시 방향을 포트레이트(세로)로 설정
        videoOutput.connections.first?.videoOrientation = .portrait
        
        // commit config
        captureSession.commitConfiguration()
        
        // 다시 카메라 스위치 버튼을 활성화합니다.
        switchCameraButton.isUserInteractionEnabled = true
    }
    
    // MARK: - Actions
    @objc func captureImage(_ sender: UIButton?){
        isTakePicture = true
    }
    
    @objc func switchCamera(_ sender: UIButton?){
        switchCameraInput()
    }
}

여기서 주의할 점은 입력을 변경했기 때문에 연결 오브젝트가 변경되었으므로 비디오의 출력 연결의 비디오 방향을 다시 세로 방향으로 재설정해야 한다는 것입니다.

 

실행하고 사진을 찍으면 출력 오브젝트가 미러링되지 않은 비디오를 리턴한 반면 우리가 사용하는 미리보기 레이어는 미러링된 전면 카메라의 비디오를 표시한다는 것을 알 수 있습니다. 오리엔테이션과 같은 경우입니다. 미리 보기 레이어는 “상위 추상화 레벨의 오브젝트“이기 때문에 자동으로 처리하지만 “하위 추상화 레벨의 오브젝트“인 출력 오브젝트에서는 처리되지 않습니다. 이 문제를 해결하려면 현재 표시되는 카메라에 따라 연결에 isVideoMirrored 속성을 설정할 수 있습니다.

이전에 비디오 방향을 수정한 것처럼 정말 빠르게 수정해 보겠습니다.

func switchCameraInput() {
    ...
    
    // 다시 방향을 포트레이트(세로)로 설정
    videoOutput.connections.first?.videoOrientation = .portrait
    
    // 전면 카메라 비디오 스트림 미러링
    videoOutput.connections.first?.isVideoMirrored = !isBackCameraOn
    
    ...
}

이제 작업을 마쳤습니다🎉. 우리가 원하는 UI 유형에 맞게 맞춤형 카메라를 사용하여 사진을 찍을 수 있는 템플릿을 만들었습니다.

 

다음 단계

  1. 더 많은 카메라 기능을 살펴보세요. “캡처 장치 구성” 섹션은 당신을 압도하기 위해 거기에 있는 것이 아니라 카메라의 기능을 확장하는 데 사용할 수 있습니다.
  2. 비디오! 사용자가 사진을 찍고 싶을 때만 출력 개체에서 가져온 프레임을 사용합니다. 나머지 시간에는 해당 프레임을 사용하지 않습니다! 비디오 캡처는 사소하지는 않지만 그리 어렵지 않습니다. 이것이 의미하는 바는 비디오 프레임을 파일로 묶는다는 것입니다. iOS에서 오디오 장치를 사용하여 탐색할 수 있는 좋은 기회이기도 합니다.
  3. 카메라에 필터를 구현해보세요!

 

결론

  • 이 튜토리얼을 즐겼고 카메라를 한 단계 더 발전시키고 싶다면 필터 적용에 대한 제 튜토리얼을 확인하세요!
  • iOS에서 그래픽에 대해 배우는 것에 관심이 있나요? Metal Shaders 사용에 대한 튜토리얼을 확인하세요.
  • 이미 Metal에 익숙하지만 이를 활용하여 멋진 일을 할 수 있는 방법을 알고 싶나요? 오디오 시각화에 대한 제 튜토리얼을 확인하세요.

 

전체 코드

ViewController.swift의 전체 코드는 다음과 같습니다.

//
//  ViewController.swift
//  CustomCamera
//
//  Created by Alex Barbulescu on 2020-05-21.
//  Copyright © 2020 ca.alexs. All rights reserved.
//

import UIKit
import AVFoundation

class ViewController: UIViewController {
    // MARK: - Vars
    var captureSession: AVCaptureSession!
    
    var backCamera: AVCaptureDevice!
    var frontCamera: AVCaptureDevice!
    var backInput: AVCaptureInput!
    var frontInput: AVCaptureInput!
    
    var previewLayer: AVCaptureVideoPreviewLayer!
    
    var videoOutput: AVCaptureVideoDataOutput!
    
    var isTakePicture = false
    var isBackCameraOn = true
    
    // MARK: - View Components
    let switchCameraButton : UIButton = {
        let button = UIButton()
        let image = UIImage(named: "switchcamera")?.withRenderingMode(.alwaysTemplate)
        button.setImage(image, for: .normal)
        button.tintColor = .white
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    let captureImageButton : UIButton = {
        let button = UIButton()
        button.backgroundColor = .white
        button.tintColor = .white
        button.layer.cornerRadius = 25
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    let capturedImageView = CapturedImageView()
    
    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        checkPermissions()
        setupAndStartCaptureSession()
    }
    
    // MARK: - Camera Setup
    func setupAndStartCaptureSession() {
        DispatchQueue.global(qos: .userInitiated).async { [unowned self] in
            // 세션 초기화
            captureSession = AVCaptureSession()
            // 구성(configuration) 시작
            captureSession.beginConfiguration()
            
            // session specific configuration
            // 세션 프리셋을 설정하기 전에 지원 여부를 확인해야 합니다.
            if captureSession.canSetSessionPreset(.photo) {
                captureSession.sessionPreset = .photo
            }
            
            // 사용 가능한 경우 세션이 자동으로 광역 색상을 사용해야 하는지 여부를 지정합니다.
            captureSession.automaticallyConfiguresCaptureDeviceForWideColor = true
            
            // Setup inputs
            setupInputs()
            
            // UI 관련 부분은 메인 스레드에서 실행되어야 합니다.
            DispatchQueue.main.async {
                // 미리보기 레이어 셋업
                self.setupPreviewLayer()
            }
            
            // Setup output
            setupOutput()
            
            // commit configuration: 단일 atomic 업데이트에서 실행 중인 캡처 세션의 구성에 대한 하나 이상의 변경 사항을 커밋합니다.
            self.captureSession.commitConfiguration()
            // 캡처 세션 실행
            captureSession.startRunning()
        }
    }
    
    func setupInputs() {
        // 후면(back) 및 전면(front) 카메라
        if let backCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
           let frontCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) {
            self.backCamera = backCamera
            self.frontCamera = frontCamera
        } else {
            fatalError("No cameras.")
        }
        
        // 이제 기기로부터 입력 오브젝트를 만들어야 합니다.
        guard let backInput = try? AVCaptureDeviceInput(device: self.backCamera) else {
            fatalError("could not create input device from back camera")
        }
        self.backInput = backInput
        if !captureSession.canAddInput(self.backInput) {
            fatalError("could not add back camera input to capture session")
        }
        
        guard let frontInput = try? AVCaptureDeviceInput(device: self.frontCamera) else {
            fatalError("could not create input device from front camera")
        }
        self.frontInput = frontInput
        if !captureSession.canAddInput(self.frontInput) {
            fatalError("could not add front camera input to capture session")
        }
        
        // 후면 카메라 입력을 세션에 연결합니다.
        captureSession.addInput(backInput)
    }
    
    func setupPreviewLayer() {
        previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        view.layer.insertSublayer(previewLayer, below: switchCameraButton.layer)
        previewLayer.frame = self.view.frame
    }
    
    func setupOutput() {
        videoOutput = AVCaptureVideoDataOutput()
        let videoQueue = DispatchQueue(label: "videoQueue", qos: .userInteractive)
        videoOutput.setSampleBufferDelegate(self, queue: videoQueue)
        
        if captureSession.canAddOutput(videoOutput) {
            captureSession.addOutput(videoOutput)
        } else {
            fatalError("could not add video output")
        }
        
        // 방향을 포트레이트(세로)로 설정
        videoOutput.connections.first?.videoOrientation = .portrait
    }
    
    func switchCameraInput() {
        // 스위치되는 동안 사용자가 버튼을 스팸처럼 연타하지 못하도록 합니다.
        // 사용자에게는 재미가 있지만 성능에는 재미가 없습니다.
        switchCameraButton.isUserInteractionEnabled = false
        
        // input 재설정
        captureSession.beginConfiguration()
        
        if isBackCameraOn {
            captureSession.removeInput(backInput)
            captureSession.addInput(frontInput)
            isBackCameraOn = false
        } else {
            captureSession.removeInput(frontInput)
            captureSession.addInput(backInput)
            isBackCameraOn = true
        }
        
        // 다시 방향을 포트레이트(세로)로 설정
        videoOutput.connections.first?.videoOrientation = .portrait
        
        // 전면 카메라 비디오 스트림 미러링
        videoOutput.connections.first?.isVideoMirrored = !isBackCameraOn
        
        // commit config
        captureSession.commitConfiguration()
        
        // 다시 카메라 버튼을 활성화합니다.
        switchCameraButton.isUserInteractionEnabled = true
    }
    
    // MARK: - Actions
    @objc func captureImage(_ sender: UIButton?){
        isTakePicture = true
    }
    
    @objc func switchCamera(_ sender: UIButton?){
        switchCameraInput()
    }
}

extension ViewController: AVCaptureVideoDataOutputSampleBufferDelegate {
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        if !isTakePicture {
            return // 이미지 버퍼로 할 일이 없습니다.
        }
        
        // 샘플 버퍼에서 CVImageBuffer를 가져오기
        guard let cvBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
            return
        }
        
        // CVImageBuffer에서 CIImage를 가져오기
        let ciImage = CIImage(cvImageBuffer: cvBuffer)
        
        // CIIImage를 UIImage로 변환
        let uiImage = UIImage(ciImage: ciImage)
        
        // 이미지 표시 (UI 영역)
        DispatchQueue.main.async {
            self.capturedImageView.image = uiImage
            self.isTakePicture = false
        }
    }
}

문의 | 코멘트 또는 yoonbumtae@gmail.com


카테고리: Swift


0개의 댓글

답글 남기기

Avatar placeholder

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다