[최종 수정일 : 2018.08.13]

원문 : https://www.appcoda.com/vision-framework-introduction/

iOS 11에서 비젼 프레임워크를 사용하여 텍스트 감지하기(Using Vision Framework for Text Detection in iOS 11)

2017년 WWDC에서 애플이 발표한 강력한 많은 프레임워크 중에, 비젼 프레임워크(Vision framework)는 그중에 하나입니다. 비젼 프레임워크로, 앱에서 컴퓨터 비젼 기술을 관련된 지식 없이도 쉽게 구현할수 있습니다. 비젼을 사용하여, 앱에서 얼굴과 얼굴 특징(예: 웃거나, 찡그리거나, 왼쪽 눈썹, 등등)을 식별, 바코드 감지, 이미지 장면 분류, 객체 탐지와 추적, 수평선 감지하는것 처럼 강력한 작업들을 수행할수 있습니다.

아마도 Swift로 프로그래밍 하는 사람이 Core Image와 AVFoundation이 있을때 비젼(Vision)의 용도가 무엇인지 궁금해할것입니다. WWDC에서 발표한 아래 표를 보면, 비젼(Vision)이 더 정확하고 더 많은 플랫폼에서 사용할수 있는 것을 알수 있습니다. 하지만, 이를 위해서 더 많은 처리 시간과 전력이 필요합니다.


WWDC 비디오 : https://developer.apple.com/videos/play/wwdc2017/506/

이 튜토리얼에서, 텍스트 감지를 위해서 비젼 프레임워크(Vision framework)를 사용할 것입니다. 우리는 폰트, 객체, 색상에 관계없이 텍스트를 감지할 수 있는 앱을 만들것입니다. 아래 그림과 같이, 비젼 프레임워크는 인쇄되거나 직접 작성한 텍스트를 인식할 수 있습니다.


앱의 UI를 만드는 시간을 줄이기 위해 비젼 프레임워크를 배우는데 집중하기 위해서, 시작 프로젝트를 다운로드 하세요.

이 튜토리얼을 컴파일하기 위해서는 Xcode 9 이상이 필요하다는 것을 기억하세요. 이 튜토리얼을 테스트 하려면 iOS11을 실행할수 있는 기기가 필요합니다. 또한 코드는 Swift 4로 작성되어 있습니다.

실시간 스트림 만들기(Creating a Live Stream)

프로젝트를 열때, 스토리보드에 있는 뷰가 이미 설정되어 있는 것을 알수 있습니다. ViewController.swift 로 가서, 아울렛(outlets)과 함수를 연결해 놓은 코드구조를 찾을수 있습니다. 첫번쩨 단계는 텍스트 감지에 사용할 실시간 스트림을 반드는 것입니다. imageView 아울렛 바로 아래에, AVCaptureSession에 대한 또다른 프로퍼티가 선언합니다.

varv  session = AVCaptureSession()

이것은 실시간 또는 오프라인 캡쳐를 수행하는 AVCaptureSession의 객체를 초기화합니다. 이것은 실시간 스트림 기반의 어떤 동작을 수행할때마다 사용됩니다. 다음으로, 기기에 세션을 연결해야합니다. ViewController.swift에서 다음 시작 함수를 추가합니다.

func startLiveVideo() {
    //1
    session.sessionPreset = AVCaptureSession.Preset.photo
    let captureDevice = AVCaptureDevice.default(for: AVMediaType.video)
    
    //2
    let deviceInput = try! AVCaptureDeviceInput(device: captureDevice!)
    let deviceOutput = AVCaptureVideoDataOutput()
    deviceOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA)]
    deviceOutput.setSampleBufferDelegate(self, queue: DispatchQueue.global(qos: DispatchQoS.QoSClass.default))
    session.addInput(deviceInput)
    session.addOutput(deviceOutput)
       
    //3
    let imageLayer = AVCaptureVideoPreviewLayer(session: session)
    imageLayer.frame = imageView.bounds
    imageView.layer.addSublayer(imageLayer)
        
    session.startRunning()
}

