longlivedrgn
Miro 찾기
longlivedrgn
전체 방문자
오늘
어제
  • 분류 전체보기 (74)
    • Swift (36)
    • iOS (31)
    • Algorithm (0)
    • Architecture, Design Patter.. (1)
    • Computer Science (6)
      • 컴퓨터 네트워크 (6)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
longlivedrgn

Miro 찾기

[iOS] 특정영역만으로 QR 코드 스캔하기(feat. AVFoundation)
iOS

[iOS] 특정영역만으로 QR 코드 스캔하기(feat. AVFoundation)

2024. 6. 26. 13:04

최근 프로젝트를 하면서, AVFoundation를 활용하여 QR code scanner를 구현한 경험이 있습니다. 공식문서를 통하여 해당 기능을 구현한 경험을 소개하고, 더 나아가 전체 Camera Preview 속 모든 input이 아니라, 특정 영역만을 포커싱하여 QR 코드를 인식하는 코드 또한 소개해보도록 하겠습니다.

 

 

AVFoundation?

  • 시각과 청각과 관련된 에셋, 디바이스 카메라 제어, 오디오 처리 등을 구성할 수 있는 Framework
  • 시청각 자산으로 작업하고, 디바이스 카메라를 제어하고, 오디오를 처리하고, 시스템 오디오 상호 작용을 구성할 수 있다.

 

(AVFoundation을 활용하기 전에 대충 해당 Framework가 카메라를 다룰 수 있다는 것은 알고 있었는데, 오디오도 처리하는지는 이번에 공식문서를 읽어보면서 알 수 있었습니다.)

 

AVCaptureSession?

  • 실제 캡처 동작을 구성하고 입력 장치에서 캡처 출력으로 데이터의 흐름을 조정하는 객체입니다.
  • 실시간 캡처를 수행하려면 캡처 세션을 인스턴스화하고 적절한 입력과 출력을 추가해야됩니다.
  • 'startRunning()' 메서드를 호출하여 입력에서 출력으로 데이터 흐름을 시작하고 'stopRunning()' 메서드를 호출하여 흐름을 중지합니다.
  • 'sessionPreset'을 활용하면 출력의 품질 수준, 비트 전송률 또는 기타 설정을 사용자 지정할 수 있습니다.
    • 단, 높은 프레임 속도와 같은 특정한 옵션 설정은 'AVCaptureDevice' 인스턴스를 통해서 바로 설정할 수 있습니다.

 

위의 AVCaptureSession을 활용한 공식문서 속 예제를 살펴보면 아래와 같습니다. (실제 제가 구현한 코드는 조금 더 아래에서 보겠습니다.)

// Create the capture session. -> 인스턴스화하기
let captureSession = AVCaptureSession()

// Find the default audio device. -> 실제 캡쳐할 디바이스 찾기
guard let audioDevice = AVCaptureDevice.default(for: .audio) else { return }


do {
    // Wrap the audio device in a capture device input. -> 위에서 캡쳐할 디바이스를 Input device로 설정한다.
    let audioInput = try AVCaptureDeviceInput(device: audioDevice)
    // If the input can be added, add it to the session.
    if captureSession.canAddInput(audioInput) { 
        captureSession.addInput(audioInput) // input deivce를 session의 input으로 설정한다.
    }
} catch {
    // Configuration failed. Handle error.
}

 

 

AVCaptureDevice?

  • 카메라나 마이크와 같은 하드웨어 또는 가상 캡처 장치
  • 'Capture session(AVCaputureSession)'의 input으로 들어가는 media data를 제공한다.
  • 'AVCaptureDevice.DiscoverySession'나 'default(_:for:position:)' 메소드를 통해서 Capture device를 받아올 수 있다.
    • 'default(_:for:position:)'의 경우, 첫 파라미터에는 defaultType → builtInWideAngleCamera 같은 타입 프로퍼티가 들어간다.
    • mediaType → Audio나 Video 둘 중 하나를 선택할 수 있다.
    • position → 전면 카메라/후면 카메라인 지를 설정할 수 있다.
    • 혹은 그냥 default(for:)를 활용하여 mediaType만 설정해 줄 수도 있다.
  • 여러 개의 configuration 옵션이 존재한다.
    • 디바이스 프로퍼티 설정(포커스 모드, 노출 모드 등등) 'lockForConfiguration'등을 활용하여 lock을 걸어두고 설정해야 된다.
      • 설정하려는 새 모드가 디바이스에 유효한지 확인하기 위해 디바이스의 기능을 쿼리해야 합니다.
    • 그리고 'unlockForConfiguration()'을 활용하여 lock을 풀 수 있다.
    • 잠금 상태를 유지할 수는 있지만, 디바이스를 공유하는 다른 앱의 캡쳐 품질을 저하시킬 수 있기에 지양해야된다.

 

