欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Android DIY抠图——想怎么抠就怎么抠

程序员文章站 2024-02-19 23:48:36
...

下面我分享Android的一个抠图技巧,这篇文章只适合有Android基础和向量基础的小伙伴,如果朋友们刚学Android不久,建议先去了解Android自定义View、Touch机制、Canvas/Path/Paint、向量等相关知识。
先来看看效果图:这张是原图
Android DIY抠图——想怎么抠就怎么抠

抠出脸部图片,下面是结果:
Android DIY抠图——想怎么抠就怎么抠

下面我给小伙伴们讲述具体流程。

1,获取本地相册,很简单,直接代码,不解释

public void scanAlbum(Context context, AlbumScanListener listener){
        try {
            if(listener == null){
                return;
            }
            List<LDAPhoto> photos = new ArrayList<>();
            String[] projection = {MediaStore.Images.ImageColumns.DATA};
            Cursor cursor = context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null,null);
            while(cursor.moveToNext()) {
                LDAPhoto photo = new LDAPhoto();
                String path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA));
                File file = new File(path);
                if(FileUtil.isFileExisted(path)) {
                    photo.setPath(path);
                    photo.setCreateTime(file.lastModified());
                    photos.add(photo);
                }
            }
            listener.onCompleted(photos);
        }catch (Exception e){
            listener.onScannerFailed();
        }
    }

2,全屏预览图片,手势层与图片层分离
整个view分图片层、带透明度的手势层(Mask),mask层覆盖图片层,手势相关逻辑都在mask层展开
Android DIY抠图——想怎么抠就怎么抠

简而言之,就是预览图片,上面铺一层蒙层,然后在蒙层里进行相关的抠图操作,预览的时候建议不是直接加载原图,而是根据屏幕宽高对图片进行自适应缩放,比如原图可能10000x10000,而屏幕只有100x100,那直接加载原图直接oom了,可以参考我的另一篇博客,如何巧妙的读取本地图片——bitmap的常用小技巧

3手势层监听手指轨迹,生成对应闭合区域(Path)
核心思想:定义mCanvas,操作bitmap,通过onDraw,中回调的canvas绘制到屏幕。

(1)初始化画笔:
mMaskPaint是蒙层画笔,mPathPaint是手指轨迹画笔,mCirclePaint是圆滑起点和终点的画笔(手指轨迹可能不是闭合,此时应该对两端点进行圆滑处理,mPath即用户手指轨迹)
令注:mStartX,mStartY是图片的左上角坐标,mBmWidth,mBmHeight是图片宽高

 private void initPaint() {
        mMaskPaint = new Paint();//mask画笔
        mMaskPaint.setAntiAlias(true);
        mMaskPaint.setStyle(Paint.Style.FILL);
        mMaskPaint.setStrokeCap(Paint.Cap.ROUND);
        mMaskPaint.setStrokeJoin(Paint.Join.ROUND);
        mMaskPaint.setColor(Color.parseColor("#d9000000"));

        mPathPaint = new Paint();//path画笔
        mPathPaint.setAlpha(0);
        mPathPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
        mPathPaint.setPathEffect(new CornerPathEffect(mPathWidth / 2));
        mPathPaint.setAntiAlias(true);
        mPathPaint.setDither(true);
        mPathPaint.setStyle(Paint.Style.STROKE);
        mPathPaint.setStrokeJoin(Paint.Join.ROUND);
        mPathPaint.setStrokeWidth(mPathWidth);

        mCirclePaint = new Paint();//圆滑两端的圆形画笔
        mCirclePaint.setAlpha(0);
        mCirclePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
        mCirclePaint.setPathEffect(new CornerPathEffect(mPathWidth / 2));
        mCirclePaint.setAntiAlias(true);
        mCirclePaint.setDither(true);
        mCirclePaint.setStyle(Paint.Style.FILL);

        mPath = new Path();//轨迹
        mPathRect = new RectF();//轨迹所在的矩形区域
    }

(2)初始化onDraw,第一次调用onDraw操作

         if(mBitmap == null){
            mBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
            mCanvas = new Canvas(mBitmap);
        }
        if(mIsFirstDrawMask) {
            mIsFirstDrawMask = false;
            mCanvas.drawRect(0, 0, getWidth(), getHeight(), mMaskPaint);
        }

