三方库开源地址:https://atomgit.com/nutpi/flutter_ohos_image_editor_dove

前言

马赛克是图片编辑器里很实用的功能。想遮住照片里的敏感信息?车牌号、手机号、人脸……用马赛克涂一涂就行了。

image_editor_dove 的马赛克功能和涂鸦功能共用同一套底层架构,但绑制方式完全不同。涂鸦是画线,马赛克是画像素块。这篇文章就来聊聊马赛克的实现原理。
请添加图片描述


马赛克的原理

马赛克效果的本质是什么?就是把图片的某个区域"像素化",让人看不清原来的内容。

image_editor_dove 里,马赛克的实现方式是:在用户手指经过的地方,绑制一堆小矩形,用不同的灰度值来模拟像素化效果。

这种方式有个好处——不需要真的去处理原图的像素,只是在图片上面叠加一层"遮罩"。实现简单,性能也好。


DrawStyle - 绘制模式切换

马赛克和涂鸦共用同一个控制器 SignatureController,通过 DrawStyle 来区分:

enum DrawStyle {
  normal,  // 普通涂鸦
  mosaic,  // 马赛克
}

控制器里有一个属性来存储当前的绘制模式:

class SignatureController extends ValueNotifier<List<Point>> {
  // ...
  DrawStyle drawStyle = DrawStyle.normal;
  final double mosaicWidth;
  // ...
}

mosaicWidth 控制马赛克像素块的大小,默认是 5.0。值越大,马赛克效果越明显。


切换到马赛克模式

在主编辑器里,用户点击马赛克按钮时会调用:

void switchPainterMode(DrawStyle style) {
  if (lastDrawStyle == style) return;
  changePainterColor(pColor);
  painterController.drawStyle = style;
}

