图片模糊化处理、灰度转换、底片反色、证件照换底色——这些操作看似高端,底层都是像素级读写。HarmonyOS NEXT 的 PixelMap 提供了 readPixelsToBuffer / writePixelsToBuffer 两个核心 API,掌握它们就能做任何图片变换。这篇从原理到实战,把 7 种常见像素操作全部讲清楚。

PixelMap 像素操作基础

核心概念

  • RGBA——每个像素 4 字节:R(红)、G(绿)、B(蓝)、A(透明度),各 1 字节 0~255
  • Buffer——整张图片的像素数据是一段连续内存,大小 = width × height × 4
  • readPixelsToBuffer——把像素数据读到 ArrayBuffer
  • writePixelsToBuffer——把修改后的 ArrayBuffer 写回 PixelMap
import { image } from '@kit.ImageKit'

在这里插入图片描述

像素读写基本流程

async function processPixelMap(pixelMap: image.PixelMap): Promise<void> {
  let width: number = pixelMap.getImageInfoSync().size.width
  let height: number = pixelMap.getImageInfoSync().size.height
  let bytesNumber: number = pixelMap.getPixelBytesNumber()

  // 1. 创建 Buffer
  let buffer: ArrayBuffer = new ArrayBuffer(bytesNumber)

  // 2. 读取像素数据
  await pixelMap.readPixelsToBuffer(buffer)

  // 3. 操作 Buffer 中的像素
  let view: Uint8Array = new Uint8Array(buffer)
  // view[i] = R, view[i+1] = G, view[i+2] = B, view[i+3] = A

  // 4. 写回 PixelMap
  await pixelMap.writePixelsToBuffer(buffer)
}

要点: 像素数据是 RGBA 顺序排列,每 4 字节一组。offset = (y × width + x) × 4 就是 (x, y) 位置的像素起始偏移。

操作一:读取像素与颜色提取

interface PixelColor {
  r: number;
  g: number;
  b: number;
  a: number;
}

function getPixelAt(view: Uint8Array, width: number, x: number, y: number): PixelColor {
  let offset: number = (y * width + x) * 4
  return {
    r: view[offset],
    g: view[offset + 1],
    b: view[offset + 2],
    a: view[offset + 3]
  }
}

function setPixelAt(view: Uint8Array, width: number, x: number, y: number, color: PixelColor): void {
  let offset: number = (y * width + x) * 4
  view[offset] = color.r
  view[offset + 1] = color.g
  view[offset + 2] = color.b
  view[offset + 3] = color.a
}

要点: getPixelAt / setPixelAt 是所有像素操作的基础工具函数。

操作二:马赛克

马赛克原理:把图片分成 NxN 的小块,每块取均值覆盖。

function applyMosaic(view: Uint8Array, width: number, height: number, blockSize: number): void {
  for (let by: number = 0; by < height; by += blockSize) {
    for (let bx: number = 0; bx < width; bx += blockSize) {
      // 计算块内均值
      let sumR: number = 0
      let sumG: number = 0
      let sumB: number = 0
      let count: number = 0

      for (let y: number = by; y < Math.min(by + blockSize, height); y++) {
        for (let x: number = bx; x < Math.min(bx + blockSize, width); x++) {
          let offset: number = (y * width + x) * 4
          sumR += view[offset]
          sumG += view[offset + 1]
          sumB += view[offset + 2]
          count++
        }
      }

      let avgR: number = Math.floor(sumR / count)
      let avgG: number = Math.floor(sumG / count)
      let avgB: number = Math.floor(sumB / count)

      // 块内所有像素设为均值
      for (let y: number = by; y < Math.min(by + blockSize, height); y++) {
        for (let x: number = bx; x < Math.min(bx + blockSize, width); x++) {
          let offset: number = (y * width + x) * 4
          view[offset] = avgR
          view[offset + 1] = avgG
          view[offset + 2] = avgB
        }
      }
    }
  }
}

要点: blockSize 越大越模糊,10 是适中值,30 以上基本看不清细节。隐私区域打码用 15~20。

操作三:灰度化

灰度公式:Gray = R×0.299 + G×0.587 + B×0.114(人眼对绿色最敏感)

function applyGrayscale(view: Uint8Array): void {
  for (let i: number = 0; i < view.length; i += 4) {
    let gray: number = Math.floor(view[i] * 0.299 + view[i + 1] * 0.587 + view[i + 2] * 0.114)
    view[i] = gray
    view[i + 1] = gray
    view[i + 2] = gray
  }
}

要点: 灰度化是很多图像处理的前置步骤(如边缘检测、OCR预处理)。不要简单取 (R+G+B)/3,效果差很多。

操作四:反色(底片效果)

反色 = 255 - 原值,每个通道独立取反。

function applyInvert(view: Uint8Array): void {
  for (let i: number = 0; i < view.length; i += 4) {
    view[i] = 255 - view[i]
    view[i + 1] = 255 - view[i + 1]
    view[i + 2] = 255 - view[i + 2]
  }
}

要点: 反色不改变透明度通道 A。常用于制作底片风格或辅助视觉障碍用户。

