새소식

iOS/Swift

iOS에서의 이미지 리사이징에 대하여

  • -

이슈의 시작: 다운 사이징을 했는데 이미지 데이터 크기가 오히려 늘어났다

예전에 개발을 담당했던 모 서비스에서 이미지를 전송할 때 이미지가 5MB(압축하지 않은 JPEG데이터 기준)를 넘어가면 긴 변(long edge)을 1920px로 리사이징하는 정책이 있었습니다. 그래서 프로젝트 내에 있었던 UIImage extension 의 resize 메소드를 사용했습니다.

그런데 이상하게도 특정상황에서 사진 전송시 10초로 설정해 놓은 타임아웃이 발생했고 디버깅한 결과 파일 사이즈가 20MB가 넘어가서 10초안에 전송이 안되는 것이 원인이었습니다. 처음에는 '특정상황에서 이미지 리사이징을 안타고 있는 모양이다'고 생각했습니다. 그런데 확인해 보니 예상과 달리 리사이징 로직을 잘 타고 있었고, 리사이징 전에 4300 여 px 정도 였던 가로 길이가 1920px에 맞춰서 있었는데, 놀랍게도 데이터 사이즈는 리사이징하기 전(약 8MB)보다 훨씬 더 늘어난 24MB 가량되었습니다. 도저히 믿기지 않아서 다른 여러가지 이미지로도 확인해보았지만 현실이었습니다.

이슈는 이슈를 낳고: CoreGraphics의 BitmapContext를 사용해 해결되는 듯 했다

검색을 시작했습니다. 참고로 UIImage extension에서는 UIKit API를 사용해서 리사이징을 하고 있었습니다. 인터넷에도 대부분 이 코드를 추천하고 있더군요.

    public func resize(toTargetSize: CGSize) -> UIImage? {
        let target = CGRect(x: 0, y: 0, width: toTargetSize.width, height: toTargetSize.height)

        UIGraphicsBeginImageContextWithOptions(target.size, false, UIScreen.main.scale)
        draw(in: target, blendMode: .normal, alpha: 1)
        let newImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()

        return newImage
    }

그러다가 애플에서 제공하는 여러가지 프레임워크 API를 이용하여 이미지 리사이징하는 방법들의 Performance를 비교한 블로그 글을 찾았습니다. 이 글에서 소개하는 여러가지 방법 중 CoreGraphics의 CGBitmapContext를 생성하여 이미지를 리사이징하는 방법이 눈에 띄었습니다. 성능도 괜찮은 편이었고 예전에 피쳐폰 시대에 BitmapContext를 다뤄봤던 기억이 있어서 익숙함에 끌렸던 것 같습니다.

샘플 코드를 참고해서 빠르게 코딩했습니다. 처음에는 문제가 있는 부분에만 반영할 생각으로 UIImage extension은 건드리지 않고 따로 함수를 만들었습니다. 그리고 테스트를 해봤습니다. 이미지 크기도 잘 바뀌고 데이터 사이즈도 많이 줄어들었습니다. 생각보다 쉽게 문제가 해결된 것 같았습니다. 팀 내에서 extension에도 적용하는 게 좋겠다는 의견이 있어서 아래와 같이 resize 메소드를 수정했습니다.

    public func resized(to targetSize: CGSize) -> UIImage? {

        let target = CGRect(x: 0, y: 0, width: targetSize.width, height: targetSize.height)
        guard let cgImage = self.cgImage,
            let context = CGContext(data: nil, width: Int(targetSize.width), height: Int(targetSize.height),
                                    bitsPerComponent: cgImage.bitsPerComponent, bytesPerRow: cgImage.bytesPerRow,
                                    space: cgImage.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
                                    bitmapInfo: cgImage.bitmapInfo.rawValue)
            else {
                return self
        }
        context.interpolationQuality = .high
        context.draw(cgImage, in: target)

        let newImage = context.makeImage().flatMap { UIImage(cgImage: $0) }
        return newImage ?? self
    }

다시 테스트를 했습니다. 그런데 전체에 적용했더니 CGContext를 생성하지 못해서 리사이징을 못하는 경우가 발견되었고 콘솔에는 CGBitmapContextCreate: unsupported parameter combination: set CGBITMAP_CONTEXT_LOG_ERRORS environmental variable to see the details 라는 메시지가 찍히고 있었습니다. 뭔가 파라미터가 안맞아서 컨텍스트를 만들수 없다는 얘기였죠.