这个方法做了两件事:

  1. 先保存当前的绘制内容(通过 changePainterColor
  2. 然后切换绘制模式

为什么要先保存?因为切换模式后,之前画的内容应该保留。如果不保存,切换模式后之前的涂鸦就没了。


SignaturePainter 中的马赛克绘制

SignaturePainterpaint() 方法会根据 drawStyle 选择不同的绑制逻辑:


void paint(Canvas canvas, _) {
  final List<Point> points = _controller.value;
  if (points.isEmpty) {
    return;
  }

  switch(_controller.drawStyle) {
    case DrawStyle.normal:
      canvas.drawPath(paintPath(), _penStyle);
      break;
    case DrawStyle.mosaic:
      for(int i=0; i < points.length; i+=2) {
        paintMosaic(points[i].offset);
      }
      break;
  }
}

注意马赛克模式里的 i+=2。为什么要跳过一半的点?

因为马赛克每个点都要绑制一个 3x3 的像素块矩阵,如果每个点都画,性能会很差,而且效果也会太密集。跳过一半可以在保证效果的同时提高性能。


paintMosaic - 马赛克绑制核心

这是马赛克绘制的核心方法:

void paintMosaic(Offset center) {
  final ui.Paint paint = ui.Paint()..color = Colors.black26;
  final double size = _controller.mosaicWidth;
  final double halfSize = size/2;
  
  final ui.Rect b1 = Rect.fromCenter(
    center: center.translate(-halfSize, -halfSize),
    width: size,
    height: size
  );

首先创建一个基准矩形 b1,以手指位置为中心,稍微偏移一点。size 就是 mosaicWidth,控制每个像素块的大小。


接下来绘制 3x3 共 9 个小矩形:

  // 第一行
  canvas.drawRect(b1, paint);  // (0,0) - 黑色 26% 透明度
  
  paint.color = Colors.grey.withOpacity(0.5);
  canvas.drawRect(b1.translate(0, size), paint);  // (0,1) - 灰色 50% 透明度
  
  paint.color = Colors.black38;
  canvas.drawRect(b1.translate(0, size*2), paint);  // (0,2) - 黑色 38% 透明度

第一列的三个矩形,用不同的灰度值。


  // 第二行
  paint.color = Colors.black12;
  canvas.drawRect(b1.translate(size, 0), paint);  // (1,0)
  
  paint.color = Colors.black26;
  canvas.drawRect(b1.translate(size, size), paint);  // (1,1)
  
  paint.color = Colors.black45;
  canvas.drawRect(b1.translate(size, size*2), paint);  // (1,2)

第二列的三个矩形。


  // 第三行
  paint.color = Colors.grey.withOpacity(0.5);
  canvas.drawRect(b1.translate(size*2, 0), paint);  // (2,0)
  
  paint.color = Colors.black12;
  canvas.drawRect(b1.translate(size*2, size), paint);  // (2,1)
  
  paint.color = Colors.black26;
  canvas.drawRect(b1.translate(size*2, size*2), paint);  // (2,2)
}

第三列的三个矩形。

为什么用不同的灰度值?如果都用同一个颜色,看起来就是一个大色块,没有马赛克的感觉。用不同的灰度值可以模拟出像素的随机感,看起来更像真正的马赛克效果。


马赛克的视觉效果

整个 3x3 矩阵的颜色分布大概是这样的:

[深灰]  [浅灰]  [中灰]
[浅灰]  [深灰]  [深灰]
[中灰]  [浅灰]  [深灰]

这种不规则的灰度分布,加上用户手指移动时产生的重叠,就形成了马赛克的效果。


调整马赛克效果

调整像素块大小

SignatureController(
  mosaicWidth: 10.0,  // 更大的像素块
  // ...
)

mosaicWidth 越大,马赛克效果越明显,但也越粗糙。默认值 5.0 是个比较平衡的选择。


调整采样频率

paint() 方法里,我们用 i+=2 跳过了一半的点。如果想要更密集的马赛克:

for(int i=0; i < points.length; i+=1) {  // 不跳过
  paintMosaic(points[i].offset);
}

如果想要更稀疏的马赛克:

for(int i=0; i < points.length; i+=4) {  // 跳过更多
  paintMosaic(points[i].offset);
}

自定义颜色

默认的马赛克是灰色系的。如果想要其他颜色,可以修改 paintMosaic 方法:

void paintMosaic(Offset center) {
  final baseColor = Colors.blue;  // 改成蓝色系
  final ui.Paint paint = ui.Paint()..color = baseColor.withOpacity(0.26);
  // ...
}

不过灰色系的马赛克是最常见的,因为它能有效遮挡各种颜色的内容。


OpenHarmony 适配:真实马赛克效果

上面介绍的马赛克是"假"马赛克——只是在图片上叠加灰色块。如果想要"真"马赛克效果(真正把图片像素化),需要调用原生 API 处理图片。

Dart 端定义方法

import 'package:flutter/services.dart';

const platform = MethodChannel('image_editor');

Future<Uint8List?> applyRealMosaic(Uint8List imageBytes, List<Rect> regions, int blockSize) async {
  try {
    final result = await platform.invokeMethod('applyRealMosaic', {
      'imageData': imageBytes,
      'regions': regions.map((r) => {
        'left': r.left,
        'top': r.top,
        'width': r.width,
        'height': r.height,
      }).toList(),
      'blockSize': blockSize,
    });
    return result as Uint8List?;
  } catch (e) {
    print('应用马赛克失败: $e');
    return null;
  }
}

传入原图数据、需要马赛克的区域列表、像素块大小,返回处理后的图片。


鸿蒙端实现

import {
  FlutterPlugin,
  FlutterPluginBinding,
  MethodCall,
  MethodCallHandler,
  MethodChannel,
  MethodResult,
} from '@ohos/flutter_ohos';
import { image } from '@kit.ImageKit';

export default class ImageEditorPlugin implements FlutterPlugin, MethodCallHandler {
  private channel: MethodChannel | null = null;

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.channel = new MethodChannel(binding.getBinaryMessenger(), "image_editor");
    this.channel.setMethodCallHandler(this);
  }

  onMethodCall(call: MethodCall, result: MethodResult): void {
    if (call.method == "applyRealMosaic") {
      this.applyRealMosaic(call, result);
    } else {
      result.notImplemented();
    }
  }

设置 MethodChannel 并分发方法调用。


  private async applyRealMosaic(call: MethodCall, result: MethodResult): Promise<void> {
    try {
      const imageData = call.argument('imageData') as Uint8Array;
      const regions = call.argument('regions') as Array<{left: number, top: number, width: number, height: number}>;
      const blockSize = call.argument('blockSize') as number;

从参数中获取图片数据、区域列表和像素块大小。


      // 创建 ImageSource
      const imageSource = image.createImageSource(imageData.buffer);
      const pixelMap = await imageSource.createPixelMap();
      
      // 获取图片信息
      const imageInfo = await pixelMap.getImageInfo();
      const width = imageInfo.size.width;
      const height = imageInfo.size.height;

使用鸿蒙的 image 模块创建 PixelMap,这是鸿蒙处理图片的核心对象。


      // 对每个区域应用马赛克
      for (const region of regions) {
        await this.mosaicRegion(pixelMap, region, blockSize, width, height);
      }
      
      // 导出处理后的图片
      const packer = image.createImagePacker();
      const packedData = await packer.packing(pixelMap, { format: 'image/png', quality: 100 });
      
      result.success(new Uint8Array(packedData));
    } catch (e) {
      result.error("MosaicError", e.toString(), null);
    }
  }

对每个区域应用马赛克,然后导出处理后的图片。


  private async mosaicRegion(
    pixelMap: image.PixelMap,
    region: {left: number, top: number, width: number, height: number},
    blockSize: number,
    imageWidth: number,
    imageHeight: number
  ): Promise<void> {
    const { left, top, width, height } = region;
    
    // 确保区域在图片范围内
    const startX = Math.max(0, Math.floor(left));
    const startY = Math.max(0, Math.floor(top));
    const endX = Math.min(imageWidth, Math.ceil(left + width));
    const endY = Math.min(imageHeight, Math.ceil(top + height));

首先确保区域在图片范围内,避免越界。


    // 按块处理
    for (let y = startY; y < endY; y += blockSize) {
      for (let x = startX; x < endX; x += blockSize) {
        // 计算当前块的范围
        const blockEndX = Math.min(x + blockSize, endX);
        const blockEndY = Math.min(y + blockSize, endY);
        
        // 获取块内第一个像素的颜色(简化处理)
        const position: image.PositionArea = { x: x, y: y, size: { width: 1, height: 1 } };
        const buffer = new ArrayBuffer(4);
        await pixelMap.readPixels(position, buffer);
        
        // 用这个颜色填充整个块
        const fillPosition: image.PositionArea = {
          x: x,
          y: y,
          size: { width: blockEndX - x, height: blockEndY - y }
        };
        await pixelMap.writePixels(fillPosition, buffer);
      }
    }
  }
}