操作五:亮度调节

亮度 = 各通道加上偏移量,clamp 到 0~255。

function applyBrightness(view: Uint8Array, offset: number): void {
  for (let i: number = 0; i < view.length; i += 4) {
    view[i] = Math.max(0, Math.min(255, view[i] + offset))
    view[i + 1] = Math.max(0, Math.min(255, view[i + 1] + offset))
    view[i + 2] = Math.max(0, Math.min(255, view[i + 2] + offset))
  }
}

要点: offset 正值变亮,负值变暗。建议范围 -50 ~ +50,超出容易过曝或全黑。

操作六:圆角裁剪

圆角原理:四个角的像素,计算到圆心距离,超过半径则 A=0(透明)。

function applyRoundCorner(view: Uint8Array, width: number, height: number, radius: number): void {
  for (let y: number = 0; y < height; y++) {
    for (let x: number = 0; x < width; x++) {
      let inCorner: boolean = false
      let cx: number = 0
      let cy: number = 0

      if (x < radius && y < radius) {
        cx = radius; cy = radius; inCorner = true
      } else if (x >= width - radius && y < radius) {
        cx = width - radius; cy = radius; inCorner = true
      } else if (x < radius && y >= height - radius) {
        cx = radius; cy = height - radius; inCorner = true
      } else if (x >= width - radius && y >= height - radius) {
        cx = width - radius; cy = height - radius; inCorner = true
      }

      if (inCorner) {
        let dist: number = Math.sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy))
        if (dist > radius) {
          let idx: number = (y * width + x) * 4
          view[idx + 3] = 0
        }
      }
    }
  }
}

要点: 只处理四个角区域,其他像素不动。圆角半径一般取图片短边的 1/8 ~ 1/4。

操作七:证件照换底色

换底色原理:取背景参考色,设定容差,与背景色接近的像素替换为目标色。

function replaceBackground(
  view: Uint8Array, width: number, height: number,
  targetR: number, targetG: number, targetB: number, threshold: number
): void {
  // 取左上角像素作为背景参考色
  let bgOffset: number = 0
  let bgR: number = view[bgOffset]
  let bgG: number = view[bgOffset + 1]
  let bgB: number = view[bgOffset + 2]

  for (let y: number = 0; y < height; y++) {
    for (let x: number = 0; x < width; x++) {
      let idx: number = (y * width + x) * 4
      let dr: number = Math.abs(view[idx] - bgR)
      let dg: number = Math.abs(view[idx + 1] - bgG)
      let db: number = Math.abs(view[idx + 2] - bgB)

      if (dr < threshold && dg < threshold && db < threshold) {
        view[idx] = targetR
        view[idx + 1] = targetG
        view[idx + 2] = targetB
      }
    }
  }
}

要点: 容差 threshold 建议 30~60,太小换不干净,太大会误伤非背景区域。蓝底→红底只需改目标色参数。更精准的做法是用多采样点取背景色均值。

PixelMap 其他常用 API

API 用途 说明
scale(x, y) 缩放 0~1 缩小,>1 放大
rotate(angle) 旋转 角度制,90/180/270
flip(h, v) 翻转 h=水平翻转,v=垂直翻转
crop(region) 裁剪 Region{ x, y, size }
getPixelBytesNumber() 字节数 = width × height × 4
release() 释放内存 用完必须调用

完整 Demo 代码

Demo 展示了 7 种像素操作的说明和模拟执行,包含马赛克块大小调节和证件照换底色选择。

interface PixelOp {
  name: string;
  desc: string;
  color: string;
}

@Entry
@Component
struct PixelMapDemo {
  @State selectedOp: string = ''
  @State resultDesc: string = '选择像素操作查看说明'
  @State isProcessing: boolean = false
  @State colorPreview: string = '#2196F3'
  @State pixelInfo: string = ''
  @State mosaicSize: string = '10'
  @State operationLog: string[] = []

  private pixelOps: PixelOp[] = [
    { name: '读取像素', desc: 'readPixelsToBuffer读取RGBA数据', color: '#1a73e8' },
    { name: '马赛克', desc: '按块取均值覆盖,实现马赛克效果', color: '#F44336' },
    { name: '灰度化', desc: 'R*0.299+G*0.587+B*0.114公式', color: '#9E9E9E' },
    { name: '反色', desc: '255-R, 255-G, 255-B取反', color: '#FF9800' },
    { name: '亮度调节', desc: '各通道加上偏移量', color: '#FFEB3B' },
    { name: '圆角裁剪', desc: '四角像素alpha设为0', color: '#4CAF50' },
    { name: '证件照换底', desc: '颜色容差匹配替换背景色', color: '#9C27B0' }
  ]

