코어이미지를 사용한 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의 디자인 역시 심플하고 쉽게 사용할 수 있기 때문에 이를 사용해서 여러가지 유용한 도구들을 만들 수 있다.

참고자료

NSPersistentContainer를 통한 코어데이터 스택생성하기

macOS Sierra로 업데이트되면서 코어데이터에 NSPersistentContainer 클래스가 추가되었다. 이 클래스를 사용하면 코어데이터 스택을 셋업하는 여러 귀찮은 과정을 생략하고 간단하게 처리할 수 있다. 사실 코어데이터 스택을 수동으로 셋업하는 과정에서 필요한 정보는 코어데이터 모델 파일의 이름과, 저장소 파일을 생성할 위치 정도이며, 그외의 대부분의 코드는 보일러 플레이트라 할 수 있다.  저장소 파일 위치는 적당한이름(?)으로 사용자 라이브러리 내에 만들어지므로 결국 최소한으로 필요한 정보는 데이터 모델 파일 이름이 된다.

수동 셋업 과정은 다음과 같다.

/// Objective-C
@interface AppController: NSObject
@property (readonly, strong) NSPersistentContainer* persistentContainer;
@end

@implmentation AppController
@synthesize persistentConatainer=_persistentContainer;

- (NSPersistentContainer*)persistentContainer
{
  if(!_persistentContainer) {
    _persistentContainer = [[NSPersistentContainer alloc]
                            initWithName: @"MyDataModel"]; // 이름에 확장자는 붙이지 않는다.
    [_persistentContainer loadPersistentStoresWithCompletionHandler:^(NSPersistentStoreDescription *desc, NSError *error){
       if(!error) {
          /// 저장소 로딩 중 에러가 발생
          NSLog(@"Unresolved error %@, %@", error, error.userInfo);
       }
    }];
  }
  return _persistentContainer;
}

매우 간단하게 모든 설정이 완료됐다. 관리객체모델과 컨텍스트는 managedObjectModel, viewContext 프로퍼티로 액세스할 수 있다. 컨텍스트만 따로 외부로 노출하려한다면, 다음과 같이 프로퍼티를 선언한다.

@property (readonly, nonatomic) NSManagedObjectContext* context;
....
- (NSManagedObjectContext*) context { return self.persistentContainer.viewContext }

저장소 디스크립션을 사용하기

NSPersistentStoreCoordinator를 직접 셋업하는 경우에는 저장소 위치와 타입등의 정보를 설정해야 했다. 이러한 설정정보를 하나의 클래스로 묶은 것이 저장소 디스크립션으로 NSPersistentStoreDescription이라는 클래스로 만들어져있다. NSPersistentContainer는 이 디스크립션을 이용해서 “각각의 저장소들을” 생성한다. 기본적으로 아무런 정보가 주어지지 않으면 컨테이너는 SQLite 타입의 저장소를 라이브러리 디렉토리 내에 저장하게 된다. 만약 저장소 위치를 옮기고 싶거나, 타입을 바꾸고 싶으면 새로운 저장소 디스크립션을 생성해서 바꿔주면 된다. 대신 이 동작이 유효하려면 loadPersistentStores...:를 호출하기 전에 설정을 변경해두어야 한다.

