[iOS] Custom Camera 만들어보기 (AVFoundation, 무음 커스텀 카메라)

hiju·2021년 5월 21일
8

iOS

목록 보기
6/8
post-thumbnail

앱에 커스텀된, 사진찍을 때 소리가 안들리는 무음 카메라를 만들기 위해 직접 만들었다.
구글링하면 튜토리얼 영어로 많이 나오던데, 한글용 문서는 많지 않더라,
쮀금 답답했지만 영어 문서들이 정말 잘 정리되어있어서 금방 보고 배웠다 :ㅇ (일단 표면적으로는)

무튼, 현재는 직접 카메라를 만들었던 기록을 남기기위해
벨로그에 쓰게 됐다. 그리고 혹시 한명이라도 내 벨로그를 찾아온 사람들도 조금이나마 편하게, 한글로 보시면서 커스텀 카메라를 만들 수 있으면 좋겠다..!:)

AVFoundation이란?

시청각 에셋들을 작업하고, 장치 카메라를 제어하고, 오디오를 프로세스, 그리고 여러 오디오 인터렉션을 구성하는 프레임워크

애플 문서 그대로 번역한 거라, 조오금 해석이 다를 수 있지만 어쨌든 큰 틀은 알 수 있다!
미디어 및 카메라, 오디오에 관한 프레임워크라는 사실을. 이 프레임워크에 Cameras and Media Capture 문서가 존재한다. 나는 지금 하나의 실시간으로 실행중인 비디오에서 찰칵 버튼을 누르면 사진을 캡쳐하기 위함이니,
그 점만 생각하면 될 것 같다.

여기는 많은 클래스들이 존재하는데, 제목 그대로 커스텀 카메라를 위한 글이니까
이 프레임 워크를 이용해 커스텀 카메라만 구현해보겠다. 카메라 세션 재생으로..!
사실 애플 문서에도 Topics들로 Capture 주제보면 Cameras and Media Capture라고 아주 친절하게 쓰여져있다.
이 문서만 읽고 따라오면, 구현이 어느정도 가능해진다.!!

캡쳐 아키텍쳐의 중요 부분은 세션, 인풋, 아웃풋이라고 되어 있다. 캡쳐 세션은 하나 이상의 인풋에서 하나 이상의 아웃풋을 연결한다고 한다. 인풋은 말그대로 디바이스에 빌트되어 있는 카메라같은거, 녹음기능 같은 자원들을 말한다. 아웃풋은 이 인풋으로부터 유용한 데이터를 받아오는 것인데, 나는 캡쳐된 사진을 받아오는 것으로만 구현했다.

실제로 어떤 과정으로 구현했는지 순서를 먼저 적었다!

  1. custom camera UI를 만들기 (사진을 직접 찍는 유저가 봐야할 화면 - 선택사항)
  2. custom camera를 이용하여 직접 photo capture을 제어할 무언가를 만들기 (버튼 같은)
  3. cameraSession 열고 camera를 실행할 코드 작성 및 previewLayer를 사용자가 보는 UI 화면에 연결 (preview할건지 말건지도 선택사항)

음.. 대강 이런식으로 코드를 구현하면 된다. 최소한의 필요조건이다. 아 물론 유저들에게 보이지는 않지만, 사진만 찍어보고 싶으면 ui를 안만들어도 구현은 되긴 된다. (preview layer가 없이 건너뛰는 방식으로다가!) input, output만 설정하면.

초기 프로퍼티 설정

제일 먼저,

import AVFoundation

임포트해주고,

var captureSession: AVCaptureSession!
var backCamera: AVCaptureDevice!
var frontCamera: AVCaptureDevice!
var backCameraInput: AVCaptureInput!
var frontCameraInput: AVCaptureInput!
var previewLayer: AVCaptureVideoPreviewLayer!
var videoOutput: AVCaptureVideoDataOutput!

var takePicture = false
var isBackCamera = true

우리가 이제 만들어야할 모든 프로퍼티를 만들 것이다.
다 써있는대로, 그 핵심 부분을 담당할 프로퍼티들이다.
일단 모두 사용을 꼭 할 예정이라 강제 언래핑으로 프로퍼티를 선언했다.
(근데 강제 언래핑 쓰지 마세요....)

capture할 session 부터 생성해주는데,

AVCaptureSession은 apple 문서에서, 캡쳐 활동을 관리하고 입력 장치의 데이터 흐름을 조정하여 출력을 캡처하는 오브젝트라고 되어있다. 하나의 문장이지만 뭔가 이해하기 어렵다..

