Vision을 사용한 이미지 분석

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

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

Vision을 사용한 이미지 분석 더보기

Swift4의 키패스 표현

키패스는 어떤 객체의 프로퍼티 혹은 프로퍼티의 프로퍼티 체인에 대해서 그 이름을 통해서 값을 찾아나가는 표현을 말한다. Objective-C에서 키패스는 키패스 경로를 나타내는 문자열을 사용해서 특정 객체의 속성을 액세스하기 때문에 컴파일 타임이 아닌 런타임에 액세스해야 할 프로퍼티를 결정하는 동적 기능으로 키밸류코딩과 키밸류 옵저빙에 사용된다. Swift2까지는 Swift 내에 키패스에 대한 기능이 별도로 마련되지 않았고, NSObject의 value(forKey:)setValue(_:forKey:)를 사용하면서 문자열을 그대로 사용했다.

문자열을 통해서 키패스를 사용하는 것은 편리할 수는 있으나, 컴파일 타임에서 오타에 의해 존재하지 않는 키패스를 참조하는 것을 체크할 방법이 없어서 디버깅이 곤란한 부분이 있었다. 이 부분을 개선하기 위해 Swift3에서 #keyPath() 문법 표현이 추가되었는데, 이 문법은 코딩 시점에는 컴파일러의 도움을 받아 올바른 키패스를 확인할 수 있고, #keyPath() 표현을 통해 해당 키패스값을 문자열로 안전하게 변환할 수 있었다.

하지만 키패스를 문자열로 치환하는 이와 같은 방법은 Swift의 디자인 관점에서는 몇 가지 한계를 갖는다. 키패스 자체는 프로퍼티를 찾아가는 경로만을 정의하므로 타입 정보를 잃고 그 결과가 Any가 되어버린다든지, 파싱이 느리고 NSObject 기반의 클래스에서만 사용할 수 있었다. Swift4에서는 이러한 단점을 보완하고 클래스외의 모든 Swift 타입에서 키패스를 통해서 프로퍼티를 참조할 수 있는 범용적인 키패스 문법(과 키패스를 위한 코어 타입)이 추가되었다.

Swift4의 키패스 문법

Swift4의 키패스 문법은 단순히 백슬래시(\)로 시작하는 키패스 값을 말한다. Objective-C와 달리 self 대신에 타입 이름을 쓰거나, 타입이 분명한 경우, 타입 이름을 생략하고 바로 . 으로 시작하는 키패스를 사용할 수 있다. 다음은 Swift Evolution의 새로운 키패스 제안서에 실린 예제이다.

class Person {
  var name: String
  var friends: [Person] = []
  var bestFriend: Person? = nil
  init(name: String) {
    self.name = name
  }
}

var han = Person(name: "Han Solo")
var luke = Person(name: "Luke Skywalker")
luke.friends.append(han)

// 키패스 객체를 생성한다. 
let firstFriendsNameKeyPath = \Person.friends[0].name
// 생성한 키패스를 사용해서 프로퍼티를 액세스한다.
let firstFriend = luke[keyPath: firstFriendsNameKeyPath] // "Han Solo"

// 항상 . 으로 시작해야 한다. 이는 배열의 요소 참조시에도 마찬가지이다.
luke.friends[keyPath: \.[0].name]
luke.friends[keyPath: \[Person].[0].name] 

// 옵셔널 프로퍼티는 ?를 붙여서 액세스해야 한다.
let bestFriendsNameKeyPath = \Person.bestFriend?.name
let bestFriendsName = luke[Keypath: bestFriendsNameKeyPath] // nil

키 패스 타입

Swift4의 키패스는 KeyPath라는 타입에 의해서 관리된다. 이 타입은 하위 속성을 참조하기 위해서 다른 키패스를 이어 붙이는 것이 가능하고, 또한 루트 타입과 키패스가 가리키는 속성의 타입을 그 인자로 가질 수 있다. 이 말은 위 예제에서와 같은 표현으로 키패스를 생성하는 경우, luke의 타입인 Personname 속성의 타입인 String 에 대한 정보가 키패스 내부에 내제된다는 것이다. 따라서 위 예제에서 bestFriend 변수의 타입은 String이 라는 점을 컴파일러는 알 수 있다.

