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