암튼! 말그대로, 인풋을 사진이나 비디오 캡쳐하기 전에 기초 구성 세팅을 도와주는 도구, 입력장치와 출력장치를 연결하게 해주는 하나의 다리인 셈, 일단 캡쳐 도구인가보다! 라고 이해했다.
카메라 기능을 이용하기 위해, 실시간으로 비디오를 진행시킨 후, 버튼을 누르면 찰칵! 소리는 안나지만! 캡쳐가 되게 만들어야 한다. 그러기위한 첫번째 준비가 캡쳐세션을 만들어주는 것.

AVCaptureSession

captureSession = AVCaptureSession()
captureSession.beginConfiguration()
if captureSession.canSetSessionPreset(.photo) {
	captureSession.sessionPreset = .photo
}
//지금 사진만 캡쳐하는 것이 목표니까 Preset설정을 .photo로 했다.
//세션의 canSetSessionPreset은 포토를 사용할 수 있는 설정인지에 대한 bool값을 나타낸다.
//사용할 수 있으면 true, 없으면 false!

프리셋 설정을 해주는데, 사실 저 if문은 없어도 잘작동한다.
해주는 이유는 출력의 품질 수준이나 비트 전송률을 각 원하는 속성으로 받을 수 있기 때문이다. 나는 고해상도 사진 품질 출력에 적합한 모델로, .photo 로 지정했는데, 각 원하는 값에 맞게 출력할 수 있으니 참고하시길. (low, medium, high, qHD960x540.. 등등을 지원하고 있음! 자세한것은 언제나 애👍플👍문👍서)

그 다음에 우리가 만든 세션 인스턴스에 입력과 출력 장치를 넣어줘야한다.
입력장치는 후면과 전면 카메라 모두 사용할꺼니까 그 장치를 넣어주는 코드가 필요하다.

AVCaptureDevice / Input

	if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
            backCamera = device
        } else {
            fatalError("후면 카메라가 없어요.")
        }
        
        if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) {
            frontCamera = device
        } else {
            fatalError("전면 카메라가 없어요.")
        }
        
        guard let backCameraDeviceInput = try? AVCaptureDeviceInput(device: backCamera) else {
            fatalError("후면 카메라로 인풋설정이 불가능합니다.")
        }
        backCameraInput = backCameraDeviceInput
        if !captureSession.canAddInput(backCameraInput) {
            fatalError("후면 카메라 설치가 되지 않습니다.")
        }
        
        guard let frontCameraDeviceInput = try? AVCaptureDeviceInput(device: frontCamera) else {
            fatalError("전면 카메라로 인풋설정이 불가능합니다.")
        }
        frontCameraInput = frontCameraDeviceInput
        if !captureSession.canAddInput(frontCameraInput) {
            fatalError("전면 카메라 설치가 되지 않습니다.")
        }
        
        captureSession.addInput(backCameraInput)

에러사항은 대강 커스텀 카메라 일부 따왔다. 사실 매우매우 간단한데, backCamera와 frontCamera 모두 디바이스에 빌트되어 있는지를 확인한 뒤, 해당 디바이스에 input 설정을 해주면 된다.
각각 첨에 선언한 인스턴스에 넣어주고, 입력장치에 일단은 먼저 후면 카메라 위주로 찍을테니까 backCameraInput으로 넣었다. 여기서 뭐, 셀카앱이다 이러면 frontCameraInput값으로 넣으면 된다. 캡쳐세션에 add해주면 끝.!!

이제 인풋들을 다 세션에 추가해줬으니 이제 세션이 돌아가지 않을까~
돌아갈 수도 있지만 내가 필요한 건 아직 남았다 ㅠㅠ 아웃풋과 프리뷰이다.
카메라에 보이는 것을 내 두눈으로 확인하려면 preview가 필요하다.

AVCaptureVideoPreviewLayer

previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
previewLayer.videoGravity = .resizeAspectFill
previewLayer.connection?.videoOrientation = .portrait
self.previewLayer.frame = self.cameraview.frame
cameraview.layer.insertSublayer(previewLayer, at: 0)

