본문 바로가기

Swift

여러장의 사진으로 동영상 만들기 - pixelBufferFromCGImage의 이해

여러 장의 사진을 이용해서 동영상을 만드는 앱을 만들고 있다. 처음 만들어 보는 앱이라 인터넷에서 떠도는 소스를 짜집기하는 수준이다.


현재까지 파악된 사진을 이용하여 동영상을 만드는 방법은 AVAssetWriter를 이용해서 만드는 것이다.

그리고 AVAssetWriter를 이용하려면 CVPixcelBuffer라는 것을 이용해야만 하는 것 같다.


인터넷에 떠도는 소스는 대개 Objective-C로 만들어진 것이라 Swift로 변환해야만 했는데 이것 역시 쉬운게 아니었다. 특히 난 C의 포인터 개념도 약해서 혼자 힘으로 Swift로 변환하는 것은 무리였다.


아무튼 여기저기 서핑한 끝에 가장 잘 알려진 pixelBufferFromCGImage라는 메소드를 찾을 수 있었고 다른 여러 사람들도 이 소스를 이용해서 동영상 만드는 것을 시도하고 있었다.


나도 이 메소드를 이용해서 동영상을 만들다가 겪었던 점과 해결 방법을 여기에 기록하고자 한다.



개요


동영상을 만들기 위해서는 CVPixelBuffer에 매 프레임의 이미지를 넣고 이 Buffer들을 Append해서 만들게 된다.

즉, 첫 번째로 CVPixelBuffer에 프레임 이미지를 채우는 과정이 필요하고 두 번째로 프레임 수 만큼 반복해서 Buffer들을 Append시키는 것이다.


여기서 두 번째 과정인 Buffer들을 Append시키는 과정은 여기서 설명할 pixelBufferFromCGImage 메소드의 범위에서 벗어나므로 간단하게 Append 과정의 문제점과 해결점을 언급하고 넘어가겠다.


인터넷에서는 이 Append 과정에서 Memory Leak 이나 Memory Usage가 급격하게 올라간다는 질문글들을 심심찮게 볼 수 있다. 나도 하다 보니 이 현상을 직접 겪었고 해결하기 위해 며칠을 허비하였으나 실제로 이 질문에 적절한 대답은 거의 찾을 수 없었다.

그러다가 비슷한 소스인데 autoreleasepool()를 사용한 소스를 보게 되었고 이걸 사용한 순간 Memory Leak이나 Memory Usage가 급격하게 올라가는 현상도 사라지는 것을 확인하였다. 아마도 Apple에서 제공하는 어떤 소스에서 보았던 것 같다.

Swift의 특징까지 얘기하자면 길어지니 다음 글에서 다루기로 하고 여기서는 이 정도만 적는다.


그럼 첫 번째 과정에 대해서 알아보자. 사실 첫 번째 과정에서 발생하는 오류들이 몇 가지 있는데 거의 다 해결한 상태이지만 아직 해결하지 못 한 것들도 있다.


pixelBufferFromCGImage 메소드에서는 다음과 같은 순서로 버퍼에 내용을 채운다.

  1. PixcelBuffer 포인터를 선언한다.
  2. CVPixelBufferCreate() 메소드를 이용하여 PixelBuffer를 생성한다.
  3. PixcelBuffer에 이미지를 채우기(?) 위해서 Context에 이미지를 그려야 한다. 즉 Context에 이미지를 그리고 이 Context를 PixcelBuffer에 넣는다(? 이 표현이 맞나 모르겠다. 나는 이렇게 이해했다)
  4. Context에 이미지를 그리려면 Context를 만들어야 한다. CGBitmapContextCreate()를 이용하여 Context를 만든다.
  5. CGContextDrawImage() 메소드를 이용하여 Context에 이미지를 그린다. 사실은 준비된 사진을 Context에 복사하는 것이 더 정확한 표현일 것이다. 아무튼 메소드 이름이 draw니까 그린다고 해 두자.

