[Swift] Image DownSampling

o_jooon_·2024년 3월 28일
1

swift

목록 보기
5/8
post-thumbnail

이번 포스팅은 이전 포스팅의 WWDC에서 설명한 이미지의 다운샘플링 방법들을 실습을 통해 구현한 결과를 보여드릴 예정입니다.
원본 이미지, UIGraphicsImageRenderer을 통해 다운샘플링한 이미지, ImageIO를 통해 다운샘플링한 이미지. 이 세 이미지의 렌더링까지 걸리는 시간, 데이터 크기를 비교할거에요.

WWDC 관련 이전 포스팅

전체 코드


구현

이번에 진행한 프로젝트는 다음과 같이 작동합니다.

  • Original 버튼을 누르면 해당 url에서 원본 이미지를 받아와 CollectionView에서 보여준다.
  • UIRenderer 버튼을 누르면 해당 url에서 받아온 이미지를 UIGraphicsImageRenderer로 다운샘플링하여 CollectionView에서 보여준다.
  • ImageIO 버튼을 누르면 해당 url에서 받아온 이미지를 ImageIO로 다운샘플링하여 CollectionView에서 보여준다.

또한, 공정한 측정을 위해 다음과 같이 설정해주었습니다.

  1. URLSession을 통한 네트워킹을 진행하기 직전부터 UIImage로 변환이 완료된 시점까지의 시간을 측정한다.
  2. 출력되는 데이터의 크기는 .jpegData(compressionQuality: 1.0)을 통해 1의 품질을 가진 JPEG 파일로 변환한 데이터 크기이다.

다운샘플링 관련 Class

FetchOptions

enum FetchOptions: String {
    case original = "OriginalImage"
    case uiRenderer = "DownsampledImageWithUIRenderer"
    case imageIO = "DownsampledImageWithImageIO"
}

이미지를 받아오는 옵션을 지정하는 열거형 입니다.

  • original -> 원본
  • uiRenderer -> UIGraphicsImageRenderer로 다운샘플링
  • imageIO -> ImageIO로 다운샘플링

DownSampler

class DownSampler {
    
    // MARK: Properties
    
    private let url = URL(string: "https://developer.apple.com/home/images/hero-wwdc24/phase1-awre32/b-wwdc24-hero-large-phase1_2x.webp")!
    
    // MARK: Init
    
    init() {}
    
    // MARK: Functions
    
    func fetchImage(_ size: CGSize, _ option: FetchOptions, completion: @escaping (UIImage?) -> Void) {
        // Fetch image with option
    }
    
    private func fetchOriginalImage(completion: @escaping (UIImage?) -> Void) {
    	// Fetch original image
    }
    
    private func downsampleWithUIRenderer(_ size: CGSize, completion: @escaping (UIImage?) -> Void) {
        // Downsample with UIGraphicsImageRenderer
    }
    
    private func downsampleWithImageIO(_ size: CGSize, completion: @escaping (UIImage?) -> Void) {
        // Downsample with ImageIO
    }
    
    private func fetchData(completion: @escaping (Data, Date) -> Void) {
		// Fetch data
    }
   
    private func displayInfoOfImage(_ option: FetchOptions, _ image: UIImage, _ start: Date) {
        // Print image information
    }
    
}

전체적인 이미지 다운로드와 다운샘플링 작업을 해줄 class 입니다.
url은 애플의 공식 홈페이지에 대문짝만하게 박혀있는 webp 형식의 해당 이미지 url입니다.

각 메서드의 역할은 다음과 같습니다.

fetchImage()

/// Fetch image with option
func fetchImage(_ size: CGSize, _ option: FetchOptions, completion: @escaping (UIImage?) -> Void) {
	switch option {
    case .original:
    	fetchOriginalImage(completion: completion)
    case .uiRenderer:
    	downsampleWithUIRenderer(size, completion: completion)
    case .imageIO:
    	downsampleWithImageIO(size, completion: completion)
    }
}

옵션에 맞는 메서드를 실행하여 이미지를 전달합니다.
해당 메서드를 만들어준 이유는, ViewController에서 이미지를 표시하는 작업을 이 메서드 하나에서 해주기 위해서입니다.
-> Cell에서 각 작업마다 case를 나누어 각각의 메서드를 불러오는 것보다 fetchImage() 하나만 호출하는게 가독성 면에서 더 좋다고 판단했습니다.

  • size: original을 제외하고 CollectionView cell의 크기를 받아 해당 사이즈로 다운샘플링을합니다.
  • option: FetchOptions타입 중 하나를 받아 switch문에서 분기처리해줍니다.

fetchData()

/// Fetch data
private func fetchData(completion: @escaping (Data, Date) -> Void) {
    let start = Date()
    URLSession.shared.dataTask(with: url) { data, response, error in
        guard let data = data, error == nil else {
            print("Failed to download image.")
            return
        }
            
        completion(data, start)
    }.resume()
}