내가 만들었던 세션을 통해 previewLayer와 연결시켜준다. 이 previewLayer은 내 맘대로 설정할 수 있다. 사용자에게 어떻게 보여줄건지는 다 입맛대로 결정하면 된다.
나는 일단 스토리보드에서 cameraview인 UIView를 만들어놨다. 화면크기랑 똑같은 . top, leading, trailing, bottom 모두 슈퍼뷰와 크기를 똑같이. (contraint 가 모두 0 임)
여기에 이제 프리뷰를 연결시켜야 하기때문에 previewLayer를 cameraview layer에 삽입시켜줬다.
여기서 고민이 좀 있었는데, 해당 카메라 화면이 완전히 모든 화면을 덮는 화면으로 나오지 않는다. 비율땜에 그런가.. 싶은데, 어쨌든 나는 전체화면으로 보고 싶었으니까 previewLayer의 videoGravity를 resizeAspectFill로 하여 무조건 조금 양옆으로 짤려도 전체크기로 불러올 수 있게끔 했다. 그래서 가로부분이 좀 짤린다. 싫은 사람은 그냥 냅두면 될듯...?

그리고 보이는 화면은 무조건 세로로 보고 싶어서 portrait로 해놨다. 이 부분 명시 안하면, 아마 가로든 세로든 시스템에서 알아서 바뀌어서 나올 듯 싶다.

이렇게까지 했으면 지금 input, preview 설정까지 완료된 상태이다!
이제 이것을 어떻게 Output으로 보이게할지 설정해야 하는 단계가 남았다.

AVCaptureVideoDataOutput / setSampleBufferDelegate

videoOutput = AVCaptureVideoDataOutput()
let cameraSampleBufferQueue = globalQueue(label: "cameraGlobalQueue", qos: .userInteractive)
videoOutput.setSampleBufferDelegate(self, queue: cameraSampleBufferQueue)
        
if captureSession.canAddOutput(videoOutput) {
	captureSession.addOutput(videoOutput)
} else {
    fatalError("아웃풋 설정이 불가합니다.")
}
videoOutput.connections.first?.videoOrientation = .portrait

captureSession.commitConfiguration()
captureSession.startRunning()

아웃풋도 인풋이나, 프리뷰처럼 AVCaptureVideoDataOutput() 같은 메서드를 사용하여 선언한다.
캡쳐세션에 아웃풋을 넣어주기 전에, setSampleBufferDelegate라는게 있는데, 이게 도대체뭘까!!
라고하면, 내가 이제 화면을 재생시킬때, output에 대한 버퍼값을 전달해주는 위임자이다.
쉽게 말하면, 카메라 기능으로 재생시킨 프리뷰에서 보이는 화면이 캡쳐된사진 버퍼값으로 계속 바뀌게 되는 것이다.
우리가 딱 카메라앱을 켰을때, 사진찍기전에 구도를 막 보면서 어떤거 찍을까 계속 보는 그 화면!!이다.
아무튼 이 위임자에 queue는 애플문서보면 serial dispatch queue를 꼭 사용하라고 해서 cameraGlobalQueue를 커스텀하여 우선순위를 제일 높은 레벨을 만들었다.(사실 qos는 꼭 쓰지않아도된다. 어짜피 os내부에서 알아서 qos를 추론한다고 한다.) 반드시 callback이 나타나야 하기 때문에, serial queue로 꼭 구현해야한다.
(원래는 main queue를 이용했는데, 이부분은 메인에서 하는것보다 커스텀큐에서 나오는 게 맞는 것 같아서 수정했다.)

아 그리고 captureSession.beginConfiguration() 및 captureSession.commitConfiguration()은 꼭 세션이 시작되기 전에, 써야하며, 전자는 세션의 인풋과 아웃풋이 변경되기 전에 써야하고, 후자는 다 변경된 후에 써야한다!!
만약에, session.startRunning()한 후에 쓰면 분명 오류가 뜨면서 동작하지 않을것이다...

사실, 지금 한거는 둘다 안써도 잘 돌아가던데!??!
일단 인풋, 아웃풋 변경이 없어서 그럴것이라 생각한다. 만약에라도 변경되는 사항이 있다면 설정하기전에 비긴, 설정후에 commit 함수 꼭 쓰기..!
이제 이 과정까지 모두 했다면 다 끝났다! 이제 해당 아웃풋 위임자에게 받는 이벤트 처리 함수만 작성하면 된다.

AVCaptureVideoDataOutputSampleBufferDelegate

func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        if !takePicture {
            return
        }
        guard let cvBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
            return
        }
        let ciImage = CIImage(cvImageBuffer: cvBuffer)
        let uiImage = UIImage(ciImage: ciImage)
        self.takePicture = false
        self.captureSession.stopRunning()
        DispatchQueue.main.async {
            guard let pictureViewController = self.storyboard?.instantiateViewController(identifier: "PictureViewController") as? PictureViewController else { return }
            pictureViewController.picture = uiImage
            self.present(pictureViewController, animated: true, completion: nil)
            
        }
}

