Post

[iOS - Swift] 봄이도 좋아하는 Vision으로 포차코 페이스 필터 만들기

들어가기 전에

 

보정을 해준다거나, 귀여운 필터를 입혀주는 등

요즘 사람의 얼굴을 인식하는 기능을 활용하는 카메라 앱이 많잖아요?! 😎

 

macOS Photo Booth

(macOS Photo Booth 필터처럼 말이죵)

 

저도 포토부스 필터 참 조아하는데요…

요렇게 내 얼굴을 인식해 필터를 씌우는 기능!을 스위프트로 구현한다면

어떻게 할 수 있을까요? 🤔

 

바로바로….

Vision Framework 를 사용하면 댑니다 ㅋ 🥹

 

오… Vision 이 몬대요 .?

 

vision

 

컴퓨터 비전 알고리즘을 적용하여 입력 이미지 및 비디오에서 다양한 작업을 수행”

하는 프레임워크라고 나와있네용

 

컴퓨터 비전(Computer Vision) 은 말 그대로 눈에 보이는 것 👀 인

이미지나 비디오 등에서 정보를 추출하도록 하는 인공지능 분야인데요!

 

이 Vision 프레임워크를 활용하여 얼굴을 인식하거나, 사진 속에서 텍스트를 검출하거나,

바코드를 인식하는 등의 기능들을 구현할 수 있다고 합니다.

카메라 필터에는 얼굴 인식 기능을 이용할 수 있겠져?ㅋ

 

포차코 페이스 필터 앱

(버튼을 누르면 갤러리에 사진이 저장돼요)

 

그래서 오늘은 이 Vision 을 활용해

내 얼굴에 포차코 사진을 띄워주는 카메라 만들기!! 를 소개해보겠습니다~~✨

 


포차코 Face filter 흐름

 

개발 흐름은 간단하게 다음과 같습니다.

 

face filter 흐름

 

우선, 미디어 중에서도 카메라로 비디오 정보를 입력 받아오기 위한 기본적인 내용을 세팅해줍니다.
카메라는 전면인지 후면인지, 기기 타입은 무엇인지 등등에 관련된 내용입니다.

이렇게 설정한 카메라로 비디오를 입력 받습니다.

이 때, Vision 의 얼굴 인식 기능을 활용해 사용자의 얼굴을 인식하고,
인식한 레이어의 크기에 맞게 원하는 사진 (저는 포차코 ㅋ) 을 얹어줍니다.

그리고 얼굴 위에 사진을 더한 이 영상을 화면에 출력합니다.

만약에 촬영 버튼이 눌렸을 경우, 화면에 보여지는 이미지 레이어를 저장합니다.

 


포차코 Face Filter 만들기

 

그럼 포차코 필터를 만들어보까요!!! 🐾

 

✅ 카메라 액세스 권한 허용

 

우선, 카메라를 사용하기 위해서는 사용자에게 카메라에 액세스 할 수 있는 권한을 받아야 합니다.

 

Privacy - Camera Usage Description

Targets → Info 에서 아무 줄에서나 + 버튼을 누른 뒤,

Privacy - Camera Usage Description 을 Key 값으로 선택합니다.

Value에는 권한 허용을 받을 때 띄울 문구를 넣을 수 있습니다.

 

Requesting authorization to capture and save media

권한 받을 때 요런 팝업 뜨잖아용!!

 

저는 The app needs to access the Camera to face tracking. 라고 적었구요!!

앱을 배포할 경우, 저 value에 권한 허용을 받는 이유를 명시하지 않으면

심사 리젝 사유가 될 수 있으므로 주의해야 한다고 합니다.

 

그 다음, 카메라 기능이 필요한 부분에서 이 권한이 허용되어 있는지 확인하는 코드를 작성합니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var isAuthorized: Bool {
      get async {
          let status = AVCaptureDevice.authorizationStatus(for: .video)
          var isAuthorized = status == .authorized    // 사용자가 이전에 카메라 접근 권한을 가지고 있었는지 아닌지 판단
          
          if status == .notDetermined {   // 만약 접근 권한을 받은 적이 없다면
              isAuthorized = await AVCaptureDevice.requestAccess(for: .video)
          }
          
          return isAuthorized
     }
}

/// 카메라 접근 권한 확인 및 처리
private func setUpCaptureSession() async {
    guard await isAuthorized else { return }
}

공식 문서에 관련 코드가 있어서 사용했습니다.

 