이전에 AVFoundation으로 작업해본적이 있다면, 이 코드의 대부분 익숙할 것입니다. 만약 그런적이 없어도, 걱정하지 마세요. 코드 한줄마다 철저히 살펴볼것입니다.

  1. AVCaptureSession의 설정을 수정합니다. 그리고나서, AVMediaType을 항상 계속 실행하하는 라이브 스트림을 원하기 때문에 비디오(video)로 설정합니다.
  2. 다음으로, 기기 입출력을 정의합니다. 입력은 카메라로 보고 있는 것이고 출력은 보여지는 비디오입니다. 비디오가 비디오 포멧 타입인 kCVPixelFormatType_32BGRA로 보여지길 원합니다. 여기에서픽셀 포멧 타입에 대한 더 자세한 것을 배울수 있습니다. 마지막으로 AVCaptureSession에 입력과 출력을 추가합니다.
  3. 마지막으로, 비디오 미리보기가 포함된 하위레이어(sublayer)를 imageView에 추가하고 세션을 실행합니다.

viewWillAppear 메소드에서 이 함수를 호출합니다.

override func viewWillAppear(_ animated: Bool) {
    startLiveVideo()
}

이미지 뷰의 범위(bounds)가 viewWillAppear()에서 마무리되지 않았기에, 레이어의 범위를 업데이트 하기 위해서 viewDidLayoutSubviews() 메소드를 오버라이드 합니다.

override func viewDidLayoutSubviews() {
    imageView.layer.sublayers?[0].frame = imageView.bounds
}

실행하기 전에, 카메라 사용이 왜 필요한지에 대한 이유를 제공하기 위해 Info.plist에 항목을 추가합니다. 이는 애플에서 iOS 10을 발표한 이후에 필요합니다.


라이브 스트림이 예상대로 동작해야 합니다. 하지만, 비젼 프레임워크(Vision framework)를 아직 구현하지 않았기 때문에 텍스트 감지를 하지 않습니다. 이것이 우리가 다음에 할 일입니다.

텍스트 감지 구현하기(Implementing Text Detection)

텍스트 감지 부분을 구현하기 전에, 비젼 프레임워크(Vision framework)에 동작에 대한 이해가 필요합니다. 기본적으로, 앱에서 비젼 구현하기 위해서는 3 단계가 있습니다.

  • 요청(Requests) - 요청은 프레임워크에 무언가를 감지하도록 요청할때 입니다.
  • 처리(Handlers) - 처리는 프레임워크에 요청한 이후에 무언가를 만들거나 요청을 처리 할때 입니다.
  • 관찰(observations) - 관찰은 제공된 데이터를 무엇을 하고자 하는 것입니다.

이제, 요청부터 시작해 봅시다. session 변수의 초기화 바로 아래에, 다음에 오는 또 다른 변수를 선언합니다.

var requests = [VNRequest]()

제네릭 VNRequest를 포함할 배열을 만듭니다. 다음으로, ViewController 클래스에서 텍스트 감지를 시작하는 함수를 생성합니다.

func startTextDetection() {
    let textRequest = VNDetectTextRectanglesRequest(completionHandler: self.detectTextHandler)
    textRequest.reportCharacterBoxes = true
    self.requests = [textRequest]
}

이 함수에서, VNDetectTextRectanglesRequesttextRequest 상수를 생성합니다. 기본적으로 VNRequest 타입은 텍스트가 있는 사각형만을 찾습니다. 프레임워크가 이 요청을 완료할때, detectTextHandler 함수를 호출하길 원합니다. 또한, 프레임워크가 인식한것을 정확히 알고 싶기에, reportCharacterBoxes 프로퍼티를 true로 설정합니다. 마지막으로, 이전에 작성한 textRequest를 requests 변수로 설정합니다.