이 함수는 꼭 !!! AVCaptureVideoDataOutputSampleBufferDelegate를 채택해야 한다. 아까 우리가 set딜리게이트해서 설정했고! 아웃풋을 받으려면 꼭 필요하다.
여기서 sampleBuffer는 계속 변경되는 화면값이다. 실시간으로 변경되기때문에 이 함수는 계속 돌아가겠죠 아마..?
나는 그래서 takePicture이란 bool값을 명시해뒀는데, 이것은 사용자가 찰칵! 버튼을 누를때까지는 이벤트처리하지마라. 라는 의미를 담았다. 더 좋게 쓸 수는 있겠지만. . 나의 방법은 일단 이랬다.

만약에 버튼을 누르면, 그 때 버튼 이벤트가 발생하고, takePicture값이 true가 되면서 해당 함수에 들어가게 된다. 버튼을 누른 순간, 그 sampleBuffer의 값을 가져오는 것이다.
먼저 CMSampleBufferGetImageBuffer을 이용해서 imageBuffer값을 가져온 뒤, ci -> uiImage로의 변환을 최종적으로 마무리했다.
열린 captureSession을 종료한 뒤, 다음 뷰콘에 uiImage을 전달함으로써 사진을 추출했다!
사진을 어떻게 할지는 다 각자의 마음이겠지만 .. 나는 어쨌든 카메라의 찰칵 소리가 싫어서 예전에 커스텀 카메라를 만들어 둔것을 이제서야 포스팅을 좀 수정수정하다가 완료한다,,!

되게 베이직한 내용이지만 그만큼 중요하다고 생각한다. 이게 왜 이렇게 열리고 닫히고 사용되는지 코드 한 줄 한 줄의 이유를 알아야 나중에 응용하기도 편할 수 있으므로 !!

+)
아 맞다, 그리고 스위치 버튼을 만들어서 해당 backCameraInput을 frontCameraInput으로 전환하는 방법도 있다.

func switchCameraInput() { //카메라 화면 전환
        switchCameraButton.isUserInteractionEnabled = false
        //이렇게 값 변경할때 필요한 begin, commit!!
        captureSession.beginConfiguration()
        if isbackCamera {
            captureSession.removeInput(backInput)
            captureSession.addInput(frontInput)
            isbackCamera = false
        } else {
            captureSession.removeInput(frontInput)
            captureSession.addInput(backInput)
            isbackCamera = true
        }
        videoOutput.connections.first?.videoOrientation = .portrait
        videoOutput.connections.first?.isVideoMirrored = !isbackCamera
        
        captureSession.commitConfiguration()
        
        switchCameraButton.isUserInteractionEnabled = true
}

여기서 isVideoMirrored는 output화면의 미러효과 넣을건지 말건지다.
만약에 true로 하면 좌우반전 돼서 나온다. 정말이지 좌우반전은 맘이 아프다.
그래서 나는 만약에 전면카메라이면 좌우반전은 안되게 나오게 바꿨다. ㅎ ㅎ

끝..!!!!!
깃헙에 관련된 내용은 나중에 앱 배포한 후에 올릴 예정.!

+) 사실, 무음카메라가 필요하지 않다면 찰칵! 소리를 원하는 거라면, 이런 방식말고 output을 VideoDataOutput -> PhotoOutput으로 설정하는 방법이 있다. 여기서는 이 아웃풋으로 프로퍼티를 생성해준뒤, 나중에 버튼을 눌렀을때 output.capturePhoto(with:, delegate:) 이 메소드를 이용하면 된다. delegate는 이에 마찬가지로 SampleBufferDelegate가 아닌, CapturePhotoDelegate쪽으로 준수하길 바란다! 사실 소리안나는 카메라는 각 나라마다 주마다 시마다 등등 법규때문에 제한두는 게 있다고 한다. 어쨌든, 나는 Video Delegate를 이용하여 무음카메라의 구현이 목적이었으므로,
그냥 찰칵 소리나는 카메라를 원할 땐 구글링에 많으니 참고하면 좋을 것 같다!

profile
IOS 개발자

2개의 댓글

comment-user-thumbnail
2022년 11월 22일

안녕하세요 혹시 질문 하나만 여쭤봐도댈까요?

1개의 답글