iOS開發中擷取相機部分畫面,切割sampleBuffer(Crop sample buffer)

小東邪發表於2018-02-23

iOS開發中擷取相機部分畫面,切割sampleBuffer(Crop sample buffer)

本例需求:在類似直播的功能介面,二維碼掃描,人臉識別或其他需求中的功能介面或其他需求中需要從相機捕獲的畫面中單獨擷取出一部分割槽域。


原理:由於需要擷取相機捕獲整個畫面其中一部分,所以也就必須拿到那一部分畫面的資料,又因為相機AVCaptureVideoDataOutputSampleBufferDelegate中的sampleBuffer為系統私有的資料結構不可直接操作,所以需要將其轉換成可以切割的資料結構再進行切割,網上有種思路說將sampleBuffer間接轉換為UIImage再對圖片切割,這種思路繁瑣且效能低,本例將sampleBuffer轉換為CoreImage中的CIImage,效能相對較高且降低程式碼繁瑣度。


最終效果如下, 綠色框中即為截圖的畫面,長按可以移動。

綠色框為擷取部分

GitHub地址(附程式碼) : Crop sample buffer

簡書地址 : Crop sample buffer

部落格地址 : Crop sample buffer

掘金地址 : Crop sample buffer


注意:使用ARC與MRC下程式碼有所區別,已經在專案中標註好,主要為管理全域性的CIContext物件,它在初始化的方法中編譯器沒有對其進行retain,所以,呼叫會報錯。

cicontextError


使用場景

  • 本專案中相機捕捉的背景解析度預設設定為2K(即1920*1080),可切換為4K ,所以需要iPhone 6s以上的裝置才支援。
  • 本例可以使用CPU/GPU切割,在VC中需要在cropView初始化前設定isOpenGPU的值,開啟則使用GPU,否則CPU
  • 本例只實現了橫屏下的Crop功能,本例預設始終為橫屏狀態,未做豎屏處理。

基本配置

1.配置相機基本環境(初始化AVCaptureSession,設定代理,開啟),在示例程式碼中有,這裡不再重複。

2.通過AVCaptureVideoDataOutputSampleBufferDelegate代理中拿到原始畫面資料(CMSampleBufferRef)進行處理

實現途徑

1.利用CPU軟體擷取(CPU進行計算並切割,消耗效能較大)

  • (CMSampleBufferRef)cropSampleBufferBySoftware:(CMSampleBufferRef)sampleBuffer;

2.利用 硬體擷取(利用Apple官方公開的方法利用硬體進行切割,效能較好, 但仍有問題待解決)

  • (CMSampleBufferRef)cropSampleBufferByHardware:(CMSampleBufferRef)buffer;

解析

// Called whenever an AVCaptureVideoDataOutput instance outputs a new video frame. 每產生一幀視訊幀時呼叫一次
- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
    CMSampleBufferRef cropSampleBuffer;
    
#warning 兩種切割方式任選其一,GPU切割效能較好,CPU切割取決於裝置,一般時間長會掉幀。
    if (self.isOpenGPU) {
         cropSampleBuffer = [self.cropView cropSampleBufferByHardware:sampleBuffer];
    }else {
         cropSampleBuffer = [self.cropView cropSampleBufferBySoftware:sampleBuffer];
    }
    
    // 使用完後必須顯式release,不在iOS自動回收範圍
    CFRelease(cropSampleBuffer);
}

複製程式碼
  • 以上方法為每產生一幀視訊幀時呼叫一次的相機代理,其中sampleBuffer為每幀畫面的原始資料,需要對原始資料進行切割處理方可達到本例需求。注意最後一定要對cropSampleBuffer進行release避免記憶體溢位而發生閃退。

利用CPU擷取

