前几天收到产品经理的一个新需求,要求在下一代的8核CPU的产品上实现 照相机的实时滤镜效果。且必须 8个(或以上)滤镜同时显示。
拿到需求,首先参考了下竞品以及主流的照相/图片处理应用,发现大部分的应用都是采取静态的图片处理,即拍完照之后再进行处理,比如meitu, camera 360(v4.6)等等。这样的话,对于图像处理的性能要求其实并不高,只要在2-3秒钟之内处理完一张图片,用户体验都不会太差。但是实时滤镜就不同了,至少15fps以上才会达到比较流畅的体验。部分较优秀的手机和应用也支持了同一时间显示一个实时滤镜,比如HTC new ONE, xiaomi v5, camera 360 v4.7, UCAM 等等。真正能够流畅地显示多个滤镜的只有 iOS 7 和 Galaxy S4。可见,多实时滤镜的实现确实是一项对硬件性能和软件编写都具有很高要求的任务。
硬件环境
由于手头还没拿到MTK最新的6592的板子,只能在现有的MT6589T + Full HD (1080 x 1920) + 13M Camera 的硬件环境上实现。MT6589T是MTK 在2013年的旗舰产品,CPU 采用 Cortex-A7 ,4核 1.5GHz。Cortex-A7号称是ARM Cortex系列功耗优化最好的产品,但是执行性能相较顶级的Cortex-A15/Snapdragon Krait/Apple A6相差近一倍(同等主频的情况下)。 GPU 采用的是 PowerVR的 SGX544 ,544属于PowerVR的5XT系列,是PowerVR的顶级产品线,虽然比不上Tegra 和 adreno 顶级产品,性能也算不错。在后来的实验中,可以发现6589在1080p的显示下,要完成一些复杂图形的处理其实还是有些吃力,同样的代码,在6589上显示有延迟,在Samsung Galaxy S4上就跑得游刃有余,非常流畅。因此,这也给程序在性能优化上提出了额外的挑战。
准备
如何从Camera 获得Preview图像?
在图像处理之前,首先要从Camera获得Preview的图像。Google的SDK文档写的比较简略,参考了StackOverflow上的一些问答,从Camera SDK中获得图像有两种方式,分别是Callback方式
Camera.setPreviewCallback() or setPreviewCallbackWithBuffer()
以及Texture方式
Camera.setPreviewTexture()
区别在于前者需要一个SurfaceView, 在Camera将数据投射到SurfaceView的同时,用户注册的callback会收到图像的数据。图像的数据格式可以由用户指定,默认为NV21(YUV格式),为了便于上层软件处理,也可以要求数据以RGB565编码。但是,除了默认的NV21,其他格式的支持与否依赖具体的硬件实现。我在Galaxy S3和S4上尝试获取 RGB565 格式的数据,都没有成功。(我们自己的测试机倒是可以)
采用Camera.setPreviewCallback()还有一个小问题,就是必须要先用Camera.setPreviewDisplay()对Camera设置一个预览的SurfaceView。对于那些不想显示原始的预览效果的应用来说,可以把SurfaceView的尺寸设为 1×1,让用户不可见。
大体来说,采用Callback方式的步骤是
- 创建一个android.hardware.Camera对象
- 创建一个SurfaceView用于原始preview的显示
- 在SurfaceCreated回调函数中用 Camera.setPreviewDisplay()将自己设置成preview的View
- 设置preview的数据格式,以及尺寸
- 调用Camera.startPreview()开始预览
- 调用Camera.setPreviewCallback()或者Camera.setPreviewCallbackWithBuffer()注册回调函数
根据网上给的建议,使用withBuffer()版本的效率会高一些,毕竟省去了framework每次分配buffer的时间,但是必须在setPreviewCallback()之前用Camera.addCallbackBuffer()将预先分配的buffer加入队列,而且在每次回调函数处理完数据后需要将使用完的buffer重新添加回队列。为了Camera在回调处理过程中能够继续填充buffer,最好在开始之前向队列中添加多于一个buffer。
Texture方式用于OpenGL ES pipeline, 留到下节再讲。
第一个版本
律在尝试所有其他技术之前,我先试着用最简单的方式实现了一个多实时滤镜的原型。在这个原型中所有的图片处理都在Java代码中完成,甚至没有采用多线程的方式。
在这个实现中,我们仅仅尝试了3种最简单的滤镜,灰阶,怀旧(褐色系)以及负片。然后将处理完的数据用Bitmap.createBitmap()写到位图中显示。
public void onPreviewFrame(byte[] data, Camera camera) { 。。。 for(...){ ... colorsBNW[pos] = 0xff000000; colorsBNW[pos] |= greyScale << 16; colorsBNW[pos] |= greyScale << 8; colorsBNW[pos] |= greyScale; colorsSepiaTone[pos] = 0xff000000; colorsSepiaTone[pos] |= (min((int)(red*0.393f + green*0.769f + blue*0.189f), 255)) << 16; colorsSepiaTone[pos] |= (min((int)(red*0.349f + green*0.686f + blue*0.168f), 255)) << 8; colorsSepiaTone[pos] |= (min((int)(red*0.272f + green*0.534 + blue*0.131f), 255)); colorsRevert[pos] = 0xff000000; colorsRevert[pos] |= (0xff ^ red)<<16; colorsRevert[pos] |= (0xff ^ green)<<8; colorsRevert[pos] |= 0xff ^ blue; } ... }
结果不出意料地很卡,打开trace一看,每次回调函数的执行时间都在300ms左右,其中数据处理花去了240ms。可以想象,即使换成多线程处理,由于回调的主体计算仍在,效率也不会有质的提升。所以,在Java中做图像处理是没有可能的,必须另辟蹊径,寻找在Android系统中可以实现高性能图像处理(高速运算)的方式。大致说来,Android支持三种高性能运算的方式:RenderScript, OpenGL ES,JNI (with ARM NEON)
RenderScript简介
我们首先尝试了RenderScript。在Google的介绍中, RenderScript是一种基于异构平台,高效计算的强大工具,尤其擅长图像,音频处理以及复杂的科学计算。RenderScript包括一个内核(kernel)编写语言和一组Java SDK。内核语言的语法类似C99标准,google为kernel提供了运行环境以及“标准库”。标准库主要提供了数学计算,向量/矩阵计算,以及一些OpenGL的功能(在4.2上已经被舍弃了),和log(调试用)。Java SDK让开发者在应用程序中管理RenderScript的整个lifecycle, 包括创建上下文,script对象,设置script全局变量,运行script等等。
通过合理地编写kernel,Android可以将可并行的计算分布到CPU的多个内核,或者GPU,甚至DSP之上。Android声称,任务非配的策略取决于当前各个硬件的负载以及kernel中的逻辑(适合CPU还是GPU)。Google的LLVM机制可以保证Renderscript在各个系统平台上运行,并保持类似native(assembler)的执行速度。从用途,以及代码的编写方式来看,RenderScript和OpenCL非常类似,网络上也有一些文章讨论两者之间的关系。
RenderScript经过几个版本的演进,最新的版本是API Level 18 (Android 4.3 JB MR2)。在2013年的Google IO上Google的工程师也演示了最新版RenderScript的各种用途(2013 – Google IO – High Performance Applications with RenderScript)。可惜的是有些很有用的特性,比如读写一个allocation的任意位置 (rsSetElementAt()),在Android 4.2中尚未启用。这样的话,在kernel中就只能实现一些色阶的变化,而无法尝试扭曲,模糊这类复杂的效果。(除非改用root方式)
Google Guide上介绍了如何写一个最简单的kernel以及如何在Android app中加载和使用RenderScript。
Advanced Renderscript更详细地讨论了RenderScript(Kernel)的运行时环境和application之间的联系,以及如何在它们之间共享内存(类似app通过JNI和native code交互)。注意一点,由于RenderScript(Kernel)本身没有allocate memory的功能(因为有可能在GPU上运行?),在kernel中使用的内存都在APP端分配Allocation,然后绑定到RenderScript中。
代码片段
在这个练习中用到的RenderScript功能比较简单,将预览图片90度旋转之后转换成灰阶,怀旧和负片。为了方便起见,我将所有的处理写到了一个script中。其中,旋转作为root函数存在(因为JB 4.3之前只有root可以往Allocation中的任意位置写数据),其余的图片处理都实现成kernel函数。
RenderScript的完整实现如下:
#pragma version(1) #pragma rs java_package_name(com.example.t_rtf_camera) int mImageWidth; int mImageHeight; const uchar2 *gInPixels; uchar4 *gRotatePixels; const static float3 gMonoMult = {0.299f, 0.587f, 0.114f}; //Grey Scale uchar4 __attribute__((kernel)) blackwhite(uchar4 in, uint x, uint y){ float4 f4 = rsUnpackColor8888(in); float3 mono = dot(f4.rgb, gMonoMult); return rsPackColorTo8888(mono); } const static float3 gSepiaToneMult = {1.2f, 1.0f, 0.8f}; //Sepia tone uchar4 __attribute__((kernel)) sepiatone(uchar4 in, uint x, uint y){ float4 f4 = rsUnpackColor8888(in); float3 mono = dot(f4.rgb, gMonoMult); float3 st = gSepiaToneMult*mono; st.r = min(1.0f, st.r); return rsPackColorTo8888(st); } //Negative color uchar4 __attribute__((kernel)) revert(uchar4 in, uint x, uint y){ uchar4 out; out.a = in.a; out.r = 0xff - in.r; out.g = 0xff - in.g; out.b = 0xff - in.b; return out; } //90 degree rotate and convert void root(const int32_t *v_in, int32_t *v_out, const void *usrData, uint32_t x, uint32_t y) { int32_t row_index = *v_in; for (int i = mImageWidth-1; i >= 0; i--) { uchar2 inPixel = gInPixels[row_index*mImageWidth+i]; uchar4 out; out.a = 0xff; out.r = inPixel[1] & 0xf8; out.g = (inPixel[1]&0x07)<>3; out.b = (inPixel[0] & 0x1f) << 3; int targetPos = i*mImageHeight + (mImageHeight-row_index-1); gRotatePixels[targetPos] = out; } }
在APP端,首先在 Activity 的 onCreate中创建RenderScript对象
mRS = RenderScript.create(this);
在开启Camera预览之前,预先分配Allocation (在运行RenderScript时才会绑定)。inType是预览图片的数据类型(RGB565), outType是经过90度旋转后,并转换成RGBA8888的数据类型,resultType是指图片处理完之后的类型(与outType一样)
Type.Builder inTypeBuilder = new Type.Builder(mRS, Element.U8_2(mRS)); Type.Builder outTypeBuilder = new Type.Builder(mRS, Element.U8_4(mRS)); Type.Builder resultTypeBuilder = new Type.Builder(mRS, Element.U8_4(mRS)); inTypeBuilder.setX(width).setY(height); outTypeBuilder.setX(height).setY(width); resultTypeBuilder.setX(height).setY(width); Type inType = inTypeBuilder.create(); Type outType = outTypeBuilder.create(); Type resultType = resultTypeBuilder.create(); mInAllocation = Allocation.createTyped(mRS, inType); mRotateAllocation = Allocation.createTyped(mRS, outType); mBlackWhiteBmp = Bitmap.createBitmap(height,width,Bitmap.Config.ARGB_8888); mSepiaToneBmp = Bitmap.createBitmap(height,width,Bitmap.Config.ARGB_8888); mRevertBmp = Bitmap.createBitmap(height,width,Bitmap.Config.ARGB_8888); mBlackWhiteAllocation = Allocation.createFromBitmap(mRS, mBlackWhiteBmp); mSepiaToneAllocation = Allocation.createFromBitmap(mRS, mSepiaToneBmp); mRevertAllocation = Allocation.createFromBitmap(mRS, mRevertBmp);
值得注意的是,我们这里还额外增加了一个“index allocation”。增加他的原因是将root分成N个可并行(N为图片行数)运算,利用RenderScript Parallization的机制,充分享受到CPU多核带来的加速。
int[] rowIndices = new int[height]; for (int i = 0; i < height; i++) { rowIndices[i] = i; } mRowIndicesAllocaction = Allocation.createSized(mRS, Element.I32(mRS), height, Allocation.USAGE_SCRIPT); mRowIndicesAllocaction.copyFrom(rowIndices);
最后,创建Script对象,并设置常量
mScript = new ScriptC_filter(mRS, getResources(), R.raw.filter); mScript.set_mImageWidth(width); mScript.set_mImageHeight(height);
在Camera Preview的帧回调onPreviewFrame()中,调用RenderScript依次旋转,做色阶变换。
//Rotate the picture mInAllocation.copyFromUnchecked(data); mScript.bind_gInPixels(mInAllocation); mScript.bind_gRotatePixels(mRotateAllocation); mScript.forEach_root(mRowIndicesAllocaction, mRowIndicesAllocaction); //Apply Grey-scale effect mScript.forEach_blackwhite(mRotateAllocation, mBlackWhiteAllocation); mBlackWhiteAllocation.copyTo(mBlackWhiteBmp); mBNWPreview.drawFrame(mBlackWhiteBmp, true); ......
最后,为了不阻塞主线程,每个特效窗口(SurfaceView)在单独的线程中更新自己的内容
public class FilterPreview extends SurfaceView{ ... public void drawFrame(final Bitmap bmp, final boolean debug){ new Thread(){ public void run(){ Canvas c = mSurfaceHolder.lockCanvas(); c.drawBitmap(bmp, 0, 0, null); mSurfaceHolder.unlockCanvasAndPost(c); } }.start(); } ... }
下图是采用RenderScript方式实现的效果,除了左上角的原始预览之外,显示3个实时滤镜可以达到11~15FPS的速率,但是随着预览窗口的增加,FPS会有明显的降低。
总体来说,由于Android 4.2对于RenderScript的支持有限(可以认为4.3是一个比较完整的实现),因此我们认为RenderScript是一项可行,但不十分理想的实时滤镜实现方式。在下一章节中,我们将讨论用OpenGL ES 2.0实现滤镜的方式以及效率问题。