코어이미지를 사용한 QRCode 생성기

지난 시간에 코어 이미지를 사용해서 QRCode 인식기를 만드는 법에 대해서 간략히 설명하였는데, 그렇다면 반대로 문자열을 인코딩하여 QR코드를 만드는 것은 어떻게 할 수 있을까?

QR코드 생성은 코어 이미지를 통해서 할 수 있다. 코어이미지는 이미지 내의 바코드와 QR코드 탐지 API를 제공하는데, 반대로 해당 코드를 이미지로 생성하는 기능도 제공한다. QRCode 생성은 CIQRCodeGenerator 라는 이름의 CIFilter를 통해서 수행할 수 있다. 해당 필터의 파라미터는 다음과 같다.

  1. inputMessage : QR코드에 인코딩될 문자열 데이터이다. 해당 문자열은 UTF8로 인코딩된 바이트스트림으로 전달한다.
  2. inputCorrectionLevel : QR코드의 보정값 수준이다. 보통 “L” 을 쓰면 된다.

실제로 이미지를 만드는 방법은 다음과 같다.

let context = CIContext()
let message = "https://soooprmx.com"
if let data = message.data(using: .utf8),
   let filter = CIFilter(name: "CIQRCodeGenerator",
                         inputParameters:[
                            "inputMessage": data,
                            "inputCorrectionLevel": "L"])
{
  let qrCode = filter.outputImage()!
  let output = UIImage(ciImage: qrCode)
  ...
}

다만, 문제는 이렇게 생성된 이미지가 매우 크기가 작다는 문제이다. (정말 쥐똥만함….) 이 이미지를 보다 큰 이미지뷰에 넣으면 확대되면서 픽셀 보간1이 일어나서 이미지가 흐려지게 된다. (물론 적당히 크기가 크다면 이런 흐린 QR이미지도 왠만한 앱에 의해서는 다 인식된다.) 하지만 QR이미지를 파일로 저장하려고 하거나 하는 경우에는 흐릿한 이미지도, 너무 작은 이미지도 쓸 수가 없으므로 다음과 같이 확대하자. 코어 그래픽을 사용해서 픽셀 보간 없이 큰 영역에 해당 이미지를 그려주면 된다.

...
let qrCode = filter.outputImage()!
let cgImg = context.createCGImage(qrCode, from: cqCode.extent)!

// 비트맵 컨텍스트를 생성한다.
let v_size: Int = 400
guard let ctx = CGContext(data:nil, width: v_size, height: v_size,
                    bitsPerComponent:8, bytesPerRow:0,
                    space: CGColorSpaceCreateDeviceRGB(),
                    bitmapInfo: CGImageAlphaInfo.none.rawValue)
else {
   return
}

let outputFrame = CGRect(x:0, y:0, width: CGFloat(v_size), height: CGFloat(v_size))

// 보간 옵션을 제외한 후, QR코드를 확대하여 그린다.
ctx.interpolationQuality = .none
ctx.draw(cgImg, in: outputFrame)

// 최종적으로 확대된 결과물
let resultImage = ctx.makeImage()!

let qrCodeImage = UIImage(cgImage: resultImage)

  1. 흔히 ‘안티앨리어싱’이라 부르는 그것. 

이미지 리사이즈 방법 총정리 – Swift

최근에 이미지 크기를 일괄적으로 줄여서 리사이징하는 간단한 도구를 만들어 봤는데, 요상하게 결과 이미지가 흐려졌다. 결과가 맘에 들지 않아서 좀 더 고품질의 결과를 얻는 방법을 찾기 위해 이것 저것 조사해보니, iOS/macOS에서는 이미지 크기를 변환하는 다양한 방법이 있다는 것을 알게 되었다. 비트맵 이미지를 리사이징하는 다양한 방법들에 대해서 살펴보도록 하자.

UIKit

