iOS開發(fā)中截取相機部分畫面食寡,切割sampleBuffer(Crop sample buffer)
本例需求:在類似直播的功能界面,二維碼掃描,人臉識別或其他需求中的功能界面或其他需求中需要從相機捕獲的畫面中單獨截取出一部分區(qū)域廓潜。
原理:由于需要截取相機捕獲整個畫面其中一部分抵皱,所以也就必須拿到那一部分畫面的數(shù)據(jù),又因為相機AVCaptureVideoDataOutputSampleBufferDelegate中的sampleBuffer為系統(tǒng)私有的數(shù)據(jù)結(jié)構(gòu)不可直接操作辩蛋,所以需要將其轉(zhuǎn)換成可以切割的數(shù)據(jù)結(jié)構(gòu)再進行切割呻畸,網(wǎng)上有種思路說將sampleBuffer間接轉(zhuǎn)換為UIImage再對圖片切割,這種思路繁瑣且性能低悼院,本例將sampleBuffer轉(zhuǎn)換為CoreImage中的CIImage,性能相對較高且降低代碼繁瑣度伤为。
最終效果如下, 綠色框中即為截圖的畫面据途,長按可以移動绞愚。
GitHub地址(附代碼) : Crop sample buffer
簡書地址 : Crop sample buffer
博客地址 : Crop sample buffer
掘金地址 : Crop sample buffer
注意:使用ARC與MRC下代碼有所區(qū)別,已經(jīng)在項目中標注好颖医,主要為管理全局的CIContext對象位衩,它在初始化的方法中編譯器沒有對其進行retain,所以,調(diào)用會報錯熔萧。
使用場景
- 本項目中相機捕捉的背景分辨率默認設(shè)置為2K(即1920*1080)蚂四,可切換為4K ,所以需要iPhone 6s以上的設(shè)備才支持光戈。
- 本例可以使用CPU/GPU切割,在VC中需要在cropView初始化前設(shè)置isOpenGPU的值遂赠,打開則使用GPU,否則CPU
- 本例只實現(xiàn)了橫屏下的Crop功能久妆,本例默認始終為橫屏狀態(tài),未做豎屏處理跷睦。
基本配置
1.配置相機基本環(huán)境(初始化AVCaptureSession筷弦,設(shè)置代理,開啟)抑诸,在示例代碼中有烂琴,這里不再重復。
2.通過AVCaptureVideoDataOutputSampleBufferDelegate代理中拿到原始畫面數(shù)據(jù)(CMSampleBufferRef)進行處理
實現(xiàn)途徑
1.利用CPU軟件截取(CPU進行計算并切割蜕乡,消耗性能較大)
- (CMSampleBufferRef)cropSampleBufferBySoftware:(CMSampleBufferRef)sampleBuffer奸绷;
2.利用 硬件截取(利用Apple官方公開的方法利用硬件進行切割,性能較好层玲, 但仍有問題待解決)
- (CMSampleBufferRef)cropSampleBufferByHardware:(CMSampleBufferRef)buffer号醉;
解析
// Called whenever an AVCaptureVideoDataOutput instance outputs a new video frame. 每產(chǎn)生一幀視頻幀時調(diào)用一次
- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
CMSampleBufferRef cropSampleBuffer;
#warning 兩種切割方式任選其一,GPU切割性能較好辛块,CPU切割取決于設(shè)備畔派,一般時間長會掉幀。
if (self.isOpenGPU) {
cropSampleBuffer = [self.cropView cropSampleBufferByHardware:sampleBuffer];
}else {
cropSampleBuffer = [self.cropView cropSampleBufferBySoftware:sampleBuffer];
}
// 使用完后必須顯式release润绵,不在iOS自動回收范圍
CFRelease(cropSampleBuffer);
}
- 以上方法為每產(chǎn)生一幀視頻幀時調(diào)用一次的相機代理线椰,其中sampleBuffer為每幀畫面的原始數(shù)據(jù),需要對原始數(shù)據(jù)進行切割處理方可達到本例需求尘盼。注意最后一定要對cropSampleBuffer進行release避免內(nèi)存溢出而發(fā)生閃退憨愉。
利用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數(shù)據(jù)結(jié)構(gòu),然后對CVImageBufferRef進行加鎖處理娇澎,如果要進行頁面渲染笨蚁,需要一個和OpenGL緩沖兼容的圖像。用相機API創(chuàng)建的圖像已經(jīng)兼容趟庄,您可以馬上映射他們進行輸入括细。假設(shè)你從已有畫面中截取一個新的畫面,用作其他處理戚啥,你必須創(chuàng)建一種特殊的屬性用來創(chuàng)建圖像奋单。對于圖像的屬性必須有Crop寬高, 作為字典的Key.因此創(chuàng)建字典的關(guān)鍵幾步不可省略猫十。
位置的計算
在軟切中览濒,我們拿到一幀圖片的數(shù)據(jù)呆盖,通過遍歷其中的數(shù)據(jù)確定真正要Crop的位置,利用如下公式可求出具體位置贷笛,具體切割原理在[YUV介紹]中有提到应又,計算時所需的變量在以上代碼中均可得到。
`NSInteger baseAddressStart = _cropY*bytesPerRow+bytesPerPixel*_cropX;
`
注意:
- 1.對X,Y坐標進行校正乏苦,因為CVPixelBufferCreateWithBytes是按照像素進行切割株扛,所以需要將點轉(zhuǎn)成像素,再按照比例算出當前位置汇荐。即為上述代碼的int cropX = (int)(currentResolutionW / kScreenWidth * self.cropView.frame.origin.x); currentResolutionW為當前分辨率的寬度洞就,kScreenWidth為屏幕實際寬度。
- 2.根據(jù)YUV 420的規(guī)則掀淘,每4個Y共用1個UV,而一行有2個Y旬蟋,所以取點必須按照偶數(shù)取點。利用CPU切割中使用的方法為YUV分隔法革娄,具體切割方式請參考YUV介紹
- 3.本例中聲明pixelBuffer與videoInfo均為靜態(tài)變量倾贰,為了節(jié)省每次創(chuàng)建浪費內(nèi)存,但是有三種情況需要重置它們:位置變化稠腊,分辨率改變躁染,重啟相機鸣哀。文章最后注意詳細提到架忌。
// hardware crop
- (CMSampleBufferRef)cropSampleBufferByHardware:(CMSampleBufferRef)buffer {
// a CMSampleBuffer's 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創(chuàng)建的圖像已經(jīng)兼容叹放,您可以馬上映射他們進行輸入。假設(shè)你從已有畫面中截取一個新的畫面挠羔,用作其他處理井仰,你必須創(chuàng)建一種特殊的屬性用來創(chuàng)建圖像。對于圖像的屬性必須有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坐標系問題):我在開始做的時候跟正常一樣用設(shè)定的位置對圖像進行切割范舀,但是發(fā)現(xiàn)合是,切出來的位置不對,通過上網(wǎng)查閱發(fā)現(xiàn)一個有趣的現(xiàn)象CoreImage 與 UIKit坐標系不相同
如下圖:
正常UIKit坐標系是以左上角為原點:
而CoreImage坐標系是以左下角為原點:(在CoreImage中锭环,每個圖像的坐標系是獨立于設(shè)備的)
所以切割的時候一定要注意轉(zhuǎn)換Y聪全,X的位置是正確的,Y是相反的辅辩。
- 如果要進行頁面渲染难礼,需要一個和OpenGL緩沖兼容的圖像娃圆。用相機API創(chuàng)建的圖像已經(jīng)兼容,您可以馬上映射他們進行輸入蛾茉。假設(shè)你從已有畫面中截取一個新的畫面讼呢,用作其他處理,你必須創(chuàng)建一種特殊的屬性用來創(chuàng)建圖像臀稚。對于圖像的屬性必須有寬高 作為字典的Key.因此創(chuàng)建字典的關(guān)鍵幾步不可省略吝岭。
- 對CoreImage進行切割有兩種切割的方法均可用:
-
ciImage = [ciImage imageByCroppingToRect:cropRect];
如果使用此行代碼則渲染時用[ciContext render:ciImage toCVPixelBuffer:pixelBuffer];
- 或者直接使用:
[ciContext render:ciImage toCVPixelBuffer:pixelBuffer bounds:cropRect colorSpace:nil];
- 注意:CIContext 中包含圖像大量上下文信息,不能在回調(diào)中多次調(diào)用吧寺,官方建議只初始化一次窜管。但是注意ARC,MRC區(qū)別。
注意:
1. 使用ARC與MRC下代碼有所區(qū)別稚机,已經(jīng)在項目中標注好幕帆,主要為管理全局的CIContext對象,它在初始化的方法中編譯器沒有對其進行retain,所以赖条,調(diào)用會報錯失乾。
2.切換前后置攝像頭:因為不同機型的前后置攝像頭差別較大,一種處理手段是在記錄iphone機型crop的plist文件中增加前后置攝像頭支持分辨率的屬性纬乍,然后在代碼中根據(jù)plist映射出來的模型進行分別引用碱茁。另一種方案是做自動降級處理,例如后置支持2K仿贬,前置支持720P,則轉(zhuǎn)換后檢測到前置不支持2K就自動將前置降低一個等級纽竣,直到找到需要的等級。如果這樣操作處理邏輯較多且初看不易理解茧泪,而前置切割功能適用范圍不大蜓氨,所以暫時只支持后置切割。
補充說明
- 屏幕邏輯分辨率與視頻分辨率
Point and pixel的區(qū)別
因為此類說明網(wǎng)上很多队伟,這里就不做太多具體闡述穴吹,僅僅簡述一下
Point 即是設(shè)備的邏輯分辨率,即[UIScreen mainScreen].bounds.size.width 得到的設(shè)備的寬高嗜侮,所以點可以簡單理解為iOS開發(fā)中的坐標系港令,方便對界面元素進行描述。Pixel: 像素則是比點更精確的單位锈颗,在普通屏中1點=1像素顷霹,Retina屏中1點=2像素。
分辨率 分辨率需要根據(jù)不同機型所支持的最大分辨率進行設(shè)置宜猜,例如iPhone 6S以上機型支持4k(3840 * 2160)分辨率拍攝視頻泼返。而當我們進行Crop操作的時候調(diào)用的API正是通過像素來進行切割,所以我們操作的單位是pixel而不是point.下面會有詳細介紹姨拥。
- ARC, MRC下所做工作不同
CIContext 的初始化
首先應(yīng)該將CIContext聲明為全局變量或靜態(tài)變量绅喉,因為CIContext初始化一次內(nèi)部含有大量信息渠鸽,比較耗內(nèi)存,且只是渲染的時候使用柴罐,無需每次都初始化徽缚,然后如下如果在MRC中初始化完成后并未對ciContext發(fā)出retain的消息,所以需要手動retain,但在ARC下系統(tǒng)會自動完成此操作革屠。
ARC:
static CIContext *ciContext = NULL;
ciContext = [CIContext contextWithOptions:nil];
MRC:
static CIContext *ciContext = NULL;
ciContext = [CIContext contextWithOptions:nil];
[ciContext retain];
- 坐標問題
1. 理解點與像素的對應(yīng)關(guān)系
首先CropView需要在手機顯示出來凿试,所以坐標系還是UIKit的坐標系,左上角為原點似芝,寬高分別為不同手機的寬高(如iPhone8 : 375*667, iPhone8P : 414 * 736, iPhoneX : 375 * 816),但是我們需要算出實際分辨率下CropView的坐標那婉,即我們可以把當前獲取的cropView的x,y點的位置轉(zhuǎn)換成對應(yīng)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的坐標是相反的呛谜,我們需要通過如下公式轉(zhuǎn)換,即將點對應(yīng)轉(zhuǎn)為正常以左上角為原點坐標系中的點枪萄。
_cropY = (int)(_currentResolutionH / _screenHeight * (_screenHeight - self.frame.origin.y - self.frame.size.height));
3. 當手機屏幕不是16:9時隐岛,如果將視頻設(shè)置為填充滿屏幕則會出現(xiàn)偏差
需要注意的是,因為部分手機或iPad屏幕尺寸并不為16:9(iPhone X, 所有iPad (4 : 3)),如果我們在2k(1920 * 1080) , 4k (3840 * 2160 ) 分辨率下對顯示的View設(shè)置了 captureVideoPreviewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill;
那么屏幕會犧牲一部分視頻填充視圖瓷翻,即相機捕獲的視頻數(shù)據(jù)并沒有完整展現(xiàn)在手機視圖里聚凹,所以再使用我們的crop功能時,由于我們使用的是UIKit的坐標系逻悠,也就是說原點(0,0)并不是該幀圖片真正像素的(0,0)元践,而如果計算則需要寫很多額外代碼韭脊,所以我們可以在Crop功能下設(shè)置captureVideoPreviewLayer.videoGravity = AVLayerVideoGravityResizeAspect;
這樣的話video視圖會根據(jù)分辨率調(diào)整為顯示完整視頻童谒。但是設(shè)置后如果設(shè)備是iPhoneX (比例大于16:9,X軸會縮小,黑邊填充),iPad(比例小于16:9沪羔,y軸縮小饥伊,黑邊填充)。
按照如上解析蔫饰,我們之前計算的點會出現(xiàn)偏差琅豆,因為相當于x或y軸會縮小一部分,而我們拿到的cropView的坐標仍然是相對于整個父View而言篓吁。
這時茫因,如果我們通過不斷更改cropView則代碼量較大,所以我在這里定義了一個videoRect屬性用來記錄Video真正的Rect,因為當程序運行時我們可以得到屏幕寬高比例杖剪,所以通過確定寬高比可以拿到真正Video的rect,此時在后續(xù)代碼中我們只需要傳入videoRect的尺寸進行計算冻押,即時是原先正常16:9的手機后面API也無須更改驰贷。
4. 為什么用int
在軟切中,我們在創(chuàng)建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;
,但是這里根據(jù)YUV 420的規(guī)則我們我們傳入的X的點不能為奇數(shù)括袒,所以我們需要if (_cropX % 2 != 0) _cropX += 1;
,而只有整型才能求余稿茉,所以這里的點我們均定義為int,在視圖展示中忽略小數(shù)點的誤差锹锰。