URLSession을 통해 이미지의 url에서 이미지 데이터를 받아옵니다.
데이터를 성공적으로 받아오는 경우, 이미지 데이터와 이 메서드가 호출된 시간을 반환합니다.

fetchOriginalImage()

/// Fetch original image
private func fetchOriginalImage(completion: @escaping (UIImage?) -> Void) {
    fetchData() { [weak self] data, start in
        guard let originalImage = UIImage(data: data) else {
            print("Failed to create UIImage.")
            completion(nil)
            return
        }

        self?.displayInfoOfImage(.original, originalImage, start)
        completion(originalImage)
    }
}

원본 이미지를 다운로드 해준 후에 이미지를 반환합니다.

/// Downsample with UIGraphicsImageRenderer
private func downsampleWithUIRenderer(_ size: CGSize, completion: @escaping (UIImage?) -> Void) {
    fetchData() { [weak self] data, start in
        guard let image = UIImage(data: data) else {
            print("Failed to create UIImage.")
            completion(nil)
            return
        }
        
        // Resize image to size maintaining original ratio
        let originalSize = image.size
        let ratio = originalSize.width / originalSize.height
        let targetSize = originalSize.width > originalSize.height ?
            CGSize(width: size.width, height: size.width / ratio) :
            CGSize(width: size.width * ratio, height: size.width)
        
        // Start downsample
        let renderer = UIGraphicsImageRenderer(size: targetSize)
        let downsampledImage = renderer.image { _ in
            image.draw(in: CGRect(origin: .zero, size: targetSize))
        }
        
        self?.displayInfoOfImage(.uiRenderer, downsampledImage, start)
        completion(downsampledImage)
    }
}

매개 변수로 받은 size(Cell의 크기)에 맞게 이미지의 크기를 UIGraphicsImageRenderer를 통해 다운샘플링합니다.
중간에 있는 코드를 통해 이미지의 원본에 맞게 비율을 수정합니다.
다운샘플링하는 작업 자체는 WWDC에서 보여줬던 예시와 동일합니다.

/// Downsample with ImageIO
private func downsampleWithImageIO(_ size: CGSize, completion: @escaping (UIImage?) -> Void) {
    fetchData() { [weak self] data, start in
        guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
            print("Failed to create image source.")
            completion(nil)
            return
        }
		
        // Resize image to size
        let options: [NSString: Any] = [
            kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height),
            kCGImageSourceCreateThumbnailFromImageAlways: true
        ]
        
        // Start downsample
        guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else {
            print("Failed to downsample image source.")
            completion(nil)
            return
        }
            
        let image = UIImage(cgImage: downsampledImage)
        self.displayInfoOfImage(.imageIO, image, start)
        completion(image)
    }
}

매개 변수로 받은 size(Cell의 크기)에 맞게 이미지의 크기를 ImageIO를 통해 다운샘플링합니다.

이미지의 데이터에서 이미지 소스를 추출해야 하기 때문에, CGImageSourceCretaeWithData()를 사용했습니다.
Data 타입을 CFData로 다운캐스팅 해주어야 합니다.
옵션은 크기를 size에 맞게 이미지 크기를 조정해주는데, 기능 자체는 이전 포스팅에서 설명한 것과 같습니다.
size에 맞게 원본의 비율을 유지하며 크기를 조정합니다.

만약 size를 명시적으로 지정해주지 않고 n배 만큼 줄어들게 만들고 싶은 경우, 다음과 같이 원본 이미지의 비율을 얻어와서 n배 만큼 줄여주면 됩니다.

guard let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: CFNumber],
	  let width = properties[kCGImagePropertyPixelWidth],
      let height = properties[kCGImagePropertyPixelHeight] else {
    print("Failed to get image size.")
    completion(nil)
    return
}
        
let scaledWidth = width / n
let scaledHeight = height / n

이 코드 작성 후 kCGImageSourceThumbnailMaxPixelSize의 Value를 max(scaledWidth, scaledHeight)로 변경시켜주면 끝입니다!

이전 포스팅에서 본 WWDC 코드에서 CGImageSourceCopyPropertiesAtIndex()가 선언만 되어있고 사용하지 않은 이유가 이와 같이 0.2배를 위해 썼다가 다른 내용을 지우지 않았나 라는 생각이 드네요..

Document를 뒤지며 알아본 결과, CGImageSourceCopyProperties() 또는 AtIndex가 붙은 메서드는 현재 이미지 소스의 데이터를 CFDictionary? 타입으로 반환합니다.
Properties에 포함되어 있는 정보 중 너비, 높이와 관련된 Key가 저 두개이고 당연히 CFString으로 되어있습니다.
Value는 CFNumber이구요. 만약 다른 타입의 데이터도 얻고 싶으면 as? [CFString: Any]로 다운캐스팅 타입을 변경해준 후, width와 height에 각각 as? CFNumber로 수정하면 될 것 같네요.

WWDC2018에서 예시로 준 코드는 무려 6년 전이다 보니, 현재는 다운캐스팅을 명시적으로 해주어야만 에러가 나지 않습니다!

