Android 系统图片编辑的原理与实现——涂鸦与马赛克

图片编辑:涂鸦与马赛克

相信大家都用过微信的图片编辑功能,非常有用,例如发送图片前可以画上一些标记,或者把隐私信息涂上马赛克。最近在杏仁医生 APP 上,我们也添加了类似功能。今天就来讲讲其中的涂鸦和马赛克的原理与实现。下图就是我们最终的实现效果。

涂鸦&马赛克

基本概念

在讲具体的实现之前,先来看一下图片编辑功能中用到的一些基本概念。了解这些对后续一些复杂计算的理解有一定的帮助。

坐标系

  1. 触屏坐标系
    这里一般会使用 MotionEvent 中的 getX()getY(),这里的坐标值就是相对于当前控件显示区域的相对坐标,不论当前控件如何显示,如
    scrollXscrollY 的值发生变化后,也不会影响触屏坐标。不过有一点就是控件如果旋转(setRotation)或者平移(setTranslation)后,坐标原点位置也是会跟着变化的。

  2. 画布坐标系
    画布坐标可以简单计算得到,当前的坐标原点就是 (-scrollX, -scrollY),这样可以通过 View 的 scrollToscrollBy 方便地实现平移效果。

图片位置

图片的位置是基于画布坐标系的,并使用一个 RectF 对象表示(下面使用 mFrame 表示),它可以表示出图片的左上角和右下角坐标,因此 mFrame
不仅表示了图片的坐标位置,还表示的图片的缩放程度。关于图片展示,缩放以及平移等操作,可以参考我写过的一个大图预览的库:IntensifyImageView

// 当前图片矩形
RectF mFrame; 
// 原始图片矩形
RectF mOriginalFrame; 
// 当前图片的缩放值
float scale = mFrame.getWidth() / mOriginalFrame.getWidth();

绘制的时候只需要将Bitmap对象绘制到mFrame矩形中即可,对图像的缩放及平移操作全部转化到了对mFrame矩形的操作。

// 绘制图像
canvas.drawBitmap(bitmap, null, mFrame, null);

图像的缩放

图片缩放会涉及到两个坐标系,手势触摸得到缩放值及缩放中心,如 (focusX, focusY, factor),然后转换成画布坐标 (focusX + scrollX, focusY + scrollY, factor),再根据这个坐标及缩放值计算 mFrame,如下:

Matrix m = new Matrix();
m.setScale(factor, factor, newFocusX, newFocusY);
m.mapRect(mFrame);

如上可以看出,缩放会影响到图像的画布坐标。

图像的平移

平移可以使用两个接口:

scrollTo(x, y);

// 还是借助scrollTo实现的
scrollBy(dx, dy);

图像的平移不会影响图片的画布位置,当前控件的视图窗口会发生变化,也就是 scrollXscrollY 的值发生变化。

涂鸦与马赛克

实现要点

  • 涂鸦
    • 画笔颜色可变(几种常用颜色)
    • 画笔粗细始终保持一致
    • 路径过渡平滑及两端圆角
  • 马赛克
    • 画笔粗细在当前缩放状态下保持一致
    • 路径过渡平滑及两端圆角

原理分析

这两个功能点其实是有很大的重合部分的,即都是绘制出一个路径,涂鸦绘制纯色路径,而马赛克绘制处理后的图片路径。

手势路径这个很简单,Java 中的 Path 想必大家都很了解,大致绘制记录代码如下:

switch(event.getActionMasked()) {
    case MotionEvent.ACTION_DOWN:
        path.reset();
        path.moveTo(event.getX(), event.getY());
        break;
    case MotionEvent.ACTION_MOVE:
        path.lineTo(event.getX(), event.getY());
        break;
    case MotionEvent.ACTION_UP:
        // 添加到Path列表中
        break;  
}

但是涂鸦和马赛克的实现原理略有不同,下面我们来详细看下。

涂鸦实现

上面的分析看似很是简单,但是必须还要解决好以下问题:

  • 当前绘制的路径要以正确的尺寸及位置绘制到界面上(手势坐标系)
  • 已绘制的路径要随着图层滚动缩放等正确变化(画布坐标系)

那么为什么会有这两个问题呢?因为图片编辑从一开始的设计思路几乎是紧贴微信的,背景图层是可以随时缩放移动的(和其他图片编辑有些区别,很多是不可以在编辑时缩放移动的),因此绘制计算难度大增。

先看下 Path 和图层间的关系(Path 使用的是控件坐标,因此如果不经变化直接绘制就会出现如下情况):

涂鸦

当前 Path 绘制到屏幕上需要缩放当前画笔粗细度,反向旋转当前画布(旋转角度为负的当前旋转度),再平移到当前的滚动偏移值:

mDoodlePaint.setColor(mPen.getColor());
mDoodlePaint.setStrokeWidth(IMGPath.BASE_DOODLE_WIDTH * mImage.getScale());

canvas.save();
RectF frame = mImage.getClipFrame();
canvas.rotate(-mImage.getRotate(), frame.centerX(), frame.centerY());
canvas.translate(mView.getScrollX(), mView.getScrollY());
canvas.drawPath(mPen.getPath(), mDoodlePaint);
canvas.restore();

