이니셜라이저 – Swift

Swift의 클래스와 구조체, enum 객체들은 사용하기 전에 반드시 초기화되어야 한다. 그러면 초기화(initialization)이란 무엇인가? 객체의 생성 자체를 초기화과정에 포함시키는 관점과 그렇지 않은 관점이 있지만, 여기서는 “객체를 만들어서 사용가능한 상태로 준비하는 일”이라고 보자. let foo = Foo() 와 같이 특정한 타입의 인스턴스를 생성하는 구문을 실행했을 때 저 아래(?)에서 벌어지는 과정은 다음과 같다.

이니셜라이저 – Swift 더보기

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

URLSession을 통한 간단한 업로드 – Swift

URLSession(NSURLSession)을 기반으로 URLSessionDataTask를 이용하면 HTTP를 통해서 서버와 간단한 데이터를 주고받을 수 있다.  Data Task를 이용하는 케이스는 흔히 키-값 쌍의 정보를 URL로 인코딩해서 주소에 포함하고 서버로부터 응답을 받는 식으로 처리하는 것이 일반적이다. 서버로 데이터나 파일을 업로드하는 경우에는 URLSessionUploadTask 클래스를 사용하는데, 이를 이용한 파일 업로드를 어떻게 구현하는지 살펴보자.

사실, 그리 크지 않은 크기의 파일 데이터는 데이터태스크를 이용해도 무방하다.

POST 전송을 통한 파일 업로드

HTTP 규격에는 메소드(METHOD)라는 항목이 포함된다. 가장 많이 쓰이는 메소드는 GET과 POST인데, GET은 서버로부터 페이지를 요청할 때 쓰는 것이고 (뭔가를 페이지로부터 GET할 때), POST는 페이지에 무언가를 넣어줄 때 쓰는 것이다. (그외에도 PUT, DELETE(????)와 같은 METHOD들도 규격 내에 정의되어 있지만, 거의 안 쓰인다.) POST 방식은 흔히 브라우저에서 폼 제출을 할 때 사용되며, 브라우저에서의 파일 업로드에서도 흔히 사용된다.

Part 0 : 파일 업로드를 처리할 수 있는 서버 만들기 (Python + aiohttp)

가장 기본적인 POST 요청을 처리해서 파일을 업로드할 수 있는 웹서버를 만들어보자. 이 글은 사실 파일을 업로드할 수 있는 클라이언트를 Swift로 만드는 것에 관한 것이므로 간단히 코드를 소개만 하겠다.

from aiohttp import web
import re

# 파일 업로드를 처리하기 위한 핸들러
async def upload(request):
  # 헤더로부터 파일 이름을 얻는다.
  filename = 'untitled.dat'
  n = request.headers.get('Content-Disposition')
  if n:
    pat = re.compile(r'filename="(.*?)"')
    m = pat.search(n)
    filename = m.group(1)
  
  # HTTP 요청 BODY를 읽어들여 파일에 쓴다.
  data = await request.read()
  with open(filename, 'wb') as f:
    f.write(data)
  
  # HTTP 응답을 보낸다.
  res = web.Response()
  res.code = 200
  res.text = 'OK'
  return res

# 서버 생성 및 라우터 설정
app = web.Application()
app.router.add_post('/upload', upload)
# 앱 시작
web.run_app(app, host='127.0.0.1', port=3378)

위 파이썬 코드를 실행하고 명령줄에서 curl을 사용해서 (윈도 환경이라면 윈도용 curl을 따로 설치해야 한다) 다음과 같은식으로 파일을 업로드해볼 수 있다.

$ curl -XPOST -H "Content-Disposition: attachment; filename=\"sample.jpg\"" \
  -Tsample.jpg http://localhost:3378/upload
OK

사실 이  Content-Disposition 헤더는 일반적으로 서버의 응답헤더나 멀티파트 요청에 사용되는 것인데, 어차피 통신규약의 일부이므로 이렇게 사용해도 잘못된 것은 아니다.

여기서 파일 업로드 구현은 결국 curl의 -T 옵션을 사용하는 파일 전송과 똑같은 기능을 수행하려는 것이다. (어? 그러면 Process로 curl을 실행시키면 되잖아…)

