Geon

Image DownSampling 본문

SwiftUI/지식 창고

Image DownSampling

jgkim1008 2024. 11. 8. 23:45

UIImage 처리방식은 크게 Load, Decode, Render 세가지 프로세스가 존재한다.

  • Load: 압축된 이미지 (PNG, JPEG)을 메모리에 로드 하는 단계
    • Data Buffer 란
      • 이미지의 크기를 설명하는 메타 데이터, 이미지 데이터 자체(JPEG 또는 PNG 형식)가 저장되어 있다.
      • 서버에서 받은 이미지(인코딩된 데이터) 이다.
      • 데이터 버퍼는 각 픽셀들이 가진 색상과 투명도가 없이떄문에 Frame Buffer로 바로 변환이 불가능하다.
  • Decode: JPEG, PNG파일을 GPU가 읽을수 있게 디코딩 작업을 하는 프로세스이다. 이 단계에서는 이미지 파일들을 압축을 해제하며, Image Buffer로 디코딩 하는 단계이다.
    • Image Buffer란
      • 이미지의 메모리를 표현하는 Buffer이다.
      • 이미지의 단일 픽셀의 색상과 투명도를 나타낸다.
      • Image Buffer의 각 요소는 단일 픽셀의 색상과 Alpha를 나타내기 때문에 버퍼의 크기와 Image 크기는 비례한다.
      • 즉 큰 이미지를 표현하려면 더많은 Image Buffer가 필요하다.
  • Render: Image Buffer를 Frame Buffer로 Rendering 하며 UIIMageView에 콘텐츠를 노출 시킨다.
    • frame buffer 란
      • 앱의 렌더링 결과를 보관하는 Buffer이다.
      • Frame Buffer는 디스플레이에 표시할 각 픽셀 섹상 정보를 제공하고, 디스플레이는 FrameBuffer가 제공하는 콘텐츠를 앱에 표시한다.

UIImageView Rendering 과정

  • 먼저 UIImage는 Data Buffer에 저장된 이미지 크기만큼 Image Buffer 를 할당 한다.
  • UIImageView는 contentmode에 맞게 Decoding 작업을 수행한다.
  • 마지막으로 UIKit이 UIImageView에 Rendering을 요청하면 Image Buffer에 저장되어 있는 이미지 데이터(색상, 투명도)를 Frame Buffer에 복사하고 크기를 조정하여 앱에 표시한다.

UIImageView Rendering 과정의 문제점

  • Decoding된 이미지 데이터는 Image Buffer 에 보관되기 때문에 이미지 크기에 비례한 메모리 할당이 필요하다.
  • Frame Buffer Image Buffer 를 복사할때 모든 픽셀을 사용하는 것이 아니다. 즉 위(UIImageView Rendering 과정의 문제점) 에서 설명했듯이 Image Buffer 의 모든 픽셀을 다 사용하지 않음에도 불구하고 불필요한 메모리 낭비를 하고 있다.
  • UIImageView  Size, scale, ContentMde 등을 고려하여 필요한 픽셀의 정보만 복사된다.
  • 만약 표시할 UIImageView의 크기가 이미지보다 작을 경우 메모리 양을 줄이기 위해 Downsampling을 고려할 수 있다.

DownSamling 방식

  • 원본 이미지를 UIImageView 사이즈 혹은 원하는 Size 만큼 렌더링을 통해 축소하여 썸네일을 만든다.
  • 썸네일을 만든 뒤에 Data Buffer 를 없애면 그만큼 메모리 사용량을 줄일 수 있다.
  • 그 이후 썸네일을 Decode 하면 할당하는 Image Buffer의 크기도 줄일 수 있다.
  • 이러한 과정을 통해 최소한의 메모리 사용량을 가질수 있다.
import ImageIO

