최근 프로젝트를 하면서, 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을 풀 수 있다.
- 잠금 상태를 유지할 수는 있지만, 디바이스를 공유하는 다른 앱의 캡쳐 품질을 저하시킬 수 있기에 지양해야된다.
- 디바이스 프로퍼티 설정(포커스 모드, 노출 모드 등등) 'lockForConfiguration'등을 활용하여 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 인식을 진행합니다.
이럴 경우 아래와 같은 문제점이 생깁니다.
- 불필요한 QR Code 인식이 생길 수 있다.(화면 내에 여러 QR Code 이미지가 존재할 경우)
- 전체 화면에서 들어오는 데이터를 처리하므로 낭비가 있다.
그래서 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를 인식할 수 있게 됩니다.
전체 코드는 아래와 같습니다.
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] 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] 공식문서로 보는 Core Location (0) | 2023.07.16 |