iOS背景模糊实现技术方案
需求背景
晖致日本提出很多代表在家里录制AI作业,希望可以将背景模糊,保护代表隐私。
类似于腾讯会议的背景虚化功能,录制视频作业 的时候将背景模糊化,只保留用户人像区域。
技术方案
业界对这个需求的实现方案基本一致。
首先录制过程中将视频帧做高斯模糊形成背景模糊中”背景”部分。
然后在实时的将每一帧图像中包含人像的部分“扣”出来形成一张黑白分明的灰度图(mask)。一般是人像区域为白色,非人像区域为黑色。
最后使用自定义三输入的滤镜,分别传入模糊后的背景视频帧+灰度mask图+原始视频帧,通过片元着色器对每一个像素点进行遴选。根据灰度图的颜色,白色取原始视频帧,黑色取模糊视频帧。
这样就形成了背景区域是模糊的,人像区域是正常的视频,达到要求。
在视频帧处理这方面我们采用项目中已经引入的GPUImage库来实现。
人像分割
这个抠图的技术我们称为人像分割,人像分割算法有很多种,常见的包括:基于颜色和纹理的方法、基于边缘检测的方法、基于深度学习的方法、基于图割的方法等。
目前这一步交由AI组伙伴提供基于TensorFlowLiteObjC的深度学习人像分割模型来实现。
自研模型
实现细节
目前chaochao提供了2个模型。
128x128_fp32.tflite 支持输入128*128像素的图片
256x144_fp32.tflite 支持输入256*144像素的图片
核心识别代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121
| #import <Foundation/Foundation.h> #import "UGPortraitTFliteProcessor.h"
@interface UGPortraitTFliteProcessor()
@property (nonatomic, assign) cv::Mat cachedMat;
@property (nonatomic, strong) NSDictionary *cachedOutput;
@end
@implementation UGPortraitTFliteProcessor
- (NSArray< NSNumber * > *)getTensorShape { CGSize modelSize = self.model.modelSize; NSNumber *dimNum1 = [NSNumber numberWithInt:modelSize.width]; NSNumber *dimNum2 = [NSNumber numberWithInt:modelSize.height]; NSArray< NSNumber * > *shape = @[ @1, dimNum2, dimNum1, @3 ]; return shape; }
+ (UGPortraitTFliteProcessor *)processor {
CGSize kTFliteModelSize = CGSizeMake(256, 144); NSBundle *targetBundle = [NSBundle bundleForClass:NSClassFromString(@"UGPortraitTFliteProcessor")]; NSURL *bundleUrl = [targetBundle URLForResource:@"Detection" withExtension:@"bundle"]; NSBundle *finalBundle = [NSBundle bundleWithURL:bundleUrl];
NSString *filePath = [finalBundle pathForResource:@"256x144_fp32" ofType:@"tflite"]; UGTFliteModel *model = [UGTFliteModel modelWithPath:filePath size:kTFliteModelSize]; UGPortraitTFliteProcessor *processor = [UGPortraitTFliteProcessor processorWithModel:model]; return processor; }
- (NSDictionary *)detectWithSampleBuffer:(CMSampleBufferRef)sampleBuffer orientation:(UIInterfaceOrientation)orientation isMirror:(BOOL)isMirror {
cv::Mat matOrigin = [UGTFliteUtil matFromCVPixelBuffer:CMSampleBufferGetImageBuffer(sampleBuffer)];
if([self isCacheAvailable] && [UGTFliteUtil isChangeMinimalWithCurrentFrame:matOrigin lastFrame:_cachedMat threshold:0.035]) { return self.cachedOutput; }
CGSize sizeOrigin = orientation == UIInterfaceOrientationLandscapeRight ? CGSizeMake(matOrigin.cols, matOrigin.rows) : CGSizeMake(matOrigin.rows, matOrigin.cols);
cv::Mat matProcess = [UGTFliteUtil processMatWithOrientation:orientation isMirror:isMirror originMat:matOrigin];
cv::Mat mat; cv::resize(matProcess, mat, cv::Size(self.model.modelSize.width, self.model.modelSize.height));
NSData *imageData = [UGTFliteUtil normalizedFloat32ImageDataFromCVMat:mat];
NSError *error; [self.interpreter resizeInputTensorAtIndex:0 toShape:[self getTensorShape] error:&error]; TFLTensor *inputTensor = [self.interpreter inputTensorAtIndex:0 error:&error]; [inputTensor copyData:imageData error:&error]; [self.interpreter invokeWithError:&error]; TFLTensor *outputTensor = [self.interpreter outputTensorAtIndex:0 error:&error]; NSData *outputData = [outputTensor dataWithError:&error];
NSDictionary *resultDict = [self detectWithTensorOutputData:outputData imageSize:sizeOrigin];
if (error) { NSLog(@"Error++: %@", error); } else { self.cachedOutput = resultDict; self.cachedMat = matOrigin; }
return resultDict; }
- (NSDictionary *)detectWithTensorOutputData:(NSData *)outputData imageSize:(CGSize)imageSize {
int inputWidth = self.model.modelSize.width;
int inputHeight = self.model.modelSize.height;
int num_pixels = inputHeight * inputWidth;
cv::Mat out_mask(inputHeight, inputWidth, CV_8UC1);
const float *output = (const float *)outputData.bytes;
for (int i = 0; i < num_pixels; i++) { out_mask.data[i] = (output[2 * i + 1] > 0.1f) ? 255 : 0; }
cv::Mat outputMat; cv::resize(out_mask, outputMat, cv::Size(imageSize.width, imageSize.height));
UIImage *outputImage = [UGTFliteUtil imageFromCVMat:outputMat];
return @{@"image":outputImage, @"imageWidth":@(imageSize.width),@"imageHeight":@(imageSize.height)}; }
- (BOOL)isCacheAvailable { return !self.cachedMat.empty() && self.cachedOutput; }
@end
|
核心渲染代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107
|
#import "GPUImage.h" #import "GPUImageThreeInputFilter.h" #import "UGImageBackgroundBlurFilter.h"
NSString *const kUGImagePortraitMaskBlendFragmentShaderString = SHADER_STRING ( precision highp float;
varying highp vec2 textureCoordinate; varying highp vec2 textureCoordinate2; varying highp vec2 textureCoordinate3;
uniform sampler2D inputImageTexture; uniform sampler2D inputImageTexture2; uniform sampler2D inputImageTexture3;
void main() { vec4 baseColor = texture2D(inputImageTexture, textureCoordinate); vec4 blurredColor = texture2D(inputImageTexture2, textureCoordinate2); vec4 maskColor = texture2D(inputImageTexture3, textureCoordinate3);
baseColor.rgb *= baseColor.a; blurredColor.rgb *= blurredColor.a;
float maskValue = dot(maskColor.rgb, vec3(0.299, 0.587, 0.114));
float smoothMaskValue = smoothstep(0.0, 1.0, maskValue);
vec4 mixedColor = mix(blurredColor, baseColor, smoothMaskValue);
if (mixedColor.a > 0.0) { mixedColor.rgb /= mixedColor.a; }
gl_FragColor = mixedColor;
} );
@interface UGImagePortraitMaskBlendFilter : GPUImageThreeInputFilter
@end
@implementation UGImagePortraitMaskBlendFilter
- (instancetype)init { self = [super initWithFragmentShaderFromString:kUGImagePortraitMaskBlendFragmentShaderString]; if (self) {
} return self; }
@end
@interface UGImageBackgroundBlurFilter() @property (nonatomic, strong) GPUImageGaussianBlurFilter *blurFilter; @property (nonatomic, strong) UGImagePortraitMaskBlendFilter *blendInputFilter; @end
@implementation UGImageBackgroundBlurFilter
- (instancetype)init { if (self = [super init]) {
self.blurFilter = [[GPUImageGaussianBlurFilter alloc] init]; self.blurFilter.blurRadiusInPixels = 31; [self addFilter:self.blurFilter];
self.blendInputFilter = [[UGImagePortraitMaskBlendFilter alloc] init]; [self addFilter:self.blendInputFilter];
[self.blurFilter addTarget:self.blendInputFilter atTextureLocation:1]; [self setInitialFilters:@[self.blurFilter, self.blendInputFilter]]; [self setTerminalFilter:self.blendInputFilter]; } return self; }
- (void)setMaskImage:(UIImage *)maskImage {
GPUImagePicture *maskImageSource = [[GPUImagePicture alloc] initWithImage:maskImage]; GPUImageErosionFilter *erosionFilter = [[GPUImageErosionFilter alloc] initWithRadius:3]; GPUImageGaussianBlurFilter *blurFilter = [[GPUImageGaussianBlurFilter alloc] init]; blurFilter.blurRadiusInPixels = 7;
[maskImageSource addTarget:erosionFilter]; [erosionFilter addTarget:blurFilter]; [blurFilter addTarget:self.blendInputFilter atTextureLocation:2];
[erosionFilter useNextFrameForImageCapture]; [blurFilter useNextFrameForImageCapture]; [maskImageSource processImage]; }
@end
|
端侧优化
在实现的过程中发现一些问题,从APP侧做了一些优化。
边缘优化
模型计算出来的mask图像的黑白分界比较明显(像素点非1即0),视觉效果上在背景和人像的分界处有较清晰的边界落差。
针对这个问题我们在使用推理出来的mask进入片元着色器遴选像素之前先过了一次7个像素的高斯模糊滤镜,将mask图的边缘的锋利区域抹平。
边缘高斯模糊后分界不再过于明显,人像周围会存在一圈透明和半透明交织的光圈。
光圈的半径大小取决于模型推理出来的mask图的尺寸,掩码图越大越精细,光圈越小越贴近真实的人像。(和小图片放大会变马赛克同样原理)
边缘腐蚀
经过边缘优化人像区域分界点弱化后发现人像周围的透明光圈会漏出一些背景信息。
为了降低漏出的背景信息,我们在高斯模糊之前对掩码图做了一次3像素的腐蚀,将人像区域向内收缩。
插值优化
结合我们的AI作业的录制场景,APP端对两帧之间变化不超过96.5%的不再执行推理,可以一定程度上提高录制的帧率。
该阈值经过一些测试得出,其效果为别的部位和背景不变,仅面部口型发生变化,其两帧之间的差距小于3.5%。
帧缓存
推理模型存在报错的可能,每一帧推理成功完成后都会缓存该推理结果。
如果当前帧推理报错则会使用上一帧的结果,避免出现模糊失效暴露完整背景信息的情况。
遗留问题
向内腐蚀导致人像边缘缺失
手指区域有时候会出现识别误差导致手指区域比较细小,因为向内腐蚀而出现手指被腐蚀掉的问题。
帧率低
端上录制的同时做了过多滤镜方面的工作,同时处理背景模糊和人脸美颜,性能较低。
不开背景模糊能跑满30pfs,开了背景模糊会降低到10-15fps。
模型识别精度不足
识别精度难以达到产品预期。当前模型尺寸较小,如果换高精度模型会进一步降低帧率,视频会卡。
Vision 模型
Vision 是 Apple 在 WWDC 2017 推出的图像识别框架。Vision库里本身就已经自带了很多训练好的Core ML模型,人脸识别、条形码检测等等功能。
如果你要实现的功能刚好是Vision库本身就能实现的,那么你直接使用Vision库自带的一些类和方法就行,但是如果想要更强大的功能,那么还是需要结合其它Core ML模型。
Apple 官方也提供了人像分割的模型VNGeneratePersonSegmentationRequest,需要iOS 15.0 以上系统可以使用该API。
核心实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| if (@available(iOS 15.0, *)) { CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); if (!pixelBuffer) { return; }
VNGeneratePersonSegmentationRequest *request = [[VNGeneratePersonSegmentationRequest alloc] init];
request.qualityLevel = VNGeneratePersonSegmentationRequestQualityLevelAccurate; request.outputPixelFormat = kCVPixelFormatType_OneComponent8;
VNImageRequestHandler *requestHandler = [[VNImageRequestHandler alloc] initWithCVPixelBuffer:pixelBuffer options:@{}]; NSError *error = nil; [requestHandler performRequests:@[request] error:&error]; if (error) { NSLog(@"Failed to perform segmentation request: %@", error); return; }
VNPixelBufferObservation *mask = (VNPixelBufferObservation *)request.results.firstObject; if (mask) { CIImage *ciImage = [CIImage imageWithCVPixelBuffer:mask.pixelBuffer]; CIContext *context = [CIContext context]; CGImageRef cgImage = [context createCGImage:ciImage fromRect:ciImage.extent]; UIImage *image = [UIImage imageWithCGImage:cgImage]; CGImageRelease(cgImage);
if(self.getDevicePostion == AVCaptureDevicePositionFront) { image = [self flipImageVertically:image]; }
[self.filter setBlurBackgroundMaskImage:image]; } } else { NSLog(@"VNGeneratePersonSegmentationRequest is not available on this iOS version."); }
|
我们项目支持到iOS 10.0 所以使用原生模型是有局限性的。
另外如果使用高精度模型VNGeneratePersonSegmentationRequestQualityLevelAccurate,其运行速度也比较慢。
如果使用精度和速度相平衡的模型则其识别效果未经过产品验证无法确定是否达到需求。
优化方向
AI
AI考虑提升模型运算效率和推理精度,由AI团队调研和研发。
APP
根据模型推理精度来决定是否移除掉腐蚀逻辑,保证人像区域完整。
检测多个滤镜链合并时候的效率,根据结果决定是否需要分离美颜和背景模糊滤镜。
使用帧采样做mask基准,尽量减少模型推理次数。
采样多个模型,根据帧率动态降级。
采用iOS Vision 推理框架 工程化优化
采用 VNGeneratePersonSegmentationRequestQualityLevelBalanced 档位的人像分割模型,效果可以接受。
测试机 iOS 15.2 iPhone 11
2024-11-29 15:53:32.273863+0800 [5274:3232793] 开始推理 1732866812.273805
2024-11-29 15:53:32.300939+0800 [5274:3232793] 推理完成 1732866812.300908
2024-11-29 15:53:32.309572+0800 [5274:3232793] 转图片完成 1732866812.309538
人像分割推理用时 27.1 ms 转图片用时 8.5 ms
当前FPS 为 15 每一帧视频用时 66.67ms
人像分割大概需要在原始耗时的基础上增加35 ms
不开人像分割可以达到设置的最佳帧率 30 FPS。
排查其他用时详情:
2024-11-29 16:54:07.306245+0800 [5358:3254717] 开始应用滤镜 1732870447.306170
2024-11-29 16:54:07.310206+0800 [5358:3254717] 应用滤镜完成 1732870447.310165
合成图像及边缘优化的滤镜用时 4ms 优化空间较小
主要优化方向还是在人像分割推理上
采用差值优化,帧相似度小于某个值才进行检测
差值计算带来额外的性能消耗,如果计算出来需要检测,则检测用时需要追加差值计算的时间消耗,如果计算出来不需要检测,就节省了一次推理时间。
如果差值计算消耗数量级远远小于人像分割推理 则效果比较明显。
2024-11-29 18:01:49.232560+0800 [5457:3292643] 开始计算差值 1732874509.232429
2024-11-29 18:01:49.237739+0800 [5457:3292643] 完成计算差值 1732874509.237706
计算差值需要 5ms
在差值2.5%的基准上,如果人像动作幅度较小则帧率提升到20-25左右。
插值基准越大,则帧率提升越明显,但是对人像分割的效果会有负面影响(有残影)。
参考资料
OpenGL ES几个概念-顶点着色器、片元着色器、EG
OpenGL学习教程
人像分割技术有哪些?
Vision 图像识别框架