카메라에 액세스 할 수 있는 권한을 받았는지, 받지 않았는지를 비동기적으로 확인하는 코드입니닷

권한이 없을 경우 AVCaptureDevicerequestAccess 로 비디오 사용을 위한 설정 권한을 가져옵니다!

아까 그 권한 팝업을 띄워주는 것이지유~~

 

🤔 헉 그럼 AVCaptureDevice 는 뭘까요?!

 

AVCaptureDevice

 

카메라마이크와 같은 하드웨어 또는 가상 캡처 장치를 나타내는 객체”

라고 나와있는데, 쉽게 말해

비디오, 오디오와 같은 AV를 캡처하는 장치들!!

카메라나 마이크 같은 장치들을 참조하는 객체를 의미합니다.

 

requestAccess

 

AVCaptureDevice 의 메소드 중 requestAccess

이렇게 장치가 데이터를 캡처할 수 있도록 권한 허용을 접근하는 역할을 합니다.

 


✅ 카메라 시작하기

 

카메라를 사용하기에 앞서, 디바이스에서 데이터를 캡처해 출력하기의 과정을 살펴볼까요?!✨

 

AVFoundation

 

캡처 아키텍처를 살펴보면 input, output, session 세 가지로 나뉨을 알 수 있는데요.

캡처 디바이스에서 입력값을 받고, 출력값을 내보내는 input → output 순서이고,

이 둘을 관리하는 session이 있음을 알 수 있습니다.

어렵지 안쵸!!!?!

 

그러면 필터 카메라를 만들기 위해 디바이스 입출력을 구현해봅시닷 🥹

 

📌 session: 비디오의 입력과 출력을 관리하는 세션 선언

 

먼저 카메라의 input과 output을 관리해주는 AVCaptureSession 의 인스턴스를 선언해줍니다.

 

AVCaptureSession

앞서 설명했던 대로 입출력 데이터의 흐름을 관리해주는 객체이구요!

 

1
private let captureSession = AVCaptureSession() // 비디오의 입력과 출력을 관리하는 세션

저는 captureSession이라는 이름으로 만들었습니답

그 다음은 카메라로 입력을 받아줘야겠죠?!

 

📌 input: 카메라로부터 비디오 입력 받기

1
2
3
4
5
6
7
8
9
10
11
/// 카메라로부터 비디오를 입력받기
private func configureVideoCapture() {
    captureSession.beginConfiguration() // AVCaptureSession의 설정 변경 시작
    let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera,
                                              for: .video,
                                              position: .front)   // 카메라 옵션 설정
    guard let videoDeviceInput = try? AVCaptureDeviceInput(device: videoDevice!),
          captureSession.canAddInput(videoDeviceInput) else { return }  // AVCaptureDeviceInput 생성 시도, 성공 시 세션에 입력 추가할 수 있는지 확인
    captureSession.addInput(videoDeviceInput)   // 세션에 생성된 비디오의 입력을 추가
    setUpPreviewLayer() // 비디오 프리뷰 출력
}

앱이 실행되면 바로 호출할 configureVideoCapture라는 함수를 만들었습니다.

  • beginConfiguration 을 통해 세션에게 ‘카메라 config 시작한다~’ 알림
  • 내가 원하는 디바이스 옵션을 videoDevice에 설정
    • deviceType.builtInWideAngleCamera (말 그대로 wide-angle camera)
    • mediaType.video (비디오를 쓸 거니까)
    • position.front (전면 카메라를 쓸 거니까)
  • 설정한 디바이스로 input 받는 걸 try 해볼 거심 ㅇㅇ 만약에 input이 된다면?
  • 그걸 captureSession에 add 해 줄 거다
  • 그러면 입력 받은 애를 화면에 출력해보자 → setUpPreviewLayer 호출

공식 문서의 코드와 함께 볼 수 있을 것 같습니답

 

다음으로는 화면에 입력값을 프리뷰로 출력해주는 setUpPreviewLayer 함수를 만들어볼까요!? 🥹

 

📌 output: 입력 받은 비디오 화면에 프리뷰 띄우기

AVCaptureVideoPreviewLayer

AVCaptureVideoPreviewLayer 는 카메라 디바이스로 입력받은 비디오를 보여주는 프리뷰 레이어인데요,

 

1
2
3
private lazy var previewLayer = AVCaptureVideoPreviewLayer(session: self.captureSession).then {    // captureSession의 시각적 출력을 프리뷰
    $0.videoGravity = .resizeAspectFill
}