马赛克的核心算法:把区域分成若干个小块,每个块用同一个颜色填充。这里简化处理,用块内第一个像素的颜色来填充整个块。

更精确的做法是计算块内所有像素的平均颜色,但那样性能会差一些。


性能优化

马赛克绑制可能会有性能问题,特别是用户快速滑动的时候。

降低绘制频率

int _lastMosaicTime = 0;

void paintMosaic(Offset center) {
  final now = DateTime.now().millisecondsSinceEpoch;
  if (now - _lastMosaicTime < 32) {  // 约 30fps
    return;
  }
  _lastMosaicTime = now;
  
  // 绘制逻辑...
}

限制马赛克的绘制频率,避免过于频繁的绘制。


使用 RepaintBoundary

RepaintBoundary(
  child: CustomPaint(
    painter: SignaturePainter(painterController),
    child: ConstrainedBox(...),
  ),
)

把马赛克画布用 RepaintBoundary 包起来,这样重绘时不会影响其他部分。


批量绘制

如果点很多,可以考虑把多个点合并成一次绘制:

case DrawStyle.mosaic:
  final path = Path();
  for(int i=0; i < points.length; i+=2) {
    final offset = points[i].offset;
    path.addRect(Rect.fromCenter(
      center: offset,
      width: _controller.mosaicWidth * 3,
      height: _controller.mosaicWidth * 3,
    ));
  }
  canvas.drawPath(path, _mosaicPaint);
  break;

Path 收集所有矩形,然后一次性绘制,比逐个绘制效率高。


常见问题

马赛克效果不明显

调大 mosaicWidth 参数。默认是 5.0,可以试试 10.0 或更大的值。


马赛克太稀疏

减小 paint() 方法里的步进值。把 i+=2 改成 i+=1


马赛克颜色太深/太浅

修改 paintMosaic 方法里的颜色值。比如把 Colors.black26 改成 Colors.black45 会更深。


性能问题

  1. 增大步进值(i+=4 或更大)
  2. 增大 mosaicWidth(像素块越大,需要绘制的块越少)
  3. 使用 RepaintBoundary 隔离重绘

小结

马赛克功能的核心要点:

  1. 和涂鸦共用 SignatureController,通过 DrawStyle 区分模式
  2. paintMosaic 方法绘制 3x3 的像素块矩阵
  3. 用不同的灰度值模拟像素化效果
  4. 跳过部分点来优化性能
  5. 真实马赛克效果需要调用原生 API 处理图片像素

马赛克功能的实现相对简单,但效果很实用。理解了它的原理,你也可以实现其他类似的效果,比如模糊、高斯模糊等。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