NSBitmpaImageRep의 사용법

CGImage는 CoreGraphics에서 사용하는 픽셀단위 비트맵이미지 데이터를 다루는 클래스이다. 이 클래스가 주로 이미지 렌더링이나 오프스크린 드로잉등에 사용되는 관계로 주요 API가 이러한 작업에 치중하고 있어서 실제로 이미지 파일로부터 데이터를 읽어서 생성하거나 데이터를 저장하는 작업은 클래스내에서 처리할 수 없다.

그나마 CIImageinit?(contentsOf:)를 제공하기 때문에 지원가능한 파일을 읽어들여서 인스턴스를 만드는 작업을 바로 수행할 수 있다. 다만 실제로 CIImage는 렌더링되는 실제 이미지라기 보다는 코어이미지 내의 필터를 적용하는 레시피로 기능하기 때문에 상황은 약간 다른다.

또한 iOS의 경우에는 주로 사용되는 UIImageCGImageCIImage로 이니셜라이저만으로 상호변환이 가능하며, JPEG 및 PNG 포맷 데이터도 간단하게 생성할 수 있기 때문에 문제가 되지 않는데, macOS의 경우에는 파일데이터와 이미지데이터가 모두 따로 놀기 때문에 혼선이 있을 수도 있고, 여러 모로 귀찮은 부분들이 많이 존재한다.

이번 시간에는 비트맵 이미지 데이터와 이미지 파일 사이를 연결해주는 NSBitmapImageRep에 대해서 좀 알아보도록 하자.

NSBitmapImageRep

NSBitmapImageRepNSImageRep의 서브클래스로 비트맵이미지를 렌더링하는 역할을 담당한다. NSImageRepNSImage의 실제 ‘그림 내용’이 되는 셈인데 왜 이런식으로 이미지를 다루고자 할 때 두 개의 클래스가 동원되어야 했을까?

이는 macOS의 그래픽환경의 역사와 관련이 깊다. 전통적으로 애플은 macOS에서 화면으로 표시될 수 있는 모든 것이, 그대로 프린터 등의 다른 장치로 표시될 수 있기를 원했다. 말 그대로 WYSIWYG가 OS의 그래픽 환경에서부터 그대로 구현되기를 원했던 것이다. 따라서 지금은 코어 그래픽이 된 쿼츠(Quartz)에서부터 ‘그래픽 컨텍스트’라는 개념을 도입하여 일련의 동일한 드로잉 명령으로 시각적 이미지를 생성하고, 출력되는 장치에 맞게 그 내용을 렌더링하는 것이다.

예를 들어 NSView를 예로들어보자. 화면에 그려지는 대부분의 뷰는 NSView의 서브클래스이고, 이들 뷰들은 draw(in:) 메소드를 통해서 자기 스스로를 그리는 방법을 알고 있다.

만약 화면에 그려진 내용을 출력하거나, PDF로 만들기를 원한다면 프로그래머는 그에 상응하는 드로잉 메소드를 따로 구현할 필요가 없다. 왜냐하면 프린터로 출력하거나 PDF 데이터로 출력할 때에도 NSView의 가족들은 화면에 자신을 그릴 때와 동일한 드로잉 명령을 사용하기 때문이다. 이 동작에서의 차이는 각각의 뷰들이 자신을 그리는 대상이 ‘서로 다른 컨텍스트’라는 것 뿐이라는 것이다.

이러한 맥락에서, 다양한 출력환경에 단일 이미지가 대응하는 최선의 방법을 생각해보자. 출력해상도가 높은 프린터와 같은 환경에 대응하기 위해서는 계단 현상이 없는 벡터 이미지를 사용하는 것이 가장 좋은 선택이 될 수 있다. 반면 잦은 갱신이 필요한 화면 디스플레이에서는 매번 벡터이미지를 비트맵으로 래스터화하는 것이 부담스러울 수 있으므로 적절한 해상도의 비트맵 이미지를 사용하는 것이 권장된다. 디스플레이 역시 고밀도 픽셀 (쉽게 말해 레티나) 환경이라면 더욱 큰 비트맵을 사용하는 것이 더 나은 품질의 결과를 보일 것이다.