iOS에서는 간단하니 비트맵이미지 처리는 제법 쉬운 편이다. UIKit의 이미지 생성 관련 함수들을 사용하면 손쉽게 비트맵 이미지를 만들 수 있다.  방법은 단순한데, 목표 크기를 가지고 해당 크기에 맞는 비트맵 컨텍스트를 생성한다. (그러면 메모리상에 비트맵데이터를 저장해둘 공간이 마련된다.) UIImage를 해당 컨텍스트에 크기를 줄여 그린 후에, 컨텍스트로부터 비트맵 이미지를 얻으면 된다. 여기서 사용되는 함수들은 다음과 같다.

대략 코드는 다음과 같다고 보면 되겠다.

/// UIKit에서 이미지 리사이징
/// 원본: UIImage, 결과: UIImage

func resize(image: UIImage, scale: CGFloat, completionHandler: ((UIImage?) -> Void)?) 
{
   let transform = CGAffineTransform(scaleX: scale, scaleY: scale)
   let size = image.size.applying(transform)
   UIGraphicsBeginBeginImageContext(size)
   image.draw(in: CGRect(origin: .zero, size: size))
   let resultImage = UIGraphicsGetImageFromCurrentContext()
   UIGraphicsEndImageContext()
   
   completionHandler(resultImage)
}

코어 그래픽

실질적으로 UIKit의 이미지 리사이징은 코어 그래픽을 사용하는 고수준API라 볼 수 있다. 직접 코어 그래픽 API를 사용하는 것은 조금 귀찮다고 생각할 수 있지만, macOS에서도 사용할 수 있고 세세한 옵션을 관리할 수 있다는 장점이 있다. 특히 여기서 관리하는 옵션 중에서는 interpolation 품질을 설정할 수 있기 때문에 보다 고품질의 결과를 얻을 수 있다는 장점이 있다.  참고로 macOS에서 이미지를 표현하는데 사용되는 NSImage는 비트맵 이미지에만 국한되어 사용되는 것이 아니기 때문에 이 예제에서는 CGImage를 사용하도록 한다.

/// Core Graphics를 사용하는 이미지 리사이징
/// 원본 : CGImage,  결과 : CGImage

