Android 马赛克绘制方法

1,547 阅读10分钟

前言

在这篇文章中,我们使用了图像分块(或者是分片)的算法,这样做的目的是降低像素扫描的时间复杂度,并且也利于均色采样和提高绘制效率。其实图像分块是很常见的图像动效处理手段,一般主要用于场景变换,这项技术也和图像光栅化类似。

我们之前写过《Android 实现LED 展示效果》文章中,也适用了类似的方法。

比如,下面的图中,我们能清楚的看到,网格的颜色不存在渐变,每个网格的展示的颜色是固定的,这就是光栅化后着色的场景,但是区别与open gl更加细腻的三角形,我们这里按正方形处理。

Qu-es-la-rasterizacin-y-cual-es-su-diferencia-con-el-Ray-Tracing.jpg (黑色三角形图案的放大效果)

这张图给我的们的启示是,我们对图片区域进行网格分割,然后在网格中绘制一样的颜色,就能达到马赛克效果。

不过,本篇会介绍两种方法,一种不放大的方法,一种先缩小再放大的方法,都能实现马赛克效果。

分片原理简述

马赛克是一种图像编辑技术,广泛应用于隐私保护和涂鸦渲染,很多手机系统自带了这种效果,那如何才能实现这种技术呢?

实际上,马赛克的绘制可以图片分配实现,我们之前就利用分片方式实现过LED效果。

了解过我之前的文章的知道,我们制作LED有几个特征

  • 每个LED单元要么亮要么不亮
  • 每个LED单元只有一种颜色
  • 每个LED单元和其他LED单元存在一定的间距
  • 所有LED的单元成网格排列
  • 每个LED单元大小一致

以上相当于顶点坐标信息,我们拿到网格的位置,就能拿到LED整个区域的片段,知道这个区域的片段我们就可以修改其像素。

着色采样:

即便是每个矩形区域,也有很多像素点,如果每个矩形区域每个像素都要进行均色计算的话,那10x10的也要100此,因此为了更快的效率,需要对LED 范围内的像素点采样,求出颜色均值,均值色就是LED最终展示的颜色。

避坑——修改像素

上一篇我们知道,通过Bitmap.setPixel方法修改像素效率是极低的,我曾经写过一篇通过修改像素生成圆形图片的文章,在那篇文章里我们看到,像素本身也是有size的,导致最终的圆形图片存在大量锯齿,主要原因是通过这种方式没法做到双线性过滤(图片放大之后会对边缘优化),还有另一个问题,就是效率极差。 总结一下修改像素的问题:

  • 无法抗锯齿
  • 效率低

避坑——透明色

像素中往往存在 color为0或者alpha通道为0的情况,甚至有的区域因为采样原因导致清晰度急剧下降,甚至出现了透明区域噪点,这些问题主要来自于alpha 通道引发的颜色稀释问题,因此在采样时一定要规避这两种情况,至于会不会失真?答案是如果采用alpha失真只会更严重。

清晰度问题

同样,清晰度也容易受到这olor为0或者alpha通道为0的情况情况干扰,除了这两种就是采样区域的大小了,理论上采样网格密度越密,清晰度越高,越接近原始图片,因此一定要权衡,太清晰不就很原图一样了么,还制作什么LED呢?

马赛克原理

实际上,马赛克原理和LED展示方式类似,为什么这么说呢?从特征来看,几乎一样,马赛克和LED效果只在两部分存在区别

  1. 马赛克网格之间不存在间距
  2. 马赛克采样次数比LED要少

马赛克没有LED间距很好理解,至于次数少的好处第一肯定是效率高,其次是采样太多容易接近原色,而LED是要有一定程度接近原色。

技术实现

本篇我们邀请一位可爱的猫猫,老师们太耀眼的图片就算了,不利于大家阅读。

ic_cat.png

我们接下来的任务是把给猫脸打上马赛克,了解完这项技术实现后,其实你不仅可以给猫脸打马赛克,自行涂鸦,指哪儿打哪儿。

基本信息

主要要加载的图片,像素块大小

private Bitmap mBitmap; //猫图
private float blockWidth = 30; //30x30的像素块
private RectF blockRect = new RectF(); //猫头区域
private RectF gridRect = new RectF(); //网格区域

Canvas 包裹Bitmap

这样好处就是不需要单独管理Bitmap了

static class BitmapCanvas extends Canvas {
    Bitmap bitmap;
    public BitmapCanvas(Bitmap bitmap) {
        super(bitmap);
        this.bitmap = bitmap;
    }
    public Bitmap getBitmap() {
        return bitmap;
    }
}

