一、初识Canvas.drawBitmapMesh()
1、方法介绍分析
先来看看 Android API 中对 drawBitmapMesh 方法的介绍:

这个方法的参数还不少, 下面稍微讲讲几个比较重要的参数的意思:
- bitmap:将要扭曲的图像
- meshWidth:控制在横向上把该图像划成多少格
- meshHeight:控制在纵向上把该图像划成多少格
- verts:网格交叉点坐标数组,长度为(meshWidth + 1) * (meshHeight + 1) * 2
- vertOffset:控制verts数组中从第几个数组元素开始才对bitmap进行扭曲
Android 中的 drawBitmapMesh() 方法与操纵像素点来改变色彩的原理类似。只不过是把图像分成一个个的小块,然后通过改变每一个图像块来改变整个图像。来看看下面这张经典的图像对比:

如上图,我们将图像分割成若干个图像块,在图像上横纵方向各划分成 N-1 格,而这横纵分割线就交织成了N*N个点,而每个点的坐标将以x1,y1,x2,y2,···,xn,yn的形式保存在 verts 数组里。也就是说,verts 数组中每两个元素保存一个交织点的位置,第一个保存横坐标,第二个保存纵坐标。而 drawBitmapMesh() 方法改变图像的方式,就是通过改变这个 verts 数组里的元素的坐标值来重新定位对应的图像块的位置,从而达到图像效果处理的功能。从这里我们就可以看得出来,借用 Canvas.drawBitmapMesh() 方法可以实现各种图像形状的处理效果,只是实现起来比较复杂,关键在于计算、确定新的交叉点的坐标。

2、方法代码实现
首先,我们将要修整的图片加载进来,然后获取其交叉点的坐标值,并将坐标值保存到 orig[] 数组中。其获取交叉点坐标的原理是通过循环遍历所有的交叉线,并按比例获取其坐标,代码如下:
//将图像分成多少格 private int WIDTH = 200; private int HEIGHT = 200; //交点坐标的个数 private int COUNT = (WIDTH + 1) * (HEIGHT + 1); //用于保存COUNT的坐标 //x0, y0, x1, y1...... private float[] verts = new float[COUNT * 2]; //用于保存原始的坐标 private float[] orig = new float[COUNT * 2]; private void initView() { int index = 0; Bitmap mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.test00); float bmWidth = mBitmap.getWidth(); float bmHeight = mBitmap.getHeight(); for (int i = 0; i < HEIGHT + 1; i++) { float fy = bmHeight * i / HEIGHT; for (int j = 0; j < WIDTH + 1; j++) { float fx = bmWidth * j / WIDTH; //X轴坐标 放在偶数位 verts[index * 2] = fx; orig[index * 2] = verts[index * 2]; //Y轴坐标 放在奇数位 verts[index * 2 + 1] = fy; orig[index * 2 + 1] = verts[index * 2 + 1]; index += 1; } } }
然后就是将 verts[] 数组里面的坐标值进行一系列的自定义的修改。这里对 verts[] 数组的修改直接体现在图像的显示效果,各种图像特效的处理关键就在于此。比如这篇文章对 verts[] 数组的修改是实现图像局部约束变形效果。
接着,我们将在onDraw()方法里,将修改过的 verts[] 数组重新绘制一遍,代码如下:
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); canvas.drawBitmapMesh(mBitmap, WIDTH, HEIGHT, verts, 0, null, 0, null); }
好,大致讲完 Canvas.drawBitmapMesh() 方法之后,我们接下来进入实践环节,也是本文的重点环节——实现人像瘦脸的功能。
二、实现瘦脸效果
1、算法提及
小弟这里用到的平滑过渡可交互的瘦脸算法是 Andreas Gustafsson 的 Interactive Image Warping 文献里提及的Uwarp's local mapping functions。截个图大家看看:



有一点兴趣的同学可以翻译一下这段。
有很大的兴趣的同学可以通篇看看这个文献 http://www.gson.org/thesis/warping-thesis.pdf
好了,接下来大家还是看看我的理解吧。
2、算法分析