这里是唯一不需要的就是缩放,因为当前情况我需要绘制的就是这么大小的尺寸。

那么已经绘制的路径为何处理方式不同呢?

应为绘制完成后我可以对图层进行缩放操作,而已经绘制过的路径是需要与绘制时的图层保持相对位置大小不变的,也就是跟着缩放。

对路径缩放的方法有如下:

Matrix matrix = new Matrix();
matrix.setScale(sx, sy, px, py);
path.transform(matrix);

这样可以,但是效率受到了极大的影响,是因为我每次手势缩放过程中的几十次连续的缩放值的变化都要对已经加入的路径进行如上缩放操作,而且每个路径的实际缩放值还是不同的。

解决办法是将已完成绘制的路径缩放平移旋转到原始坐标中,这样每次绘制时都是统一缩放平移旋转一次画布,如此一来所需要的代价仅仅是绘制一个路径。

具体效果图如下:

涂鸦

public void addPath(IMGPath path, float sx, float sy) {
    if (path == null) return;

    float scale = 1f / getScale();

    M.setTranslate(sx, sy);
    M.postRotate(-getRotate(), mClipFrame.centerX(), mClipFrame.centerY());
    M.postTranslate(-mFrame.left, -mFrame.top);
    M.postScale(scale, scale);
    path.transform(M);

    switch (path.getMode()) {
        case DOODLE:
            mDoodles.add(path);
            break;
        case MOSAIC:
            path.setWidth(path.getWidth() * scale);
            mMosaics.add(path);
            break;
    }
}

当需要绘制这一系列路径时如下(旋转操作在绘制最初已设置):

public void onDrawDoodles(Canvas canvas) {
    if (!isDoodleEmpty()) {
        canvas.save();
        float scale = getScale();
        canvas.translate(mFrame.left, mFrame.top);
        canvas.scale(scale, scale);
        for (IMGPath path : mDoodles) {
            path.onDrawDoodle(canvas, mPaint);
        }
        canvas.restore();
    }
}

马赛克也是类似的路径绘制问题,下面再具体分析并实现。

马赛克实现

马赛克路径的确定和涂鸦基本一致,不同的在于马赛克的路径宽度是不同的,当前绘制时的马赛克宽度永远是一个值,因此缩放后再绘制所形成的马赛克路径粗细各不相同,所以每个马赛克路径需要额外记录一下路径宽度(涂鸦需要记录颜色值)。

那么每个路径画出的马赛克是如和形成的呢?其实很简单,马赛克就是将整个区域的颜色变成一个颜色值,如将 10x10 区域内的颜色变成其中的一个颜色值,所以我们将一张图片缩放到一个较小的尺寸,然后再放大到原始尺寸去显示,这个图片就很模糊了,然后关闭 Paint 的滤波功能
paint.setFilterBitmap(false),这样就得到了一个图片的马赛克效果,如下:

马赛克

所以为什么要将一整张图变成马赛克呢?可以用一张图简单表示如下:

马赛克

将马赛克路径图层与马赛克图层合并显示即可。也就是 Paint 的如下功能:
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));

关键代码如下:

private void makeMosaicBitmap() {
    if (mMosaicImage != null || mImage == null) {
        return;
    }

    if (mMode == IMGMode.MOSAIC) {

        int w = Math.round(mImage.getWidth() / 64f);
        int h = Math.round(mImage.getHeight() / 64f);

        w = Math.max(w, 8);
        h = Math.max(h, 8);

        // 马赛克画刷
        if (mMosaicPaint == null) {
            mMosaicPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
            mMosaicPaint.setFilterBitmap(false);
            mMosaicPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
        }

        mMosaicImage = Bitmap.createScaledBitmap(mImage, w, h, false);
    }
}

从原图创建一个马赛克位图,绘制时分两段,分别是绘制马赛克路径图层和绘制马赛克图层(当前马赛克路径和涂鸦的当前路径绘制逻辑基本一致,这里就不放出了):

public int onDrawMosaicsPath(Canvas canvas) {
    int layerCount = canvas.saveLayer(mFrame, null, Canvas.ALL_SAVE_FLAG);

    if (!isMosaicEmpty()) {
        canvas.save();
        float scale = getScale();
        canvas.translate(mFrame.left, mFrame.top);
        canvas.scale(scale, scale);
        for (IMGPath path : mMosaics) {
            path.onDrawMosaic(canvas, mPaint);
        }
        canvas.restore();
    }

    return layerCount;
}

public void onDrawMosaic(Canvas canvas, int layerCount) {
    canvas.drawBitmap(mMosaicImage, null, mFrame, mMosaicPaint);
    canvas.restoreToCount(layerCount);
}

马赛克路径的加入和涂鸦也是一致的,都统一在上面提到的 addPath 方法中完成了,也是需要缩放旋转平移到原始坐标中去的。

至此涂鸦和马赛克核心编码思想已经全部出现出,部分细节在与贴片和裁剪等功能结合时可能略微调整。

结语

我已经把本文提到的图片编辑功能抽取成独立的类库并开源,大家如果有什么建议,欢迎和我讨论,一起来优化它。Github 的地址是:https://github.com/kareluo/Imaging

0

我们正在招聘Java工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com

发表评论

电子邮件地址不会被公开。 必填项已用*标注