본문 바로가기
이모저모/AVFoundation

AVFoundation - 영상을 이미지 배열로 저장 시 메모리 초과되는 이슈, Accelerate

by ARpple 2025. 3. 27.

문제 원인: 너무 많은 메모리 사용

이 이벤트(네 컷 영상 추출하러 가기)를 누르면 크게 3가지 일련의 작업을 처리한다.

  1. 각각 4개의 영상에서 프레임별 이미지 추출 (1초 당, 24개의 이미지) 후 CGImage로 저장 ⇒ [[CGImage]]
  1. 각 영역별 이미지를 하나의 이미지로 합성
    (CGImage[0][0], CGImage[0][1],CGImage[0][2],CGImage[0][3]) ⇒ 합성된 CGImage[0]
  2. 이미지를 영상으로 변환

문제의 원인: 영상을 이미지 배열로 변환하는 부분

⇒ 여기에서 갑자기 메모리를 2GB나 사용하는 것을 확인

ExtractService의 extractFrameImages() 메서드

struct AVAssetContainer:Identifiable {
    var id: String
    let idx: Int
    let minDuration:Float
    let originalAssetURL:String
}



class ExtractService{
    var avAssetContainers: [AVAssetContainer] = []
    var minDuration: Double = 0.47
    var frameCounts:Int{ avAssetContainers.count }
    private let fps:Double = 24
    
    func extractFrameImages() async throws -> [[CGImage]] {
        guard !avAssetContainers.isEmpty else { throw ExtractError.emptyContainer }
        let imageDatas: [[CGImage]] = try await withThrowingTaskGroup(of: (Int,[CGImage]).self) { taskGroup in
            for (offset,v) in avAssetContainers.enumerated(){
                taskGroup.addTask {[minDuration,fps] in
                    let asset = AVAsset(url: URL(string: v.originalAssetURL)!)
                    let generator = AVAssetImageGenerator(asset: asset)
                    generator.appliesPreferredTrackTransform = true
                    generator.requestedTimeToleranceBefore = .init(seconds: Double(1 / (fps * 2)), preferredTimescale: 600)
                    generator.requestedTimeToleranceAfter = .init(seconds: Double(1 / (fps * 2)), preferredTimescale: 600)
                    var imageDatas:[CGImage] = []
                    var lastImage: CGImage!
                    let time = CMTime(seconds: 0, preferredTimescale: 600)
                    let imgContain: (image: CGImage, actualTime: CMTime) = try await generator.image(at: time)
                    imageDatas.append(imgContain.image)
                    lastImage = imgContain.image
                    for idx in (1..<Int(minDuration * fps)){
                        let time = CMTime(seconds: Double(idx) / 24, preferredTimescale: 600)
                        let imgContain = try? await generator.image(at: time)
                        if let imgContain{ 
                            imageDatas.append(imgContain.image)
                            lastImage = imgContain.image
                        }
                        else{ imageDatas.append(lastImage) }
                    }
                    return (offset,imageDatas)
                }
            }
            var imageContainers: [[CGImage]] = Array(repeating:[], count: frameCounts)
            for try await imageDatas in taskGroup{ imageContainers[imageDatas.0] = imageDatas.1 }
            return imageContainers
        }
        return imageDatas
    }
}

⇒ Concurrency 병렬 처리를 이용한다.

  1. 각각의 AVAssetContainer 를 병렬로 처리한다.
  2. 각 Task는AVAssetContainer는 영상의 주소를 통해 AVAsset을 가져온다.
  3. 가장 짧은 영상을 기준으로 프레임 이미지 배열의 length를 정한다.
  4. 각 Asset을 AVAssetImageGenerator 를 이용하여 프레임 단위의 CGImage를 가져온다.
  5. 각 Task 별로 생성된 CGImage 배열을 하나로 합쳐 2차원 배열을 만든다. CGImage[4][N]

➕ CPU 이용률

➕ 작업 속도


# 해결방법 1 - 이미지를 파일에 담기

extractFrameImages() 로직을 각각의 이미지를 tempDirectory에 저장해서 이미지를 저장한 경로를 반환한다.

실행 결과

메모리 사용량

✅ 2.5GB ⇒ 160MB → 90% 이상의 메모리 효율 상승

메서드 실행 시간

❌ 1521ms ⇒ 7955ms → 실행 시간 4배 이상 증가…

 

➕ 디스크 사용량과 CPU 사용량


# 해결방법 2 - DownSampling 이용하기

기존에 작성한 코드를 보면 AVAssetImageGenerator를 통해 이미지를 가져오면 그 이미지를 그대로 쓰고 있었다..!