Part 1 : 바이너리 전송을 위한 Swift 구현

간단히 명령줄에서 바이너리 파일을 업로드할 수 있는 기능을 구현하는 Swift 코드를 작성해보자. 시작하기 전에 몇 가지 참고할 사항은 다음과 같다.

  1. 전송할 파일이름은 인자로 받는다.
  2. URLSessionUploadTask를 사용한다.
  3. 서버의 구현상, 크기가 큰 파일은 전송할 수 없다.

인자로 받게되는 내용은 CommandLine.arguments 로  액세스할 수 있다. 여느 언어들과 마찬가지로 0번 요소는 프로그램 자신의 이름이며 추가되는 인자는 그 다음부터 들어오게 된다. 받아들인 인자는 파일 이름일수도 있고 경로일 수도 있는데, 이것을 URL 타입의 객체로 변환해야 한다.

import Foundation

// 인자로부터 받은 파일이름을 URL로 변환한다.
let fileurl: URL = {
  guard CommandLine.arguments.count > 1, 
      case let filepath = CommandLine.arguments[1]
  else {
    fatalError("needs filename.")
  }
  return URL(fileURLWithPath: filepath)
}()

서버쪽에서는 헤더의 정보를 통해서 파일의 이름을 파악하며, 파일의 데이터가 HTTP 요청의 payload에 해당하게 된다. 헤더와 페이로드에 각각 데이터를 넣어서 POST 전송을 하기 위해서 요청 객체를 만들어보자. URLRequest는 클래스가 아닌 Struct 값이므로 변경 가능한 값으로 생성해야 한다.

let serverURL = URL(string:"http://localhost:3378/upload")!
let request: URLRequest = {
   // 헤더에 포함시키기 위한 파일 이름은 파일의 URL로부터 구할 수 있다.
   let filename = fileURL.lastPathComponent
   var req = URLRequest(url: serverURL)
   req.httpMethod = "POST"
   req.addValue("attachment; filename=\"\(filename)\";",
         forHTTPHeaderField: "Content-Disposition")
   return req
}()

다음은 업로드 작업 객체를 생성한다. 작업 객체는 세션으로부터 만들어지며, 요청객체와 업로드할 데이터, 그리고 처리 완료시 핸들러를 필요로 한다. 처리 완료 핸들러는 따로 지정하지 않을 수 있고, 별도의 델리게이트를 지정하여 처리를 위임할 수 있는데, 델리게이트를 지정하는 경우 업로드 작업은 데이터를 전송하는 현황을 체크하는 것이 가능하다.

if let data = try? Data(contentsOf: fileURL) {
  let session = URLSession.shared
  let task = session.uploadTask(with: request, from: data) { data, res, error in 
    
    /// 1: 실제로 비동기로 동작하는 업로드 동작 때문에, 완료핸들러의 처리 후에는
    //     런루프를 멈춰서 프로그램이 종료할 수 있게 해주어야 한다. 
    defer {
      CFRunLoopStop(CFRunLoopGetMain())
    }

    // 에러 체크
    guard error == nil else {
      print("Error: \(error!.localizedDescription)")
      return
    }

    // 서버로부터 응답이 제대로 내려왔는지 체크
    // 응답값은 URLResponse? 인데, 
    // 이를 HTTPURLResponse로 캐스팅해서 statusCode를 확인한다.
    if let res= res as? HTTPURLResponse,
        res.statusCode != 200 {
       print("Server failed")
    }

    if let data = data, let message = String(data:data, encoding:.utf8)
    {
       pritn(message)
    }
  }

  // 업로드 시작
  task.resume()
  // 위 함수는 non-blocking이기 때문에
  // 런루프를 돌려서 대기해야 한다. 
  CFRunLoopRun()
} else {
  // 파일 읽기에 실패
  fatalError("Failed to read file")
}

위 스크립트를 실행해보자.

$ swift upload.py sample.jpg
OK

서버로부터 정상적인 응답이 떨어졌다면, 서버가 돌아가고 있던 폴더를 확인해보면 파일이 만들어져 있는 것을 확인할 수 있다.

 

Part 2 : 폼 전송을 사용한 업로드