  build() {
    Column({ space: 0 }) {
      Row() {
        Button('< 返回')
          .fontSize(14)
          .backgroundColor(Color.Transparent)
          .fontColor('#1a73e8')
          .onClick(() => { router.back(); })
        Text('PixelMap 像素操作')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
        Text(this.isProcessing ? '处理中' : '就绪')
          .fontSize(12)
          .fontColor(this.isProcessing ? '#F44336' : '#4CAF50')
      }
      .width('100%')
      .height(56)
      .padding({ left: 12, right: 12 })
      .alignItems(VerticalAlign.Center)
      .backgroundColor('#FFFFFF')

      Scroll() {
        Column({ space: 16 }) {
          Column({ space: 12 }) {
            Text('像素操作列表')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .width('100%')
            ForEach(this.pixelOps, (op: PixelOp) => {
              Row({ space: 12 }) {
                Column()
                  .width(6)
                  .height(40)
                  .borderRadius(3)
                  .backgroundColor(op.color)
                Column({ space: 2 }) {
                  Text(op.name)
                    .fontSize(15)
                    .fontWeight(FontWeight.Medium)
                  Text(op.desc)
                    .fontSize(12)
                    .fontColor('#999999')
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                Button('模拟')
                  .fontSize(12)
                  .height(32)
                  .onClick(() => this.simulateOp(op.name))
              }
              .width('100%')
              .padding(10)
              .borderRadius(8)
              .backgroundColor(this.selectedOp === op.name ? '#E3F2FD' : '#FAFAFA')
            }, (op: PixelOp) => op.name)
          }
          .width('100%')
          .padding(16)
          .borderRadius(12)
          .backgroundColor('#FFFFFF')

          Column({ space: 8 }) {
            Text('操作说明')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .width('100%')
            Text(this.resultDesc)
              .fontSize(14)
              .fontColor('#333333')
              .width('100%')
              .padding(12)
              .borderRadius(8)
              .backgroundColor('#F5F5F5')
          }
          .width('100%')
          .padding(16)
          .borderRadius(12)
          .backgroundColor('#FFFFFF')
        }
        .padding(16)
      }
      .layoutWeight(1)
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  private simulateOp(opName: string): void {
    this.selectedOp = opName
    this.isProcessing = true
    let desc: string = ''
    if (opName === '读取像素') {
      desc = '1. 创建PixelMap\n2. 创建ArrayBuffer(size = width*height*4)\n3. pixelMap.readPixelsToBuffer(buffer)\n4. 解析RGBA值:每4字节为一像素(R,G,B,A)\n5. 获取指定位置颜色:offset = (y*width+x)*4'
    } else if (opName === '马赛克') {
      desc = '1. 读取全部像素到buffer\n2. 按块遍历(块大小=' + this.mosaicSize + ')\n3. 每块内计算R/G/B均值\n4. 块内所有像素设为均值\n5. writePixelsToBuffer写回'
    } else if (opName === '灰度化') {
      desc = '1. 读取像素buffer\n2. 对每个像素:gray = R*0.299 + G*0.587 + B*0.114\n3. R = G = B = gray\n4. 写回buffer'
    } else if (opName === '反色') {
      desc = '1. 读取像素buffer\n2. 对每个像素:R=255-R, G=255-G, B=255-B\n3. 写回buffer'
    } else if (opName === '亮度调节') {
      desc = '1. 读取像素buffer\n2. 对每个像素各通道加偏移:clamp(R+offset)\n3. 写回buffer\noffset>0变亮,<0变暗'
    } else if (opName === '圆角裁剪') {
      desc = '1. 读取像素buffer\n2. 对四个角的像素:计算到圆心距离\n3. 距离>半径则A=0(透明)\n4. 写回buffer'
    } else if (opName === '证件照换底') {
      desc = '1. 读取像素buffer\n2. 取左上角像素作为原背景色参考\n3. 设定颜色容差threshold\n4. 与背景色差值<threshold的像素替换为目标色\n5. 写回buffer'
    }
    this.resultDesc = desc
    this.operationLog.push(`[${new Date().toLocaleTimeString()}] ${opName} - 模拟完成`)
    this.isProcessing = false
  }
}

性能优化建议

优化点 做法 效果
大图先缩放 scale 到 2048 以内再处理 速度提升 3~5 倍
避免频繁 read/write 一次 read → 全部修改 → 一次 write 减少 IO 次数
区域裁剪 先 crop 感兴趣区域再处理 减少计算量
Worker 线程 像素操作放 ThreadWorker 不卡 UI
release 及时 处理完立即 release() 释放内存

踩坑清单

问题 原因 解决
readPixelsToBuffer 崩溃 Buffer 大小不对 用 getPixelBytesNumber() 获取精确大小
写回后图片没变化 忘记 writePixelsToBuffer 修改 buffer 后必须写回
RGBA 顺序搞反 误以为是 ARGB PixelMap 用 RGBA 顺序,不是 Android 的 ARGB
马赛克边缘有黑线 blockSize 不整除 处理边界时 Math.min 限制不越界
灰度图偏绿 用了简单平均 (R+G+B)/3 必须用加权公式 0.299/0.587/0.114
换底色不干净 容差太小 threshold 调大到 40~60
换底色误伤头发 容差太大 减小 threshold,或用多采样点
圆角锯齿明显 无抗锯齿 边缘像素做 alpha 渐变过渡
处理大图 OOM 未缩放直接处理 先 scale 到合适尺寸
亮度溢出 offset 太大 clamp(0, 255) 不能忘
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