요렇게 previewLayer라는 이름으로 선언해 줄 겁니닷

그리고 setUpPreviewLayer라는 함수를 만들어서 previewLayer에 영상을 출력해볼까욥?!

 

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
/// 입력받은 비디오 출력
private func setUpPreviewLayer() {
    let width: CGFloat = 300
    let height: CGFloat = 300

    self.previewLayer.frame = CGRect(
        x: (view.bounds.width - width) / 2,
        y: (view.bounds.height - height) / 2,
        width: width,
        height: height
    )
    
    view.layer.addSublayer(previewLayer)    // 현재 뷰의 하위 레이어로 추가
    
    let videoDataOutput = AVCaptureVideoDataOutput()
    videoDataOutput.videoSettings = [(kCVPixelBufferPixelFormatTypeKey as NSString) : NSNumber(value: kCVPixelFormatType_32BGRA)] as [String : Any]    // [Core Viedo에서 사용되는 픽셀 형식 : 32bit BGRA 형식, 일반적으로 사용되는 형식]
    videoDataOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "camera queue")) // 비디오 출력에 대한 샘플 버퍼 딜리게이트 설정
    self.captureSession.addOutput(videoDataOutput) // captureSession에 output 추가
    
    // input과 output 간의 connection 생성
    let videoConnection = videoDataOutput.connection(with: .video)
    videoConnection?.videoOrientation = .portrait   // 비디오 방향 세로 설정
    
    self.captureSession.commitConfiguration() // AVCaptureSession의 설정 변경 완료
    // AVCaptureSession를 백그라운드 스레드에서 시작
    DispatchQueue.global().async {
        self.captureSession.startRunning()
    }
}
  • previewLayer.frame 으로 이 레이어가 화면에 어떻게 위치할 지 설정한 건데, 정사각형 모양이고 화면 정중앙에 위치시킴
  • 그리고 previewLayer를 view에 add  
  • 아까 input에 넣을 videoDevice를 설정한 것처럼, previewLayer에 띄울 videoDataOutput을 설정
    • .videoSettings 에서 출력 비디오의 형식을 설정
      (저는 32bit BGRA 형식으로 설정한 건데, 일반적으로 이 형식을 사용한다 합니다.)
    • .setSampleBufferDelegate 으로 비디오 출력에 관련한 sampleBuffer 를 처리하기 위한 delegate 설정

 

여기서 잠깐!! sampleBuffer 요..? 🥹

sampleBuffer 는 캡처된 데이터들을 담아두는 버퍼입니다.

sampleBuffer 랑 관련된 메서드들을 호출하기 위해서 delegate를 쓰는데요,

setSampleBufferDelegate 를 살펴 보면 queue 를 파라미터로 넘겨주도록 되어있습니다.

 

setSampleBufferDelegate

 

delegate에서 호출되는 메서드들을 이 큐 안에서 비동기적으로 처리하기 때문인데요. (메모리 효율성 등의 이유로)

연속적으로 비디오 프레임이 입력되면, 들어온 순서대로 처리를 해줘야겠죠!!

따라서 이 queue에는 도착 순서대로 처리하는 직렬큐인 DispatchQueue 를 넘겨주는 겁니닷

 

이어서 살펴보면…

  • 이렇게 output을 설정했으니까 addOutput 으로 captureSession에 add
  • videoConnection에서 .videoOrientation.portrait 로 해서 세로 방향으로 비디오를 캡처
  • output 관련 내용을 .commitConfiguration으로 commit
  • 자!! input과 output 모두 됐으니까 captureSession을 비동기적으로 .startRunning

 


✅ 얼굴을 감지하자

 

input, output, session을 설정해서 이제 비디오 데이터가 생겼습니닷 😎

그럼 이제 얼굴을 인식해서 스티커를 붙이는 챕터 투로 넘어옵니다 ..

Vision 에서는 눈코입 landmark를 트래킹하는 것까지 가능하던데, 눈코입 따로 붙이니까 뭔가 기괴해서 (ㅎㅎ..)

그냥 얼굴 전체를 감지하고 그 위에 스티커를 뙇 붙이려고 해욥!!

 

방금 위에서 sampleBuffer캡처된 데이터들이 들어 있고,

SampleBufferDelegate 를 통해 관련 메서드들을 활용한다고 했잔아요?!

 

그래서 얼굴 감지sampleBuffer 안에 있는 데이터들로 하면 댑니다 ✨

 