서버 사이드를 직접 만들었다면 문제가 없는데, 그렇지 않은 상황도 있을 수 있으니 문제이다. 예를 들어 파일을 업로드하려는 서버가 실제로는 웹브라우저에서 form을 사용해서 업로드를 수행하는 프론트엔드와 짝이되는 경우를 생각해볼 수 있다. 이 경우에는 브라우저가 파일을 업로드하는 방식을 그대로 흉내내어야 한다. 문제는 브라우저는 위와 같은 간단한 방식으로 파일을 업로드 하지 않고 multipart/form-data라는 형식을 사용해서 요청의 BODY 부분을 특별한 규칙에 맞게 구성한다는 것이다. 기본적으로 웹에서의 폼은 단일 파일 하나만을 서버로 전송하는 것이 아니라 그외 다른 input, textarea 등의 폼 필드 요소의 값을 하나로 묶어서 서버로 전송하기 때문이다.

따라서 요청 헤더에 Content-Type 정보를 정의하고 그에 맞게 데이터를 구성해주어야 한다. 멀티파트 폼데이터의 실제 구성에 대해서 간략히 설명하면 다음과 같다.

  1. 바운더리를 구분하기 위한 문자열을 임의로 정의한다.
  2. 각 폼필드 요소의 값은 --바운더리 모양의 라인 하나로 구분된다.
  3. 이후 해당 필드 요소 데이터에 대한 헤더를 정의한다. (Content-Disposition 헤더는 주로 여기서 사용된다.)
  4. HTTP헤더와 마찬가지로 각 파트의 헤더와 본체는 빈줄 1개로 구분된다.
  5. 해당 데이터값을 쓴다.
  6. 다시 줄을 바꾸고 두 번째 바운더리를 시작한다. (2~5의 과정)
  7. 모든 요소의 기입이 끝났으면 줄을 바꾸고 --바운더리--의 모양으로 데이터를 기록하고 끝낸다.

여기서 중요한 것은 HTTP 요청에서 줄 바꿈은 플랫폼에 상관없이 \r\n 으로 고정되어 있다는 것과, 전송되는 BODY는 항상 “이진 데이터”이기 때문에 문자열을 그대로 쓸 수 없다는 것이다. 즉 헤더 정보는 문자열로 작성한 후 UTF8로 인코딩하여 Data 타입으로 변환해주어야 한다.

예를 들어 바운더리 문자열을 XXXXX 라고 했다면 HTTP POST의 본체는 다음과 같이 구성될 것이다. 폼 필드에서 필드 이름을 “file”이라고 정했다고 가정하자.

--XXXXX
Content-Disposition: form-data; name="file"; filename="image.jpg"
Content-Type: image/jpeg

.....{파일의 바이너리 데이터}
--XXXXX--

자, 그렇다면 파일의 내용을 읽어서 멀티파트 데이터를 생성하는 함수를 하나 작성해봐야겠다.

func buildBody(with fileURL: URL, fieldName: String) -> Data? {
  // 파일을 읽을 수 없다면 nil을 리턴
  guard let filedata = try? Data(contentsOf: fileURL),
  else { return nil }

  // 바운더리 값을 정하고, 
  // 각 파트의 헤더가 될 라인들을 배열로 만든다.
  // 이 배열을 \r\n 으로 조인하여 한 덩어리로 만들어서 
  // 데이터로 인코딩한다.
  let boundary = "XXXXX"
  let headerLines = ["--\(boundary)",
    "Content-Disposition: form-data; name=\"file\"; filename=\"\(fileURL.lastPathComponent)\"",
    "Content-Type: \(mimetype)",
    "\r\n"]
  var data = headerLines.joined(separator:"\r\n").data(using:.utf8)!

  // 그 다음에 파일 데이터를 붙이고
  data.append(contentsOf: filedata)
  // 마지막으로 데이터의 끝임을 알리는 바운더리를 한 번 더 사용한다. 
  // 이는 '새로운 개행'이 필요하므로 앞에 \r\n이 있어야 함에 유의 한다. 
  data.append(contentsOf: "\r\n--\(boundary)--".data(using:.utf8)!)
  return data
}