소스는 아래와 같다.

SamplePixelBuffer.zip


계속 테스트 중이라 코드가 좀 지저분하다.

    func pixelBufferFromImage(image: UIImage) {
        var outputSize: CGSize
        
        outputSize = CGSizeMake(image.size.width, image.size.height)
        
        debugPrint("arrayImages[0] = \(image)")
        debugPrint("outputSize = \(outputSize)")
        
        let tempPath = NSTemporaryDirectory().stringByAppendingString("temp.mp4")
        do {
            try NSFileManager.defaultManager().removeItemAtPath(tempPath)
            debugPrint("removeItemAtPath = success..")
        }
        catch {
            debugPrint("removeItemAtPath = error occured...")
        }
        // UIImage를 쉽게 CGImage로 바꿀 수 없다는 것을 알았다. 아래의 과정을 거쳐야만 한다. 검은 화면이 나오는 것도 다 이런 이유였다.
        let ciimage = CIImage(image: image)
        let cgimage = convertCIImageToCGImage(ciimage!)

        // 아래 주석문의 Objective-C 구문을 swift로 변경하기 위해 이렇게 기나긴 코드를 작성해야 하다니!!
        // 오죽하면 아래 코드의 원작자도 stupid라는 주석을 달아놓았다!! ㅋㅋ
        /*
         NSDictionary *options = @{(id)kCVPixelBufferCGImageCompatibilityKey: @YES,
         (id)kCVPixelBufferCGBitmapContextCompatibilityKey: @YES};
        */
        // stupid CFDictionary stuff
        let keys: [CFStringRef] = [kCVPixelBufferCGImageCompatibilityKey, kCVPixelBufferCGBitmapContextCompatibilityKey]
        let values: [CFTypeRef] = [kCFBooleanTrue, kCFBooleanTrue]
        let keysPointer = UnsafeMutablePointer>.alloc(1)
        let valuesPointer =  UnsafeMutablePointer>.alloc(1)
        keysPointer.initialize(keys)
        valuesPointer.initialize(values)
        //let options = CFDictionaryCreate(kCFAllocatorDefault, keysPointer, valuesPointer, keys.count, UnsafePointer(), UnsafePointer())
        // 원래 위 코드였는데 swift3에서 변경되었다고 한다.
        let options = CFDictionaryCreate(kCFAllocatorDefault, keysPointer, valuesPointer, keys.count, nil, nil)
        // 여기까지가 CFDictionary를 위한 코드

        let width = CGImageGetWidth(cgimage)
        let height = CGImageGetHeight(cgimage)
        
        let pxbuffer = UnsafeMutablePointer.alloc(width * height)
        // pxbuffer = nil 할 경우 status = -6661 에러 발생한다.
        var status = CVPixelBufferCreate(kCFAllocatorDefault, width, height,
                                         kCVPixelFormatType_32ARGB, options, pxbuffer)
        debugPrint("status = \(status)")
        status = CVPixelBufferLockBaseAddress(pxbuffer.memory!, 0);
        
        let bufferAddress = CVPixelBufferGetBaseAddress(pxbuffer.memory!);
        
        
        let rgbColorSpace = CGColorSpaceCreateDeviceRGB();
        let context = CGBitmapContextCreate(bufferAddress, width,
                                            height, 8, 4 * width, rgbColorSpace,
                                            CGImageAlphaInfo.NoneSkipFirst.rawValue);
        //debugPrint("image = \(image)")
        CGContextDrawImage(context, CGRectMake(0, 0, CGFloat(width), CGFloat(height)), cgimage);
        

        // context에 그림이 제대로 그려졌는지 이미지로 변경하여 확인
        if let contextImage = CGBitmapContextCreateImage(context) {
            let checkImage1 = UIImage.init(CGImage: contextImage)
            let parentVC = sender as! ViewController
            //parentVC.animatedImageView.image = checkImage1
            
            let checkImage2 = CIImage.init(CVPixelBuffer: pxbuffer.memory!)
            parentVC.imageview.image = UIImage.init(CIImage: checkImage2)
            
            // 아래와 같이 비동기 방식을 이용하면 더 저장이 안 된다.
            //dispatch_async(dispatch_get_main_queue()) {
            
            // 이렇게 해도 카메라롤 가면 9장 저장 날렸는데 3~4장 밖에 저장이 안 된다.
            //UIImageWriteToSavedPhotosAlbum(checkImage, nil, nil, nil)
            //debugPrint("save..")
        }
        else {
            debugPrint("why context is null?")
        }

        
        status = CVPixelBufferUnlockBaseAddress(pxbuffer.memory!, 0);
        

        //return pxbuffer
        
    }
    
    func convertCIImageToCGImage(inputImage: CIImage) -> CGImage! {
        let context = CIContext(options: nil)
        
        return context.createCGImage(inputImage, fromRect: inputImage.extent)

    }


