[Swift] 코어 이미지

Beginning Core Image in Swift

http://www.raywenderlich.com/76285/beginning-core-image-swift

코어 이미지는 이미지에 손쉽게 필터처리를 할 수 있는 강력한 프레임워크이다. 이는 어지간한 기본적인 사진 효과를 만들어낼 수 있는데, CPU 혹은 GPU 기반으로 프로세싱하고 있어서 매우 빠르다. 그냥 빠른 게 아니라 매우 빠르기 때문에 리얼타임으로 비디오 프레임에 적용될 수도 있다.

코어 이미지 필터는 여러 필터가 연쇄적으로 하나의 사진이나 비디오 프레임에 한꺼번에 적용될 수 있다. 이 때 여러 필터는 하나의 필터로 결합되어 이미지에 적용된다. 이런 방식은 한 번에 하나씩 이미지에 적용되는 것에 비해 훨씬 효율적이다.

이 글에서는 코어이미지 맛배기를 할 것이다. 몇 가지 다른 필터들을 적용해보면서 실시간으로 쿨한 이미지를 만들어보자.

시작하기

시작하기 이전에 코어 이미지 프레임 워크에서 핵심이 되는 클래스 세 가지를 짚고 넘어가겠다.

  • CIContext : 모든 코어이미지 프로세싱은 CIContext 내에서 이루어진다. (일종의 상태 스택이라 보면 된다.)
  • CIImage : 이 클래스는 이미지 데이터를 받는다. UIImage, 이미지파일, 이미지데이터 등으로부터 생성가능하다.
  • CIFilter : 이 클래스는 필터의 종류와 각 속성을 담고 있는 일종의 사전객체이다.

프로젝트 만들기

CoreImageFun이라는 프로젝트를 신규로 생성한다. 메인 스토리보드에서는 메인 뷰에 이미지 뷰를 하나 추가해준다. 크기 조절 모드는 Aspect Fit를 설정하여 이미지가 찌그러지지 않도록 하자. 그리고 이 이미지 뷰를 루트 뷰 컨트롤러의 imageView 아울렛으로 설정해주자.

기본 이미지 필터링

기본적으로 간단히 이미지에대해 CIFilter를 적용하고 이를 스크린에 표시할 것이다. 이미지에 필터를 한 번 적용할 때마다 다음 네 가지 과정을 거치게 된다.

  1. CIImage 객체를 생성한다.
  2. CIContext 객체를 생성한다.
  3. CIFilter 객체를 생성한다.
  4. 필터 결과를 받는다.

자, 다음 코드는 뷰 컨트롤러의 viewDidLoad() 내에 들어갈 코드 조각이다.

//  from MainViewController.swift
//  in -viewDidLoad()
let fileURL = NSBundle.mainBundle().URLForResource("image", withExtension: "png")
//  1
let beginImage = CIImage(contentsOfURL: fileURL)
//  2
let filter = CIFilter(name: "CISepiaTone")
//  3
filter.setValue(beginImage, forKey: kCIInputImageKey)
filter.setValue(0.5, forKey:kCIInputIntensityKey)
//  4
let newImage = UIImage(CIImage: filter.outputImage)
self.imageView.image = newImage

1) CIImage를 만드는 가장 간단한 방법은 이미자 파일의 경로를 사용하는 것이다.
2) CIFilter는 미리 정의된 필터 이름을 사용하여 생성한다.
3) 필터에 필요한 값들을 세팅하고
4) outputImage를 호출하면 프로세싱이 완료된 CIImage 객체가 반환되는데, 이를 UIImage 객체로 변환하면 뷰에 그릴 수 있다.

컨텍스트로