그렇다면 이후의 과정은 거의 동일하다. 단, Content-Type 헤더 값이 mulitpart/form-data 여야 하고, boundary 값을 정해주어야 한다는 점만 다르다.  다음 함수는 위 buildBody를 사용해서 주어진 이미지 파일을 서버로 업로드하는 기능을 수행한다.

func uploadImageFile(at filepath:String, completionHandler:@escaping(Data?, URLResponse?) -> Void)
{
  // 경로를 준비하고
  let fileURL = URL(fileURLWithPath: (filepath as NSString).expandingTildeInPath))
  let url = URL(string: "http://localhost:7070/fileupload")!
  
  // 경로로부터 요청을 생성한다. 이 때 Content-Type 헤더 필드를 변경한다.
  var request = URLRequest(url: url)
  request.httpMethod = "POST"
  request.setValue("multipart/form-data; boundary=\"XXXXX\"",
       forHTTPHeaderField: "Content-Type")
  
  // 파일URL로부터 multipart 데이터를 생성하고 업로드한다.
  if let data = buildBody(with: fileURL) {
    let task = URLSession.shared.uploadTask(with: request, from: data){ data, res, _ in
      completionHandler(data, res)
    }
    task.resume()
  }
}

참고자료

 

코어 이미지 시작하기

코어이미지를 사용한 이미지 프로세싱 방법에 대해 알아보려고 한다. 가장 먼저 코어 이미지를 사용한 이미지 프로세싱에서 가장 핵심적인 세 가지 클래스에 대해서 살펴보자.이 들 클래스는 각각 CIContext, CIImage, CIFilter 이다.

핵심 클래스들

컨텍스트 : CIContext

코어그래픽에서 컨텍스트는 실제 이미지 비트맵 데이터 영역을 캔버스로 하고, 여기에 그림을 그리는 여러가지 선과 색등에 관련된 옵션을 가지고 있는 객체였다. 이는 곧 프로그래밍 API를 통해서 제어하는 가상의 그림판 앱이나 마찬가지인 셈이다. 비슷하게 코어 이미지의 컨텍스트는 이미지에 필터를 적용하는 무대가 되며, 가상의 포토샵류 프로그램을 API를 통해서 제어한다고 생각하면 된다.

필터 : CIFilter

CIFilter는 포토샵의 필터와 같이 이미지를 처리해서 변형하는 장치를 표현하는 클래스이다. 코어 이미지에서는 엄청나게 많은 필터 효과를 지원하는데, 이들 각각의 필터가 각각 CIFilter의 서브 클래스로 존재하는 것이 아니라, CIFilter 하나로 사용하게 되고, 대신에 문자열로 필터 이름을 지정하여 사용할 효과를 선택하게 된다.

또한 필터마다 사용하는 파라미터가 다르기 때문에 (극단적으로 어떤 필터는 입력 이미지를 2개 이상 받기도 하고, 어떤 필터는 입력 이미지가 필요 없는 것도 있다.) 필터에 전달되는 모든 파라미터는 키-값쌍을 통해서 설정한다.

필터는 주로 CIImage 객체를 입력 값으로 받고, 다시 CIImage 객체를 출력한다. (원본은 immutable하다.) 이 출력은 outputImage 라는 프로퍼티로 접근이 가능하다. outputimage는 필터가 적용된 결과 이미지가 아니라, 필터가 어떤 식으로 적용될지에 대한 “레시피”가 쓰여진 상태이다. 실제로 하나의 효과를 만들기 위해서는 여러 가지의 필터를 순차적으로 적용하여 최종 효과를 만들게 되는데, 이 때 중간과정의 이미지를 실제로 렌더링하는 것은 메모리나 성능에 있어 그다지 도움이 되지 않을 것이기 때문이다.

최종 렌더링을 통해 결과 이미지를 얻어내는 통작은 CIImage에 기록된 “레시피”에 따라 컨텍스트를 통해서 이루어진다.

이미지 : CIImage