看上图,这个坐标系对应着我们 Android 屏幕上的绘图坐标,点 C 就是我们手指触摸按下的坐标点,半径为 rmax 的圆形范围就是我们要平滑变形的区域,当我们在 C 位置按下屏幕并拖动到点 M 位置时,半径为 rmax 的变形区域内的每一个像素点将按照上述提及的算法公式进行位移,效果就是点 U 移动到点 X 的位置。所以,关键就是找到上面这个变换的逆变换——给出点 X 时,可以求出它变换前的坐标 U,然后用变化前图像在 U 点附近的像素进行插值,求出U的像素值。如此对圆形选区内的每一个像素进行求值,便可得出变换后的图像。在这里,就是求出点 U 的在 verts 数组对应的坐标值,并将此坐标值赋给 X 点在 verts 数组对应的元素,然后重新绘制,就可以得到我们想要的变形后的图像。
说白了就是需要我们实现以下特点:
- 只有圆形选区内的图像才进行变形(这里需要自己用代码控制一下)
- 拖动距离 MC 越大变形效果越明显(这里需要自己用代码控制一下,下面我会给大家讲讲)
- 越靠近圆心,变形越大,越靠近边缘的变形越小,边界处无变形(算法公式已经实现)
- 变形是平滑的(算法公式已经实现)
那有同学会注意到,文献中讲到的公式是向量的计算,这算法公式并不能直接用啊!且看我们中学的数学知识:
坐标系解向量加减法:
在直角坐标系里面,定义原点为向量的起点.两个向量和与差的坐标分别等于这两个向量相应坐标的和与差若向量的表示为(x,y)形式,
A(X1,Y1) ; B(X2,Y2),则:
A+B=(X1+X2,Y1+Y2),A-B=(X1-X2,Y1-Y2)
这样,我们可以从横纵坐标入手。话不多说,来实现吧。
3、算法的代码实现
首先通过 onTouchEvent() 方法获取到触摸按下时的点 C 的坐标,以及拖动结束时的点 M 的坐标:
@Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: startX = event.getX(); startY = event.getY(); break; case MotionEvent.ACTION_UP: //调用warp方法根据触摸屏事件的坐标点来扭曲verts数组 warp(startX, startY, event.getX(), event.getY()); break; } return true; }
定义一下我们局部变形的作用半径 rmax:
//作用范围半径private int r = 100;
接着就是最关键的代码,这里是将圆形范围内的每一个交叉点的横纵坐标分别求出其逆变换的坐标,并将求得的值重新赋给这个交叉点,下面将算法转换成java代码:
private void warp(float startX, float startY, float endX, float endY) { //计算拖动距离 float ddPull = (endX - startX) * (endX - startX) + (endY - startY) * (endY - startY); float dPull = (float) Math.sqrt(ddPull); //文献中提到的算法,并不能很好的实现拖动距离 MC 越大变形效果越明显的功能,下面这行代码则是我对该算法的优化 dPull = screenWidth - dPull >= 0.0001f ? screenWidth - dPull : 0.0001f; for (int i = 0; i < COUNT * 2; i += 2) { //计算每个坐标点与触摸点之间的距离 float dx = verts[i] - startX; float dy = verts[i + 1] - startY; float dd = dx * dx + dy * dy; float d = (float) Math.sqrt(dd); //文献中提到的算法同样不能实现只有圆形选区内的图像才进行变形的功能,这里需要做一个距离的判断 if (d < r) { //变形系数,扭曲度 double e = (r * r - dd) * (r * r - dd) / ((r * r - dd + dPull * dPull) * (r * r - dd + dPull * dPull)); double pullX = e * (endX - startX); double pullY = e * (endY - startY); verts[i] = (float) (verts[i] + pullX); verts[i + 1] = (float) (verts[i + 1] + pullY); } } invalidate(); }
好了,代码写完了。
说了半天,无图无真相啊。还是看看我的 Demo 的实现效果吧,看看下面的对比图,胖哥的腮帮是不是瘦了,当然,本来P图就是个技术活,我这里只是随手推了推胖哥的脸,难免显得不专业,感兴趣的同学可以到文末下载我的 Demo 玩一玩:

写到这里,大家已经可以动手做一个修图APP出来了,结合我上一篇文章提到的滤镜效果,相信大家可以的。
4、补充
我的 Demo 里面加了作用范围圆形的显示和瘦脸拖动方向的显示,以及一键复原的按钮,方便同学们更加直观的理解和使用。
4.1.添加作用范围圆形的显示和瘦脸拖动方向的显示
在 onDraw() 方法里加上绘制圆形和直线的代码,如下:
//是否显示变形圆圈 private boolean showCircle; //是否显示变形方向 private boolean showDirection; @Override protected void onDraw(Canvas canvas) { super.