func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height)
    ]

    guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
        let image = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary)
    else {
        return nil
    }

    return UIImage(cgImage: image)
}
  1. 디스크에서 이미지 파일을 불러온다.
  2. 'CGImageSourceCreateWithURL' 을 사용하여 'CGImageSource' 를 생성한다.
  3. 'CGImageSourceCreateThumbnailAtIndex' 를 사용하여 축소된 이미지를 생성한다.

이 방법을 사용하면,

첫 째, 원본 이미지의 전체 크기를 메모리에 로드하지 않아도 되므로 메모리 사용량이 크게 감소한다.

'CGImageSourceCreateThumbnailAtIndex' 메서드는 원본 이미지의 전체 크기를 메모리에 로드하지 않고도 이미지를 처리할 수 있다. 이것은 이 함수가 이미지를 작은 조각으로 분할하고, 각 조각을 개별적으로 처리하기 때문이다.

둘 째, ImageIO는 내부적으로 스트리밍 API를 사용하여 이미지를 처리하기 때문에 처리속도가 더 빠르다.

스트리밍 API란 데이터를 작은 조각으로 나누어 처리하는 API를 말한다.

이 방식은 큰 데이터 세트를 처리하는데 사용되며, 각 조각은 독립적으로 처리되고, 메모리에서 해제될 수 있다.

따라서 전체 데이터 세트를 메모리에 한 번에 로드하는 것에 비해 메모리 사용량을 크게 줄일 수 있다.

Technique #2: Drawing to a Core Graphics Context

import UIKit
import CoreGraphics

// Technique #2
func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
    guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
        let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
    else {
        return nil
    }

    let context = CGContext(data: nil,
                            width: Int(size.width),
                            height: Int(size.height),
                            bitsPerComponent: image.bitsPerComponent,
                            bytesPerRow: 0,
                            space: image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!,
                            bitmapInfo: image.bitmapInfo.rawValue)
    context?.interpolationQuality = .high
    context?.draw(image, in: CGRect(origin: .zero, size: size))

    guard let scaledImage = context?.makeImage() else { return nil }

    return UIImage(cgImage: scaledImage)
}
  • 비트맵은 픽셀의 정보들을 가지고 있는 객체
  • 비트맵컨텍스트는 이미지를 처리하기 위한 메모리 공간을 생성하고, 이미지를 그릴수 있다.
  • CGContext를 통해 비트맵 컨텍스트를 생성할수 있다.
  • 데이터 버퍼가 디코딩되어 이미지 버퍼로 이동한 후, 비트맵 컨텍스트가 생성되고, 이를 기반으로 이미지를 처리하고 프레임 버퍼에 렌더링하는 과정을 거칩니다. 따라서, 비트맵 컨텍스트는 이미지 버퍼 다음 단계입니다.

문제있는 코드

 

보통 원본 이미지를 작은 크기의 썸네일로 변환하려는 경우, 혹은 화면에 표시할 이미지가 원본 크기보다 훨씬 작아야 하는 경우에 다운샘플링을 사용한다.

 

그러나 여기서 중요한 점은 UIImage를 사용하여 이미지를 다운샘플링하지 않아야 한다는 것이다.

왜냐하면 UIImage를 사용하여 그릴 경우, 내부 좌표 공간 변환으로 인해 성능이 다소 저하되며, 이미지 전체를 메모리에 압축 해제하게 된다. 이로 인해 많은 메모리가 사용되게 된다.

 

    // MARK: - 4:3 비율 맞추기
    func resizeImageTo4By3Ratio() -> UIImage? {
        let targetAspectRatio: CGFloat = 4.0 / 3.0
        let originalSize = self.size

        let originalAspectRatio = originalSize.width / originalSize.height
        let targetSize: CGSize

        if originalAspectRatio > targetAspectRatio {
            targetSize = CGSize(width: originalSize.height * targetAspectRatio, height: originalSize.height)
        } else {
            targetSize = CGSize(width: originalSize.width, height: originalSize.width / targetAspectRatio)
        }

        let renderer = UIGraphicsImageRenderer(size: targetSize)
        let resizedImage = renderer.image { (context) in
            self.draw(in: CGRect(origin: .zero, size: targetSize))
        }

        return resizedImage
    }

 