코어 이미지 프레임워크에서 CIFilter, CIContext 등과 같이 맞물려 동작하는 이미지 클래스. 하지만 CIImage는 (참조하는 이미지 데이터가 있을 수 있지만) 그 자체로 이미지가 아니다. 대신에 앞서 설명한 바와 같이 이미지를 만드는 레시피가 기록된 객체라고 보면 된다. 필터로부터 생성되는 outputImage는 필터의 입력 이미지에 어떤 효과를 적용할 것인지에 대한 정보가 기록된 상태이고, 최종 결과물은 컨텍스트를 통해서 렌더링 되어 생성된다. CIImage가 다룰 수 있는 비트맵은 CGImage부터,  CGContext의 비트맵데이터, NSBitmapImageRep, (iOS 한정으로) UIImage 등이며, URL로 부터 이미지 파일을 읽어서 생성할 수도 있고, Core Video 이미지 버퍼를 사용할 수도 있다.

CIImagecgImage(CGImage?타입) 프로퍼티가 있어서 CGImage로 변환할 수 있다. 이 때 이 값은 실제로 컨텍스트에 의해서 렌더링된 결과를 가지고 있는 상태일 때만 유효하다.

예제

다음은 특정한 URL상의 이미지 파일에 대해 세피아 톤 필터를 적용하는 예이다.

import Cocoa
import CoreImage


let fileURL = ...
let image = CIImage(contentsOf: fileURL)!
let ctx = CIContext()


let filter = CIFilter(name: "CISepiaTone")! // #1

// #2
filter.setValue(image, forKey: kCIInputImageKey)
filter.setValue(0.8, forKey: kCIInputIntensityKey)

let result = filter.outputImage // #3

if let result = result {
  // #4
  let cgImage: CGImage? = ctx.createCGImage(result, from: result.extent)
  // do something with resultImage
}
  1. 세피아 톤을 적용하기 위한 필터를 생성한다. 각각의 필터 이름은 문자열로 기입하며, 아직 상수로 정의된 바는 없다. 사용가능한 필터의 목록은 코어 이미지 필터 레퍼런스를 참고한다.
  2. 필터의 적용에 필요한 입력 이미지와 입력 값들을 키-값 기반으로 적용해준다.
  3. 이 때의 결과물은 “최종 이미지를 렌더링 하기 위한 레시피”임에 주의하자. 즉 아직까지 최종 렌더링은 이루어지지 않았다.
  4. 레시피를 렌더링하여 실제 비트맵을 생성하는 것은 컨텍스트의 몫이다.  createCGImage(_:from:)가 호출되는 시점에 만들어질 것이다.

UI와 함께 쓰기

코어 이미지가 제공하는 주된 API들에서 입출력되는 이미지는 늘 CIImage 타입을 사용한다. 하지만 UIKit이나 AppKit에서 사용되는 대표적인 이미지 표현 클래스는 UIImage, CGImage, NSImage 들이다. 이 들과 CIImage 간의 관계에 대해서 살펴보자.

iOS – UIImage

iOS의 UIImage는 이미지 비트맵 데이터를 가지고 있는 형식이며, 따라서 CGImageCIImage와는 간단하게 상호 변환이 가능하다.

// Swift 4.0

let image = UIImange(named: "myimage.png")
// CGImage 얻기
let myCGImage = image.cgImage!
// CGImage로부터 CIImage를 만들 수 있다. 
let someCIImage = CIImage(cgImage: myCGImage)

// CIImage는 UIImage로부터 바로 변환된다. 
let myCIImage = CIImage(image: image)!
// 반대로 UIImage 역시 CIImage로부터 바로 얻을 수 있다. 
let displayedImage = UIImage(ciImage: myCIImage)

따라서 필터링의 결과물인 CIImage를 화면에 디스플레이하기 위해서는 init(ciImage:)를 사용하여 CIImageUIImage로 변환하여 사용할 수 있다.

macOS – NSImage

AppKit의 NSImage는 데이터 상으로는 비트맵 이미지를 나타내는 클래스가 아니다. 결국 네이밍의 문제이긴한데, 어떤 콘텐츠는 “어디로 출력되냐”에 따라서 사실 다른 데이터로 이루어져야 맞는데, (저해상도 사진 파일을 프린터로 출력하면 깨져서 나오는 것 처럼) NSImage는 특정한 출력의 맥락에서 최선의 결과물을 자동으로 골라주기 위한 장치이다. 따라서 NSImage는 그 스스로는 비트맵 데이터를 관리하지 않으며, 그 내부에 여러 개의 표현형 데이터를 가지고 있는 배열 비슷한 클래스이다.