컨텍스트를 사용하는 단계로 진행하기 이전에 몇 가지 최적화와 관련된 팁. 앞서 과정에서 설명한 것 중, CIContext를 만드는 과정이 위 예제에서는 생략되었다. 사실 이 과정은 UIImage(CIImage: )에서 대신 처리한다. (해당 작업들은 가능한한 lazy하게 동작한다) 그래서 이 프레임워크가 매우 쓰기 쉽다는 것이다. 하지만 CIContext는 재사용이 가능해야 하고, 그를 통해서 성능을 향상 시켜야 한다.

예를 들어 슬라이드를 통해서 세피아의 강도를 조정해야 한다면, 위 코드에서는 약간의 움직임만 일어나도 새로운 CIContext가 만들어질 텐데, 손가락이 움직이면 계속해서 CIContext가 만들어지고 이는 앱을 매우 느리게 만들 것이다.

그래서 위 예제를 CIContext를 명시적으로 생성하는 방법을 통해 최적화해보자.

let fileURL = NSBundle.mainBundle().URLForResource("image", withExtension: "png")
let beginImage = CIImage(contentsOfURL: fileURL)
let filter = CIFilter(name: "CISepiaTone")
filter.setValue(beginImage, forKey: kCIInputImageKey)
filter.setValue(0.5, forKey:kCIInputIntensityKey)
/*******************************************************/
//  컨텍스트를 명시적으로 추가
let context = CIContext(options:nil)
let cgImg = context.createCGImage(filter.outputImage, 
    fromRect:filter.outputImage.extent())
/*******************************************************/
//  let newImage = UIImage(CIImage: filter.outputImage)
let newImage = UIImage(CGImage: cgImg)
self.imageView.image = newImage

이제 CIContext 객체를 만들고, 이를 사용해서 CGImage를 그리게 하였다. CIContext의 컨스트럭터 메소드는 컬러포맷, CPU/GPU 사용 등의 정보를 담는 옵션 정보(사전)를 인자로 받지만 여기서는 디폴트 값으로도 충분하다.

컨텍스트 객체에 대해서 createCGImage(outputImage:fromRect:)를 호출하면 이는 새로운 CIImage 인스턴스를 리턴한다. 그리고 newImage를 UIImage(CGImage:)를 사용하여 CGImage로부터 이미지 객체를 만들도록 수정했다. 여기서 주목할 것은 CGImage 객체에 대해서 Objective-C와 달리 Swift는 별도의 릴리즈 과정이 필요없다는 점이다.

필터값 변경하기

이제 컨텍스트를 통해서 필터를 적용하는 방법을 숙지했으니 실시간으로 필터의 값을 변경해보자. 컨텍스트, 필터, 원본이미지를 viewDidLoad가 아닌 함수에서도 액세스할 수 있도록 이를 프로퍼티로 만들자. 이 프로퍼티의 초기화는 viewDidLoad에서 해도 되지만, 초기화 메소드에서 하도록 한다.

스토리보드에 저장된 모든 객체는 UIApplicationMain이 호출되면서 파싱되고 unarchiving되면서 객체 그래프가 복원된다. 이 때 init(coder:)가 호출되기 때문에 이 부분을 오버라이드하면 된다.

class RootViewController : UIViewController {
@IBOutlet weak var imageView : UIImageView!
@IBOutlet weak var slider : UISlider!
//
var beginImage: CIImage!
var context: CIContext!
var filter: CIFilter!
var intensity: Float = 0.5
//
required init(coder aDecoder:NSCoder) {
    super.init(coder:aDecoder)
    context = CIContext(options: nil)
    filter = CIFilter(name: "CISepiaTone")
}
//
override func viewDidLoad() {
    super.viewDidLoad()
    let fileURL = NSBundle.mainBundle().URLForResource("lucy", withExtension: "jpg")
    beginImage = CIImage(contentsOfURL: fileURL)
    self.updateImageView()
}
//
func updateImageView() {
    filter.setValue(beginImage, forKey:kCIInputImageKey)
    filter.setValue(intensity, forKey:kCIInputIntensityKey)
    let output = context.createCGImage(filter.outputImage, 
        fromRect: filter.outputImage.extent())
    let newImage = UIImage(CGImage: output)
    self.imageView.image = newImage
}
//
@IBAction amountOfSliderDidChange(sender: AnyObject) {
    intensity = sender.value
    self.updateImageView()
    }
}   