- (CMSampleBufferRef)cropSampleBufferBySoftware:(CMSampleBufferRef)sampleBuffer {
    OSStatus status;
    
    //    CVPixelBufferRef pixelBuffer = [self modifyImage:buffer];
    CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
    // Lock the image buffer
    CVPixelBufferLockBaseAddress(imageBuffer,0);
    // Get information about the image
    uint8_t *baseAddress     = (uint8_t *)CVPixelBufferGetBaseAddress(imageBuffer);
    size_t  bytesPerRow      = CVPixelBufferGetBytesPerRow(imageBuffer);
    size_t  width            = CVPixelBufferGetWidth(imageBuffer);
    // size_t  height           = CVPixelBufferGetHeight(imageBuffer);
    NSInteger bytesPerPixel  =  bytesPerRow/width;
    
    // YUV 420 Rule
    if (_cropX % 2 != 0) _cropX += 1;
    NSInteger baseAddressStart = _cropY*bytesPerRow+bytesPerPixel*_cropX;
    static NSInteger lastAddressStart = 0;
    lastAddressStart = baseAddressStart;
    
    // pixbuffer 與 videoInfo 只有位置變換或者切換解析度或者相機重啟時需要更新,其餘情況不需要,Demo裡只寫了位置更新,其餘情況自行新增
    // NSLog(@"demon pix first : %zu - %zu - %@ - %d - %d - %d -%d",width, height, self.currentResolution,_cropX,_cropY,self.currentResolutionW,self.currentResolutionH);
    static CVPixelBufferRef            pixbuffer = NULL;
    static CMVideoFormatDescriptionRef videoInfo = NULL;
    
    // x,y changed need to reset pixbuffer and videoinfo
    if (lastAddressStart != baseAddressStart) {
        if (pixbuffer != NULL) {
            CVPixelBufferRelease(pixbuffer);
            pixbuffer = NULL;
        }
        
        if (videoInfo != NULL) {
            CFRelease(videoInfo);
            videoInfo = NULL;
        }
    }
    
    if (pixbuffer == NULL) {
        NSDictionary *options = [NSDictionary dictionaryWithObjectsAndKeys:
                                 [NSNumber numberWithBool : YES],           kCVPixelBufferCGImageCompatibilityKey,
                                 [NSNumber numberWithBool : YES],           kCVPixelBufferCGBitmapContextCompatibilityKey,
                                 [NSNumber numberWithInt  : g_width_size],  kCVPixelBufferWidthKey,
                                 [NSNumber numberWithInt  : g_height_size], kCVPixelBufferHeightKey,
                                 nil];
        
        status = CVPixelBufferCreateWithBytes(kCFAllocatorDefault, g_width_size, g_height_size, kCVPixelFormatType_32BGRA, &baseAddress[baseAddressStart], bytesPerRow, NULL, NULL, (__bridge CFDictionaryRef)options, &pixbuffer);
        if (status != 0) {
            NSLog(@"Crop CVPixelBufferCreateWithBytes error %d",(int)status);
            return NULL;
        }
    }
    
    CVPixelBufferUnlockBaseAddress(imageBuffer,0);
    
    CMSampleTimingInfo sampleTime = {
        .duration               = CMSampleBufferGetDuration(sampleBuffer),
        .presentationTimeStamp  = CMSampleBufferGetPresentationTimeStamp(sampleBuffer),
        .decodeTimeStamp        = CMSampleBufferGetDecodeTimeStamp(sampleBuffer)
    };
    
    if (videoInfo == NULL) {
        status = CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault, pixbuffer, &videoInfo);
        if (status != 0) NSLog(@"Crop CMVideoFormatDescriptionCreateForImageBuffer error %d",(int)status);
    }
    
    CMSampleBufferRef cropBuffer = NULL;
    status = CMSampleBufferCreateForImageBuffer(kCFAllocatorDefault, pixbuffer, true, NULL, NULL, videoInfo, &sampleTime, &cropBuffer);
    if (status != 0) NSLog(@"Crop CMSampleBufferCreateForImageBuffer error %d",(int)status);
    
    lastAddressStart = baseAddressStart;
    
    return cropBuffer;
}

複製程式碼
  • 以上方法為切割sampleBuffer的物件方法 首先從CMSampleBufferRef中提取出CVImageBufferRef資料結構,然後對CVImageBufferRef進行加鎖處理,如果要進行頁面渲染,需要一個和OpenGL緩衝相容的影像。用相機API建立的影像已經相容,您可以馬上對映他們進行輸入。假設你從已有畫面中擷取一個新的畫面,用作其他處理,你必須建立一種特殊的屬性用來建立影像。對於影像的屬性必須有Crop寬高, 作為字典的Key.因此建立字典的關鍵幾步不可省略。

位置的計算

在軟切中,我們拿到一幀圖片的資料,通過遍歷其中的資料確定真正要Crop的位置,利用如下公式可求出具體位置,具體切割原理在[YUV介紹]中有提到,計算時所需的變數在以上程式碼中均可得到。

 `NSInteger baseAddressStart = _cropY*bytesPerRow+bytesPerPixel*_cropX;
    `
複製程式碼