AVCaptureMetadataOutput?

  • Capture session에 의해서 생성된 Capture Output이다.
  • 해당 객체는 캡쳐 커넥션에서 나온 meta data 객체를 중간에 인터셉트하여 Delegate 객체에게 던져준다.
  • 해당 객체를 caputure session의 output으로 설정해 준다.
  • `setMetadataObjectsDelegate(_:queue:)`을 통하여 delegate 설정을 해준다.
    • objectsDelegate - delegate 객체를 설정
    • objectsCallbackQueue - delegate 메소드를 실행할 Dispatch Queue를 설정한다. → 직렬큐로 설정해야된다.
  • `metadataObjectTypes` - metadata 객체의 타입을 설정한다.
    • qr/humanBody/Face 등이 존재한다.
  • `rectOfInterest`
    • 시각적 meta data에서 집중해서 확인할 사각형의 공간
    • video preview에서 모든 부분을 detect하는 게 아니라 특정 공간에 있는 qr만 확인하고 싶을 때 활용한다.
    • default 값 - CGRect (0.0, 0.0, 1.0, 1.0)

 

AVCaptureVideoPreviewLayer?

  • 카메라 기기로부터 비디오를 보여주는 CALayer이다.
  • 카메라 캡쳐의 프리뷰를 보여주는 데 활용된다.

 

QRScanner 구현

실제 QRScanner를 구현한 코드는 아래와 같습니다.

 

AVFoundation을 import하고, captureSession을 프로퍼티로 선언합니다.

import AVFoundation
...
private let captureSession = AVCaptureSession()

 

 

 

그리고, 아래와 같이 QRScanner를 구성합니다.

 

먼저 CaptureDevice를 받아온 후,
CaptureSession과 CaptureDevice를 이어주는 AVCaptureDeviceInput에 해당 captureDevice를 넣어줍니다.

guard let captureDevice = AVCaptureDevice.default(for: .video) else { return }

do {
    let input = try AVCaptureDeviceInput(device: captureDevice)
    self.captureSession.addInput(input)
    ....

 

 

 

그리고 AVCaptureMetatdatatOuput을 생성하여 captureSession의 output으로 설정하고, 해당 meta data object를 받기 위한 delegate 설정과 실행 큐를 설정합니다.(+meta data object 타입도 설정합니다.)

let output = AVCaptureMetadataOutput()
self.captureSession.addOutput(output)

output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
output.metadataObjectTypes = [.qr]

 

 

 

그 후, videolayer를 생성하여, 실제 화면에서 카메라 프리뷰를 볼 수 있도록 구현하고, startRunning 메소드를 실행하여 카메라 preview 캡쳐를 시작합니다.

let videoLayer = AVCaptureVideoPreviewLayer(session: self.captureSession)

videoLayer.frame = view.layer.bounds
videoLayer.videoGravity = .resizeAspectFill
self.view.layer.addSublayer(videoLayer)

DispatchQueue.global(qos: .userInitiated).async {
    self.captureSession.startRunning()
}

 

 

 

그리고 이렇게 카메라를 통해서 인식한 QR 코드는 아래와 같은 delegate 메소드를 통해서 meta data object 형태로 받아옵니다.

func metadataOutput(_ captureOutput: AVCaptureMetadataOutput,
                        didOutput metadataObjects: [AVMetadataObject],
                        from connection: AVCaptureConnection) {
        if presentedViewController == nil, let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject
        {
        	// metadataObject 속 string 값을 쏙 빼오기
            guard let decodedCode = metadataObject.stringValue else {
                self.viewModel.output.qrCodeValidationResponse.accept(.invalid)
                return
            }

            AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
            // session을 잠시 멈춘다.
            self.captureSession.stopRunning()
            self.viewModel.input.detectQRCode.accept(decodedCode)

            debugPrint("🚪 인식된 입장 코드: \(decodedCode) 🚪")

            DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
            	// 1초 뒤에 다시 카메라 capture flow 시작한다.
                self.captureSession.startRunning()
            }
        }
    }

 

 

 

근데, 위와 같이 코드를 구현할 경우 카메라 preview에서 보이는 모든 화면에서 QR Code 인식을 진행합니다.

이럴 경우 아래와 같은 문제점이 생깁니다.

  1. 불필요한 QR Code 인식이 생길 수 있다.(화면 내에 여러 QR Code 이미지가 존재할 경우)
  2. 전체 화면에서 들어오는 데이터를 처리하므로 낭비가 있다.

 

 

 

그래서 AVCaptureMetadataOutput의 rectOfInterest 값을 설정해주어, 화면 내 특정 영역에 있는 QR code만 인식할 수 있도록 구현하였습니다.

  • 인식 영역은 전체 화면의 중심에서 너비의 0.7배, 높이의 0.5배 정도되는 구간으로 설정
  • metadataOutputRectConverted를 통해서 전체 preview 화면에서 해당 rect의 비율을 계산하여 Rect를 {0~1, 0~1, 0~1, 0~1}로 변경해줍니다. 
    • Converts a rectangle from layer coordinates to the coordinate space of the metadata output.
    • 즉, Layer 좌표계를 metadata output 좌표계 공간으로 컨버팅하는 함수
  • 그리고, 이렇게 변경된 focusAreaRect는 AVCaptureMetadataOutput의 rectOfInterest로 설정해준다.