1
private var faceLayers: [CALayer] = []

얼굴을 감지한 결과들을 저장해주는 faceLayers를 만듭니다.

 

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
extension PochacoFilterVC: AVCaptureVideoDataOutputSampleBufferDelegate {
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        
        guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }  // CMSampleBuffer에서 이미지 버퍼를 가져옴
        
        let faceDetectionRequest = VNDetectFaceLandmarksRequest(completionHandler: { [weak self] (request: VNRequest, error: Error?) in
            guard let self = self else { return }
            
            DispatchQueue.main.async {
                self.faceLayers.forEach({ $0.removeFromSuperlayer() })
                
                if let observations = request.results as? [VNFaceObservation] {
                    self.handleFaceDetectionObservations(observations: observations)
                }
            }
        })
        
        let imageRequestHandler = VNImageRequestHandler(cvPixelBuffer: imageBuffer, orientation: .rightMirrored, options: [:])
        
        do {
            try imageRequestHandler.perform([faceDetectionRequest])
            currentSampleBuffer = sampleBuffer
        } catch {
            print(error.localizedDescription)
        }
    }
}
  • AVCaptureVideoDataOutputSampleBufferDelegate 프로토콜 채택
    • AVCaptureVideoDataOutput에서 video sample buffers들을 받아 오고 관련 인터페이스를 정의하는 프로토콜
  • 이 프로토콜의 captureOutput 함수로 비디오 프레임에 접근
    • captureOutput: capture output 객체
    • sampleBuffer: 비디오 프레임을 갖고 있는 샘플 버퍼
    • connection: 비디오를 받은 connection
  • CMSampleBufferGetImageBuffer 로 샘플 버퍼에서 imageBuffer를 추출
  • VNDetectFaceLandmarksRequest얼굴 감지 요청 💡
  • 감지된 얼굴에 대한 처리를 메인 스레드에서 수행, 현재 표시된 얼굴들의 레이어를 제거
    • 새로운 이미지를 업데이트하거나 그릴 때, .removeFromSuperlayer이전에 그려진 얼굴 이미지를 제거
      (이거 안 하면 이전 레이어가 남아있어서, 에러난 것처럼 스티커 겁나 많이 떠요!!!!)
    • 얼굴을 감지해서 observations에 가져다 넣고, 이를 들고 handleFaceDetectionObservations에 전달해 처리 💡
  • VNImageRequestHandler 에서 아까의 얼굴 감지 요청을 처리 💡

 

face detection

(내가 보려고 정리한..)

 

정리하자면, 얼굴 감지 요청을 생성하고 그 요청을 받아 얼굴을 감지합니다.
그리고 얻은 결과를 처리하는 (저의 경우 스티커 붙이기) handleFaceDetectionObservations를 호출하고,
이 과정에서 faceLayers에 저장된 이전 얼굴 레이어을 제거하고 새로운 레이어을 추가합니다!

 


✅ 얼굴에 이미지를 넣어보자 ㅋ

 

이제 감지된 얼굴에 포차코 이미지를 넣을 차례입니닷ㅋ 🫢

얼굴 인식된 결과를 처리할 handleFaceDetectionObservations 함수를 만듭니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// Face Observation 값들 처리
private func handleFaceDetectionObservations(observations: [VNFaceObservation]) {
    for observation in observations {
        let faceRectConverted = self.previewLayer.layerRectConverted(fromMetadataOutputRect: observation.boundingBox)

        let faceLayer = CALayer()
        faceLayer.bounds = faceRectConverted
        faceLayer.position = CGPoint(x: faceRectConverted.midX, y: faceRectConverted.midY)

        // 얼굴 범위에 이미지 추가하기
        let faceImage = UIImage(named: "pochaco_face_img")
        let faceImageLayer = CALayer()
        faceImageLayer.contents = faceImage?.cgImage
        faceImageLayer.bounds = faceLayer.bounds
        faceImageLayer.position = CGPoint(x: faceLayer.bounds.midX, y: faceLayer.bounds.midY)
        faceLayer.addSublayer(faceImageLayer)

        // preview layer에 face layer 더하기
        self.faceLayers.append(faceLayer)
        self.previewLayer.addSublayer(faceLayer)
    }
}
  • .boundingBox 은 얼굴이 감지된 경계 상자로, 이를 .layerRectConverted 를 통해 화면에 보이는 좌표로 변환
  • 감지한 얼굴을 나타내는 faceLayer 생성
    • bounds: 바운딩 박스 범위랑 똑같이
    • position: 위치도 똑같이~~
  • faceLayerbounds 가 같은 faceImageLayer 추가하기
    • 원하는 이미지를 contents 로 넣어줌 (저는 포차코 이미지)
  • faceImageLayerfaceLayeradd
  • 완성된 faceLayerfaceLayers에 넣어주고~
  • previewLayer에 넣어서 화면에 표시될 수 있게 함!!

 