另外也方便绘制,不然每次都需要通过进行绘制时创建Canva

Canvas c = new Canvas(mBitmap);
c.drawXXX(...)

定位猫头位置

由于时间关系,我没有做TOUCH事件处理,就写了这个猫头区域

/ 这个区域到时候可以自己调制,想让什么部位有马赛克那就会有马赛克
blockRect.set(bitmapCanvas.bitmap.getWidth() - 400, 0, bitmapCanvas.bitmap.getWidth() - 100, 300);

网格分割

主要计算出有多少行和多少列,blockRect前面提到过,负责记录要打码的位置

//根据平分猫头矩形区域
int col = (int) (blockRect.width() / blockWidth);
int row = (int) (blockRect.height() / blockWidth);

网格定位

通过下面的方式,可以计算出每个网格分片的位置,计算之后,就能往网格中填充颜色了。

float startX = blockRect.left;
float startY = blockRect.top;
for (int i = 0; i < row * col; i++) {
    int x = i % col;
    int y = (i / col);
    gridRect.set(startX + x * blockWidth, startY + y * blockWidth, startX + x *     blockWidth + blockWidth, startY + y * blockWidth + blockWidth);
  ......
  
}

采样和着色

所谓采样就是取网格内部的颜色,这里其实我们没必要纠结那个颜色更准确,直接取每个网格中心位置色值就行,取完颜色之后,利用drawRect将颜色填充到网格中。

float startX = blockRect.left;
float startY = blockRect.top;
for (int i = 0; i < row * col; i++) {
    int x = i % col;
    int y = (i / col);
    gridRect.set(startX + x * blockWidth, startY + y * blockWidth, startX + x * blockWidth + blockWidth, startY + y * blockWidth + blockWidth);
   //采样
    int sampleColor = mBitmap.getPixel((int) gridRect.centerX(), (int) gridRect.centerY());
    mCommonPaint.setColor(sampleColor);
    //着色,我们这里不修改像素,而是drawRect,避免性能问题和锯齿问题
    bitmapCanvas.drawRect(gridRect, mCommonPaint);
}

渲染到View上

实际上,我们这个过程中是先绘制到Bitmap上,再绘制到网格上的,这样有利有弊,如果仅仅为了展示,直接绘制到canvas上也行,如果是为了编辑图片,那绘制到Bitmap上。

另外,Bitmap是自带Buffer的可视化缓冲,好处是可以复用,不过这里我们没有用到。

canvas.drawBitmap(bitmapCanvas.bitmap, null, mainRect, mCommonPaint);

效果预览

fire_56.gif

注:这张图上有红框残影,应该是gif录制的问题,有时间再替换吧

避坑点

网格区间不易过小,和LED一样,越小清晰度越高,就会失去了处理的意义。

全部代码

下面是马赛克实现的完整逻辑

public class MosaicView extends View {
    private final DisplayMetrics mDM;
    private TextPaint mCommonPaint;
    private RectF mainRect = new RectF();

    private BitmapCanvas bitmapCanvas; //Canvas 封装的
    private Bitmap mBitmap; //猫图
    private float blockWidth = 30; //30x30的像素快
    private RectF blockRect = new RectF(); //猫头区域
    private RectF gridRect = new RectF(); //网格区域
    private boolean showMask = false;