注意:

  • 1.對X,Y座標進行校正,因為CVPixelBufferCreateWithBytes是按照畫素進行切割,所以需要將點轉成畫素,再按照比例算出當前位置。即為上述程式碼的int cropX = (int)(currentResolutionW / kScreenWidth * self.cropView.frame.origin.x); currentResolutionW為當前解析度的寬度,kScreenWidth為螢幕實際寬度。
  • 2.根據YUV 420的規則,每4個Y共用1個UV,而一行有2個Y,所以取點必須按照偶數取點。利用CPU切割中使用的方法為YUV分隔法,具體切割方式請參考YUV介紹
  • 3.本例中宣告pixelBuffer與videoInfo均為靜態變數,為了節省每次建立浪費記憶體,但是有三種情況需要重置它們:位置變化,解析度改變,重啟相機。文章最後注意詳細提到。
// hardware crop
- (CMSampleBufferRef)cropSampleBufferByHardware:(CMSampleBufferRef)buffer {
    // a CMSampleBuffer CVImageBuffer of media data.
    
    CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(buffer);
    CGRect           cropRect    = CGRectMake(_cropX, _cropY, g_width_size, g_height_size);
    //        log4cplus_debug("Crop", "dropRect x: %f - y : %f - width : %zu - height : %zu", cropViewX, cropViewY, width, height);
    
    /*
     First, to render to a texture, you need an image that is compatible with the OpenGL texture cache. Images that were created with the camera API are already compatible and you can immediately map them for inputs. Suppose you want to create an image to render on and later read out for some other processing though. You have to have create the image with a special property. The attributes for the image must have kCVPixelBufferIOSurfacePropertiesKey as one of the keys to the dictionary.
      如果要進行頁面渲染,需要一個和OpenGL緩衝相容的影像。用相機API建立的影像已經相容,您可以馬上對映他們進行輸入。假設你從已有畫面中擷取一個新的畫面,用作其他處理,你必須建立一種特殊的屬性用來建立影像。對於影像的屬性必須有kCVPixelBufferIOSurfacePropertiesKey 作為字典的Key.因此以下步驟不可省略
     */
    
    OSStatus status;
    
    /* Only resolution has changed we need to reset pixBuffer and videoInfo so that reduce calculate count */
    static CVPixelBufferRef            pixbuffer = NULL;
    static CMVideoFormatDescriptionRef videoInfo = NULL;
    
    if (pixbuffer == NULL) {
        NSDictionary *options = [NSDictionary dictionaryWithObjectsAndKeys:
                                 [NSNumber numberWithInt:g_width_size],     kCVPixelBufferWidthKey,
                                 [NSNumber numberWithInt:g_height_size],    kCVPixelBufferHeightKey, nil];
        status = CVPixelBufferCreate(kCFAllocatorSystemDefault, g_width_size, g_height_size, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, (__bridge CFDictionaryRef)options, &pixbuffer);
        // ensures that the CVPixelBuffer is accessible in system memory. This should only be called if the base address is going to be used and the pixel data will be accessed by the CPU
        if (status != noErr) {
            NSLog(@"Crop CVPixelBufferCreate error %d",(int)status);
            return NULL;
        }
    }
    
    CIImage *ciImage = [CIImage imageWithCVImageBuffer:imageBuffer];
    ciImage = [ciImage imageByCroppingToRect:cropRect];
    // Ciimage get real image is not in the original point  after excute crop. So we need to pan.
    ciImage = [ciImage imageByApplyingTransform:CGAffineTransformMakeTranslation(-_cropX, -_cropY)];
    
    static CIContext *ciContext = nil;
    if (ciContext == nil) {
        //        NSMutableDictionary *options = [[NSMutableDictionary alloc] init];
        //        [options setObject:[NSNull null] forKey:kCIContextWorkingColorSpace];
        //        [options setObject:@0            forKey:kCIContextUseSoftwareRenderer];
        EAGLContext *eaglContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3];
        ciContext = [CIContext contextWithEAGLContext:eaglContext options:nil];
    }
    [ciContext render:ciImage toCVPixelBuffer:pixbuffer];
    //    [ciContext render:ciImage toCVPixelBuffer:pixbuffer bounds:cropRect colorSpace:nil];
    
    CMSampleTimingInfo sampleTime = {
        .duration               = CMSampleBufferGetDuration(buffer),
        .presentationTimeStamp  = CMSampleBufferGetPresentationTimeStamp(buffer),
        .decodeTimeStamp        = CMSampleBufferGetDecodeTimeStamp(buffer)
    };
    
    if (videoInfo == NULL) {
        status = CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault, pixbuffer, &videoInfo);
        if (status != 0) NSLog(@"Crop CMVideoFormatDescriptionCreateForImageBuffer error %d",(int)status);
    }
    
    CMSampleBufferRef cropBuffer;
    status = CMSampleBufferCreateForImageBuffer(kCFAllocatorDefault, pixbuffer, true, NULL, NULL, videoInfo, &sampleTime, &cropBuffer);
    if (status != 0) NSLog(@"Crop CMSampleBufferCreateForImageBuffer error %d",(int)status);
    
    return cropBuffer;
}

