我在开发鸿蒙图片编辑器时,实现像素马赛克功能遇到了两大噩梦:绘制卡顿图片色调异常。经过多次重构,最终找到了一套既简单又高效的方案。本文将完整复盘踩坑过程与解决方案,希望能帮你少走弯路。

完整源码ImageEditor

一、需求与初步尝试

图片编辑器中,马赛克画笔常用于遮盖人脸、车牌、文字等敏感信息。常见的实现方式有两种:

  • 纹理马赛克:用预设的纹理图片(如小方格图片)重复填充涂抹区域。
  • 像素马赛克:将涂抹区域划分为一个个方块,每个方块的颜色取原图该方块内所有像素的平均色。

纹理马赛克性能极佳,但效果像贴了一层“瓷砖”,不够自然。因此我选择了更真实的像素马赛克

1.1 初版思路(错误示范)

大致流程:

  1. 用户在 Canvas 上涂抹,记录所有矩形的坐标。
  2. 每次涂抹结束,将所有矩形和原图传入 TaskPool,在后台线程中逐矩形计算平均色,修改整张图片的像素
  3. 将修改后的图片重新绘制到 Canvas 上;撤销时重新执行所有历史矩形。

这种思路在逻辑上说得通,但实际运行时卡成 ppt,而且因为直接操作像素缓冲区,出现了未知的色调偏移(红变蓝、整体偏色)。

二、问题根源分析

2.1 卡顿:每次修改整张图片,而非增量绘制

初版在每笔涂抹结束后,都对整张图片的所有矩形重新做一遍像素处理。随着矩形数量增加(一次快速涂抹可能产生上百个矩形),处理时间线性增长。用户手指一抬,要等几百毫秒才能看到马赛克,体验极差。

2.2 色调异常:直接修改像素极易出错

readPixelsToBuffer 获取的原始像素数据,其内存布局、颜色空间、Alpha 通道的处理等细节与预期可能存在差异。多次调整通道顺序仍无法完全消除色偏。这促使我寻找完全不修改原图的替代方案——既然马赛克只是“遮挡”,何必改变底图呢?

2.3 其他问题

  • 没有插值:手指滑动过快时,触摸事件采样点间隔较大,导致马赛克块之间出现缝隙。
  • 重复计算:历史重绘时又重新扫描像素,浪费 CPU。

三、解决方案:放弃修改原图,采用实时绘制 + 颜色缓存

核心思想:不再修改原图,而是将马赛克视为独立的绘图指令,直接在 Canvas 上层绘制矩形。每个矩形在生成时就计算好颜色并缓存,重绘时直接使用缓存的颜色,无需再触碰像素数据。

这样做的好处

  • 原图永远不变,彻底避免像素操作带来的色偏风险。
  • 绘制速度只与矩形数量有关,与图片尺寸无关。
  • 撤销/重绘仅需重新执行绘图指令,极快。

3.1 运行效果

伪像素马赛克.gif

3.2 整体架构

用户涂抹 → 生成矩形 → 立即取中心点颜色(同步)→ 绘制到 Canvas(实时预览)
                ↓
         涂抹结束 → 保存矩形+颜色 → 入命令栈
                ↓
         撤销/重绘 → 直接使用保存的颜色重新绘制

3.3 数据结构

// 带颜色的马赛克矩形
export interface MosaicRect {
  rect: DrawRect;   // {x, y, width, height} 图像坐标系
  color: string;    // "rgb(r,g,b)"
}

// 历史命令
export interface PixelMosaicCommand {
  id: string;
  type: 'pixelMosaic';
  rects: MosaicRect[];
  blockSize: number;
}

3.4 像素马赛克管理器(核心代码)

// PixelMosaicManager.ets
import { DrawRect } from '../model/CanvasInfo';

export class PixelMosaicManager {
  private currentRects: MosaicRect[] = [];
  private brushSize = 20;
  private rawPixels: Uint8ClampedArray | null = null;
  private imageWidth = 0, imageHeight = 0;
  private lastX = 0, lastY = 0, hasLast = false;

  // 设置原始像素数据(仅用于取色,不修改)
  setRawPixels(pixels: Uint8ClampedArray, width: number, height: number) {
    this.rawPixels = new Uint8ClampedArray(pixels);
    this.imageWidth = width;
    this.imageHeight = height;
  }