(3)通过onTouch事件监听轨迹——非常重要
关键概述:记录用户手指按下、移动、抬起过程中的轨迹,如果在移动过程中轨迹出现闭合,则终止记录,抠出闭合区域,如果抬起是恰好闭合曲线,则抠出闭合区域,如果抬起时轨迹不闭合,则自动闭合,并抠出闭合区域。判断轨迹是否闭合,需要通过向量叉乘的知识,同时需要计算闭合轨迹的面积,如果面积过小(小于100像素),则不扣图,提示“请在图片上画出封闭区域”,

 @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                touchStart(x, y);
                break;
            case MotionEvent.ACTION_MOVE:
                touchMove(x, y);
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL://用户的抬手操作也可能出发的是cancel
                touchUp(x, y);
                break;
        }
        return true;
    }

private void touchStart(int x, int y) {
        if(mIsPathClose){//曲线已经闭合
            return;
        }
        mPath.reset();
        if(mPoints == null){
            mPoints = new ArrayList<>();
        }
        mPoints.clear();
        mPathPaint.setStyle(Paint.Style.STROKE);//设置画线
        mPath.moveTo(x, y);
        mCanvas.drawPath(mPath, mPathPaint);
        mCanvas.drawCircle(x, y, mPathWidth / 2, mCirclePaint);//起点边缘圆滑
        LDAPoint point = new LDAPoint(x, y);
        mPoints.add(point);
        mLastPoint = point;
        setPathRect(point);
        invalidate();
        mHadStarted = true;
    }

 private void touchMove(int x, int y) {
        if(!mHadStarted){
            //ACTION_DOWN可能在屏幕外触发
            touchStart(x, y);
            return;
        }
        if(mIsPathClose){
            return;
        }
        if(mLastPoint != null){
            //点距超过10才记录, 防止记录的点过多
            if(!isValidDistance(x, y)){
                return;
            }
        }
        //贝塞尔曲线
        mPath.quadTo(mLastPoint.x / 2 + x / 2, mLastPoint.y / 2 + y / 2, x, y);
        LDAPoint point = new LDAPoint(x, y);
        mPoints.add(point);//记录点
        //检测是否闭合
        int pos = checkPathClose();
        //实时更新path所在矩形
        updatePathRect(point);
        mLastPoint = point;
        if(pos >= 0){//已经闭合
            mIsPathClose = true;
            getClosePath(pos + 1);
            mPath.close();
            mPathPaint.setStyle(Paint.Style.FILL);
            drawMask();
            mCanvas.drawPath(mPath, mPathPaint);
            mCanvas.drawCircle(x, y, mPathWidth / 2, mCirclePaint);//端点圆滑
        }else {
            mCanvas.drawPath(mPath, mPathPaint);
            mCanvas.drawCircle(x, y, mPathWidth / 2, mCirclePaint);
        }
        invalidate();
    }

    private void touchUp(int x, int y) {
        mHadStarted = false;
        //moving过程已经闭合
        if(mIsPathClose){ return; }
        mPathPaint.setStyle(Paint.Style.FILL);//设置填充
        mPath.quadTo(mLastPoint.x / 2 + x / 2, mLastPoint.y / 2 + y / 2, x, y);
        LDAPoint point = new LDAPoint(x, y);
        mPoints.add(point);
        updatePathRect(point);
        mPath.close();//直接闭合
        Region region = new Region();
        Rect rect = new Rect();
        rect.left = (int) mPathRect.left;
        rect.right = (int) mPathRect.right;
        rect.bottom = (int) mPathRect.bottom;
        rect.top = (int) mPathRect.top;
        region.setPath(mPath, new Region(rect));
        if(calculateArea(region) < 100){//面积判断,小于100则提示
            Toast.makeText(getContext(), "请在图片上画出封闭区域", Toast.LENGTH_SHORT);
            reset();
            invalidate();
            return;
        }
        mIsPathClose = true;
        mCanvas.drawPath(mPath, mPathPaint);
        mCanvas.drawCircle(x, y, mPathWidth / 2, mCirclePaint);//端点圆滑
        invalidate();
    }