다시 검색을 했습니다. 여기를 참조해서 CGBITMAP_CONTEXT_LOG_ERRORS 환경변수를 설정하고 다시 실행했더니 아래와 같은 메시지가 나왔습니다.

CGBitmapContextCreate: unsupported parameter combination:
8 integer bits/component;
32 bits/pixel;
RGB color space model; kCGImageAlphaLast;
1760 bytes/row.
Valid parameters for RGB color space model are:
16 bits per pixel, 5 bits per component, kCGImageAlphaNoneSkipFirst
32 bits per pixel, 8 bits per component, kCGImageAlphaNoneSkipFirst
32 bits per pixel, 8 bits per component, kCGImageAlphaNoneSkipLast
32 bits per pixel, 8 bits per component, kCGImageAlphaPremultipliedFirst
32 bits per pixel, 8 bits per component, kCGImageAlphaPremultipliedLast
64 bits per pixel, 16 bits per component, kCGImageAlphaPremultipliedLast
64 bits per pixel, 16 bits per component, kCGImageAlphaNoneSkipLast
128 bits per pixel, 32 bits per component, kCGImageAlphaNoneSkipLast |kCGBitmapFloatComponents
128 bits per pixel, 32 bits per component, kCGImageAlphaPremultipliedLast |kCGBitmapFloatComponents
See Quartz 2D Programming Guide (available online) for more information.

이미지가 8bpc, 32bpp 그리고 kCGImageAlphaLast 라는 형태로 설정되어 있는데 이런 조합의 이미지로는 CoreGraphics의 CGContext를 만들 수 없다는 이야기였습니다. 난감했습니다. 다시 검색을 했죠. 이번에는 UIKit으로 리사이징 했을 때 왜 이미지 사이즈가 늘어나는지에 주안점을 두고 검색을 했습니다. 그때까지만 해도 이미지 리사이징을 통해 이미지가 원본과 달라질 수 있다는 것은 전혀 생각하지 못했습니다.

해결의 실마리: 듣보잡 vImage? 진짜가 나타났다

위의 퍼포먼스 비교 글과 비슷한 뉘앙스의 글을 찾았는데 애플에서 제공하는 프레임워크들을 비교해 놓은 것은 똑같았지만 이번에는 이미지의 퀄리티에 초점이 맞춰져 있었습니다. Resizing Techniques and Image Quality That Every iOS Developer Should Know. 제목이 "모든 iOS 개발자가 알아야 하는..." 이라니 꼭 읽어봐야만 할 것 같더라구요. 이 글을 읽으면서 CoreGraphics를 사용했을 때 이미지가 블러처리한 것처럼 선명도가 떨어지고 CoreImage를 사용했을 때 이미지가 더 밝고 선명도가 높아지는(sharpen) 효과가 나타나는 것을 보고 적잖이 놀랐습니다. 마치 포토샵으로 필터를 건 것과 같은 효과라니! 이 글을 통해 이미지 리사이징 과정에서 원본의 손실과 왜곡이 일어난다는 당연하지만 새삼스러운 사실과 그 수준이 생각보다 크다는 사실을 깨닫게 되었습니다.

이 글의 결론은 (저자에게도 의외였던 듯한데) 문서화도 제대로 안되어 있는 vImage가 가장 탁월한 결과를 보여줬다는 것입니다. vImage가 UIKit, CoreGraphics, CoreImage에 비해 에러율이 월등히 낮았다고 합니다. 심지어 포토샵으로 리사이징 한 것보다도 원본과 유사성이 가장 높았다고 하네요. Wow~! 이글을 보기전까지는 들어보지도 못했고 이름으로 봐도 애플에서 공식으로 제공하는 것이라고는 생각이 들지 않는 이 녀석은 오픈소스가 아니라 엄연히 애플에서 제공되는 Accelerate 프레임워크에 들어있었습니다.