  setBrushSize(size: number) { this.brushSize = size; }

  // 触摸开始
  startDraw(x: number, y: number) {
    this.addRect(x, y);
    this.lastX = x; this.lastY = y; this.hasLast = true;
  }

  // 触摸移动(自动插值)
  updateDraw(x: number, y: number) {
    if (!this.hasLast) { this.startDraw(x, y); return; }
    const dx = x - this.lastX, dy = y - this.lastY;
    const distance = Math.hypot(dx, dy);
    const step = Math.max(1, this.brushSize / 2);
    if (distance > step) {
      const steps = Math.ceil(distance / step);
      for (let i = 1; i <= steps; i++) {
        const t = i / steps;
        this.addRect(this.lastX + dx * t, this.lastY + dy * t);
      }
    } else {
      this.addRect(x, y);
    }
    this.lastX = x; this.lastY = y;
  }

  // 添加矩形并快速取色(取中心点,同步,极快)
  private addRect(centerX: number, centerY: number) {
    const half = this.brushSize / 2;
    const rect: DrawRect = {
      x: centerX - half, y: centerY - half,
      width: this.brushSize, height: this.brushSize
    };
    // 去重:避免连续相同矩形
    const last = this.currentRects[this.currentRects.length - 1];
    if (last && last.rect.x === rect.x && last.rect.y === rect.y) return;
    const color = this.getColorAtCenter(rect);
    this.currentRects.push({ rect, color });
  }

  // 取矩形中心点像素颜色(同步,极快,仅读取)
  private getColorAtCenter(rect: DrawRect): string {
    if (!this.rawPixels) return '#888';
    let cx = Math.floor(rect.x + rect.width / 2);
    let cy = Math.floor(rect.y + rect.height / 2);
    cx = Math.min(this.imageWidth - 1, Math.max(0, cx));
    cy = Math.min(this.imageHeight - 1, Math.max(0, cy));
    const idx = (cy * this.imageWidth + cx) * 4;
    const r = this.rawPixels[idx];
    const g = this.rawPixels[idx + 1];
    const b = this.rawPixels[idx + 2];
    return `rgb(${r}, ${g}, ${b})`;
  }

  // 实时绘制所有当前矩形
  drawCurrent(ctx: CanvasRenderingContext2D, imageRect: DrawRect) {
    for (const item of this.currentRects) {
      this.drawSingleRect(ctx, item.rect, item.color, imageRect);
    }
  }

  // 绘制单个矩形(图像坐标 → 屏幕坐标)
  drawSingleRect(ctx: CanvasRenderingContext2D, rect: DrawRect, color: string, imageRect: DrawRect) {
    let left = Math.max(0, Math.floor(rect.x));
    let top = Math.max(0, Math.floor(rect.y));
    let right = Math.min(this.imageWidth, Math.ceil(rect.x + rect.width));
    let bottom = Math.min(this.imageHeight, Math.ceil(rect.y + rect.height));
    if (left >= right || top >= bottom) return;
    const screenX = imageRect.x + (left / this.imageWidth) * imageRect.width;
    const screenY = imageRect.y + (top / this.imageHeight) * imageRect.height;
    const screenW = ((right - left) / this.imageWidth) * imageRect.width;
    const screenH = ((bottom - top) / this.imageHeight) * imageRect.height;
    ctx.fillStyle = color;
    ctx.fillRect(screenX, screenY, screenW, screenH);
  }

  // 结束绘制,返回矩形列表(用于保存命令)
  endDraw(): MosaicRect[] {
    const rects = [...this.currentRects];
    this.clear();
    return rects;
  }

  clear() {
    this.currentRects = [];
    this.hasLast = false;
  }
}

3.5 在 CanvasManager 中集成

// 加载图片时,读取像素数据给马赛克管理器(仅用于取色)
async setOriginalImage(pixelMap: image.PixelMap) {
  // ... 原有代码 ...
  const buffer = new ArrayBuffer(this.imageWidth * this.imageHeight * 4);
  await pixelMap.readPixelsToBuffer(buffer);
  const pixels = new Uint8ClampedArray(buffer);
  this.pixelMosaicManager.setRawPixels(pixels, this.imageWidth, this.imageHeight);
  // ...
}