다운샘플링 후에 반환된 CGImage를 UIImage로 바꾼 후 이미지를 반환합니다.

displayInfoOfImage()

 /// Print image information
private func displayInfoOfImage(_ option: FetchOptions, _ image: UIImage, _ start: Date) {
    guard let data = image.jpegData(compressionQuality: 1.0) else {
        print("Failed to create JPEG data.")
        return
    }
        
    print("____\(option.rawValue)____")
    print("Time: \(Date().timeIntervalSince(start))")
    print("Data size: \(data.count) bytes")
    print("Image size \(image.size)")
}

이미지 다운 및 다운샘플링 작업이 끝나면 호출되는 메서드입니다.
데이터의 공정한 비교를 위해 JPEG 타입으로 변경 후 데이터를 출력합니다.
해당 메서드는 가장 마지막에 호출되기 때문에, UIImage를 ViewController에 보내기 직전의 시간과 start로 받은 이미지 다운로드 직전의 시간을 비교하여 시간을 출력합니다.
이미지의 다운샘플링이 잘 적용되었는지 확인하기 위해 이미지의 사이즈도 출력합니다.


ViewController의 코드는 별거 없으니 여기에 작성하지 않도록 하겠습니다.
처음에는 이미지 뷰 하나만 놓고 다운샘플링 작업을 비교하였습니다.
하지만, 줄어든 시간, 데이터, 사이즈는 잘 보이지만 CPU와 메모리의 사용량 차이도 비교해보고 싶었기 때문에 CollectionView로 변경하였습니다.
Cell마다 같은 이미지를 불러오도록 구현하였습니다.
그럼 결과를 볼까요?

결과

데이터 크기와 시간 비교

초기 이미지 뷰로 구현했을 때, 확인한 결과입니다.
확실히 원본 > UIGraphicsImagerenderer > ImageIO 순으로 시간, 데이터가 눈에 띄게 차이나네요!
특히, ImageIO의 경우 15배 이상으로 데이터가 줄어들었네요!!

하지만, 가장 중요한 건 메모리의 사용량이잖아요?? 그래서 CollectionView로 수정한 후에 이미지를 여러개 받아오도록 구현한 후 비교해보았습니다.

CPU, 메모리 사용량 비교

빠르게 스크롤링 하는 경우를 비교해보았습니다.

원본

CPU는 100%가 넘어가고, 메모리는 110MB가 넘어가네요.

UIGraphicsImageRenderer

CPU는 50~70%정도, 메모리는 10후반~20중반네요.
확실히 이 작업만으로도 퍼포먼스가 많이 좋아진게 보이죠?

ImageIO

CPU는 50%보다 작게, 메모리는 10중반~20초반이네요.
이전보다 나은 퍼포먼스를 보입니다.


하지만 문제가 하나 있었으니...
바로 이미지 퀄리티인데, 아래와 같습니다.

순서는 원본, UIGraphicsImageRenderer, ImageIO 입니다.
보시다시피, 데이터가 줄어듦과 동시에 퀄리티도 줄어들었네요.
퀄리티가 너무 낮아져서 이것저것 수정해봤는데, 원본 이미지보다 사이즈가 너무 작아져서 그런 것 같더라구요.
원본은(2560, 679)이고 size는(156, 41)으로 약 16배씩 작아졌습니다.
메모리를 줄이기위해 무조건 사이즈를 팍 줄이면 줄어든 만큼 압축 과정에서 손실이 발생하기 때문에 적절한 조절이 필요하다고 해요.

결론

다운샘플링을 통해 확실하게 CPU 및 메모리 사용량을 줄일 수 있다.
하지만 원본의 이미지와 크기가 많이 차이날수록 압축 과정에서 손실이 일어나게 되므로 적절한 조절이 필요하다.
한 번에 보여주려는 이미지의 수와 사이즈는 반비례하고 퀄리티와 메모리의 사용량은 비례한다.
-> 이미지를 많이 보여주는 경우는 보통 썸네일의 경우이기 때문에, 상호 합의하여 퀄리티와 메모리 사용 사이의 균형을 조절한다.


느낀점

다운샘플링을 위해서 생각보다 긴 여정을 했습니다..
WWDC 영상 시청부터 정리, 예제보고 실습을 하면서 모르는 부분들 하나하나 다 document 뒤져보기 등등을 했네요.
하지만 확실히 기초가 탄탄해진다는게 느껴져서 아주 좋은 공부법인 것 같아요.
혹시라도 잘못된 부분이 있거나, 사이즈를 저만큼 줄이면서 퀄리티를 유지하는 방법을 아시는 분은 댓글 남겨주시면 감사하겠습니다!

profile
iOS개발 공부 중입니다.

1개의 댓글

comment-user-thumbnail
2024년 3월 28일

글이 깔끔하게 잘 정리되어 있어서 참고하기 좋네요 ^^

답글 달기