따라서 macOS 앱을 만들 때 코어 이미지를 쓴다면 이미지의 입출력은 모두 CIImage 기반으로 하되,  NSImageView를 사용해서 이미지를 디스플레이해야 하는 시점에 NSImage를 생성하는 전략을 선택하는 것이 바람직하겠다.

CIImage로부터 NSImage를 생성하기 위해서는 NSImageRep의 서브 클래스인 CIImageRep 클래스를 사용한다. 공교롭게도 이 클래스는 코어이미지가 아니라 AppKit에 정의되어 있기 때문에 코어 이미지 레퍼런스만 찾아서는 알 수가 없었다.

// CIImage를 NSImageViwe에 사용하기 

// 먼저 CIImage -> CIImageRep을 생성
let resultImage = someCIFilter.outputImage!
let rep = CIImageRep(ciImage: resultImage)

// 빈 NSImage를 생성하고, representation을 추가한다.
let displayedImage = NSImage()
displayedImage.addRepresentataion(rep)
self.imageView.image = displayedImage

CIImage를 만들기

사실 CIImage는 비트맵데이터, CGImage, NSBitmapImageRep 뿐만 아니라 코어 비디오 프레임(CVImageBuffer)이나 이미지 파일로부터도 생성이 가능하다. 또한 CIImage를 다양한 파일 포맷의 비트맵 데이터로 변환할 수도 있는데, 이는 CIContext 객체의 기능을 통해서 구현이 가능하다.

즉 AppKit에서는 NSImage를 사용하지 않고도 얼마든지 파일로부터 CIImage를 읽어들여서 조작하고 저장하는 작업을 수행할 수 있다. 다음은 파일의 경로 문자열로부터 이미지 파일을 읽어서 필터를 적용하고, 다시 이를 저장하는 과정을 구현한 것이다.

// 경로 준비
let filePath = "~/images/sample01.png"
let fileURL = URL(fileURLWithPath: (filePath as NSString).expandingTildeInPath)!

let saveURL = ... 

// 컨텍스트
let context = CIContext() // #1

// 필터를 적용한 이미지
if let image = CIImage(contentsOf: fileURL) {
  let myFilter = CIFilter(name: "CISepiaTone", withInputParameters:
    [kCIInputImageKey: image,
     kCIInputIntensityKey: 0.8])
  let result = myFilter.outputImage!

  // 결과 이미지를 디스크에 기록한다.
  do {
    try writePNGRepresentation(of: result, to: saveURL, format: kCIFormatARGB8,
                               colorSpace: CGColorSpaceCreateDeviceRGB(),
                               options:[:])
  catch {
    fatalError("Fail to write PNG file")
  }
}

그외 알아둘 것

CIFilter는 mutable한 객체이기 때문에 여러 스레드에서 동시에 참조하는 것은 안전하지 않다. 같은 기능을 하는 필터를 여러 스레드에서 사용한다면 각각의 스레드마다 필터를 생성하도록 하자. 대신에 필터를 통해서 거쳐 나오는 CIImage는 변경불가능한 객체이기 때문에 여러 스레드 사이에서 동일 인스턴스를 참조하는 것에 별다른 문제가 생기지 않는다.

CIContext 역시 불변 객체이기에 스레드 안전하다. 또, CIContext는 생성, 유지하는데 비용이 크게 들어가는 객체이기 때문에 가급적 하나만 생성하여 사용할 것이 권장된다.

정리

  • CIContext는 원본 이미지와 필터 레시피를 가지고 효과를 적용한 결과물을 렌더링해내는 포토샵 같은 존재이다.
  • CIFilter는 개별 필터에 대한 세세한 설정 정보를 기록한다.
  • CIImage는 원본 이미지로부터 생성되어, 필터들을 거치며 각각의 필터에 대한 레시피 기록이 부가되는 이미지이다.
  • 포토샵에서 필터를 적용하듯, CIImage 자체에 필터를 적용한 결과물을 얻기 위해서는 필터의 출력 이미지를 다시 컨텍스트를 통해 렌더링해야 한다.

참고자료

이미지 리사이즈 방법 총정리 – 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를 사용하는 방법이 빠르고, 결과의 품질 면에서도 가장 좋은 것 같다.