func resize(image: CGImage, scale: CGFloat, completionHandler: (CGImage?) -> Void)
{
  let size = CGSize(width: CGFloat(image.width), height: CGFloat(image.height))
  let context = CGContext( // #1
       data: nil,
       width: Int(size.width * scale),
       height: Int(size.height * scale),
       bitsPerComponent: 8,
       bytesPerRow: 0,
       space: CGColorSpaceCreateDeviceRGB(),
       bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
  context.interpolationQuality = .high // #2
  context.draw(image, in: CGRect(origin:.zero, size:size))
  let resultImage = context.makeImage()
  completionHandler(resultImage)
}

실제로 이미지뷰에서 뷰보다 더 큰 이미지를 줄여서 표시하는 경우에 내부적으로는 이 방식과 비슷하게 현재 컨텍스트에 캐시된 비트맵을 빠르게 그려준다고 보면 된다. 물론 속도를 위해서 어느 정도 품질을 하락시키는데, 특히 큰 이미지를 작은 크기의 NSImageView에 넣었을 때, 실제로 위 방법으로 리사이징 한 것보다 더 품질이 나빠진다. (보간 옵션이 .low인 것 같음.)

사실 썸네일을 만드는 데 있어서 이 방법은 간단하기는 하나, 큰 이미지를 적절한 크기로 줄여서 사용하려는 경우, 이미지가 흐려지는 것이 체감될 정도로 품질은 좋지 못하다. (UIKit의 방법들도 비슷한 퀄리티 문제를 안고 있다.) 코어 그래픽에서 내부적으로 사용하는 기본 샘플링 방식의 한계로 보인다. 그래서 보다 고품질의 결과물을 만들 수 있는 방법을 추가로 소개하고자 한다.

코어 이미지

디지털 이미지를 확대/축하거나 왜곡하는 등의 변형을 가하게 되면 각 픽셀의 수학적인 거리가 변경되는데, 이것을 다시 비트맵으로 환산하기 위해서는 벌어진 픽셀 사이를 새로운 픽셀로 메꾸거나, 혹은 겹쳐진 두 개 이상의 픽셀을 하나의 픽셀로 대체/치환해야 한다. interpolation은 이렇게 이미지를 변환할 때 픽셀을 보정하는 것인데, 이와 관련해서는 다양한 알고리듬들이 개발되어 쓰인다. 속도를 우선한다면 계산이 간단한 대신에 빠르게 적용할 수 있는 보간법을 적용할 것이고, 품질을 중시한다면 보다 복잡한 계산을 요하는 이미지처리 알고리듬을 사용할 것이다. UIKit이나 코어 그래픽에서 사용하는 기본 보간법도 보통의 이미지에서는 충분히 괜찮은 품질을 보여주지만, 아무래도 조금 흐릿해지는 경향이 있다. (Bilinear  혹은 Bicubric 보간을 쓰지 않나 싶다.)

코어 이미지에서는 이미지의 변형을 담당하는 필터들이 있는데, 이중 CILanczosScaleTransform 필터는 Lanczos 보간법을 사용하여 보다 좋은 품질의 결과를 만들 수 있다. CIImageCGImageUIImage와 상호변환이 쉬우며, 이 방법은 여느 CIFilter를 사용하는 방식과 동일하기 때문에 유용한 방법이다. 단 CIImage를 실제로 렌더링하기 위해서는 CIContext 객체가 필요한데, 이것을 생성하는 비용이 제법 크게 들어가므로 컨텍스트 객체는 계속 재사용하는 것이 권장된다.

이 필터의 파라미터는 inputImage, inputScale, inputAspectRatio 이며, 앞서 소개한 방법과 달리 특정 크기에 맞추는 것이 아니라 비율을 유지하고 n배로 스케일한다.

/// Core Image를 사용하여 리사이징 (고화질)
/// 입력: CIImage, 결과: CIImage
func resize(image: CIImage, scale: CGFloat, completionHandler: (CIImage?) -> Void) 
{
  let filter: CIFilter = { 
    let f = CIFilter(name: "CILanczosScaleTransform")
    f.setValue(image, forKey: kCIInputImageKey)
    f.setValue(scale, forKey: kCIInputScaleKey)
    f.setValue(1.0, forKey: kCIInputAspectRatioKey)
    return f
  }()
  let resultImage = f.outputImage()
  completionHandler(resultImage)
}

Lanczos 보간을 사용하는 경우, 경계면의 콘트라스트가 두드러지면서 전체적으로 이미지가 과도하게 날카롭고 거친 느낌을 준다. 한가지 문제라면, 오른쪽 끝에 1픽셀짜리 흰줄이 생기는 경우가 종종 있다는 점이다.

Image IO를 사용하는 방법

Image IO는 AppKit, UIKit 과 별개로 보다 다양한 포맷의 이미지 파일을 다룰 수 있게 하는 프레임워크이다. 다만 이 프레임워크는 코어파운데이션 기반의 C로 작성된 것이며, 레퍼런스나 가이드 문서가 그다지 잘 구비되어 있지는 않은 편이다. 이미지 소스를데이터나 URL로부터 생성해서 이로부터 썸네일 이미지를 생성하면 된다. (이 프레임워크는 말 그대로 파일로 저장되어 있는 형태의 이미지를 읽고, 프로세싱하여 다시 파일로 쓰는 것에 특화되어 있다.)

CGImageSourceCreateThumbnailAtIndex(_:_:_:) 함수를 사용하여 이미지 소스로부터 축소된 썸네일을 얻을 수 있다. 이 때 필요한 것은 이미지 소스와 옵션 값(CFDictionary)이다.

이미지 소스는 URL이나 데이터로부터 생성할 수 있는데, 여기에는 몇가지 옵션 값을 넘겨주어야 한다. 옵션키에 대해서는 소스 옵션 키 문서를 참조하도록 하고, 간단히 예제 코드만 소개하겠다. 참고로 옵션을 넘겨줄 때의 타입은 CFDictionary 인데, 이는 Dictionary<String:AnyObject> 타입에서 as로 캐스팅하여 넘겨줄 수 있다.

import ImageIO  // #1

/// ImageIO를 사용하여 이미지를 리사이징한다. 
/// 입력: CGImage, 출력: CGImage

func resize(image: CGImage, pixelSize: Int, completionHandler: (CGImage?) -> Void)
{
  // CGImage의 비트맵데이터 얻기
  guard case let rep = NSBitmapImageRep(cgImage: image),
        let data = rep.tiffRepresentation(using: .none, factor: 1.0)
  else { return }

  let imageSourceOptions = [
     kCGImageSourceShouldCache : true,
     kCGImageSourceShouldAllowFloat: true
  ] as CFDictionary

  guard let imageSource = CGImageSourceCreateWithData(data as CFData, imageSourceOptions)
  else { return }

  // 썸네일 옵션
  let thumbnailOptions = [
    kCGImageSourceCreateThumbnailFromImageAlways: true,
    kCGImageSourceThumbnailMaxPixelSize: pixelSize,
    kCGImageSourceCreateThumbnailWithTransform: true
  ] as CFDictionary

  // 썸네일 생성
  let resultImage = CGImageSourceCreateThumbnail(imageSource, 0, thumbnailOptions)
  completionHandler(resultImage)
}

참고로 ImageI/O의 CGImageSource는 디스크등으로부터 이미지 데이터를 읽어오는 작업을 추상화하는 객체이다. 반대로 이미지를 쓰기 위해서는 CGImageDestination을 사용한다.  ImageIO를 사용하면 CG이미지를 다음과 같이 CGImageDestinationFinalize() 함수에 넘겨서 파일로 저장할 수 있다. (macOS에서 실행하려는 경우, 프로젝트 설정에서 앱의 샌드박스 옵션을 꺼야 한다.)

DispatchQueue.global().async {
  let imageType = "public.jpeg" // #1
  let url = <# ... #>
 
  // #2
  guard let imageDest = CGImageDestinationCreateWithURL(url as CFURL, imageType, 1, nil)
  else { return }

  // #
  let options= [
    kCGImageDestinationLossyCompressionQuality: 1.0,
    kCGImageDestinationBackgroundColor: CGColor.white
  ] as CFDictionary
  CGImageDestinationAddImage(imageDest, cgImage, options)
  _ = CGImageDestinationFinalize(imageDest)
}

Image I/O는 속도가 빠르며, 특히 수백~수천만 픽셀의 매우 큰 이미지에서 강점을 보인다.

vImage

이번에는 또다른 Apple의 잘 알려지지 않은 프레임 워크인 Accelerate를 사용하는 방법이다. 이 프레임워크는 CPU의 Vector 연산 유닛을 사용하여 고성능에 최적화된 연산을 제공하는데, 여기에는 특정 분야에 쓰이는 고속 연산 API들이 모여있다. 이중, vImage라는 고속 이미지 연산을 사용해서 대용량 이미지도 거뜬하게 리사이징할 수 있다. (그리고 역시 품질도 매우 우수하다.) 다만 vImage를 사용하여 이미지를 제어하려는 경우에는 이미지 프레임 버퍼를 수동으로 다루어야 한다는 불편함이 있다.

vImage를 사용하여 이미지를 변환하기 위해서는 다음의 단계를 거쳐야 한다.

  1. CGImage의 비트맵 데이터를 vImage의 프레임 버퍼로 변환하기 위해서는 이미지 포맷이라는 데이터를 만들어야 한다. CGImage는 통상 4개의 8비트 채널로 이루어지기 때문에 하드코딩된 설정으로 이를 생성할 수 있다. 이미지 포맷을 기준으로 비트맵데이터와 프레임버퍼간의 변환이 이루어진다.
  2. 원본 이미지를 가지고 소스 프레임을 생성한다.
  3. 조정된 크기만큼의 대상 프레임을 생성한다.
  4. vImage API를 사용하여 소스프레임을 변환하여 대상프레임으로 복사한다.
  5. 대상 프레임을 다시 비트맵 이미지로 변환한다.

각 과정에 대해서 애플은 4개의 문서를 할애해서 설명하고 있다. 아래 코드는 이 과정을 정리한 것이다.

import Accelerate

/// 이미지 축소

do {

  guard let sourceImage = getSourceImage() // 소스 이미지는 이미 있다고 가정한다.

  // 이미지 포맷 데이터 생성
  var format = vImage_CGImageFormat(
      bitsPerComponent: 8,
      bitsPerPixel: 8 * 4,
      colorSpace: nil, // Unmanaged.passRetained(CGColorSpaceCreateDeviceRGB())
      bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.first.rawValue),
      version: 0,
      decode: nil,
      renderingIntent: .defaultIntent)

  /* 또는 원본 이미지로부터 생성할 수도 있다.
  guard let sourceColorSpace = cgImage.colorSpace 
  else {
    print("Unable to initialize cgImage or ColorSpace")
    break
  }

  var sourceImageFormat = vIamge_CGImageFormat(
    bitsPerComponent: UInt32(sourceImage.bitsPerComponent),
    bitsPerPixel: UInt32(sourceImage.bitsPerPixel),
    colorSpace: Unmanaged.passRetained(sourceColorSpace),
    bitmapInfo: sourceImage.bitmapInfo,
    version: 0,
    decode: nil,
    renderingIntent: sourceImage.renderingIntent)
  */

  // 소스 프레임 버퍼를 만든다.
  var error = kvImageNoError
  error = vImageBuffer_InitWithCGImage(
    &sourceBuffer,
    &format,
    nil,
    image,
    vImage_Flags(kvImageNoFlags))

  guard error == kvImageNoError else { fatalError("Error in vImageBuffer_InitWithCGImage: \(error)") }
  
  // 대상 프레임 버퍼를 만든다. 
  let targetWidth = sourceImage.width / 3
  let targetHeight = sourceImage.height / 3
  var destinationBuffer = vIamge_Buffer()
  error = vImageBuffer_Init(
    &destinationBuffer,
    targetHeight,
    targetWidth,
    format.bitsPerPixel,
    vImage_Flags(kvImageNoFlags))
  guard error == kvImageNoError else {
    fatalError("Error in vImageBuffer_Init")
  }

  // 이미지를 리사이징한다.
  error = vImageScale_ARGB8888(
    &sourceBuffer,
    &destinationBuffer,
    nil,  // temp buffer
    vImage_Flags(kvImageHighQualityResampling))
  guard error == kvImageNoError else {
    fatalError("Error in vImageScale_ARGB8888")
  }


  // 대상 버퍼로부터 이미지를 만들기
  let resultImage = vImageCreateCGImageFromBuffer(
    &destinationBuffer,
    &format,
    nil, // callback
    nil, // userData
    vImage_Flags(kvImageNoFlags),
    &error)
  guard error == kvImageNoError else {
    fatalError("Error in vImageCreateCGImageFromBuffer")
  }

  // 버퍼를 해제한다. 사실 이 부분은 defer { ... } 를 사용해서 위에서 선언해야 한다.
  free(sourceBuffer.data)
  free(destinationBuffer.data)

  // resultImage는 Unmanged 객체이다. 따라서 최종 이미지는 이를 리테인한다.
  let result = resultImage.takeRetained()
}