//检查path是否已经闭合,先通过矩形相交排斥的方法进行判断,在通过向量叉乘的方法进行判断
    private int checkPathClose() {
        int size = mPoints.size() - 3;
        LDAPoint q2 = mPoints.get(mPoints.size() - 1);
        LDAPoint q1 = mPoints.get(mPoints.size() - 2);
        for(int i = 0; i < size; i++){
            LDAPoint p1 = mPoints.get(i);
            LDAPoint p2 = mPoints.get(i + 1);

            if(!isScopeIntersect(p1, p2, q1, q2)){
                continue;
            }

            float d1 = crossProduct(p1, q2, q1);
            float d2 = crossProduct(q2, p2, q1);
            float d3 = crossProduct(q1, p2, p1);
            float d4 = crossProduct(p2, q2, p1);
            //d1 * d2 >= 0 说明p1p2线段和直线q1q2有交点
            //d3 * d4 >= 0 说明q1q2线段和直线p1p2有交点
            //两者同时满足则相交
            if(d1 * d2 >= 0 && d3 * d4 >= 0){
                return i;
            }
        }
        return -1;
    }

 //矩形相交排斥,p1 p2作为对角线的矩形和q1q2作为对角线的矩形没有相交
    public boolean isScopeIntersect(LDAPoint p1, LDAPoint p2, LDAPoint q1, LDAPoint q2){
        float maxQx = q2.x > q1.x ? q2.x : q1.x;
        float minQx = q1.x < q2.x ? q1.x : q2.x;
        if(p1.x < minQx && p2.x < minQx){
            return false;
        }
        if(p1.x > maxQx && p2.x > maxQx){
            return false;
        }
        float maxQy = q2.y > q1.y ? q2.y : q1.y;
        float minQy = q1.y < q2.y ? q1.y : q2.y;
        if(p1.y < minQy && p2.y < minQy){
            return false;
        }
        if(p1.y > maxQy && p2.y > maxQy){
            return false;
        }
        return true;
    }

//向量差乘
    private float crossProduct(LDAPoint p1, LDAPoint q2, LDAPoint q1) {
        LDAPoint p = new LDAPoint();
        p.x = p1.x - q1.x;
        p.y = p1.y - q1.y;
        LDAPoint q = new LDAPoint();
        q.x = q2.x - q1.x;
        q.y = q2.y - q1.y;
        return p.x * q.y- q.x * p.y;
    }

/**
     * 计算闭合区域面积
     * @param region
     * @return
     */
    private float calculateArea(Region region) {
        RegionIterator regionIterator = new RegionIterator(region);
        float area = 0;
        Rect tmpRect= new Rect();
        //通过计算矩形数量来获取面积
        while (regionIterator.next(tmpRect)) {
            area += tmpRect.width() * tmpRect.height();
        }
        return area;
    }

4绘制轨迹——非常重要
通过回调onDraw函数进行绘制得到的轨迹,绘制之前需要对轨迹进行插值,因为如果用户手指滑动很快,得到的轨迹的点很少,导致最后得到的轨迹棱角非常明显,所以需要对轨迹进行插值,具体方法后面详述。
下面是绘制代码

 if(mIsPathClose){
            mBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
            mCanvas = new Canvas(mBitmap);
            canvas.drawBitmap(mBitmap, 0, 0, mMaskPaint);
            mCanvas.drawRect(0, 0, getWidth(), getHeight(), mMaskPaint);
            //向量角度插值
            interploatePointsWithVertor();
            if(Build.VERSION.SDK_INT >= 19) {
                fixPath();//直接修正path
                mCanvas.drawPath(mPath, mPathPaint);
            }else{//通过四周填充矩形来修正path
                mCanvas.drawPath(mPath, mPathPaint);
                fixDrawOutside();
            }
            if(mPathListener != null){
                mPathListener.onPathClosed(mPath, mPathRect);
            }
            mPathPaint.setStrokeWidth(mPathWidth);
        }