그런데 이 과정에서 이상한 현상이 발생되었다.

분명 에러가 발생하지 않는 코드를 만들어내었는데 정작 실행시키면 검은 화면만 나오는 것이다. 뭐가 문제인지 도저히 감이 오지 않았다. 웹에서도 해결책을 찾을 수 없었다. 그래서 pixelBufferFromCGImage 메소드의 과정 매 단계에서 생성되는 이미지들을 일일이 확인해보기로 했다.




원인 1. UIImage에서 CGImage로의 형변환


웹에서 돌아다니는 소스는 UIImage를 바로 CGImage로 형변환을 한다. 실제로 이렇게 형변환을 해도 에러가 발생하지 않는다. 그 이유는 UIImage가 겉모습은 NSArray 형태이기 때문에 NSArray에서 CGImage로 변하는 것이라 에러가 발생하지 않는 것이었다.


그러다가 알게 된 것이 Swift에서는 UIImage를 바로 CGImage로 변환할 수 없다는 것이었다. 이 내용은 별도의 글로 작성해 두었다. Swift: Convert between CGImage, CIImage and UIImage 글을 참조하자.


실제로 이 글대로 테스트를 해 보니까 검은 화면이 나오는 것은 해결할 수 있었다. 드디어 여러가지 색으로 된 그림이 보이기 시작했다.

그러나 내가 원하는 그림은 아니었다. 그림을 자세히 보니 내가 원하는 그림이 매우 심하게 비틀어져 있음을 알게 되었다.

비틀어져 있는 것은 알겠는데 왜 비틀어진 것인지 알 수 없었다.


Context를 이미지로 변환하여 출력해보면 제대로 나오는 것을 확인할 수 있었으나 이 Context가 연결된 PixcelBuffer를 이미지로 변환하여 출력하면 비틀어져 나왔다.


아래 그림 중 왼쪽이 원래 이미지이고 오른쪽이 비틀어진 이미지이다.

비틀어진 이미지를 잘 보면 뭐가 문제인지는 감이 온다.

비틀어진 이미지를 보면 1센티 정도 간격으로 층이 진 것을 볼 수 있는데 이것은 실제 그림이 그려지는 종이의 크기와 종이가 보여지는 창의 크기가 달라서 생기는 것임을 느낌적인 느낌으로 알 수 있을 것이다.

PixelBuffer는 일종의 포인터이므로 실제 이미지를 가지고 있는 것이 아니라 각 Pixel의 나열된 정보만 가지고 있다. 물론 PixelBuffer에 width, height 정보가 들어있으므로 이 정보를 적절히 조정하면 제대로 된 그림이 그려질 것이라는 생각이다.


1시간 가량 테스트를 진행하다가 딱히 이상한 것은 잡히지 않아 이것 저것 출력창에 찍어보다가 pxbuffer.memory를 우연히!!! 찍어보게 되었다. 그리고 이렇게 나오는 이유를 알게 되었다.