이제, 이 시점에 오류가 발생해야 합니다. 이는 요청을 처리하는 함수를 정의하지 않았기 때문입니다. 오류를 제거하기 위해 다음과 같은 함수를 생성합니다.


2
3
4
5
6
7
8
func detectTextHandler(request: VNRequest, error: Error?) {
    guard let observations = request.results else {
        print("no result")
        return
    }
        
    let result = observations.map({$0 as? VNTextObservation})
}

위 코드에서, VNDetectTextRectanglesRequest의 모든 결과를 포함하는 observations 상수를 정의합니다. 다음으로, 요청의 모든 결과를 통해 VNTextObservation의 타입으로 변환하는 또 다른 result 상수를 정의합니다.

이제 viewWillAppear() 메소드를 업데이트 합니다.

override func viewWillAppear(_ animated: Bool) {
    startLiveVideo()
    startTextDetection()
}

이제 앱을 실행해도, 아무런 차이가 없습니다. 이는 VNDetectTextRectanglesRequest에 텍스트 박스를 알려달라고 했지만, 어떻게 처리해야 하는지를 알려주지 않았기 때문입니다. 이것이 우리가 다음에 해야할 일입니다.

박스 그리기(Drawing the Boxes)

앱에서, 박스 2개를 그리는 프레임워크를 사용할 것입니다. 하나는 각 문자에 대해서 탐지하고, 다른 하나는 단어를 탐지합니다. 먼저 각 단어에 대한 함수를 생성하도록 합시다.

func highlightWord(box: VNTextObservation) {
    guard let boxes = box.characterBoxes else {
        return
    }
        
    var maxX: CGFloat = 9999.0
    var minX: CGFloat = 0.0
    var maxY: CGFloat = 9999.0
    var minY: CGFloat = 0.0
        
    for char in boxes {
        if char.bottomLeft.x < maxX {
            maxX = char.bottomLeft.x
        }
        if char.bottomRight.x > minX {
            minX = char.bottomRight.x
        }
        if char.bottomRight.y < maxY {
            maxY = char.bottomRight.y
        }
        if char.topRight.y > minY {
            minY = char.topRight.y
        }
    }
        
    let xCord = maxX * imageView.frame.size.width
    let yCord = (1 - minY) * imageView.frame.size.height
    let width = (minX - maxX) * imageView.frame.size.width
    let height = (minY - maxY) * imageView.frame.size.height
        
    let outline = CALayer()
    outline.frame = CGRect(x: xCord, y: yCord, width: width, height: height)
    outline.borderWidth = 2.0
    outline.borderColor = UIColor.red.cgColor
        
    imageView.layer.addSublayer(outline)
}

이 함수에서 요청한 모든 characterBoxes를 결합한 boxes 상수를 정의하는 것으로 시작합니다. 그리고나서, 박스를 배치하는데 도움되는 몇가지를 정의합니다. 마지막으로, CALayer를 주어진 상수들로 정의하여 만들고 imageView에 적용합니다. 다음으로, 각 글자에 대한 박스를 만들어 봅시다.

func highlightLetters(box: VNRectangleObservation) {
    let xCord = box.topLeft.x * imageView.frame.size.width
    let yCord = (1 - box.topLeft.y) * imageView.frame.size.height
    let width = (box.topRight.x - box.bottomLeft.x) * imageView.frame.size.width
    let height = (box.topLeft.y - box.bottomLeft.y) * imageView.frame.size.height
        
    let outline = CALayer()
    outline.frame = CGRect(x: xCord, y: yCord, width: width, height: height)
    outline.borderWidth = 1.0
    outline.borderColor = UIColor.blue.cgColor
    
    imageView.layer.addSublayer(outline)
}

이전에 작성한 코드와 비슷하며, 상자를 쉽게 그릴수 있는 제약조건을 정의하기 위해 VNRectangleObservation를 사용합니다. 이제, 모든 함수를 준비했습니다. 마지막 단계는 모든 점을 연결하는 것입니다.