카메라롤에서 이미지 불러오기

카메라롤에서 이미지를 불러올 때는 UIImagePickerController를 사용한다. 이 클래스는 자체적으로 카메라롤로부터 이미지를 가져오거나 즉석에서 사진을 찍을 수 있는 뷰 컨트롤러를 포함하고 있다.

사진 선택 이후의 동작을 위해서 델리게이트가 필요하며, 추가적으로 Swift에서는 presentViewController(_:, animated:, completion:)을 사용하여 뷰 컨트롤러 위에 뷰 컨트롤러를 올리기 위해서 UINavgationControllerDelegate프로토콜을 따라야 한다.

class RootViewController : UIViewController, UIImagePickerControllerDelegate,
UINavigationControllerDelegate {

이렇게 클래스 선언부를 수정하고, 스토리 보드에서 버튼을 하나 적당한 위치에 추가하고 IBAction을 연결한다.

@IBAction loadPhoto(sender: AnyObject) {
        //  Get Photo 버튼을 탭하면 이미지 피커뷰를 올린다.
        let imagePickerViewController = UIImagePickerController()
        imagePickerViewController.delegate = self
        self.presentViewController(imagePickerViewController,
            animated: true,
            completion: nil)
}

이 액션은 이미지 피커 컨트롤러의 인스턴스를 생성하고, 해당 뷰 컨트롤러를 presentViewController(_:animated:completion:) 메소드를 통해서 표시하게 된다. 특이한 점은 이미지 피커 컨트롤러의 델리게이트는 imagePickerControllerDelegate 프로토콜과 UINavigationControllerDelegate를 함께 따라야 한다. 후자의 경우 전체 메소드가 선택적으로 구현하면 되는거라 그냥 지정만 필요하다.

이미지 피커 컨트롤러의 뷰에서 사진을 선택하면 델리게이트의 imagePickerController(picker:, didFinishPickingMediaWithInfo:)가 호출된다. 여기서 info 파라미터는 선택된 미디어에 대한 정보를 담고 있는데, 그 내용을 출력해보면

UIImagePickerControllerMediaType = "public.image";
UIImagePickerControllerOriginalImage = " size {1165, 770} orientation 0 scale 1.000000";
UIImagePickerControllerReferenceURL = "assets-library://asset/asset.PNG?id=DCFE1435-2C01-4820-9182-40A69B48EA67&ext=PNG";

미디어 타입이나, 이미지, 참조url 같은 것을 확인할 수 있다. 이중에서 UIImagePickerControllerOriginalImage는 UIImage 타입으로 실제 이미지 객체 인스턴스이다. 따라서,

func imagePickerViewController(picker: UIImagePickerController!, 
        didFinishPickingMediaWithInfo info: NSDictionary!) {
        self.dismissViewControllerAnimated(true, completion: nil)
        beginImage = CIImage(image: info[UIImagePickerControllerOriginalImage] as UIImage)
        self.updateImageView()
}

열었던 뷰 컨트롤러를 닫고, 넘어온 이미지로 CIImage를 새로 만들어서 이미지 뷰를 업데이트한다. 이 때 info의 타입은 [NSString!: AnyObject] 이기 때문에 as UIImage로 다운캐스팅할 필요가 있다.

카메라롤에 저장하기

카메라롤에 저장하기 위해서는 AssetsLibrary 프레임워크가 필요하다. (음? 이거 그냥 되는 거 아니었나?) 암튼 그래서 프로젝트에 이 프레임워크를 추가하고, 파일 상단에 다음 문구를 추가해준다.

import AssertsLibrary

그리고 카메라롤에 저장하기 위한 버튼을 스토리보드에 만들고 다음 IBAction과 연결한다.

@IBAction func savePhoto() {
    let imageToSave = filter.outputImage
    let softwareContenxt = CIContext(options:[kCIContextUseSoftwareRenderer: true])
    let renderedImage = softwareContenxt.createCGImage(imageToSave, fromRect: imageToSave.extent())
    let library = ALAssetsLibrary()
    library.writeImageToSavedPhotosAlbum(renderedImage, 
        metadata: renderedImage.properties(),
        completionBlock:nil)
}   

이 예에서는 소프트웨어 렌더러를 사용하여 파일을 렌더링하도록 했다. (그래서 iOS시뮬레이터에서는 작동하지 않는다.)

메타 정보들

이미지의 메타정보에는 사진의 위치, 찌힌 시각이나 노출, 초점 거리, 회전 여부등이 기록되어 있다. 위 예제에서는 이 정보들을 그대로 저장해준다. 만약 사진이 돌아간 경우라면 이를 회전 시켜서 화면에 표시해보도록 하자.

func imagePickerViewController(picker: UIImagePickerController!, 
   didFinishPickingMediaWithInfo info: NSDictionary!) {
    self.dismissViewControllerAnimated(true, completion: nil)
    pickedImage = info[UIImagePickerControllerOriginalImage] as UIImage
    // orientation check
    var orientation = pickedImage.imageOrientation
    beginImage = UIImage(CGImage: pickedImage, scale: 1.0, orientation:orientation)
    self.updateImageView()
}  

더 많은 필터들

기본필터들은 kCICategoryBuiltIn 카테고리에 정의되어 있고 이는 +filterNamesInCategory: 메소드로 알아낼 수 있다.

다음 함수는 기본 필터들의 정보를 출력하는 유틸리티 함수이다.

func logAllFilters() {
    let properties = CIFilter.filterNamesInCategory(kCICategoryBuiltIn)
    println(properties)
    for filterName in properties as String{
        let fltr = CIFilter(name: filterName as String)
        println(fltr.attributes())
    }
}

필터 체이닝

필터의 outputImage의 타입은 CIImage이고, 대부분 필터의 inputImageKey의 타입 역시 CIImage 타입이므로 다음과 같은 식으로 필터를 연쇄적으로 적용하면 된다.

func chainFilters(#img: CIImage, withAmount intensity:Float) -> CIImage {
    //  1
    let sepia = CIFilter(name: "CISepiaTone")
    sepia.setValue(img, forKey: kCIInputImageKey)
    sepia.setValue(intensity, forKey: kCIInputImageKey)
    //  2
    let random = CIFilter(name: "CIRandomGenerator")
    //  3
    let lighten = CIFilter(name: "CIColorControls")
    lighten.setValue(random.outputImage, forKey:kCIInputImageKey)
    lighten.setValue(1 - intensity, forKey:"inputBrightness")
    lighten.setValue(0, forKey: "inputSaturation")
    //  4
    let croppedImage = lighten.outputImage.imageByCroppingToRect(beginImage.extent())
    //  5
    let composite = CIFilter(name: "CIHardLightBlendMode")
    composite.setValue(sepia.outputImage, forKey:kCIInputImageKey)
    composite.setValue(croppedImage, forKey: kCIInputBackgroundImageKey)
    // 6
    let vignette = CIFilter(name: "CIVgnette")
    vignette.setValue(composite.outputImage, forKey: kCIInputImageKey)
    vignette.setValue(intensity * 2, forKey: "inputIntensity")
    vignette.setValue(intensity * 30, forKey: "inputRadius")
    //
    return vignette.outputImage
}

여기서 random 필터는 랜덤 노이즈를 생성해 내는데, 결과 이미지는 동일 패턴이 끊임없이 타일링 되고 있으므로 이를 적절한 사이즈로 잘라내어야 한다. 이는 CIImage에서 imageByCroppingToRect() 메소드를 통해 지원한다.