output 출력창에 보면 width=400 height=100 bytesperRow=448 이라고 찍힌 것이 나온다. 일반적으로 bytesPerRow는 4 * width이다. 하나의 픽셀을 표현하는데 4바이트가 필요하기 때문이다. 그런데 448이니 이것은 4로 나누면 width=112인 것으로 환산되는 것이며 12픽셀만큼씩 엉뚱한데 픽셀이 그려지게 되어 결과적으로 이미지가 비틀어진 것이다.


원인은 알았는데 이걸 어떻게 바로잡느냐가 문제다.

아무리 보아도 소스 상에서는 CVPixelBufferCreate의 인자 중 options 말고는 바로잡을 곳이 없기 때문이다. 그런데 이 options는 어떻게 손을 대야 할지 전혀 감이 안 온다. 흠냐리~


(다음 날..)

드디어 해결 방법을 찾아내었다.

새로 생성된 PixelBuffer의 bytesPerRow를 변경하는 방법은 찾아낼 수 없었다.

그래서 그 다음에 만드는 Context의 bytesPerRow와 같은 값을 주면 어떨까 하는 생각에 실험을 해 보았다. 그랬더니 와우! 제대로 나오는 것이다.


그러나 다음 문제..

원본 이미지의 크기에 따라 PixelBuffer의 bytesPerRow의 값이 제각각이었다. 허나 여기저기 찾아보니 CVPixelBufferGetBytesPerRow라는 메소드가 있다는 것을 알게 되었다. 이 메소드로 pixelBuffer의 bytesPerRow를 알아내서 Context에 적용만 하면 되는 것이었다.


그래서 이 문제는 해결 완료!


(며칠 후 업데이트)

애플 개발자 홈피에서 다음과 같은 글을 발견했다.

출처 : https://developer.apple.com/library/ios/qa/qa1829/_index.html#//apple_ref/doc/uid/DTS40014453


Technical Q&A QA1829

Understanding the bytes per row value returned by CVPixelBufferGetBytesPerRow

Q:  Why does CVPixelBufferGetBytesPerRow not always return a value equal to the width of the pixel buffer multiplied by the bytes per pixel?

A: There are differences in the hardware alignment requirements between the various hardware platforms. The CVPixelBufferGetBytesPerRow function will correctly report the buffer alignment (stride) being used by the particular hardware. 

You should always write your code to account for any padding so that it works across all platforms. Do not assume the buffer alignment will be the same on different hardware. Pay attention to the bytes per row when you are processing each row of data.



아래 코드가 최종 코드이다.


//
//  File.swift
//  BurstAnimator
//
//  Created by SeoDongHee on 2016. 5. 2..
//  Copyright © 2016년 SeoDongHee. All rights reserved.
//

import Foundation
import AVFoundation
import UIKit

class ImagesToVideo {
    
    var sender: AnyObject
    