private func configureVideoLayer() -> CGRect {
    let videoLayer = AVCaptureVideoPreviewLayer(session: self.captureSession)

    videoLayer.frame = view.layer.bounds
    videoLayer.videoGravity = .resizeAspectFill
    self.view.layer.addSublayer(videoLayer)

    let widthRatio: CGFloat = 0.7
    let heightRatio: CGFloat = 0.5

    let width = UIScreen.main.bounds.width * widthRatio
    let height = UIScreen.main.bounds.height * heightRatio

    let focusAreaRect = CGRect(
        x: (UIScreen.main.bounds.width - width) / 2,
        y: (UIScreen.main.bounds.height - height) / 2,
        width:  UIScreen.main.bounds.width * widthRatio,
        height: UIScreen.main.bounds.height * heightRatio
    )

    return videoLayer.metadataOutputRectConverted(fromLayerRect: focusAreaRect)
}

...
    
let rectConverted = self.configureVideoLayer()
output.rectOfInterest = rectConverted

 

 

 

 

위와 같이 구현할 경우, 아래 스크린샷과 같이 전체 화면에서 QRCode를 인식하는 것이 아닌, 특정 영역(빨간 영역)에서만 QRCode를 인식할 수 있게 됩니다.

<rectOfInterest 설정>

 

 

 

전체 코드는 아래와 같습니다.

final class QRScannerViewController: BooltiViewController {
    private func configureQRScanner() {
        guard let captureDevice = AVCaptureDevice.default(for: .video) else { return }

        do {
            let input = try AVCaptureDeviceInput(device: captureDevice)
            self.captureSession.addInput(input)

            let output = AVCaptureMetadataOutput()
            self.captureSession.addOutput(output)

            output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
            output.metadataObjectTypes = [.qr]

            let rectConverted = self.configureVideoLayer()
            output.rectOfInterest = rectConverted

            DispatchQueue.global(qos: .userInitiated).async {
                self.captureSession.startRunning()
            }
        } catch {
            print("Error: \(error.localizedDescription)")
        }
    }

    private func configureVideoLayer() -> CGRect {
        let videoLayer = AVCaptureVideoPreviewLayer(session: self.captureSession)

        videoLayer.frame = view.layer.bounds
        videoLayer.videoGravity = .resizeAspectFill
        self.view.layer.addSublayer(videoLayer)

        let widthRatio: CGFloat = 0.7
        let heightRatio: CGFloat = 0.5

        let width = UIScreen.main.bounds.width * widthRatio
        let height = UIScreen.main.bounds.height * heightRatio

        let focusAreaRect = CGRect(
            x: (UIScreen.main.bounds.width - width) / 2,
            y: (UIScreen.main.bounds.height - height) / 2,
            width:  UIScreen.main.bounds.width * widthRatio,
            height: UIScreen.main.bounds.height * heightRatio
        )

        return videoLayer.metadataOutputRectConverted(fromLayerRect: focusAreaRect)
    }
}
    
    
extension QRScannerViewController: AVCaptureMetadataOutputObjectsDelegate {
    func metadataOutput(_ captureOutput: AVCaptureMetadataOutput,
                        didOutput metadataObjects: [AVMetadataObject],
                        from connection: AVCaptureConnection) {
        if presentedViewController == nil, let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject
        {
            guard let decodedCode = metadataObject.stringValue else {
                self.viewModel.output.qrCodeValidationResponse.accept(.invalid)
                return
            }

            AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
            self.captureSession.stopRunning()
            self.viewModel.input.detectQRCode.accept(decodedCode)

            debugPrint("🚪 인식된 입장 코드: \(decodedCode) 🚪")

            DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
                self.captureSession.startRunning()
            }
        }
    }
}
저작자표시 (새창열림)

'iOS' 카테고리의 다른 글

[iOS] 동시에 여러 Gesture 인식하기(feat.GestureRecognizer)  (0) 2024.09.20
[iOS] TabbarController의 presentingViewController(feat.currentContext)  (0) 2024.03.26
[iOS] JSONEncoding과 URLEncoding의 차이점(Alamofire/Moya)  (1) 2024.03.05
[iOS] CollectionView Reordering (feat. WWDC 20)  (3) 2023.09.15
[iOS] Core Location Unit Test하기(feat. WWDC18)  (0) 2023.07.23
    'iOS' 카테고리의 다른 글
    • [iOS] 동시에 여러 Gesture 인식하기(feat.GestureRecognizer)
    • [iOS] TabbarController의 presentingViewController(feat.currentContext)
    • [iOS] JSONEncoding과 URLEncoding의 차이점(Alamofire/Moya)
    • [iOS] CollectionView Reordering (feat. WWDC 20)
    longlivedrgn
    longlivedrgn

    티스토리툴바