複製程式碼
  • 以上為硬體切割的方法,硬體切割利用GPU進行切割,主要利用CoreImage中CIContext 物件進行渲染。

  • CoreImage and UIKit coordinates (CoreImage 與 UIKit座標系問題):我在開始做的時候跟正常一樣用設定的位置對影像進行切割,但是發現,切出來的位置不對,通過上網查閱發現一個有趣的現象CoreImage 與 UIKit座標系不相同 如下圖: 正常UIKit座標系是以左上角為原點:

UIKit座標系.png

而CoreImage座標系是以左下角為原點:(在CoreImage中,每個影像的座標系是獨立於裝置的)

CoreImage座標系.png

所以切割的時候一定要注意轉換Y,X的位置是正確的,Y是相反的。

  • 如果要進行頁面渲染,需要一個和OpenGL緩衝相容的影像。用相機API建立的影像已經相容,您可以馬上對映他們進行輸入。假設你從已有畫面中擷取一個新的畫面,用作其他處理,你必須建立一種特殊的屬性用來建立影像。對於影像的屬性必須有寬高 作為字典的Key.因此建立字典的關鍵幾步不可省略。
  • 對CoreImage進行切割有兩種切割的方法均可用:
  1. ciImage = [ciImage imageByCroppingToRect:cropRect]; 如果使用此行程式碼則渲染時用[ciContext render:ciImage toCVPixelBuffer:pixelBuffer];
  2. 或者直接使用: [ciContext render:ciImage toCVPixelBuffer:pixelBuffer bounds:cropRect colorSpace:nil];
  • 注意:CIContext 中包含影像大量上下文資訊,不能在回撥中多次呼叫,官方建議只初始化一次。但是注意ARC,MRC區別。

注意:

1. 使用ARC與MRC下程式碼有所區別,已經在專案中標註好,主要為管理全域性的CIContext物件,它在初始化的方法中編譯器沒有對其進行retain,所以,呼叫會報錯。

cicontextError

2.切換前後置攝像頭:因為不同機型的前後置攝像頭差別較大,一種處理手段是在記錄iphone機型crop的plist檔案中增加前後置攝像頭支援解析度的屬性,然後在程式碼中根據plist對映出來的模型進行分別引用。另一種方案是做自動降級處理,例如後置支援2K,前置支援720P,則轉換後檢測到前置不支援2K就自動將前置降低一個等級,直到找到需要的等級。如果這樣操作處理邏輯較多且初看不易理解,而前置切割功能適用範圍不大,所以暫時只支援後置切割。

補充說明

  • 螢幕邏輯解析度與視訊解析度
  1. Point and pixel的區別 因為此類說明網上很多,這裡就不做太多具體闡述,僅僅簡述一下 Point 即是裝置的邏輯解析度,即[UIScreen mainScreen].bounds.size.width 得到的裝置的寬高,所以點可以簡單理解為iOS開發中的座標系,方便對介面元素進行描述。

  2. Pixel: 畫素則是比點更精確的單位,在普通屏中1點=1畫素,Retina屏中1點=2畫素。

  3. 解析度 解析度需要根據不同機型所支援的最大解析度進行設定,例如iPhone 6S以上機型支援4k(3840 * 2160)解析度拍攝視訊。而當我們進行Crop操作的時候呼叫的API正是通過畫素來進行切割,所以我們操作的單位是pixel而不是point.下面會有詳細介紹。

  • ARC, MRC下所做工作不同

CIContext 的初始化

首先應該將CIContext宣告為全域性變數或靜態變數,因為CIContext初始化一次內部含有大量資訊,比較耗記憶體,且只是渲染的時候使用,無需每次都初始化,然後如下如果在MRC中初始化完成後並未對ciContext發出retain的訊息,所以需要手動retain,但在ARC下系統會自動完成此操作。

ARC:

static CIContext *ciContext = NULL;
ciContext = [CIContext contextWithOptions:nil];
複製程式碼
MRC:

static CIContext *ciContext = NULL;
ciContext = [CIContext contextWithOptions:nil];
[ciContext retain];
複製程式碼
  • 座標問題