/// 위치를 사용자 문서 폴더로 바꾸고 싶을 때
  NSURL* dirURL = [[[NSFileManager defaultManager] 
                       URLsForDirectory:NSDocumentDirectory
                       inDomains:NSUserDomainMasks] lastObject];
  NSURL* storeURL = [dirURL URLByAppendingPahtComponent:@"mydata.db"];
  NSPersistentStoreDescription* desc = [[NSPersistentStoreDescription alloc] initWithURL:storeURL];
  desc.type = NSSQLiteStoreType;
  [_persistentContainer setPersistentStoreDescriptions:@[desc]];
  [_persistentContainer loadPersistentStoresWithHandler:^ ...

NSPersistentStoreCoordinator를 사용하는 경우에도 -addPersistentStoreWithDescription:completionHandler:를 사용할 수 있으니 참고하자.

Swift 버전 코드

같은 내용인데, Swift 버전의 코드는 아래와 같다.

lazy var container: NSPersistentContainer = {
  let container = NSPersistentContainer(name:"MyDataModel")
  contaner.loadPersistentStore{ desc, error in 
    if error {
      fatalError("Fail to load : \(error)")
    }
}()

var context: NSManagedObjectContext {
  return self.container.viewContext
}

조금 더 깊이

Swift에서 init(name:) 은 편의 이니셜라이저이다. 만약 이 컨테이너를 서브 클래싱할 때 편의 이니셜라이저를 만드려면 지정 이니셜라이저를 호출해야한다. 컨테이너의 지정 이니셜라이저는 init(name:managedObjectModel:) 이므로 전달된 이름을 가지고 관리 객체 모델을 구해서 이를 호출해야 한다. 관리 객체 모델은 수동 셋업때와 같이 모델 파일로부터 로딩해서 생성하면 된다.

convenience init(completionHandler: @escaping () -> ()) {
  guard let mURL = Bundle.main.url(forResource:"MyDataModel", withExtension:"momd")
  else {
    fatalError("Can't load model from bundle.")
  }

  guard let mom = NSManagedObjectModel(contensOf:mURL) else { 
    fatalError("Error initializing MOM")
  }
  init(name:"MyDataModel", managedObjectModel:mom)
  completionHadler()
}

참고 자료

Segue를 통한 뷰 컨트롤러 전환과 데이터 교환 방법

꽤 오래전에 iOS에서 뷰를 전환하는 방법에 대해 글을 포스팅한 적이 있는데, 최근에도 비슷한 질문을 종종 받는다. 단순히 뷰를 표시하는 것보다는 어떻게 뷰 간에 데이터를 주고 받느냐는 것이다. 오늘은 이와 관련하여 스토리보드를 사용할 때의 방법 위주로 조금 자세히 살펴보도록 하겠다.

스토리보드 Segue에 의한 뷰 전환

스토리보드가 여전히 불편하다는 사람도 많이 있지만, 사실 스토리보드의 도입은 미리 정해진 콘티에 따른 콘텐츠 표시를 위한 목적의 앱을 코드 한 줄 없이 만들 수 있게 해준다는 점에서 높이 평가받을만 하다. 다만 문제는 그런식으로 만드는 앱은 큰 기능성을 가지지 못하기 때문에 많이 쓰이지는 않는다….

스토리보드의 구성 방식

스토리보드에서 뷰 전환이 어떻게 일어나는지에 대해 살펴보기위해서 먼저 스토리보드가 어떻게 만들어지는지부터 간략하게 살펴보자. 스토리보드 이전에도 Xcode에서는 인터페이스 빌더를 UI를 구성하는데 사용해왔다. 인터페이스 빌더를 사용하면 기본적으로 개별 뷰 단위로 화면을 정의하고, 화면 내 들어갈 UI 요소와 각 UI요소의 위치와 크기, 기본 속성값들을 정의할 수 있다. 뷰들은 부모자식의 상하 관계를 맺고 계층화되며, 각각의 고유한 속성값들을 가지고 있기 때문에 이러한 정보는 프로퍼티 리스트 형태로 기술되고, 파일에 저장될 때에는 XML 포맷을 사용해서 고정된다. XML 포맷으로 저장되는 Interface Builder 파일이라는 의미로 인터페이스 빌더의 데이터 저장파일은 .xib 라는 확장자를 사용한다.

Xcode는 프로젝트를 컴파일 할 때, 프로젝트 내의 각 소스코드를 컴파일 하는 과정에 더해 이러한 xib 파일들도 컴파일된다. 인터페이스 빌더 파일들은 앱 시작 시에 로딩되기 때문에 로딩시간을 단축시키기 위한 것으로 생각된다. 이렇게 컴파일된 인터페이스 빌더 파일은 .nib 확장자를 갖는다.1

스토리보드는 실제로 여러 개의 뷰에 대한 Nib 파일을 모아둔 번들이다. 번들은 macOS에서 특별한 속성을 갖는 디렉토리인데, 그냥 폴더라 생각해도 무방하겠다.  따라서 스토리보드 안에 있는 각각의 뷰들은 개별 nib 파일이며, 따라서 뷰와 뷰를 연결하는 방법은 존재하지만, 하나의 동일한 어떤 속성을 여러 뷰가 공유하는 것은 스토리보드 내에서 설정할 수 없다. 대신에 스토리보드는 개별 뷰들의 nib 파일외에 각 뷰들이 어떻게 연결될 것인가에 대한 정보를 별도로 추가한다. 각 뷰들이 어떤 관계를 갖고 연결되는가 하는 것을 나타내는 개념이 바로 Segue이다. (여기서 중요한 것은 두 화면 사이의 관계이다. Segue는 단순히 전환의 흐름이 아니다.)

Segue

Segue는 두 뷰사이의 관계를 말한다고 했다. 하지만 실질적으로 뷰는 해당 뷰를 제어하는 뷰 컨트롤러말고는 어떤 것과도 관계하지 않는다. (그렇게 해야 정상이다.) MVC는 코코아터치의 매우 중요한 기조 중 하나이고, 이걸 애플이 나서서 지키지 않을 이유는 없을 것이다. 따라서 두 화면 사이의 관계를 정의한다는 것은 Segue가 두 뷰 컨트롤러 인스턴스 사이의 관계를 규정한다는 것이다. 따라서 Segue는 다음과 같은 두 가지 속성만을 갖게 된다.

  • source : Segue의 관계는 방향성을 가진다. source는 전환시 시작점에 해당하는 뷰 컨트롤러를 가리킨다.
  • destination : 전환하고자하는 도착점에 대항하는 뷰 컨트롤러를 가리킨다.

그렇다면 도대체 어떻게 Segue는 앱의 화면 전환을 가능케하는 걸까?

Segue에 의한 화면 전환

간단한 스토리보드의 예를 살펴보자. 2개의 씬을 갖는 앱이 있고, 메인 뷰와 서브 뷰가 각각 존재한다. 스토리보드의 구성은 다음과 같을 것이다.

initial -> [main view(MainViewController)] -segue-> [subview(SubViewController)]

먼저 앱이 시작되면 스토리보드 내에서 최초 씬에 해당하는 nib 파일이 로딩되고, 뷰 컨트롤러와 뷰가 만들어진다. 사용자가 어떤 액션을 취해서 서브 뷰로 넘어가려 한다면 Segue가 트리거된다. 이 때 Segue는 destination 속성으로 어떤 뷰와 연결될 것인지를 알고 있다. 그러면 스토리보드 런타임은 destination에 해당하는 nib 파일을 읽어서 서브 뷰와 뷰 컨트롤러를 생성하고 초기화한다. 목적지 씬이 만들어진다면 화면의 전환은 source에게 present(_:animated:completion:)을 호출하여 화면을 전환하게 한다. 이 과정을 조금 더 자세히 들여다보자.

  1. Segue가 트리거된다.
  2. nib 파일이 로딩되고 destination에 해당하는 뷰 컨트롤러의 인스턴스가 생성, 초기화된다.
  3. source에 해당하는 MainViewController에게 prepare(for:sender:)가 호출된다.
  4. Segue는 perform()을 호출받는다. 이 메소드는 직접 호출해서는 안되며, 런타임에 의해 자동으로 호출된다.
  5. 다시 source에 present(_:animated:completion:)이나 그외 화면 전환용 메소드가 호출되면서 화면이 전환된다.

데이터 교환 1 – 서브 뷰에게 데이터를 전달하기

위에서 살펴본 과정중에서 화면 전환이 일어나기 직전에 소스씬은 prepare(for:sender:)를 호출받는다. 이 시점에 destination은 생성되어 있고, segue를 통해서 참조할 수 있다. 바로 여기가 서브 뷰에게 데이터를 전달할 위치이다. segue.destination을 통해서 서브 뷰 컨트롤러의 인스턴스를 참조할 수 있으므로, 서브 뷰 컨트롤러는 외부로부터 전달받을 데이터에 대한 프로퍼티를 만들어두고, 메인 뷰 컨트롤러가 이 메소드 내에서 데이터를 할당해주면 된다. 이 때 중요한 것은, 서브 뷰 컨트롤러가 초기화된 시점에는 해당 프로퍼티 값을 가지고 있지 못하다는 것이다. 따라서 서브 뷰 컨트롤러에서는 외부에서 넣어줘야 하는 값에 대해서는 옵셔널로 프로퍼티를 선언해야 한다.

만약 메인 뷰에서 서브 뷰로 넘겨줘야 하는 데이터의 타입이 MyData라 가정하자.  그러면 서브 뷰 컨트롤러의 코드에서 프로퍼티는 다음과 같이 정의되어야 한다.

/// SubViewController
class SubViewController: UIViewController
{
  var data: MyData = nil
...
}

메인 뷰 컨트롤러는 데이터를 전달해주기 위해서 다음과 같이 prepare(for:sender:)를 오버라이딩한다.

/// MainViewController
class MainViewController: UIViewController 
{
...
/// myData 값을 가지고 있다. 
var myData: MyData = ...
...
override func prepare(for segue:UIStoryboardSegue, sender:Any?) {
  if segue.identifier == "GotoSubView",
     let dest = segue.destination as? SubViewController
  {
    dest.myData = myData
  }
}
...

뷰 컨트롤러 하나에는 여러 개의 Segue가 연결될 수 있으므로, identifier 속성을 통해서 Segue를 구분해야 한다. (물론 하나 밖에 없다고 확정됐다면 굳이 체크할 필요는 없을 것이다.)

이렇게 데이터를 전달하고 나면, 앱이 알아서 화면을 전환할 것이다. 화면이 전환될 때 서브 뷰 컨트롤러의 viewWillAppear()가 호출될 것이므로, 해당 데이터를 화면에 표현하는 작업은 거기서 처리하면 될 것이다.

데이터 교환 2 – 서브 뷰에서 메인뷰로 데이터를 전달하기

iOS에서 뷰를 전환하는 것은 사실 전환이 아니라, 새로 표시할 뷰를 현재 뷰 위에 쌓아서 가리는 것이다. 따라서 Segue를 사용해서 서브 뷰를 표시했다면, 메인 뷰로 되돌아가는 것은 메인뷰를 다시 present 하는 것이 아니라, 자신을 호출한 뷰 컨트롤러에게 dismisss(animated:completion:)을 호출하여 자신을 버리도록 알려주게 된다. 서브 뷰 컨트롤러 입장에서는 dismiss가 되는 시점에 자신은 파괴될 것이므로 만약 여기서 데이터를 수정하거나 했다면, 이 데이터를 다시 메인뷰에게 돌려줄 방법이 있어야 한다. 이는 두 가지 방법이 있다.

나를 호출한 뷰 컨트롤러

첫째로 나를 호출한 뷰 컨트롤러를 이용하는 것이다. 모든 UIViewControllerpresentingViewController라는 프로퍼티를 가지고 있는데, 바로 자신을 호출하여 표시한 뷰 컨트롤러를 참조한다. 그렇다면 이걸 바로 사용하면 되는가? 만약 스토리보드를 사용하지 않고 화면을 전환했다면 그래도 된다. 하지만 잊지 말아야 할 것은 나를 실제로 호출한 뷰 컨트롤러가 MainViewController가 될 것이라는 보장이 없다는 것이다. 메인 뷰 컨트롤러에서 네비게이션 컨트롤러를 가지고 있었다면, 실제로는 네비게이션 컨트롤러가 나를 호출했을 수도 있다. 따라서 이 방법은 유동적인 요소가 있는 상황에서는 써먹을 수가 없으므로 그리 권장하지는 않겠다.

델리게이트

델리게이트는 훌륭한 대화수단이지.

죽음을 앞둔 서브 뷰 컨트롤러는 자신이 공들여서 가꾼 데이터를 그냥 날려버릴 위기에 처해있다. 무턱대고 데이터를 넘겨주자니 방법도 없고, 특히 네비게이션 컨트롤러는 당체 믿을 수가 없다. 이 때 가장 합리적인 선택은, 믿을만한 대리인에게 데이터를 위임해주는 것이다. 이 방식이 특히 좋은 이유는 확장성이다. 델리게이트와 상호작용을 위해서는 어떤 식으로 데이터를 전달할 것인가에 대한 약속이 미리 정해져있어야 한다. 델리게이트는 특정한 클래스로 고정될 필요없이, 어떤 프로토콜이기만 하면 된다. 따라서 서브 뷰 클래스는 자신의 데이터를 넘겨 받을 클래스가 누군지 알고 있을 필요가 없다.

델리게이트 패턴 적용하기

이 상황에서는 메인뷰 컨트롤러가 서브 뷰 컨트롤러의 델리게이트가 되어야 한다. 그러기 위해서 먼저 델리게이트 프로토콜을 하나 정의하도록 하자.

protocol MyDataDelegate {
  func providerSent(_ data: MyData) 
}

여기에는 기본적으로 달랑 하나의 메소드만 정의했다. 바로 데이터를 제공해주기로 한 고용주(?)가 나에게 데이터를 보냈다는 의미이다. 그러면 메인 뷰 컨트롤러는 다음과 같은 식으로 약간 디자인이 변경된다.

class MainViewController: UIViewController, MyDataDelegate {
...
var myData : MyData
...

// Adopt MyDataDelegate
func providerSent(_ data: MyData) {
  // 외부에서 보내준 데이터를 내가 받아서 갖는다.
  myData = data
}
...

그리고 서브 뷰 컨트롤러의 델리게이트가 내가 되어야 한다. 언제? 바로 prepare(for:sender:)에서!

@override func prepare(for segue:UIStoryboardSegue, sender:Any?) {
  if segue.identifier == "GotoSubView",
     let dest = segue.destination as? SubViewController
  {
    dest.myData = myData
    dest.delegate = self  // 내가 서브뷰의 델리게이트가 된다! 
  //^^^^^^^^^^^^^^^^^^^^
  }
}

이제 서브 뷰 컨트롤러에서는 다음과 같이 델리게이트를 선언한다. 물론 델리게이트도 최초 초기화시에는 존재하지 않으므로 옵셔널이어야 할 것이다. 또한 뷰를 델리게이트에게 되돌려주는 시점은 자신의 뷰가 사라지는 시점이면 된다.

class SubViewController: UIViewController {
...
var myData: MyData?
var delegate: MyDataDelegate?
...

// 뷰가 제거되기 전에 델리게이트에게 데이터를 위임하자.
override func viewWillDisappear(_ animated: Bool)) {
  super.viewWillDisappear(animated)
  delegate?.providorSent(myData)
}

정리

여기까지 스토리보드에서 뷰 전환시에 데이터를 양방향으로 주고 받는 방법에 대해 살펴보았다. 스토리보드를 사용하지 않는 경우에라도 방법은 사실 여기서 소개한 것과 똑같다. present하기 직전에 서브 뷰에 데이터와 델리게이트를 지정해주는 부분만 차이가 있고, 서브 뷰에서 데이터를 되돌려주는 방법은 여전히 동일하게 델리게이트 메소드를 호출하기만 하면 된다.

물론 앱 델리게이트라는 모든 뷰에서 전역적으로 사용할 수 있는 객체가 있기도 해서, 다른 뷰 컨트롤러를 참조할 수 없는 위치에서 앱 델리게이트를 전역 데이터 스토리지처럼 쓰던 시절도 존재했지만, 이건 마치 전역 변수를 쓰는 것처럼 몸에 해로운 습관이다. 여기서 보여진 패턴에 익숙해지면, 앱이 커지고 복잡해지더라도 여전히 동일한 패턴을 사용해서 뷰 컨트롤러간의 데이터 교환에 어려움을 겪지는 않을 것이다.


  1. Foundation 내에서도 인터페이스 빌더 번들은 Nib이라고 언급되며, 때문에 .xib 파일도 실제 읽을 때 nib 이라고 읽으며, 대체로 nib 파일로 통칭해서 사용된다.