add image on face

(내가 보려고 정리한..투투)

 


✅ 촬영 버튼을 누르면! 사진을 저장하자

 

그러면 이제 마지막으로 촬영 버튼이 눌렸을 경우, 사진을 저장하도록 구현해보겠습니다.

 

1
2
3
4
5
self.takePictureButton.addTarget(self, action: #selector(takePictureButtonDidTap), for: .touchUpInside)

@objc private func takePictureButtonDidTap() {
    savePhoto()
}

우선 버튼에 addTarget 으로 버튼이 눌렸을 때의 액션을 추가해줍니다.

버튼이 눌리면, 사진을 저장하는 savePhoto 함수를 호출하도록 할 거예유

 

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
private func savePhoto() {
    guard let currentSampleBuffer = self.currentSampleBuffer else { return }
    guard let imageBuffer = CMSampleBufferGetImageBuffer(currentSampleBuffer) else { return }
		// 현재 프레임에서 UIImage 생성
    let ciImage = CIImage(cvPixelBuffer: imageBuffer)
    let uiImage = UIImage(ciImage: ciImage)
    
    let rect = previewLayer.bounds
    let renderer = UIGraphicsImageRenderer(size: rect.size)
    let renderedImage = renderer.image { context in
        // 비디오 프레임을 렌더링 (비율을 유지하며 잘라내기)
        let aspectRatio = uiImage.size.width / uiImage.size.height
        let targetWidth = rect.width
        let targetHeight = targetWidth / aspectRatio
        let yOffset = (rect.height - targetHeight) / 2.0
        let targetRect = CGRect(x: 0, y: yOffset, width: targetWidth, height: targetHeight)
        uiImage.draw(in: targetRect)

        // previewLayer를 렌더링
        previewLayer.render(in: context.cgContext)
    }
    
    // 캡쳐한 이미지를 저장
    UIImageWriteToSavedPhotosAlbum(renderedImage, self, #selector(saveImage(_:didFinishSavingWithError:contextInfo:)), nil)
}
  • 현재 프레임을 가지고 UIImage 를 생성함
    • currentSampleBuffer에서 imageBuffer를 가져와 이를 CIImage 로 변환, 이를 UIImage 로 다시 생성
      (CMSampleBuffer 에서 직접 UIImage 를 생성하는 방법이 불가능하므로)
  • UIGraphicsImageRenderer 로 새 이미지 렌더링
    • 전체 영상 화면에서 previewLayer 화면에 보이는 부분을 잘라내 새로운 이미지 생성
  • 이미지 저장, 저장이 완료되면 saveImage 호출됨

 

1
2
3
4
5
6
7
8
@objc private func saveImage(_ image: UIImage, didFinishSavingWithError error: NSError?, contextInfo: UnsafeRawPointer) {
    if let error = error {
       print("Error saving image: \(error.localizedDescription)")
   } else {
       print("Image saved successfully!")
       showToast(message: "포차코를 저장했어요")
   }
}

이미지가 성공적으로 저장될 경우, 살짝 토스트 메세지를 띄워주었습니다 (안해도 됨)

 


결과물

결과물

짜잔~~

 

요렇게 조촐하게.. 마무리했습니다 .. 🥹

촬영 버튼을 누르면 저장이 댑니다!!!

 

테스트의 흔적..

테스트의 흔적 ...

 


마치며

 

이렇게 오늘은 Swift Vision얼굴 감지 기능을 이용해

포차코 필터를 만들어 보았는데요 😅

처음 해보는 부분이라 오류가 많을 수도 있고,

찾아보니 더 간단하게 구현하신 분들도 있는 것 같았습니다.

그러니 .. 잘못된 부분이 있다면 댓글 남겨주세요 😉✨

 

귀여운 봄이

봄이도 포차코 필터를 좋아해~~😙

 

🏅 전체 코드 살펴보기

🌿 참고 자료

💻 공식 문서

This post is licensed under CC BY 4.0 by the author.