이상으로 iOS/macOS에서 사용할 수 있는 이미지 크기 변환에 관한 방법을 정리해 보았다. 널리 쓰이는 방법들이 아무래도 가장 쉬운 길이기는 하지만, 고품질/고성능의 리사이징을 사용하고 싶다면 이 글이 도움이 되길 바란다. 개인적으로 체감했을 때, vImage를 사용하는 방법이 빠르고, 결과의 품질 면에서도 가장 좋은 것 같다.

코어 이미지를 통한 이미지 분석 예제

코어 이미지(Core Image)는 흔히 알려진 바와 같이 이미지에 대한 고성능 필터 효과 처리를 지원하는 프레임워크이면서 이미지에서 사람의 얼굴이나 QR코드, 텍스트를 탐지해내는 탐지 기능도 제공하고 있다. 코어 이미지가 제공하는 이미지 분석 기술을 제공하면 이러한 탐지를 빠르게 수행할 수 있을 뿐 아니라, 코어 이미지의 여러 필터 기능을 활용해서 찾아낸 부분을 하이라이트 처리하는 등의 기능을 손쉽게 구현할 수 있다. 이 글에서는 코어 이미지의 디텍터 클래스인 CIDetector를 사용하여 이미지에서 특정한 형상을 찾는 방법에 대해서 알아보고자 한다.