class ExtractService{
	func extractFrameImages() async throws -> [[CGImage]] {
	...
		let imageDatas: [[CGImage]] = try await withThrowingTaskGroup(of: (Int,[CGImage]).self) { taskGroup in
	...
      let imgContain: (image: CGImage, actualTime: CMTime) = try await generator.image(at: time)
      imageDatas.append(imgContain.image)
      lastImage = imgContain.image
	}
}

imgContain.image 의 CGImage의 크기를 줄이자

⇒ 어차피 한 영역만 차지하기 때문에 큰 픽셀의 이미지일 필요는 없다.

#1 CIImage를 이용해 이미지 줄이기

class ExtractService{
	func extractFrameImages() async throws -> [[CGImage]] {
	...
	let imageDatas: [[CGImage]] = try await withThrowingTaskGroup(of: (Int,[CGImage]).self) { taskGroup in
	...
      let imgContain: (image: CGImage, actualTime: CMTime) = try await generator.image(at: time)
      let downImage = self.downSample(image: imgContain.image)
      imageDatas.append(downImage)
      lastImage = downImage
		}
	}
}

extension ExtractService {
	func downSample(image: CGImage) -> CGImage {
      let ciImage = CIImage(cgImage: image)
      let targetWidth : CGFloat = 360
      let scale = targetWidth / ciImage.extent.width
      let targetHeight = ciImage.extent.height * scale
      let scaleSize = CGSize(width: targetWidth, height: targetHeight)
      let transformedCIImage: CIImage = ciImage.transformed(by: .init(scaleX: scale, y: scale), highQualityDownsample: true)
      let context = CIContext()
      let afterDownsamplingImage: CGGimage = context.createCGImage(transformedCIImage, from: .init(origin: .zero, size: scaleSize))!
      return afterDownsamplingImage
  }
}

⇒ 목표 너비를 360 픽셀로 지정한다.

⇒ CIImage는 AffineTransform을 할 수 있으므로 scale 배율 변화하는 transform 메서드를 적용해서 배율을 줄인다.

⇒ 이 너비로 줄이기 위한 Scale을 지정해서 적용한다.

#1 실행 결과

메모리 사용량

✅ 2.5GB ⇒ 267MB → 88% 이상의 메모리 효율 상승

메서드 실행시간

⚠️ 1521ms ⇒ 3304ms → 실행 시간 2배 이상 증가…

 

➕ 이미지 압축률 ⇒ ✅ 약 90% 이상 DownSampling 달성

➕ CPU 사용량


#2 vImage를 이용해 이미지 줄이기

💡 GPT가 갑자기 이런 방법을 쓰라고 멋대로 적었다..!

Accelerate라는 프레임워크를 더 찾아봤으며, 이 프레임워크는 CPU에 직접 대규모 연산을 빠르게 처리하는 명령어를 통해 속도를 향상시키는데 사용한다고 한다…

⇒ 대규모 연산 중 백터(배열)과 관련된 처리를 빠르게 할 수 있다.

✅ Down Sampling과 관련된 Article이 있었고 여기 나온 코드들을 약간 수정해서 적용했다.

 

 

Building a Basic Image-Processing Workflow | Apple Developer Documentation

Resize an image with vImage.

developer.apple.com

 

▶️ 적용 코드

class ExtractService{
	func extractFrameImages() async throws -> [[CGImage]] {
	...
		let imageDatas: [[CGImage]] = try await withThrowingTaskGroup(of: (Int,[CGImage]).self) { taskGroup in
		...
        	let imgContain: (image: CGImage, actualTime: CMTime) = try await generator.image(at: time)
	        let downImage = self.downsampleVImage(image: imgContain.image)
    	    imageDatas.append(downImage)
	        lastImage = downImage
		}
	}
}

extension ExtractService {
	func downsampleVImage(image: CGImage, targetSize: CGSize = .init(width: 300, height: 300*1.77)) -> CGImage {
        guard let format = vImage_CGImageFormat(
            bitsPerComponent: 8,
            bitsPerPixel: 32,
            colorSpace: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
            bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue),
            renderingIntent: .defaultIntent
        ) else {
            return image
        }
        do {
            var sourceBuffer = try vImage_Buffer(cgImage: image, format: format)
            var destinationBuffer = vImage_Buffer()
            defer {
                free(destinationBuffer.data) // 메모리를 직접 해제해줘야함!!
                free(sourceBuffer.data) // 메모리를 직접 해제해줘야함!!
            }
            // 이미지 크기 조절 알고리즘
            let scaleX = targetSize.width / CGFloat(image.width)
            let scaleY = targetSize.height / CGFloat(image.height)
            let scale = min(scaleX, scaleY)
            let destWidth = Int(CGFloat(image.width) * scale)
            let destHeight = Int(CGFloat(image.height) * scale)
            let bytesPerPixel = 4 // rgba를 대응하기 1바이트당 하나씩
            destinationBuffer.width = UInt(destWidth)
            destinationBuffer.height = UInt(destHeight)
            destinationBuffer.rowBytes = destWidth * bytesPerPixel // 한 열의 bytes
            destinationBuffer.data = malloc(destHeight * destinationBuffer.rowBytes) // 데이터 할당한다.
            // openCV에서 scaling을 해주는 것과 비슷한 메서드
            vImageScale_ARGB8888(&sourceBuffer, &destinationBuffer, nil, vImage_Flags(kvImageHighQualityResampling))
            let destCGImage = try destinationBuffer.createCGImage(format: format)
            return destCGImage
        } catch {
            return image
        }
    }
}

#2 실행 결과

메모리 사용량

✅ 2.5GB ⇒ 126.7 → 약 95%의 메모리 사용량 감소

실행 시간

✅ 1521ms ⇒ 1458ms → 실행 시간 감소
ps. 현재 기기의 상태에 따라 다를 수 있다… 암튼, 실행 시간이 거의 차이가 없다..!

 

➕ 이미지 압축률 ✅ 약 90% 이상 DownSampling 달성

 

 

➕ CPU 사용량

결론

⇒ 모든 기기에 vImage를 사용해 압축시키고 이미지 배열을 반환하는 방법 채택

Why?

  1. iOS 4.0 부터 지원하는 프레임워크, 앱 버전은 16.0 이상으로 모든 사용자가 Accelerate 프레임워크를 쓸 수 있다.
  2. 기존 방식보다 실행시간이 초 단위가 아닌 정도의 오차범위 내에 오히려 감소하는 모습을 보여줌…
  3. 디스크에 이미지를 저장하는 방식보다 메모리 효율이 더 좋음

실행 비교 표

  원본 이미지 배열 디스크에 이미지 저장 CIImage DownSample 배열 vImage DownSample 배열
메모리 사용량 2.5GB 131mb 267mb 126mb
실행 시간 1521ms 7955ms 3304ms 1458ms
CPU 사용량 262 326% 120% 5%

댓글