    init(sender: AnyObject) {
        self.sender = sender
    }
    
    
    func pixelBufferFromImage(image: UIImage) {
        var outputSize: CGSize
        
        outputSize = CGSizeMake(image.size.width, image.size.height)
        
        debugPrint("arrayImages[0] = \(image)")
        debugPrint("outputSize = \(outputSize)")
        
        let tempPath = NSTemporaryDirectory().stringByAppendingString("temp.mp4")
        do {
            try NSFileManager.defaultManager().removeItemAtPath(tempPath)
            debugPrint("removeItemAtPath = success..")
        }
        catch {
            debugPrint("removeItemAtPath = error occured...")
        }
        // It's not easy that "Change UIImage to CGImage", if you would try even to change NSArray to CGImage, you get black screen finally. so you have to step below.
        let ciimage = CIImage(image: image)
        let cgimage = convertCIImageToCGImage(ciimage!)

        /*
         NSDictionary *options = @{(id)kCVPixelBufferCGImageCompatibilityKey: @YES,
         (id)kCVPixelBufferCGBitmapContextCompatibilityKey: @YES};
        */
        // stupid CFDictionary stuff
        let cfnumPointer = UnsafeMutablePointer>.alloc(1)
        let cfnum = CFNumberCreate(kCFAllocatorDefault, .IntType, cfnumPointer)
        let keys: [CFStringRef] = [kCVPixelBufferCGImageCompatibilityKey, kCVPixelBufferCGBitmapContextCompatibilityKey, kCVPixelBufferBytesPerRowAlignmentKey]
        let values: [CFTypeRef] = [kCFBooleanTrue, kCFBooleanTrue, cfnum]
        let keysPointer = UnsafeMutablePointer>.alloc(1)
        let valuesPointer =  UnsafeMutablePointer>.alloc(1)
        keysPointer.initialize(keys)
        valuesPointer.initialize(values)

        let options = CFDictionaryCreate(kCFAllocatorDefault, keysPointer, valuesPointer, keys.count, nil, nil)


        let width = CGImageGetWidth(cgimage)
        let height = CGImageGetHeight(cgimage)
        
        let pxbuffer = UnsafeMutablePointer.alloc(1)
        // if pxbuffer = nil, you will get status = -6661
        var status = CVPixelBufferCreate(kCFAllocatorDefault, width, height,
                                         kCVPixelFormatType_32ARGB, options, pxbuffer)
        debugPrint("status = \(status)")
        status = CVPixelBufferLockBaseAddress(pxbuffer.memory!, 0);
        
        let bufferAddress = CVPixelBufferGetBaseAddress(pxbuffer.memory!);
        debugPrint("pxbuffer.memory = \(pxbuffer.memory)")
        
        let rgbColorSpace = CGColorSpaceCreateDeviceRGB();
        //debugPrint("rgbColorSpace = \(rgbColorSpace)")
        let bytesperrow = CVPixelBufferGetBytesPerRow(pxbuffer.memory!)
        let context = CGBitmapContextCreate(bufferAddress, width,
                                            height, 8, bytesperrow, rgbColorSpace,
                                            CGImageAlphaInfo.NoneSkipFirst.rawValue);
        //debugPrint("context = \(context.debugDescription)")
        CGContextDrawImage(context, CGRectMake(0, 0, CGFloat(width), CGFloat(height)), cgimage);
        

        // check context
        if let contextImage = CGBitmapContextCreateImage(context) {
            let checkImage1 = UIImage.init(CGImage: contextImage)
            let parentVC = sender as! ViewController
            //parentVC.imageview.image = checkImage1
            
            let checkImage2 = CIImage.init(CVPixelBuffer: pxbuffer.memory!)
            parentVC.imageview.image = UIImage.init(CIImage: checkImage2)
            
            //UIImageWriteToSavedPhotosAlbum(checkImage1, nil, nil, nil)
            //debugPrint("save..")
        }
        else {
            debugPrint("why context is null?")
        }

        status = CVPixelBufferUnlockBaseAddress(pxbuffer.memory!, 0);
        
    }
    
    func convertCIImageToCGImage(inputImage: CIImage) -> CGImage! {
        let context = CIContext(options: nil)
        
        return context.createCGImage(inputImage, fromRect: inputImage.extent)

    }

    /*
    // below is for debug, it's not working
    func drawOutput(pixelbuffer: UnsafeMutablePointer, width: Int, height: Int) {
        let pixels = pixelbuffer
        for var ii in 0...height {
            for var jj in 0...width {
                let color = pixels.memory! as Int
                print("\(r8(color)+g8(color)+b8(color)/3.0)")
                pixels ++
            }
            print("\n");
        }
    }
    func mask8(int: Int) -> Int {
        return int & 0xFF
    }
    func r8(int: Int) -> Int {
        return mask8(int)
    }
    func g8(int: Int) -> Int {
        return mask8(int) >> 8
    }
    func b8(int: Int) -> Int {
        return mask8(int) >> 16
    }
    */
    
}