// 触摸移动时实时绘制
private drawCurrentPixelRects() {
  if (!this.canvasInfo) return;
  this.ctx.save();
  this.ctx.beginPath();
  this.ctx.rect(this.canvasInfo.imageRect.x, this.canvasInfo.imageRect.y,
               this.canvasInfo.imageRect.width, this.canvasInfo.imageRect.height);
  this.ctx.clip();
  this.pixelMosaicManager.drawCurrent(this.ctx, this.canvasInfo.imageRect);
  this.ctx.restore();
}

async onTouchMove(canvasX: number, canvasY: number) {
  // ...
  if (this.currentTool === 'mosaic' && this.currentMosaicMode === MosaicMode.PIXEL) {
    this.pixelMosaicManager.updateDraw(imgX, imgY);
    this.drawCurrentPixelRects();  // 实时看到马赛克
  }
}

// 触摸结束时保存命令
async onTouchEnd() {
  if (this.currentTool === 'mosaic' && this.currentMosaicMode === MosaicMode.PIXEL) {
    const rects = this.pixelMosaicManager.endDraw();
    if (rects.length > 0) {
      const command = { id: Date.now() + '', type: 'pixelMosaic', rects, blockSize: this.currentSize };
      this.commands.push(command);
      await this.redrawAll();  // 重绘所有命令(确保最终效果)
    }
  }
}

3.6 历史命令重绘(CommandRenderer)

// 渲染所有命令时,对 pixelMosaic 类型直接使用保存的颜色绘制
for (const cmd of commands) {
  if (cmd.type === 'pixelMosaic') {
    for (const item of cmd.rects) {
      this.drawMosaicRect(item.rect, item.color);
    }
  }
}

四、性能对比与优化效果

测试场景 初版方案 最终方案
一次笔触(50个矩形) 300ms 延迟 <16ms 实时
连续涂抹10笔 卡顿明显,帧率 <20fps 满帧 60fps
撤销操作 重新处理所有矩形,延迟 >500ms 直接重绘,<50ms
内存占用 每次修改整图,不断 clone 仅保存矩形坐标和颜色,极低
色调准确性 初版有未知色偏 完全忠于原图,无任何色偏

如何做到无色偏?
最终版从不修改原图,马赛克只是绘制在 Canvas 上层的彩色矩形,颜色直接从原图对应位置读取(只读),因此原图颜色被原样“挪”到马赛克块上,不可能出现色偏。

五、优化技巧与踩坑总结

5.1 坚决不修改原图

只要你的目标是“遮挡”而不是“变形”,就一定不要直接操作 PixelMap。把它当作只读的调色板,所有的编辑效果都通过 Canvas 绘图指令叠加。

5.2 取色策略:中心点 vs 平均色

  • 中心点颜色:计算量极小,适合实时预览,肉眼几乎看不出差异。
  • 精确平均色:更准确但需要扫描矩形内所有像素,适合后台异步更新。

本文采用中心点颜色已经足够自然,因为矩形尺寸较小(通常 20~40px),中心点颜色能很好地代表整个区域。

5.3 插值步长的选择

步长设为 brushSize / 2 可以保证矩形之间有重叠,避免缝隙。步长太小会生成过多矩形,影响性能;步长太大则可能仍有缝隙。经过测试,brushSize / 2 是最佳平衡点。

5.4 坐标转换的坑

Canvas 中图片是居中显示的,因此涂抹时获取的屏幕坐标需要转换为图像坐标系,绘制时再转换回屏幕坐标系。务必封装好这两个转换函数,避免到处重复计算。

private canvasToImageCoords(canvasX: number, canvasY: number): DrawPoint { ... }
private imageToScreenCoords(imgX: number, imgY: number): DrawPoint { ... }

5.5 内存管理

原始像素缓存(rawPixels)会占用 宽 × 高 × 4 字节的内存。对于超大图片(例如 4000×3000),大约 48MB,可以接受。但若追求极致内存优化,可以改为按需读取,不过不推荐,因为会增加复杂度且影响性能。

六、结语

像素马赛克功能的核心难点在于性能颜色准确性。初版因直接操作像素导致卡顿和色偏,最终通过“只读原图、实时绘制矩形”的方案,不仅让涂抹过程丝般顺滑,还彻底杜绝了色偏风险。希望本文的复盘能帮助你避开类似的坑,轻松实现高质量的图片编辑功能。完整代码下载工程查看。

如果你也有类似的问题或更好的实现思路,欢迎在评论区交流。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