V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
gq6
V2EX  ›  分享创造

开源一个上架 App Store 的相机 App

  •  
  •   gq6 ·
    hawk0620 · 2017-02-18 09:14:46 +08:00 · 1709 次点击
    这是一个创建于 2860 天前的主题,其中的信息可能已经有所发展或是发生改变。

    开源一个上架 App Store 的相机 App

    Osho 相机是我独立开发上架的一个相机 App ,它支持 1:1 , 4:3 , 16:9 多种分辨率拍摄,滤镜可在取景框的实时预览,拍摄过程可与滤镜实时合成,支持分段拍摄,支持回删等特性。下面先分享分享开发这个 App 的一些心得体会,文末会给出项目的下载地址,阅读本文可能需要一点点 AVFoundation 开发的基础。

    1 、 GLKView 和 GPUImageVideoCamera

    一开始取景框的预览我是基于 GLKView 做的, GLKView 是苹果对 OpenGL 的封装,我们可以使用它的回调函数 -glkView:drawInRect: 进行对处理后的 samplebuffer 渲染的工作(samplebuffer 是在相机回调 didOutputSampleBuffer 产生的),附上当初简版代码:

    - (CIImage *)renderImageInRect:(CGRect)rect {
        CMSampleBufferRef sampleBuffer = _sampleBufferHolder.sampleBuffer;
    
        if (sampleBuffer != nil) {
            UIImage *originImage = [self imageFromSamplePlanerPixelBuffer:sampleBuffer];
            if (originImage) {
               if (self.filterName && self.filterName.length > 0) {
    
                   GPUImageOutput<GPUImageInput> *filter;
                    if ([self.filterType isEqual: @"1"]) {
                        Class class = NSClassFromString(self.filterName);
                        filter = [[class alloc] init];
                    } else {
                        NSBundle *bundle = [NSBundle bundleForClass:self.class];
                        NSURL *filterAmaro = [NSURL fileURLWithPath:[bundle pathForResource:self.filterName ofType:@"acv"]];
                        filter = [[GPUImageToneCurveFilter alloc] initWithACVURL:filterAmaro];
                    }
                    [filter forceProcessingAtSize:originImage.size];
                    GPUImagePicture *pic = [[GPUImagePicture alloc] initWithImage:originImage];
                    [pic addTarget:filter];
                    [filter useNextFrameForImageCapture];
                    [filter addTarget:self.gpuImageView];
                    [pic processImage];              
                    UIImage *filterImage = [filter imageFromCurrentFramebuffer];
                    //UIImage *filterImage = [filter imageByFilteringImage:originImage];
    
                    _CIImage = [[CIImage alloc] initWithCGImage:filterImage.CGImage options:nil];
                } else {
                _CIImage = [CIImage imageWithCVPixelBuffer:CMSampleBufferGetImageBuffer(sampleBuffer)];
            }
        }  
        CIImage *image = _CIImage;
    
        if (image != nil) {
            image = [image imageByApplyingTransform:self.preferredCIImageTransform];
    
            if (self.scaleAndResizeCIImageAutomatically) {
               image = [self scaleAndResizeCIImage:image forRect:rect];
            }
        }
    
        return image;
    }
    
    - (void)glkView:(GLKView *)view drawInRect:(CGRect)rect {
        @autoreleasepool {
            rect = CGRectMultiply(rect, self.contentScaleFactor);
            glClearColor(0, 0, 0, 0);
            glClear(GL_COLOR_BUFFER_BIT);
    
            CIImage *image = [self renderImageInRect:rect];
    
            if (image != nil) {
                [_context.CIContext drawImage:image inRect:rect fromRect:image.extent];
            }
        }
    }
    

    这样的实现在低端机器上取景框会有明显的卡顿,而且 ViewController 上的列表几乎无法滑动,虽然手势倒是还可以支持。 因为要实现分段拍摄与回删等功能,采用这种方式的初衷是期望更高度的自定义,而不去使用 GPUImageVideoCamera, 毕竟我得在 AVCaptureVideoDataOutputSampleBufferDelegateAVCaptureAudioDataOutputSampleBufferDelegate 这两个回调做文章,为了满足需求,所以得在不侵入 GPUImage 源代码的前提下点功夫。

    怎么样才能在不破坏 GPUImageVideoCamera 的代码呢?我想到两个方法,第一个是创建一个类,然后把 GPUImageVideoCamera 里的代码拷贝过来,这么做简单粗暴,缺点是若以后 GPUImage 升级了,代码维护起来是个小灾难;再来说说第二个方法——继承,继承是个挺优雅的行为,可它的麻烦在于获取不到私有变量,好在有强大的 runtime ,解决了这个棘手的问题。下面是用 runtime 获取私有变量:

    - (AVCaptureAudioDataOutput *)gpuAudioOutput {
        Ivar var = class_getInstanceVariable([super class], "audioOutput");
        id nameVar = object_getIvar(self, var);
        return nameVar;
    }
    

    至此取景框实现了滤镜的渲染并保证了列表的滑动帧率。

    2 、实时合成以及 GPUImage 的 outputImageOrientation

    顾名思义,outputImageOrientation 属性和图像方向有关的。GPUImage 的这个属性是对不同设备的在取景框的图像方向做过优化的,但这个优化会与 videoOrientation 产生冲突,它会导致切换摄像头导致图像方向不对,也会造成拍摄完之后的视频方向不对。 最后的解决办法是确保摄像头输出的图像方向正确,所以将其设置为 UIInterfaceOrientationPortrait,而不对 videoOrientation 进行设置,剩下的问题就是怎样处理拍摄完成之后视频的方向。

    先来看看视频的实时合成,因为这里包含了对用户合成的 CVPixelBufferRef 资源处理。还是使用继承的方式继承 GPUImageView,其中使用了 runtime 调用私有方法:

    SEL s = NSSelectorFromString(@"textureCoordinatesForRotation:");
    IMP imp = [[GPUImageView class] methodForSelector:s];
    GLfloat *(*func)(id, SEL, GPUImageRotationMode) = (void *)imp;
    GLfloat *result = [GPUImageView class] ? func([GPUImageView class], s, inputRotation) : nil;
    
    ......
    
    glVertexAttribPointer(self.gpuDisplayTextureCoordinateAttribute, 2, GL_FLOAT, 0, 0, result);
    

    直奔重点——CVPixelBufferRef 的处理,将 renderTarget 转换为 CGImageRef 对象,再使用 UIGraphics 获得经 CGAffineTransform 处理过方向的 UIImage ,此时 UIImage 的方向并不是正常的方向,而是旋转过 90 度的图片,这么做的目的是为 videoInput 的 transform 属性埋下伏笔。下面是 CVPixelBufferRef 的处理代码:

    int width = self.gpuInputFramebufferForDisplay.size.width;
    int height = self.gpuInputFramebufferForDisplay.size.height;
    
    renderTarget = self.gpuInputFramebufferForDisplay.gpuBufferRef;
    
    NSUInteger paddedWidthOfImage = CVPixelBufferGetBytesPerRow(renderTarget) / 4.0;
    NSUInteger paddedBytesForImage = paddedWidthOfImage * (int)height * 4;
    
    glFinish();
    CVPixelBufferLockBaseAddress(renderTarget, 0);
    GLubyte *data = (GLubyte *)CVPixelBufferGetBaseAddress(renderTarget);
    CGDataProviderRef ref = CGDataProviderCreateWithData(NULL, data, paddedBytesForImage, NULL);
    CGColorSpaceRef colorspace = CGColorSpaceCreateDeviceRGB();
    CGImageRef iref = CGImageCreate((int)width, (int)height, 8, 32, CVPixelBufferGetBytesPerRow(renderTarget), colorspace, kCGBitmapByteOrder32Little | kCGImageAlphaPremultipliedFirst, ref, NULL, NO, kCGRenderingIntentDefault);
    
    UIGraphicsBeginImageContext(CGSizeMake(height, width));
    CGContextRef cgcontext = UIGraphicsGetCurrentContext();
    CGAffineTransform transform = CGAffineTransformIdentity;
    transform = CGAffineTransformMakeTranslation(height / 2.0, width / 2.0);
    transform = CGAffineTransformRotate(transform, M_PI_2);
    transform = CGAffineTransformScale(transform, 1.0, -1.0);
    CGContextConcatCTM(cgcontext, transform);
    
    CGContextSetBlendMode(cgcontext, kCGBlendModeCopy);
    CGContextDrawImage(cgcontext, CGRectMake(0.0, 0.0, width, height), iref);
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    self.img = image;
    
    CFRelease(ref);
    CFRelease(colorspace);
    CGImageRelease(iref);
    CVPixelBufferUnlockBaseAddress(renderTarget, 0);
    

    而 videoInput 的 transform 属性设置如下:

    _videoInput.transform = CGAffineTransformRotate(_videoConfiguration.affineTransform, -M_PI_2);
    

    经过这两次方向的处理,合成的小视频终于方向正常了。此处为简版的合成视频代码:

    CIImage *image = [[CIImage alloc] initWithCGImage:img.CGImage options:nil];
    CVPixelBufferLockBaseAddress(pixelBuffer, 0);
    [self.context.CIContext render:image toCVPixelBuffer:pixelBuffer];
    ...
    [_videoPixelBufferAdaptor appendPixelBuffer:pixelBuffer withPresentationTime:bufferTimestamp]
    

    可以看到关键点还是在于上面继承自 GPUImageView 这个类获取到的 renderTarget 属性,它应该即是取景框实时预览的结果,我在最初的合成中是使用 sampleBuffer 转 UIImage ,再通过 GPUImage 添加滤镜,最后将 UIImage 再转 CIImage ,这么做导致拍摄时会卡。当时我几乎想放弃了,甚至想采用拍好后再加滤镜的方式绕过去,最后这些不纯粹的方法都被我 ban 掉了。

    既然滤镜可以在取景框实时渲染,我想到了 GPUImageView 可能有料。在阅读过 GPUImage 的诸多源码后,终于在 GPUImageFramebuffer.m 找到了一个叫 renderTarget 的属性。至此,合成的功能也告一段落。

    3 、关于滤镜

    这里主要分享个有意思的过程。 App 里有三种类型的滤镜。基于 glsl 的、直接使用 acv 的以及直接使用 lookuptable 的。 lookuptable 其实也是 photoshop 可导出的一种图片,但一般的软件都会对其加密,下面简单提下我是如何反编译“借用”某软件的部分滤镜吧。使用 Hopper Disassembler 软件进行反编译,然后通过某些关键字的搜索,幸运地找到了下图的一个方法名。

    reverse 只能说这么多了....在开源代码里我已将这一类敏感的滤镜剔除了。

    小结

    开发相机 App 是个挺有意思的过程,在其中邂逅不少优秀开源代码,向开源代码学习,才能避免自己总是写出一成不变的代码。最后附上项目的开源地址 ,希望能够帮到有需要的朋友,也欢迎 star 和 pull request 。

    4 条回复    2017-02-24 11:07:49 +08:00
    gq6
        1
    gq6  
    OP
       2017-02-18 09:29:50 +08:00
    改着改着就不让编辑了,只好在回复里贴上项目地址: https://github.com/hawk0620/ZPCamera
    580a388da131
        2
    580a388da131  
       2017-02-19 01:47:02 +08:00
    这个主题怎么进这里了?
    Livid
        3
    Livid  
    MOD
       2017-02-20 22:16:45 +08:00 via iPhone
    @580a388da131 可能是作者在 /go/chamber 中先写好草稿吧。已经移动到 /go/create
    enjoeq
        4
    enjoeq  
       2017-02-24 11:07:49 +08:00
    谢谢分享。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1190 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 23ms · UTC 18:01 · PVG 02:01 · LAX 10:01 · JFK 13:01
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.