이미지에서 특정한 형상을 찾기

코어이미지가 제공하는 이미지 분석 기능을 사용하면 이미지 내에서 특정한 형상(feature)을 찾을 수 있다. 이 작업에서는 크게 두 가지 클래스를 다루게 된다.

  1. CIDetector : 이미지 분석처리를 담당한다.
  2. CIFeature : 분석된 결과에 대한 정보를 담는다.

CIDetector 클래스는 매우 간단한 API를 가지며, 다음의 절차를 거쳐 사용할 수 있다.

  1. init?(ofType: context: options:)를 통해서 새로운 디텍터를 생성한다.
  2. 생성된 디텍터에게 이미지를 전달하여 목표로 하는 형상을 탐지한다. 이 때 사용하는 메소드는 feature(in: options:) 이다.
  3. 탐지된 결과는 [CIFeature] 타입의 값으로 리턴된다.

CIFeature – 탐지된 결과에 대한 정보

CIFeature는 이미지 분석 결과에서 탐지된 매 항목에 대한 정보를 담고 있는데, 기본적으로 bounds 속성으로 이미지 내에서 해당 형상이 차지하는 부분을 알아 낼 수 있다. CIFeature는 여러 타입의 분석 결과에 대한 추상 클래스 타입이며, 어떤 것을 탐지하려고 했는가에 따라서 구체적인 서브 클래스를 사용하게 된다.

  1. CIRectangleFeature : 이미지 내에서 사각형을 탐지한 결과이다. 사각형은 딱 떨어지는 직사각형이 아니기 때문에 bottomLeft, bottomRight, topLeft, topRight 의 4개의 모서리 점 위치에 대한 정보를 추가적으로 가지고 있다.
  2. CITextFeature :  이미지에서 글자를 찾았을 때, 사용된다.
  3. CIQRCodeFeature : 이미지 내에서 QR코드를 탐지한 결과이다. QR코드를 디코딩한 문자열을 messageString 이라는 프로퍼티로 액세스할 수 있다.
  4. CIFaceFeature : 이미지 내에서 사람의 얼굴을 탐지한 결과이다.