이 방법에는 문제가 두 가지 문제가 발생

1. 원본 이미지의 전체 크기가 메모리에 로드되므로 메모리 사용량이 크게 증가할 수 있다.

 우리가 이미지를 메모리에 로드할 때, 원본 이미지의 전체 크기가 메모리에 올라간다는 것을 이해해야 한다.

이는 디스크에서 읽은 이미지 데이터가 디코드되어 각 픽셀마다 메모리를 차지하기 때문이다.

2. 'UIImage' 를 사용하여 이미지를 그리는 것은 내부 좌표 변환 때문에 성능이 떨어질 수 있다.

코드를 보면 'UIImage' 의 'draw' 메서드를 사용하여 이미지를 다시 그리고면 'UIImage' 내부적으로 좌표 변환을 수행하고, 이 변환이 CPU에 부담을 주어 성능을 저하시키는 요인이 되는 것이다.

상용에 적용한 코드

  func resizeImageTo4By3Ratio() -> UIImage? {
            let targetAspectRatio: CGFloat = 4.0 / 3.0
            let originalSize = self.size

            let originalAspectRatio = originalSize.width / originalSize.height
            let targetSize: CGSize
            //
            if originalAspectRatio > targetAspectRatio {
                targetSize = CGSize(width: originalSize.height * targetAspectRatio, height: originalSize.height)
            } else {
                targetSize = CGSize(width: originalSize.width, height: originalSize.width / targetAspectRatio)
            }


            // 되는 코드
            let colorSpace = CGColorSpaceCreateDeviceRGB()
            let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)

            guard let context = CGContext(data: nil, width: Int(targetSize.width), height: Int(targetSize.height), bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: bitmapInfo.rawValue) else {
                return nil
            }

            context.interpolationQuality = .high
            context.draw(self.cgImage!, in: CGRect(origin: .zero, size: targetSize))

        return UIImage(cgImage: context.makeImage()!)

결론

  • 이미지 리사이징을 위한 방법은 크게 3가지로 나눌수 있다.
  • [포맷 자동화 적용]
    • 이미지 렌더시 다양한 색상 포멧을 사용할수 있는데, 이러한 색상 포멧들은 넒은 범위의 색상 포멧을 얻기 위해 픽셀당 2byte ~ 4byte 까지 메모리 사용량이 늘어난다.
    • 우리는 이러한 포맷을 직접 선택할수 없기에 시스템에서 자동으로 최적의 포맷을 선택해주는(UIGraphicsBeginImageContextWithOptions) 을 사용하면 메모리 사용량을 줄일수 있다.
  • [디코딩할 영역 축소]
  • 디코딩시 이미지 크기에 맞게 데이터 버퍼가 이미지버퍼로 변환되고, 프레임 버퍼는 이미지 버퍼를 읽어서 렌더를 한다.
  • 이떄 프레임 버퍼는 항상 이미지 버퍼를 백프로 사용하지 않는다.
  • [Optimize when in the background]
  • 앱이 백그라운드로 이동하면, 화면에는 보이지 않지만 이미지의 리소스는 메모리에 남아있게 된다.
  • 이는 비효율적인 메모리 사용을 의미한다. 이런 상황을 최적화하기 위해 사용자가 보지 않는 큰 리소스는 'Unloading' 하는 것이 좋다.
  • 앱의 라이프 사이클을 이용하거나, ViewController의 라이프 사이클을 이용하는것

https://januaryone.tistory.com/138

https://iosjiho.tistory.com/m/134

https://developer.apple.com/videos/play/wwdc2018/416/

https://velog.io/@dohyun8032/WWDC-2018-Image-and-Graphics-Best-Practices