一.初识Canvas.drawBitmapMesh()
1. 方法介绍分析
先看drawBitmapMesh官方api介绍:
打开元源码看看drawBitmapMesh的详细介绍,就知道这个方法参数的具体描述。
函数的几个参数的意思如下:
- bitmap:将要扭曲的图像
- meshWidth:控制在横向上把该图像划成多少格
- meshHeight:控制在纵向上把该图像划成多少格
- verts:网格交叉点坐标数组,长度为(meshWidth + 1) * (meshHeight + 1) * 2
- vertOffset:控制verts数组中从第几个数组元素开始才对bitmap进行扭曲
drawBitmapMesh() 方法与操作像素点来改变色彩的原理类似。只不过是把图像分成一个个的小块,然后通过改变每一个图像块来改变整个图像。而 drawBitmapMesh() 方法改变图像的方式,就是通过改变这个 verts 数组里的元素的坐标值来重新定位对应的图像块的位置,从而达到图像效果处理的功能。从这里我们就可以看得出来,借用 Canvas.drawBitmapMesh() 方法可以实现各种图像形状的处理效果,只是实现起来比较复杂,关键在于计算、确定新的交叉点的坐标。
verts数组里其实存的就是将图像分割成若干个图像块,在图像上横纵方向各划分成 N-1 格,而这横纵分割线就交织成了N*N个点,而每个点的坐标将以x1,y1,x2,y2,···,xn,yn的形式保存在 verts 数组里。如下图所示:
你会发现,经过drawBitmapMesh扭曲后,verts 数组的坐标点就会有所变动,而肉眼所能看到的图片最终都是bitmap按照verts数组里坐标点一点点描绘出来,这样就实现了瘦脸的效果。
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);
}
分析至此,就代码实战实现人像瘦脸的功能。
二、实现瘦脸效果
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 越大变形效果越明显(这里需要自己用代码控制一下,下面我会给大家讲讲)
- 越靠近圆心,变形越大,越靠近边缘的变形越小,边界处无变形(算法公式已经实现)
- 变形是平滑的(算法公式已经实现)
既然,知道了具体的操作,好了,直接上代码吧。
3.代码实战
等等,在实战的时候,你会发现算法文献里用的是向量,公式是向量的计算,这算法公式并不能直接用啊!所以需要我们做一下转换,向量转换的方式如下列。
3.1向量的坐标计算
坐标系解向量加减法:
在一个在直角坐标系里面,定义原点为向量的起点。两个向量和与差的坐标分别等于这两个向量相应坐标的和与差若向量的表示为(x,y)形式:
A(X1,Y1) B(X2,Y2),则A + B=(X1+X2,Y1+Y2),A - B=(X1-X2,Y1-Y2)
简单地讲:向量的加减就是向量对应分量的加减,类似于物理的正交分解。
好了,下面就直入正题吧。
3.2算法的代码实现
为了方便看到瘦脸效果,这里我做成动态的更新显示,首先通过 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;
接下来就是最为关键的算法代码,这里是将圆形范围内的每一个交叉点的横纵坐标分别求出其逆变换的坐标,并将求得的值重新赋给这个交叉点,代码如下:
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);
Log.i("postion","dPull:"+dPull);
Log.i("postion","startX:"+startX);
Log.i("postion","startY:"+startY);
Log.i("postion","endX:"+endX);
Log.i("postion","endY:"+endY);
Log.i("postion","ddPull:"+ddPull);
Log.i("postion","endY:"+endY);
//文献中提到的算法,并不能很好的实现拖动距离 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();
}
关键的代码写完了,接下来就需要在onTouchEvent方法里做实时回调绘制。具体的绘制就是在监听action的事件为MotionEvent.ACTION_UP时调用warp方法,代码如下:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//绘制变形区域
startX = event.getX();
startY = event.getY();
showCircle = true;
invalidate();
break;
case MotionEvent.ACTION_MOVE:
//绘制变形方向
moveX = event.getX();
moveY = event.getY();
showDirection = true;
invalidate();
break;
case MotionEvent.ACTION_UP:
showCircle = false;
showDirection = false;
//调用warp方法根据触摸屏事件的坐标点来扭曲verts数组
warp(startX, startY, event.getX(), event.getY());
onStepChangeListener.onStepChange(false);
break;
}
return true;
}
好了,支持人脸的瘦脸效果就实现了,最后附上完整的代码。
package com.example.mydrawbitmapmesh;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import java.util.Stack;
/**
* 算法来源:http://www.gson.org/thesis/warping-thesis.pdf
*/
public class MultalPSView extends View {
private int screenWidth, screenHeight;//屏幕的宽高
private int mWidth, mHeight;//View 的宽高
//作用范围半径
private int r = 100;
private Paint circlePaint;
private Paint directionPaint;
//是否显示变形圆圈
private boolean showCircle;
//是否显示变形方向
private boolean showDirection;
//变形起始坐标,滑动坐标
private float startX, startY, moveX, moveY;
//将图像分成多少格
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 Bitmap mBitmap;
private IOnStepChangeListener onStepChangeListener;
public MultalPSView(Context context) {
super(context);
init();
}
public MultalPSView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public MultalPSView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
circlePaint = new Paint();
circlePaint.setStyle(Paint.Style.STROKE);
circlePaint.setStrokeWidth(5);
circlePaint.setColor(Color.parseColor("#bc2a35"));
directionPaint = new Paint();
directionPaint.setStyle(Paint.Style.FILL);
directionPaint.setStrokeWidth(10);
directionPaint.setColor(Color.parseColor("#bc2a35"));
}
private void initView() {
int index = 0;
Bitmap oriBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.dbj);
mBitmap = zoomBitmap(oriBitmap, mWidth, mHeight);
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;
}
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mWidth = w;
mHeight = h;
initView();
}
private Bitmap zoomBitmap(Bitmap bitmap, int width, int height) {
int w = bitmap.getWidth();
int h = bitmap.getHeight();
Matrix matrix = new Matrix();
float scaleWidth = ((float) width / w);
float scaleHeight = ((float) height / h);
float scale = Math.min(scaleWidth,scaleHeight);
matrix.postScale(scale, scale);
return Bitmap.createBitmap(bitmap, 0, 0, w, h, matrix, true);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawBitmapMesh(mBitmap, WIDTH, HEIGHT, verts, 0, null, 0, null);
if (showCircle) {
canvas.drawCircle(startX, startY, r, circlePaint);
}
if (showDirection) {
canvas.drawLine(startX, startY, moveX, moveY, directionPaint);
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//绘制变形区域
startX = event.getX();
startY = event.getY();
showCircle = true;
invalidate();
break;
case MotionEvent.ACTION_MOVE:
//绘制变形方向
moveX = event.getX();
moveY = event.getY();
showDirection = true;
invalidate();
break;
case MotionEvent.ACTION_UP:
showCircle = false;
showDirection = false;
//调用warp方法根据触摸屏事件的坐标点来扭曲verts数组
warp(startX, startY, event.getX(), event.getY());
onStepChangeListener.onStepChange(false);
break;
}
return true;
}
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);
Log.i("postion","dPull:"+dPull);
Log.i("postion","startX:"+startX);
Log.i("postion","startY:"+startY);
Log.i("postion","endX:"+endX);
Log.i("postion","endY:"+endY);
Log.i("postion","ddPull:"+ddPull);
Log.i("postion","endY:"+endY);
//文献中提到的算法,并不能很好的实现拖动距离 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();
}
/**
* 一键恢复
*/
public void resetView() {
for (int i = 0; i < verts.length; i++) {
verts[i] = orig[i];
}
onStepChangeListener.onStepChange(true);
invalidate();
}
public void setScreenSize(int screenWidth, int screenHeight) {
this.screenWidth = screenWidth;
this.screenHeight = screenHeight;
}
public void setOnStepChangeListener(IOnStepChangeListener onStepChangeListener) {
this.onStepChangeListener = onStepChangeListener;
}
public interface IOnStepChangeListener {
void onStepChange(boolean isEmpty);
}
}
好了,接下来是见证奇迹的时刻,先看一下最原始的图片吧:
好了,再看一下整个瘦脸的效果吧:
三.总结
好了,通过实战,你会发现,这里不仅仅可以瘦脸,还可以瘦各种地方。如果需要做拉伸处理,只需要将 verts[] 数组里的元素做相应的处理即可。