바로 적용해 보았습니다.

    public func resized(to targetSize: CGSize) -> UIImage? {
        guard let cgImage = self.cgImage else { return self }

        var format = vImage_CGImageFormat(bitsPerComponent: UInt32(cgImage.bitsPerComponent), bitsPerPixel: UInt32(cgImage.bitsPerPixel), colorSpace: nil,
                                          bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.first.rawValue),
                                          version: 0, decode: nil, renderingIntent: CGColorRenderingIntent.defaultIntent)
        var sourceBuffer = vImage_Buffer()
        defer {
            sourceBuffer.data.deallocate()
        }

        var error = vImageBuffer_InitWithCGImage(&sourceBuffer, &format, nil, cgImage, numericCast(kvImageNoFlags))
        guard error == kvImageNoError else { return self }
        
        // create a destination buffer
        let destWidth = Int(targetSize.width)
        let destHeight = Int(targetSize.height)
        let bytesPerPixel = cgImage.bitsPerPixel / 8
        let destBytesPerRow = destWidth * bytesPerPixel
        let destData = UnsafeMutablePointer<UInt8>.allocate(capacity: destHeight * destBytesPerRow)
        defer {
            destData.deallocate()
        }

        var destBuffer = vImage_Buffer(data: destData, height: vImagePixelCount(destHeight), width: vImagePixelCount(destWidth), rowBytes: destBytesPerRow)

        // scale the image
        error = vImageScale_ARGB8888(&sourceBuffer, &destBuffer, nil, numericCast(kvImageHighQualityResampling))
        guard error == kvImageNoError else { return self }

        // create a CGImage from vImage_Buffer
        let destCGImage = vImageCreateCGImageFromBuffer(&destBuffer, &format, nil, nil, numericCast(kvImageNoFlags), &error)?.takeRetainedValue()
        guard error == kvImageNoError else { return self }

        // create a UIImage
        let resizedImage = destCGImage.flatMap { UIImage(cgImage: $0, scale: 0.0, orientation: self.imageOrientation) }
        return resizedImage
    }

뭔가 함수 이름도 이상하고 메모리 해제도 직접해줘야 하는 것이 Swift 스럽지 않은 코드가 되었지만 결과는 좋았습니다. 모든 이미지들이 잘 리사이징 되었고 데이터 사이즈도 잘 줄어들었습니다. 게다가 이미지 퀄리티도 원본과 가장 유사하다고 하니 코드가 보기 싫은 것은 참아줄만 한 것 같네요.

마무리: vImage 괜찮을까?

UIImage extension의 리사이징 메소드가 앱 전체에서 엄청나게 많이 사용되고 있는 코드는 아니었지만 그래도 앱 전반에 걸쳐 사용되려면 성능이 보장되어야 하겠죠. 그런데 위에 링크했던 성능 비교글에서는 vImage를 고성능 저전력 low-level API라고 소개하면서도, 고해상도 JPEG 이미지의 경우에는 무슨 이유인지 vImage의 측정결과를 제공하지 않고 있고, 적당한 크기의 PNG 이미지에서도 나쁘지는 않지만 썩 좋은 결과를 보여주지도 못하고 있더군요. 결론에서도 프로젝트에서 이미 사용하고 있는 게 아니라면 굳이 성능을 위해 vImage로 전환해서 얻는 이득은 없다고 말하고 있습니다. 그리고 이미지 퀄리티 비교글에서도 애플 공식문서에 언급된 vImage의 단점을 소개하고 있습니다. vImage가 Lanczos5라는 리샘플링 필터를 사용하는데 이 리샘플링 방법이 링잉 효과를 발생시킬 수 있다는 이야기입니다.

The Lanczos resampling method usually produces better-looking results than simpler approaches such as linear interpolation. However, the Lanczos method can produce ringing effects near regions of high frequency signals (such as line art).

참고로 여기서 얘기한 ringing effect는 아래와 같은 현상인가봐요.

(출처: http://imaging.cs.msu.ru/en/research/ringing)

하지만 성능 면에서 다른 방법보다 많이 떨어진다는 이야기가 아니라 큰 이득은 없다는 정도이고 ringing effect도 눈에 띌 정도로 발견된 건 없어서 일단 적용했습니다. 그래서 UIImageView와 UIButton estension에 imageSize를 파라미터로 받는 setImage 메소드가 있었는데 여기에 imageSize 파라미터가 입력되면 UIImage extension의 resized(to targetSize:) 메소드가 사용되도록 하였습니다.

리뷰: 그러나 현실은...

이후 테스트 중에 라인이 많이 들어간 특정 이미지에서 링잉 이펙트와 비슷하게 이미지의 해상도가 떨어져 보이는 현상이 발견되었습니다. 그래서 다시 기존의 로직을 복원하여 UIImageView와 UIButton의 setImage 메소드에서는 기존 로직을 사용하도록 하고 애초 문제가 있었던 부분만 vImage_resized 메소드를 추가하여 vImage를 사용한 리사이즈 로직을 사용하도록 분리하였습니다.

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.