Android:修图技术之瘦脸效果的实现(drawBitmapMesh)

一、初识Canvas.drawBitmapMesh()

1、方法介绍分析

先来看看 Android API 中对 drawBitmapMesh 方法的介绍:


drawBitmapMesh方法

这个方法的参数还不少, 下面稍微讲讲几个比较重要的参数的意思:

  • bitmap:将要扭曲的图像
  • meshWidth:控制在横向上把该图像划成多少格
  • meshHeight:控制在纵向上把该图像划成多少格
  • verts:网格交叉点坐标数组,长度为(meshWidth + 1) * (meshHeight + 1) * 2
  • vertOffset:控制verts数组中从第几个数组元素开始才对bitmap进行扭曲

Android 中的 drawBitmapMesh() 方法与操纵像素点来改变色彩的原理类似。只不过是把图像分成一个个的小块,然后通过改变每一个图像块来改变整个图像。来看看下面这张经典的图像对比:

drawBitmapMesh效果

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

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 玩一玩:


Demo效果

写到这里,大家已经可以动手做一个修图APP出来了,结合我上一篇文章提到的滤镜效果,相信大家可以的。

4、补充

我的 Demo 里面加了作用范围圆形的显示和瘦脸拖动方向的显示,以及一键复原的按钮,方便同学们更加直观的理解和使用。

4.1.添加作用范围圆形的显示和瘦脸拖动方向的显示

在 onDraw() 方法里加上绘制圆形和直线的代码,如下:

    //是否显示变形圆圈
    private boolean showCircle;
    //是否显示变形方向
    private boolean showDirection;

    @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);
        }
    }

接着重新写写 onTouchEvent() 方法里的代码,在 MotionEvent.ACTION_DOWN 中绘制变形区域,在 MotionEvent.ACTION_MOVE 中绘制变形方向直线,在 MotionEvent.ACTION_UP 中 去掉变形区域和变形方向直线,代码如下:

    @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());
                break;
        }
        return true;
    }
4.2.添加一键复原的按钮

还记得上面提到最初获取分割图片的交叉点的坐标,我们将原始坐标保存在了 orig[] 数组中。这里,当我们点击复原按钮,我们就将 orig[] 数组的值赋给 verts[] 数组,然后重新绘制即可,很简单,添加一个接口监听即可,然后在 MainActivity 中调用一下,代码如下:

    /**
     * 一键恢复
     */
    public void resetView() {
        for (int i = 0; i < verts.length; i++) {
            verts[i] = orig[i];
        }
        onStepChangeListener.onStepChange(true);
        invalidate();
    }

    public void setOnStepChangeListener(IOnStepChangeListener onStepChangeListener) {
        this.onStepChangeListener = onStepChangeListener;
    }

    public interface IOnStepChangeListener {
        void onStepChange(boolean isEmpty);
    }

最后按照惯例,上一个Demo的动态图给大家看看吧,我这里就直接将拖动距离加大,好让大家直观地看到效果:


MyDrawBitmapMeshDemo

后续

  1. Demo中还有很多可以完善的细节,这里只做原理分析,感兴趣的同学可以继续完善,比如,变形区域的动态设置,记录每一次变形的数组值用于撤销上一步操作,等等。同样的,这里不仅仅可以瘦脸,还可以瘦各种地方。如果需要做拉伸处理,只需要将 verts[] 数组里的元素做相应的处理即可。

  2. 如果对图像滤镜效果感兴趣,可以看看我的上一篇文章 Android:修图技术之滤镜效果实现及原理分析——ColorMatrix

  3. Demo 下载地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容