完整的onDraw函数:

   @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //初始化绘制--------------------------------------------
        if(mBitmap == null){
            mBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
            mCanvas = new Canvas(mBitmap);
        }
        if(mIsFirstDrawMask) {
            mIsFirstDrawMask = false;
            mCanvas.drawRect(0, 0, getWidth(), getHeight(), mMaskPaint);
        }
        //闭合轨迹绘制------------------
        if(mIsPathClose){
            mBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
            mCanvas = new Canvas(mBitmap);
            canvas.drawBitmap(mBitmap, 0, 0, mMaskPaint);
            mCanvas.drawRect(0, 0, getWidth(), getHeight(), mMaskPaint);
            //向量角度插值
            interploatePointsWithVertor();
            if(Build.VERSION.SDK_INT >= 19) {
                fixPath();//直接修正path
                mCanvas.drawPath(mPath, mPathPaint);
            }else{//通过四周填充矩形来修正path
                mCanvas.drawPath(mPath, mPathPaint);
                fixDrawOutside();
            }
            if(mPathListener != null){
                mPathListener.onPathClosed(mPath, mPathRect);
            }
            mPathPaint.setStrokeWidth(mPathWidth);
        }else{
            canvas.drawBitmap(mBitmap, 0, 0, mMaskPaint);
        }
    }

(1)轨迹插值——让曲线圆滑
向量插值:当角度< 135,则进行插值C1,C2,如图,AOB为原轨迹的三个点,通过向量计算角度小于135,则取OA,OB终点C1,C2插值,最后得到的是AC1C2B四个点,从而使棱角变得圆滑,如果有必要,可以进行二度插值,比如AC1C2角度小于135,则可以在C1A和C1C2终点插值。
Android DIY抠图——想怎么抠就怎么抠
下面是插值代码:

    private void interploatePointsWithVertor() {
        for(int j = 0; j < 2; j++) {//二度插值
            int size = mPoints.size();
            List<LDAPoint> list = new ArrayList<>();
            for (int i = 0; i < mPoints.size(); i++) {
                LDAPoint O = mPoints.get(i % size);

                LDAPoint A, B;
                if (i != 0) {
                    A = mPoints.get((i - 1) % size);
                } else {
                    A = mPoints.get(size - 1);
                }
                if (i == size - 1) {
                    B = mPoints.get(0);
                } else {
                    B = mPoints.get(i + 1);
                }
                double angel = calculateAngle(A, O, B);//计算夹角
                if (angel < LIMIT_ANGLE) {//插入中值
                    LDAPoint cOA = calculateCenterPoint(O, A);
                    LDAPoint cOB = calculateCenterPoint(O, B);
                    list.add(cOA);
                    list.add(cOB);
                } else {
                    list.add(O);
                }
            }
            mPoints = list;
        }
        getClosePath();//更新path
    }

//计算OA向量和OB向量夹角
    public double calculateAngle(LDAPoint A, LDAPoint O, LDAPoint B){
        LDAPoint OA = getVector(O, A);
        LDAPoint OB = getVector(O, B);
        double LOA = getVectorLength(OA);
        double LOB = getVectorLength(OB);
        double product = getVectorProduct(OA, OB);
        double cos = product / (LOA * LOB);
        double angle = Math.acos(cos) * (180 / Math.PI);
        return angle;
    }

//向量点乘
 private double getVectorProduct(LDAPoint oa, LDAPoint ob) {
        return oa.x * ob.x + oa.y * ob.y;
    }

//向量长度
 private double getVectorLength(LDAPoint v) {
      return Math.sqrt(v.x * v.x + v.y * v.y);
  }

//通过两点获取向量
 private LDAPoint getVector(LDAPoint start, LDAPoint end) {
     return new LDAPoint(end.x - start.x, end.y - start.y);
 }

(2)去除超出图片的闭合区域
如下图,黄色圈圈是手指轨迹,具有非常多的不确定性,对于非法区域则需要做出非法提示,对于局部理想区域则需要抠出包含图片的区域,超出的区域则需要舍弃掉。
Android DIY抠图——想怎么抠就怎么抠

           if(Build.VERSION.SDK_INT >= 19) {
                fixPath();//直接修正path
                mCanvas.drawPath(mPath, mPathPaint);
            }else{//通过四周填充矩形来修正path
                mCanvas.drawPath(mPath, mPathPaint);
                fixDrawOutside();
            }