#####1. 理解點與畫素的對應關係 首先CropView需要在手機顯示出來,所以座標系還是UIKit的座標系,左上角為原點,寬高分別為不同手機的寬高(如iPhone8 : 375*667, iPhone8P : 414 * 736, iPhoneX : 375 * 816),但是我們需要算出實際解析度下CropView的座標,即我們可以把當前獲取的cropView的x,y點的位置轉換成對應pixel的位置。

// 注意這裡求的是X的畫素座標,以iPhone 8 為例 (點為375 * 667),解析度為(1920 * 1080)
_cropX  = (int)(_currentResolutionW / _screenWidth  * (cropView.frame.origin.x);
即
_cropX  = (int)(1920 / 375  * 當前cropView的x點座標;
複製程式碼

#####2. CPU / GPU 兩種方式切割時座標系的位置不同

原點位置

CPU : UIKit為座標系,原點在左上角

GPU : CoreImage為座標系,原點在左下角

因此計算時如果使用GPU, y的座標是相反的,我們需要通過如下公式轉換,即將點對應轉為正常以左上角為原點座標系中的點。
_cropY  = (int)(_currentResolutionH / _screenHeight * (_screenHeight - self.frame.origin.y  -  self.frame.size.height)); 
複製程式碼

#####3. 當手機螢幕不是16:9時,如果將視訊設定為填充滿螢幕則會出現偏差

需要注意的是,因為部分手機或iPad螢幕尺寸並不為16:9(iPhone X, 所有iPad (4 : 3)),如果我們在2k(1920 * 1080) , 4k (3840 * 2160 ) 解析度下對顯示的View設定了 captureVideoPreviewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill; 那麼螢幕會犧牲一部分視訊填充檢視,即相機捕獲的視訊資料並沒有完整展現在手機檢視裡,所以再使用我們的crop功能時,由於我們使用的是UIKit的座標系,也就是說原點(0,0)並不是該幀圖片真正畫素的(0,0),而如果計算則需要寫很多額外程式碼,所以我們可以在Crop功能下設定captureVideoPreviewLayer.videoGravity = AVLayerVideoGravityResizeAspect; 這樣的話video檢視會根據解析度調整為顯示完整視訊。但是設定後如果裝置是iPhoneX (比例大於16:9,X軸會縮小,黑邊填充),iPad(比例小於16:9,y軸縮小,黑邊填充)。

按照如上解析,我們之前計算的點會出現偏差,因為相當於x或y軸會縮小一部分,而我們拿到的cropView的座標仍然是相對於整個父View而言。

這時,如果我們通過不斷更改cropView則程式碼量較大,所以我在這裡定義了一個videoRect屬性用來記錄Video真正的Rect,因為當程式執行時我們可以得到螢幕寬高比例,所以通過確定寬高比可以拿到真正Video的rect,此時在後續程式碼中我們只需要傳入videoRect的尺寸進行計算,即時是原先正常16:9的手機後面API也無須更改。

#####4. 為什麼用int 在軟切中,我們在建立pixelBuffer時需要使用

CV_EXPORT CVReturn CVPixelBufferCreateWithBytes(
   CFAllocatorRef CV_NULLABLE allocator,
   size_t width,
   size_t height,
   OSType pixelFormatType,
   void * CV_NONNULL baseAddress,
   size_t bytesPerRow,
   CVPixelBufferReleaseBytesCallback CV_NULLABLE releaseCallback,
   void * CV_NULLABLE releaseRefCon,
   CFDictionaryRef CV_NULLABLE pixelBufferAttributes,
   CV_RETURNS_RETAINED_PARAMETER CVPixelBufferRef CV_NULLABLE * CV_NONNULL pixelBufferOut)
複製程式碼

這個API,我們需要將x,y的點放入baseAddress中,這裡又需要使用公式NSInteger baseAddressStart = _cropY*bytesPerRow+bytesPerPixel*_cropX;,但是這裡根據YUV 420的規則我們我們傳入的X的點不能為奇數,所以我們需要if (_cropX % 2 != 0) _cropX += 1;,而只有整型才能求餘,所以這裡的點我們均定義為int,在檢視展示中忽略小數點的誤差。

TODO :

在硬體切割(GPU)的過程中發現 [ciContext render:ciImage toCVPixelBuffer:pixelBuffer]; 渲染時間不斷增加,導致掉幀,而ciContext只初始化一次,並未發生記憶體洩露,如果input resolution 為 2k, 切割720P 在7plus上效能較好,其他機型和尺寸則掉幀嚴重。而軟體切割(CPU)雖然CPU使用率相比GPU提高15%左右但是效能相對穩定,掉幀也只有在長時間直播後偶爾發生,但是CPU使用率較高。

相關文章