이렇게 상황에 따라서 다른 소스를 쓰면서, 고수준 API는 하나로 통일하는 방법으로 NSImage는 일종의 ‘이미지의 배열’이면서 그 내부의 실제 ‘그림’하고는 상관없는 클래스가 되었다. NSImage는 여러 개의 “표현형”을 가지고 있으면서 이미지를 렌더링할 때 가장 최적의 표현형을 선택해주는 역할을 담당하는 셈이다.

NSImage의 표현형으로서 기능하는 클래스가 NSImageRep이며, 이는 이미지 자체의 종류에 따라 NS*imageRep의 여러 서브클래스로 나눠진다. 대표적으로 많이 쓰이는 것이 NSBitmapImageRep, NSCIImageRep, NSPICTImageRep, NSPDFImageRep, NSEPSImageRep이다.

즉 만약 어떤 NSImage 객체가 비트맵, PDF, EPS 타입의 표현형을 모두 가지고 있다면, 화면에 비트맵으로 출력되거나 PDF로 만들어지거나, 프린터로 출력될 때 모두 가장 적절한 표현형을 자동으로 사용하여 최적의 결과로 렌더링될 수 있다는 것을 의미한다.

생성

NSBitmapImagRep은 크게 세 가지 방법으로 생성할 수 있다.

  • init?(data: Data)
  • init(cgImage: CGImage)
  • init(ciImage: CIImage)

데이터는 NSBitmapImageRep가 지원하는 타입의 파일 데이터를 넘겨주면 된다. 실질적으로 이미지 파일로부터 비트맵이미지를 생성하는 셈이다. 지원가능한 타입은 NSBitmapImageRep.FileType이라는 열거타입에 정의되어 있는데, .bmp, .gif, .jpg, .jpeg2000, .png, .tiff 가 있다.

CGImage, CIImage로의 변환

init?(cgImage:)를 사용하면 CGImage로부터 비트맵표현형을 만들어낼 수 있으며, cgImage:CGImage? 프로퍼티를 통해서 곧바로 CGImage 객체를 얻을 수 있다.  반대로 CIImage의 경우에는 init(bitmapImageRep:) 을 통해서 생성할 수 있다.

따라서 파일경로로부터 데이터를 읽어들여 CGImage를 만드는 과정은 다음과 같이 처리할 수 있다.

func readCGImage(from filepath: String) -> CGImage? {
  let url = URL(fileURLWithPath: filepath)
  if let data = try? Data(contentsOf: url),
     let rep = NSBitmapImageRep(data: data) {
    return rep.cgImage
  }
  return nil
}

이미지 파일로 저장하기

비트맵 이미지 표현형이 CIImage, CGImage로 자유롭게 변환가능하다는 것은 곧 해당 타입의 이미지들을 파일로 저장할 때, NSBitmapImageRep를 사용할 수 있다는 것을 의미한다.  representation(using:properties:)는 이미지 표현형으로부터 특정 이미지 타입의 파일 데이터를 생성한다.  (이 때 properties 파라미터는 거의 쓸 일이 없다고 보면 되는데, 궁금하다면 해당 레퍼런스를 찾아보자.)

// saving image
let url = ...
let rep = NSBitmapImageRep(cgImage: source)
if let data = rep.representation(using:.png, properties:[:]) {
  try? data.write(to: url)
}

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이 라는 점을 컴파일러는 알 수 있다.

오일러 프로젝트 72

오일러 프로젝트 72 번 문제는 여태껏 나왔던 문제에서의 최고 난이도를 또 한 번 갱신했다. 오일러 피 함수(\phi )의 1에서 100만까지의 자연수에 대한 피함수 값의 합을 구해야하는 문제이며, 피 함수를 빠르게 작성하는 것이 얼마나 고된(?)일인지 알고 있다면 이 문제를 brute force로 푸는 것은 정말 답이 없다는 점에서 마음을 단단히 먹어야 한다.

오일러 프로젝트 72 더보기

코어이미지를 사용한 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. 흔히 ‘안티앨리어싱’이라 부르는 그것.