    public MosaicView(Context context) {
        this(context, null);
    }
    public MosaicView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    public MosaicView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mDM = getResources().getDisplayMetrics();
        initPaint();
    }

    private void initPaint() {
        //否则提供给外部纹理绘制
        mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mCommonPaint.setAntiAlias(true);
        mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
        mBitmap = decodeBitmap(R.mipmap.ic_cat);

    }
    private Bitmap decodeBitmap(int resId) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inMutable = true;
        return BitmapFactory.decodeResource(getResources(), resId, options);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);

        if (widthMode != MeasureSpec.EXACTLY) {
            widthSize = mDM.widthPixels / 2;
        }
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        if (heightMode != MeasureSpec.EXACTLY) {
            heightSize = widthSize / 2;
        }
        setMeasuredDimension(widthSize, heightSize);
        mBitmap = decodeBitmap(R.mipmap.ic_cat);

    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        if (bitmapCanvas != null && bitmapCanvas.bitmap != null && !bitmapCanvas.bitmap.isRecycled()) {
            bitmapCanvas.bitmap.recycle();
        }
        bitmapCanvas = null;

    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int width = getWidth();
        int height = getHeight();
        if (width < 1 || height < 1) {
            return;
        }
        if (bitmapCanvas == null || bitmapCanvas.bitmap == null) {
            bitmapCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmap.getWidth(), mBitmap.getHeight(), Bitmap.Config.ARGB_8888));
        } else {
            bitmapCanvas.bitmap.eraseColor(Color.TRANSPARENT);
        }
        float radius = Math.min(width / 2f, height / 2f);

      //关闭双线性过滤
      //  int flags = mCommonPaint.getFlags();
      //  mCommonPaint.setFlags(flags &~ Paint.FILTER_BITMAP_FLAG);
      //  mCommonPaint.setFilterBitmap(false);

        int save = bitmapCanvas.save();
        bitmapCanvas.drawBitmap(mBitmap, 0, 0, mCommonPaint);


        // 这个区域到时候可以自己调制,想让什么部位有马赛克那就会有马赛克
        blockRect.set(bitmapCanvas.bitmap.getWidth() - 560, 10, bitmapCanvas.bitmap.getWidth() - 100, 410);

        if(showMask) {
            //根据平分猫头矩形区域
            int col = (int) (blockRect.width() / blockWidth);
            int row = (int) (blockRect.height() / blockWidth);

            float startX = blockRect.left;
            float startY = blockRect.top;

            for (int i = 0; i < row * col; i++) {
                int x = i % col;
                int y = (i / col);
                gridRect.set(startX + x * blockWidth, startY + y * blockWidth, startX + x * blockWidth + blockWidth, startY + y * blockWidth + blockWidth);
                //采样
                int sampleColor = mBitmap.getPixel((int) gridRect.centerX(), (int) gridRect.centerY());
                mCommonPaint.setColor(sampleColor);
                //着色,我们这里不修改像素,而是drawRect,避免性能问题和锯齿问题
                bitmapCanvas.drawRect(gridRect, mCommonPaint);
            }
        }else{
            Paint.Style style = mCommonPaint.getStyle();
            mCommonPaint.setStyle(Paint.Style.STROKE);
            mCommonPaint.setColor(Color.MAGENTA);
            mCommonPaint.setStrokeWidth(8);
            bitmapCanvas.drawRect(blockRect, mCommonPaint);
            mCommonPaint.setStyle(style);

        }

        bitmapCanvas.restoreToCount(save);
        int saveCount = canvas.save();
        canvas.translate(width / 2f, height / 2f);
        mainRect.set(-radius, -radius, radius, radius);
        canvas.drawBitmap(bitmapCanvas.bitmap, null, mainRect, mCommonPaint);
        canvas.restoreToCount(saveCount);

    }

    public void openMask() {
        showMask = true;
        postInvalidate();
    }

    public void closeMask() {
        showMask = false;
        postInvalidate();

    }

    static class BitmapCanvas extends Canvas {
        Bitmap bitmap;
        public BitmapCanvas(Bitmap bitmap) {
            super(bitmap);
            //继承在Canvas的绘制是软绘制,因此理论上可以绘制出阴影
            this.bitmap = bitmap;
        }
        public Bitmap getBitmap() {
            return bitmap;
        }
    }
}

全图马赛克

实际上还有另一种方法,我们绘制图片时关闭双线性过滤,同时,接着,将图片缩小20倍左右之后,在放大到原来的大小,这个时候,整张图片都会被打上马赛克。

关闭线性过滤

关闭线性过滤的目的是避免绘制时对图片锯齿或者色块进行优化。

//关闭双线性过滤
  int flags = mCommonPaint.getFlags();
  mCommonPaint.setFlags(flags &~ Paint.FILTER_BITMAP_FLAG);
  mCommonPaint.setFilterBitmap(false);

先缩小再放大

下图是先缩小20倍然后画到原来大小的效果

企业微信20231205-221500@2x.png

实现代码

本来不打算放代码的,想想还是放上吧

public class BitmapMosaicView extends View {
    private final DisplayMetrics mDM;
    private TextPaint mCommonPaint;
    private RectF mainRect = new RectF();
    private BitmapCanvas bitmapCanvas; //Canvas 封装的
    private BitmapCanvas srcThumbCanvas; //Canvas 封装的
    private Bitmap mBitmap; //猫图
    private RectF blockRect = new RectF(); //猫头区域

