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 下載地址

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容