이미지에서 사람 얼굴을 찾기

CIDetector를 사용하면 사람의 이미지 내에서 사람의 얼굴을 포착할 수 있다. 사람의 얼굴을 찾기 위해서는 CIDetector의 타입을 CIDetectorTypeFace로 주어 인스턴스를 생성한다. 이 때 컨텍스트는 기본적으로 nil을 전달할 수 있는데, 만약 소스로 주어지는 이미지와 연관된 CIContext 객체가 있다면 이를 넘겨줄 수 있다. (그렇게 하여 성능을 향상시킬 수 있다.) 주어진 이미지가 있을 때, 사람의 얼굴이 표현된 영역을 찾는 것은 다음과 같은 코드를 통해서 구현할 수 있다.

let image: CIImage = .... // #1
// #2
let detector = CIDetector(ofType: CIDetectorTypeFace,
                          context: nil,
                          options:
            [CIDetectorAccuracy: CIDetectorAccuracyHigh])!
// #3
if let features = detector.features(in: image) as? [CIFaceFeature] {
  let rects = features.map{ $0.bounds } // #4
  for rect in rects {
    // ... do something with face rect
  }
}
  1. 이미지는 이미 주어졌다고 가정한다.
  2. 디텍터를 생성한다. 디텍터 생성시 컨텍스트는 nil을 보낼 수 있고, 옵션은 정확도 수준을 지정할 수 있다.
  3. 이미지 내에 탐지되는 결과는 1개 이상일 수 있다. [CIFaceFeature]로 캐스팅한다.
  4. 각각의 CIFeature에 대해서 bounds 속성을 이용해서 이미지 내에 각 얼굴이 들어있는 영역을 지정할 수 있다.

