Vision을 사용한 이미지 분석

애플의 Vision은 컴퓨터 시각화 기술을 사용하여 이미지 내에서 얼굴이나 문자, 바코드등을 인식하는 기능을 제공하는 프레임워크이다. 이미 기존에 Core ImageAVFoundation에서 비슷한 기능을 제공하고 있지만, Vision은 여기서 몇 가지 개선된 기능을 제공한다. 먼저 얼굴인식의 정확도면에서 기존 API보다 우수하며, 단순히 얼굴이 존재하는 영역만 찾는 것이 아니라 윤곽, 눈, 코, 입을 각각 찾아낼 수 있다. 그외에 CoreML과 연동하여 훈련된 기계학습 모델을 적용해서 이미지로부터 특정한 형상이 무엇인지를 파악하는 기능등을 제공한다.

이번 글에서는 Vision을 사용해서 얼굴 인식 및 QRCode 인식을 처리하는 예제를 살펴보도록 하겠다.

Vision의 일반적인 API 사용법

Vision의 API는 크게 두 가지 클래스에 의해서 동작한다. 찾아내고자 하는 형상의 특성에 따라서 요청 객체가 구분된다. 그리고 이 요청을 처리할 수 있는 핸들러 객체를 이미지를 기반으로 생성하고, 핸들러로 하여금 원하는 형상을 이미지 내에서 찾아내게 된다. 요청 핸들러는 VNImageRequestHandler라는 클래스의 인스턴스를 공통으로 사용하며, 개별 요청은 찾아내고자 하는 형상의 타입에 따라서 VNRequest의 서브 클래스들 중에서 선택하여 사용한다. Vision이 제공하는 몇 가지 기본 요청 클래스에는 다음과 같은 것들이 있다.

  • VNDetectRectanglesRequest : 이미지에서 사각형을 찾을 때 사용한다.
  • VNDetectFaceRectanglesRequest : 이미지에서 얼굴을 찾을 때 사용한다.
  • VNDetectFaceLandmarksRequest : 이 요청은 이미지에서 얼굴영역뿐 아니라, 얼굴 내의 여러 랜드마크(눈, 코, 입, 중앙선, 윤곽 등)를 찾을 때 사용한다.
  • VNDetectBarcodesRequest : 바코드를 찾는 요청
  • VNDetectTextRectanglesRequest : 텍스트를 찾을 때 사용한다.
  • VNCoreMLRequest : Core ML과 연계하여 이미지 분석을 시도할 때 사용한다.

실제 이미지 분석은 핸들러 객체의 perform(_:)을 통해서 수행된다. 이 메소드는 별도의 큐에서 작업을 수행하면서 즉시 리턴되며, 리턴값은 존재하지 않는다. 이미지 분석이 끝나면 개별 요청 건의 results 프로퍼티에 분석 결과가 들어가게 되며, 같은 큐에서 각각의 요청건의 완료 핸들러를 호출하게 된다.

이미지 분석 결과는 [VNObservation] 타입이며, 어떤 형상을 찾아내느냐에 따라서 VNObservation의 서브 클래스를 사용하게 된다. 예를 들어 VNDetectFaceLandmarksRequest를 처리했다면, 그 결과는 VNFaceObservation 타입의 값들이 될 것이다. (물론 다운캐스팅해야한다.) 각각의 결과값은 유형에 따라 필요한 부가 정보를 가지고 있다.

잠시 요약하자면 Vision의 이미지 분석은 분석하려는 정보의 카테고리에 맞는 적절한 리퀘스트 객체를 생성한 다음, 처리하고자 하는 이미지로부터 VNImageRequestHandler 객체를 만들어서 이 핸들러 객체에 리쿼스트 객체를 넘겨서 그 결과를 사용하는 것이다. 그리고 이 때의 결과 타입은 리퀘스트 타입에 따라서 VNObservation의 여러 서브 클래스 중 하나가 된다. 이 과정을 따라가며 이미지 내에서 얼굴을 찾는 예제를 살펴보겠다.

얼굴을 인식하기

Vision을 사용하여 이미지에서 얼굴을 찾는 과정을 살펴보자. Vision에서 지원하는 이미지 데이터 유형은 다양한데, CGImageCIImage 뿐만 아니라 코어 비디오 프레임이나 비트맵 데이터 및 파일 URL 등을 지원한다. 다음 예제에서는 CGImage로부터 얼굴을 찾고 이를 하이라이트 하는 과정을 구현했다.

참고로 VNObservation에서는 이미지 내에서 형상이 탐지된 영역을 boundingBox 라는 프로퍼티를 통해 제공하는데, 이  값은 실제 이미지 상의 좌표가 아닌 0.0 ~ 1.0 사이의 상대적인 좌표값을 기준으로 한다. 따라서 노멀라이징된 영역을 다시 이미지 좌표계 내의 좌표로 환산해서 사용해야 한다.

요청 객체 생성하기

요청 객체를 생성할 때에는 해당 요청에 대한 분석이 끝났을 때 처리될 핸들러를 지정해주는 것이 필요하다. 실제 분석 결과를 처리하는 부분이 이 부분인데, completionHandler?라는 프로퍼티로 (VNRequest, Error?) -> Void의 타입을 갖는다.

let request = VNDetectFaceLandmarksRequest()
request.completionHandler = { request, error in
  // 핸들러 코드
}