    public BitmapMosaicView(Context context) {
        this(context, null);
    }
    public BitmapMosaicView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    public BitmapMosaicView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mDM = getResources().getDisplayMetrics();
        initPaint();
    }

    private void initPaint() {
        //否则提供给外部纹理绘制
        mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mCommonPaint.setAntiAlias(true);
        mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
        mBitmap = decodeBitmap(R.mipmap.ic_cat);

    }
    private Bitmap decodeBitmap(int resId) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inMutable = true;
        return BitmapFactory.decodeResource(getResources(), resId, options);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);

        if (widthMode != MeasureSpec.EXACTLY) {
            widthSize = mDM.widthPixels / 2;
        }
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        if (heightMode != MeasureSpec.EXACTLY) {
            heightSize = widthSize / 2;
        }
        setMeasuredDimension(widthSize, heightSize);

    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        if (bitmapCanvas != null && bitmapCanvas.bitmap != null && !bitmapCanvas.bitmap.isRecycled()) {
            bitmapCanvas.bitmap.recycle();
        }
        if (srcThumbCanvas != null && srcThumbCanvas.bitmap != null && !srcThumbCanvas.bitmap.isRecycled()) {
            srcThumbCanvas.bitmap.recycle();
        }
        bitmapCanvas = null;

    }

    private Rect srcRectF = new Rect();
    private Rect dstRectF = new Rect();

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int width = getWidth();
        int height = getHeight();
        if (width < 1 || height < 1) {
            return;
        }
        if (bitmapCanvas == null || bitmapCanvas.bitmap == null) {
            bitmapCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmap.getWidth(), mBitmap.getHeight(), Bitmap.Config.ARGB_8888));
        } else {
            bitmapCanvas.bitmap.eraseColor(Color.TRANSPARENT);
        }
        if (srcThumbCanvas == null || srcThumbCanvas.bitmap == null) {
            srcThumbCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmap.getWidth()/35, mBitmap.getHeight()/35, Bitmap.Config.ARGB_8888));
        } else {
            srcThumbCanvas.bitmap.eraseColor(Color.TRANSPARENT);
        }
        float radius = Math.min(width / 2f, height / 2f);

      //关闭双线性过滤
        int flags = mCommonPaint.getFlags();
        mCommonPaint.setFlags(flags &~ Paint.FILTER_BITMAP_FLAG);
        mCommonPaint.setFilterBitmap(false);
        mCommonPaint.setDither(false);


        srcRectF.set(0,0,mBitmap.getWidth(),mBitmap.getHeight());
        dstRectF.set(0,0, srcThumbCanvas.bitmap.getWidth(), srcThumbCanvas.bitmap.getHeight());

        int save = bitmapCanvas.save();
        srcThumbCanvas.drawBitmap(mBitmap, srcRectF, dstRectF, mCommonPaint);

        srcRectF.set(dstRectF);
        dstRectF.set(0,0,bitmapCanvas.bitmap.getWidth(),bitmapCanvas.bitmap.getHeight());
        bitmapCanvas.drawBitmap(srcThumbCanvas.bitmap, srcRectF,dstRectF, mCommonPaint);
        // 这个区域到时候可以自己调制,想让什么部位有马赛克那就会有马赛克
        blockRect.set(bitmapCanvas.bitmap.getWidth() - 560, 10, bitmapCanvas.bitmap.getWidth() - 100, 410);
        bitmapCanvas.restoreToCount(save);
        int saveCount = canvas.save();
        canvas.translate(width / 2f, height / 2f);
        mainRect.set(-radius, -radius, radius, radius);
        canvas.drawBitmap(bitmapCanvas.bitmap, null, mainRect, mCommonPaint);
        canvas.restoreToCount(saveCount);

    }
    static class BitmapCanvas extends Canvas {
        Bitmap bitmap;
        public BitmapCanvas(Bitmap bitmap) {
            super(bitmap);
            //继承在Canvas的绘制是软绘制,因此理论上可以绘制出阴影
            this.bitmap = bitmap;
        }
        public Bitmap getBitmap() {
            return bitmap;
        }
    }
}

全图马赛克优化

以上全图马赛克的实现,实际上有些复杂,我们还可以优化

其实我们可以利用Matrix + createBitmap实现类似的效果

Matrix matrix = new Matrix();
float scale = 21.0f / bmp.getWidth();
matrix.postScale(scale, scale);

mMosaicBmp = Bitmap.createBitmap(bm, 0, 0, bmp.getWidth(), bmp.getHeight(),
        matrix, false);

总结

本篇的方法是非常适合生产环境的,在这个过程中,无需关注图片大小,只对要打码的区域进行分片即可,也方便通过触摸实践进行联动打码。

同时,如果是全图马赛克,也可以使用第二种方案。

总结下本文分享技术特点:

  • 网格化
  • 采样
  • canvas着色,不要去修改像素