얼굴이 들어간 부분을 하이라이트하기

얼굴을 찾아서 얼굴이 들어간 부분을 하이라이트하려면 어떻게 해야할까? CIImage 타입의 원본으로부터 이를 분석하여 얼굴의 영역들을 얻은 다음, 원본위에 하이라이트 영역을 칠한 결과물을 만드는 함수가 있다면 여기에 원본과 영역의 배열을 넘겨주어 최종 결과 이미지를 얻을 수 있다.

입력과 출력이 이렇게 결정되면 이 함수의 타입이 (CIImage, [CGRect]) -> CIImage라는 것을 알 수 있고, 이러한 함수를 구현하면 되는 것이다. 이를 구현하는 방법은 크게 코어 이미지를 이용해서 원본을 그린 후, 그 위에 하이라이트 영역을 그리는 방법이 있을 수 있고 또 하이라이트 영역에 대한 이미지를 만든 후에 이를 블렌드모드 필터를 이용해서 합성하는 방법이 있다. 여기서는 후자의 방법을 사용해보도록 하겠다.

/// 이미지의 특정 영역을 하이라이트 하기
func highlight(in source: CIImage, rects: [CGRect]) -> CIImage {
  let imageSize = source.extent.size
  let mask: CGImage = {
    let ctx = CGContext(data: nil, width: Int(imageSize.width), height: Int(imageSize.height),
                        bitsPerComponent: 8, bytesPerRow: 0,
                        space: CGColorSpaceCreateDeviceRGB(),
                        bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
    ctx.setFillColor(NSColor.yellow.cgColor)
    ctx.fill(rects)
    return ctx.makeImage()!
  }()

  // mask를 InputImage로 하고 source를 BackgroundImage로 해서 하이라이트된 이미지를 생성
  let inputImage = CIImage(cgImage: mask)
  return inputImage.applyingFilter("CIOverlayBlendMode", parameters:
         [kCIInputBackgroundImageKey: source])
}

사실 이 함수에서 이미지를 합성하는 방법만 약간 바꾸면, 어떤 사진 내에서 사람 얼굴만 모자이크 처리하는 익명화 프로그램을 만드는 도구를 간단히 생성할 수 있을 것이다. (원본을 모자이크/블러처리한 이미지를 만들고, 영역 내에 사각형을 그린 이미지와 컴포지팅하여 다시 원본에 덧그리는 방식이다. 이는 재미있는 토픽으로 보이니 조만간 살펴보도록 하겠다.)

QRCode를 찾기

이미지 분석에서 유용한 기술 중 하나는 QRCode를 탐지하고, QRCode내에 인코딩된 메시지를 얻는 것이다. 이는 위의 예제에서 Face 대신 QRCode만 넣으면 된다고 할 정도로 간단한 작업이다. QR코드 탐지 결과인 CIQRCodeFeaturemessageString이라는 프로퍼티를 통해서 인코딩된 메시지에 액세스할 수 있다.

다음 함수는 주어진 CIImage에 대해서 QRCode를 찾고, 그 속에 인코딩된 메시지를 완료 핸들러로 전달하는 함수이다.

/// QRCode를 찾아서 해석하기
func detectQRCode(in image: CIImage, completionHandler: ((String) -> Void)?) 
{
  let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil,
                   options:[CIDetectorAccuracy: CIDetectorAccuracyHigh])!
  if let result = detector.features(in: image).first as? CIQRCodeFeature,
     let message = result.messageString
  {
    completionHandler?(message)
  }
}

정리

코어 이미지가 제공하는 이미지 분석 도구는 Vision에 비해서 정확도는 아주 약간 떨어질 수 있지만, 여전히 빠르고 쓸만하게 동작한다. 또 API의 디자인 역시 심플하고 쉽게 사용할 수 있기 때문에 이를 사용해서 여러가지 유용한 도구들을 만들 수 있다.

참고자료