要摒弃非法区域和超出区域,其实只需要将轨迹和图片区域取交集即可,path取交集操作有API限制,当API大于19的手机,可以采用此方法,代码如***意,mStartX,mStartY是图片的左上角坐标,mBmWidth,mBmHeight是图片宽高

 @TargetApi(Build.VERSION_CODES.KITKAT)
    private void fixPath() {
        fixRect();
        Path path = new Path();
        path.moveTo(mPathRect.left, mPathRect.top);
        path.lineTo(mPathRect.right, mPathRect.top);
        path.lineTo(mPathRect.right, mPathRect.bottom);
        path.lineTo(mPathRect.left, mPathRect.bottom);
        path.close();
        mPath.op(path, Path.Op.INTERSECT);
    }

    private void fixRect() {
        if(mPathRect.left < mStartX){
            mPathRect.left = mStartX;
        }
        if(mPathRect.top < mStartY){
            mPathRect.top = mStartY;
        }
        if(mPathRect.right > mStartX + mBmWidth){
            mPathRect.right = mStartX + mBmWidth;
        }
        if(mPathRect.bottom > mStartY + mBmHeight){
            mPathRect.bottom = mStartY + mBmHeight;
        }
    }

当Api小于19的时候,采用四周填充法:
Android DIY抠图——想怎么抠就怎么抠

上面是最极端的用户轨迹,方法是,先画出轨迹,然后四周填充蒙层:
Android DIY抠图——想怎么抠就怎么抠


//四周填充法
    private void fixDrawOutside() {
        if(mPathRect.left < mStartX){
            Rect rect = new Rect();
            rect.right = mStartX;
            rect.bottom = getHeight();
            rect.top = 0;
            rect.left = 0;
            mCanvas.drawRect(rect, mMaskPaint);
        }
        if(mPathRect.right > mStartX + mBmWidth){
            Rect rect = new Rect();
            rect.right = getWidth();
            rect.bottom = getHeight();
            rect.top = 0;
            rect.left = mStartX + mBmWidth;
            mCanvas.drawRect(rect, mMaskPaint);
        }
        if(mPathRect.top < mStartY){
            Rect rect = new Rect();
            rect.right = getWidth();
            rect.bottom = mStartY;
            rect.top = 0;
            rect.left = 0;
            mCanvas.drawRect(rect, mMaskPaint);
        }
        if(mPathRect.bottom > mStartY + mBmHeight){
            Rect rect = new Rect();
            rect.right = getWidth();
            rect.bottom = getHeight();
            rect.top = mStartY + mBmHeight;
            rect.left = 0;
            mCanvas.drawRect(rect, mMaskPaint);
        }

    }

5,得到抠图——非常重要
以上的操作都是为了正常的预览和得到正确的轨迹,下面代码是真正从图片中把指定区域抠出来

 private void saveBitmap(Path mPath, RectF pathRect) {
        Bitmap bm = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(bm);
        Paint paint = new Paint();
        paint.setAntiAlias(true);
        int saveCount = canvas.saveLayer(0, 0, getWidth(), getHeight(), null, Canvas.ALL_SAVE_FLAG);
        paint.setStyle(Paint.Style.FILL);
        canvas.drawPath(mPath, paint);
        //path bitmap取交集
        paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
        canvas.drawBitmap(mBgBitmap, mStartX, mStartY, paint);
        paint.setXfermode(null);
        canvas.save(Canvas.ALL_SAVE_FLAG );
        canvas.restoreToCount(saveCount);
        saveBitmapToLocal(bm, pathRect);//保存bitmap到本地,普通的文件流即可
    }

下面是成果展示:
手指移动过程:
Android DIY抠图——想怎么抠就怎么抠

手指抬起:
Android DIY抠图——想怎么抠就怎么抠

得到图片:
Android DIY抠图——想怎么抠就怎么抠

过程比较复杂,但自认为文章写的很仔细,一个流程看下来,必然了然于胸,有疑问的小朋友,欢迎评论