분석이 끝났을 때의 핸들러는 다음과 같은 작업들을 처리하게 된다.

  1. 분석 과정에서 넘겨진 에러가 있는지 살펴보고 에러가 발생한 경우 적절히 처리한다.
  2. 분석 결과는 요청 객체의 results? 라는 프로퍼티에 저장되어 있다. 이 값은 [Any]? 이므로 적절한 타입으로 다운 캐스팅해야 하는데, 얼굴 인식의 경우 분석 결과 1개당 VNFaceObservation 객체 1개가 그 결과이므로, [VNFaceObservation] 으로 통째로 변환하면 된다.
  3. 얼굴이 들어있는 영역은 각 observation의 boundingBox 속성이다. 실제 landmarks 는 얼굴 내의 윤곽, 눈, 눈썹, 입술 등의 요소를 추적/인식한 결과를 담는다.
request.completionHandler = { request, error in 
  // 에러 확인
  guard error == nil else {
    print("ERROR: \(error!.localizedDescription)")
    return
  }

  // 결과 수집
  guard let faces = request.results as? [VNFaceObservation] else {
    print("Not supported detection type")
    return
  }

  // 얼굴이 들어간 영역을 이미지 크기에 맞는 
  // 영역으로 환산한다.
  // 이미지는 source: CGImage로 참조 가능하다고
  // 가정한다. 
  let rects = faces.filter{ $0.landmarks != nil }
       .map{ VNImageRectForNormalizedRect($0, 
                source.width,
                source.height) }
  
  // 원본 이미지에서 얼굴이 표현된 영역을 하이라이트 처리
  let resultImage = getHighlightedImage(for: source, rects:rects)

  /* 
     resultImage를 어딘가로 사용하는 코드
  */

}

결과 이미지를 생성하기

위 함수에서 호출하는 getHighlightedImage는 이미지와 CGRect 의 배열을 인자로 받아서, 주어진 영역이 하이라이트 표시되는 이미지를 생성하는 함수이다. 이는 비트맵 컨텍스트에서 이미지를 한 번 그려주고 다시 각 영역에 사각형을 그려주어 그 결과를 다시 CGImage 형태로 리턴하면 된다.

func getHighlightedImage(for source: CGImage, rects: [CGRect]) -> CGImage
{
  // 주어진 source 위에 사각형들을 그리는 함수이다. 
  // 먼저 그림을 그리기 위한 비트맵 컨텍스트를 생성한다. 

  let ctx = CGContext(data: nil,
                 width: source.width,
                 height: source.height,
                 bitsPerComponent: 8,
                 bytesPerRow: 0,
                 space: CGColorSpaceCreateDeviceRGB(),
                 bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!

  // 먼저 배경이 될 이미지를 그린다.
  let imageRect = CGRect(x:0, y:0, width:source.width, height: source.heihgt)
  ctx.draw(source, in: imageRect)
  // 패스를 하나 만들고, 패스에 주어진 사각형들을 추가한다. 
  let path = CGMutablePath()
  path.addRects(rects)

  // 패스를 그린다. 
  ctx.setLineWidth(8.0)
  ctx.setStrokeColor(NSColor.green.cgColor)
  ctx.strokePath(path)

  // 결과를 리턴
  return ctx.makeImage()!
}

핸들러 생성 및 실행

이미지에서 얼굴을 찾아서 해당 영역에 사각형이 그려진 이미지를 만들어내는 과정까지를 어떻게 구현하는지 살펴보았다. 이제 최종적으로 VNImageRequestHandler를 생성해서 요청 객체를 어떤식으로 처리하는지 살펴보면 되겠다. 실제 코드는 간단하다. 이미지를 기반으로 핸들러를 생성하고, 핸들러의 perform() 메소드에 배열 형태의 요청 객체(들)을 넘겨주면 끝이다.

let hander = VNImageRequsetHandler(cgImage: source, options:[:])
do {
  try handler.perform([request])
} catch {
  fatalError("Fail to execute image analysis")
}

참고로

QRCode를 인식하기

Vision은 바코드와 QR코드를 인식할 수도 있다. QR코드는 바코드의 한 종류로 긴 문자열을 인코딩할 수 있고, 말그대로 빠르게 인식되며, 이미지의 일부가 손상되어도 복구정보를 내포하고 있기 때문에 놀랄 정도로 잘 인식된다.

바코드를 인식하기 위한 요청으로 VNDetectBarcodeRequest를 사용한다. 여기에 인식하기 위한 바코드의 종류를 설정할 수 있는데, 바코드의 종류는 VNBarcodeSymbology 라는 클래스에 정의되어 있으며, 원하는 바코드의 종류를 배열로 symbologies 카테고리에 할당해주면 된다.  우리는 QR코드 한 종류만을 사용할 것이고, 이에 대응하는 코드는 .QR이다.

let request = VNDetectBarcodesRequest()
request.symbologies = [.QR]

다음은 완료 핸들러를 작성할 차례이다. 간단하게 인식된 코드 내의 문자열을 출력하기로 하자. 완료 핸들러는 요청 객체를 생성하는 시점에 작성할 수도 있지만, completionHandler? 라는 별도의 프로퍼티이므로 생성 후에 할당해줄 수도 있다.

request.completionHandler = { request, error in
  // 에러 검사
  guard error == nil else {
    print("ERROR: \(error!.localizedDescription)")
    return
  }
   
  // 결과 타입 검사
  guard let results = request.results as? [VNBarcodeObservation] else {
    print("Invalid result types")
    return
  }

  // 각각의 결과에 대해 인코딩된 결과 값을 출력하자.
  for result in results {
    if let message = result.pyloadStringValue {
      print(message)
    }
  }
}

해당 분석을 Vision에게 처리해달라고 요청하는 것은 얼굴 인식과 동일하다.

let handler = VNImageRequestHandler(cgImage: source, options: [:])
try? handler.perform([request])