점 연결하기(Connecting the Dots)

여기에 연결할 두개의 중점이 있습니다. 첫번째는 요청한 처리함수에 대한 박스입니다. 먼저 해봅시다. detactTextHandler 메소드를 다음과 같이 업데이트 합니다.

func detectTextHandler(request: VNRequest, error: Error?) {
    guard let observations = request.results else {
        print("no result")
        return
    }
    
    let result = observations.map({$0 as? VNTextObservation})
    
    DispatchQueue.main.async() {
        self.imageView.layer.sublayers?.removeSubrange(1...)
        for region in result {
            guard let rg = region else {
                continue
            }
            
            self.highlightWord(box: rg)
            
            if let boxes = region?.characterBoxes {
                for characterBox in boxes {
                    self.highlightLetters(box: characterBox)
                }
            }
        }
    }
}

코드를 비동기적으로 실행하는 것으로 시작합니다. 먼저, ImageView의 가장 아래쪽 레이어((bottommost layer)를 제거합니다(눈치챈 경우에, ImageView에 많은 레이어를 추가하고 있습니다). 다음으로, VNTextObservation의 결과에 영역이 존재하는지 확인합니다. 이제, 범위에 대한 박스를 그리는 함수를 호출하거나, 정의한대로 그 단어를 호출합니다. 그리고나서, 범위에 문자 박스가 있는지 확인합니다. 만약 있다면, 각 문자에 대한 박스 테두리를 그리는 함수를 호출합니다.

이제 점을 연결하는 마지막 단계는 라이브 스트림으로 비젼(Vision) 코드를 실행하는 것입니다. 비디오 출력을 가져오고 CMSampleBuffer로 변환해줘야 합니다. ViewController.swift의 확장에서 다음 코드를.삽입합니다.

func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
    guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
        return
    }
        
    var requestOptions:[VNImageOption : Any] = [:]
        
    if let camData = CMGetAttachment(sampleBuffer, kCMSampleBufferAttachmentKey_CameraIntrinsicMatrix, nil) {
        requestOptions = [.cameraIntrinsics:camData]
    }
        
    let imageRequestHandler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: 6, options: requestOptions)
        
    do {
        try imageRequestHandler.perform(self.requests)
    } catch {
        print(error)
    }
}

코드의 마지막 부분입니다. 그 확장은 AVCaptureVideoDataOuputSamplebufferDelegate 프로토콜을 채택(adopt)했습니다. 기본적으로 이 함수는 CMSampleBuffer이 존재하는지와 AVCaptureOutput이 주어지는지 확인합니다. 다음으로, VNImageOption 타입에 대한 딕셔너리인 requestOptions 변수를 생성합니다. VNImageOption은 카메라에서 프로퍼티와 데이터를 가져오는 구조체 타입입니다. 마지막으로 VNImageRequestHandler객체를 생성하고 이전에 작성한 텍스트 요청을 수행합니다.

앱을 빌드하고 실행해 보세요.


결론(Conclusion)

큰 일을 해냈습니다! 다른 폰트, 크기, 객체, 밝기등으로 앱을 테스트 해보세요. 앱을 확장할수 있는지 보세요. Core ML 과 비젼(Vision)을 결합할수도 있습니다. Core ML에 대한 자세한 정보는 Core ML 튜토리얼을 보세요.

참고로, Github에 있는 완정된 Xcode 프로젝트를 참조할 수있습니다.

비젼 프레임워크(Vision framework)에 대한 자세한 정보는 공식 비젼 프레임워크 문서(offical Vision framework documentation)을 참조하세요. 또한, WWDC 2017의 비젼 프레임워크 세션을 참조할 수 있습니다.

Vision Framework: Building on Core ML
Advances in Core Image: Filters, Metal, Vision, and More

Posted by 까칠코더