Android修圖處理算法—— drawBitmapMesh算法實現瘦臉效果

一.初識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 數組對應的元素,然後重新繪製,就可以得到我們想要的變形後的圖像。

經過分析,你會發現,一下幾點使我們要關注的,也是我們要實現的。

  1. 只有圓形選區內的圖像才進行變形(這裏需要自己用代碼控制一下)
  2. 拖動距離 MC 越大變形效果越明顯(這裏需要自己用代碼控制一下,下面我會給大家講講)
  3. 越靠近圓心,變形越大,越靠近邊緣的變形越小,邊界處無變形(算法公式已經實現)
  4. 變形是平滑的(算法公式已經實現)

既然,知道了具體的操作,好了,直接上代碼吧。

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[] 數組裏的元素做相應的處理即可。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章