HarmonyOS开发中的图像变换:旋转、翻转、缩放、裁剪与矩阵变换

核心要点:掌握 PixelMap 图像变换 API,理解仿射变换矩阵原理,熟练运用旋转/翻转/缩放/裁剪操作,实现色彩空间转换


一、背景与动机

想象一下这个场景:你在开发一个图片编辑器,用户拍了张照片,发现歪了——想旋转 15 度;又觉得左右反了——想水平翻转;还想把远处的人放大看看——要缩放;最后只想要中间那块——得裁剪。

这些操作有个共同点:它们都在改变图像的几何结构,而不是改变像素的颜色值。这就是"图像变换"和"图像滤镜"的本质区别——变换改的是"位置",滤镜改的是"颜色"。

HarmonyOS 的 PixelMap 提供了一套完整的变换 API,从简单的旋转翻转,到复杂的矩阵变换,基本覆盖了日常开发的所有需求。但这里面有不少细节值得深挖,比如旋转后的画布尺寸怎么算?缩放的插值算法选哪个?矩阵变换的数学原理是什么?今天我们就来一一搞清楚。


二、核心原理

2.1 图像变换的分类

图像变换可以分为两大类:

类别 操作 特点
几何变换 旋转、翻转、缩放、裁剪、平移 改变像素的位置关系
色彩变换 色彩空间转换、亮度/对比度调整 改变像素的颜色值

图像变换

几何变换

色彩变换

旋转 rotate

翻转 flip

缩放 scale

裁剪 crop

平移 translate

矩阵变换 transform

色彩空间转换

亮度调整

对比度调整

仿射变换

透视变换

2.2 仿射变换与变换矩阵

所有几何变换都可以用矩阵乘法统一表示。一个 2D 仿射变换可以用一个 3×3 矩阵描述:

| a  b  tx |   | x |   | x' |
| c  d  ty | × | y | = | y' |
| 0  0   1 |   | 1 |   | 1  |

其中:

  • a, d 控制缩放
  • b, c 控制剪切/旋转
  • tx, ty 控制平移

常见变换的矩阵形式:

变换 矩阵
平移 (tx, ty) [1, 0, tx, 0, 1, ty]
缩放 (sx, sy) [sx, 0, 0, 0, sy, 0]
旋转 θ [cosθ, sinθ, 0, -sinθ, cosθ, 0]
水平翻转 [-1, 0, width, 0, 1, 0]
垂直翻转 [1, 0, 0, 0, -1, height]

2.3 变换的执行顺序

矩阵乘法不满足交换律,所以变换的顺序很重要。先旋转再平移先平移再旋转,结果完全不同。

// 顺序1:先旋转90°,再平移(100, 0)
// 效果:图片先转90°,然后沿旋转后的X轴平移100像素

// 顺序2:先平移(100, 0),再旋转90°
// 效果:图片先右移100像素,然后绕原点旋转90°
// 结果完全不同!

2.4 插值算法

图像缩放时,目标位置的像素值需要从源图像中"推算"出来,这个过程叫插值

插值方法 速度 质量 适用场景
最近邻 最快 最差(锯齿) 缩略图、像素风
双线性 中等 较好 通用场景
双三次 最慢 最好 高质量放大

HarmonyOS 的 scale() 方法默认使用双线性插值,在速度和质量之间取得了不错的平衡。


三、代码实战

3.1 旋转与翻转

旋转和翻转是最基础也最常用的变换操作。

import { image } from '@kit.ImageKit';
import { fileIo as fs } from '@kit.CoreFileKit';

@Entry
@Component
struct RotateFlipPage {
  @State pixelMap: PixelMap | null = null;
  @State transformInfo: string = '';

  build() {
    Scroll() {
      Column({ space: 16 }) {
        // 显示变换后的图片
        if (this.pixelMap) {
          Image(this.pixelMap)
            .width(280)
            .height(280)
            .objectFit(ImageFit.Contain)
            .border({ width: 1, color: '#444444' })
        }

        Text(this.transformInfo)
          .fontSize(13)
          .fontColor('#AAAAAA')
          .textAlign(TextAlign.Center)

        // 旋转操作
        Text('旋转操作')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .width('100%')
          .margin({ top: 8 })

        Row({ space: 8 }) {
          Button('90°').onClick(() => this.rotate(90))
          Button('180°').onClick(() => this.rotate(180))
          Button('270°').onClick(() => this.rotate(270))
          Button('45°').onClick(() => this.rotate(45))
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceEvenly)

        // 翻转操作
        Text('翻转操作')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .width('100%')

        Row({ space: 8 }) {
          Button('水平翻转').onClick(() => this.flip(true, false))
          Button('垂直翻转').onClick(() => this.flip(false, true))
          Button('双向翻转').onClick(() => this.flip(true, true))
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceEvenly)

        Button('加载图片')
          .width('80%')
          .onClick(() => this.loadImage())
      }
      .width('100%')
      .padding(20)
    }
  }

  /**
   * 加载测试图片
   */
  async loadImage() {
    try {
      const filePath = getContext(this).filesDir + '/test_photo.jpg';
      const file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
      const imageSource = image.createImageSource(file.fd);
      this.pixelMap = await imageSource.createPixelMap({
        editable: true,
        desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
      });
      imageSource.release();
      fs.closeSync(file);
      this.transformInfo = '图片加载成功,请进行变换操作';
    } catch (error) {
      this.transformInfo = `加载失败: ${(error as Error).message}`;
    }
  }

  /**
   * 旋转图片
   * 注意:rotate() 是原地操作,会直接修改 PixelMap
   * 旋转后图片的宽高可能会互换
   */
  async rotate(angle: number) {
    if (!this.pixelMap) return;

    try {
      const beforeInfo = await this.pixelMap.getImageInfo();

      // 执行旋转(角度制,顺时针方向)
      await this.pixelMap.rotate(angle);

      const afterInfo = await this.pixelMap.getImageInfo();
      this.transformInfo = `旋转 ${angle}°\n` +
        `变换前: ${beforeInfo.size.width}×${beforeInfo.size.height}\n` +
        `变换后: ${afterInfo.size.width}×${afterInfo.size.height}`;

      // 触发UI刷新
      this.pixelMap = this.pixelMap;
    } catch (error) {
      this.transformInfo = `旋转失败: ${(error as Error).message}`;
    }
  }

  /**
   * 翻转图片
   * horizontal: true = 水平翻转(左右镜像)
   * vertical: true = 垂直翻转(上下镜像)
   */
  async flip(horizontal: boolean, vertical: boolean) {
    if (!this.pixelMap) return;

    try {
      await this.pixelMap.flip(horizontal, vertical);

      const dir = [];
      if (horizontal) dir.push('水平');
      if (vertical) dir.push('垂直');
      this.transformInfo = `${dir.join('+')}翻转完成`;

      // 触发UI刷新
      this.pixelMap = this.pixelMap;
    } catch (error) {
      this.transformInfo = `翻转失败: ${(error as Error).message}`;
    }
  }
}

3.2 缩放与裁剪

缩放改变图片尺寸,裁剪截取图片的某个区域。

import { image } from '@kit.ImageKit';
import { fileIo as fs } from '@kit.CoreFileKit';

@Entry
@Component
struct ScaleCropPage {
  @State pixelMap: PixelMap | null = null;
  @State transformInfo: string = '';
  @State scaleX: number = 1.0;
  @State scaleY: number = 1.0;

  build() {
    Scroll() {
      Column({ space: 16 }) {
        if (this.pixelMap) {
          Image(this.pixelMap)
            .width(280)
            .height(280)
            .objectFit(ImageFit.Contain)
            .border({ width: 1, color: '#444444' })
        }

        Text(this.transformInfo)
          .fontSize(13)
          .fontColor('#AAAAAA')

        // 缩放控制
        Text('缩放控制')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .width('100%')

        Row({ space: 8 }) {
          Text('X:').fontSize(14)
          Slider({ value: this.scaleX * 100, min: 10, max: 300 })
            .width('35%')
            .onChange((v: number) => { this.scaleX = v / 100 })
          Text('Y:').fontSize(14)
          Slider({ value: this.scaleY * 100, min: 10, max: 300 })
            .width('35%')
            .onChange((v: number) => { this.scaleY = v / 100 })
        }
        .width('100%')

        Row({ space: 8 }) {
          Text(`(${this.scaleX.toFixed(2)}, ${this.scaleY.toFixed(2)})`)
            .fontSize(13)
            .fontColor('#FF9800')
          Button('应用缩放')
            .onClick(() => this.applyScale())
        }

        // 裁剪操作
        Text('裁剪操作')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .width('100%')

        Row({ space: 8 }) {
          Button('中心裁剪')
            .onClick(() => this.centerCrop())
          Button('顶部裁剪')
            .onClick(() => this.topCrop())
          Button('自由裁剪')
            .onClick(() => this.freeCrop())
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceEvenly)

        Button('加载图片')
          .width('80%')
          .onClick(() => this.loadImage())
      }
      .width('100%')
      .padding(20)
    }
  }

  async loadImage() {
    try {
      const filePath = getContext(this).filesDir + '/test_photo.jpg';
      const file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
      const imageSource = image.createImageSource(file.fd);
      this.pixelMap = await imageSource.createPixelMap({
        editable: true,
        desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
      });
      imageSource.release();
      fs.closeSync(file);

      const info = await this.pixelMap.getImageInfo();
      this.transformInfo = `加载成功: ${info.size.width}×${info.size.height}`;
      this.scaleX = 1.0;
      this.scaleY = 1.0;
    } catch (error) {
      this.transformInfo = `加载失败: ${(error as Error).message}`;
    }
  }

  /**
   * 缩放图片
   * scaleX/scaleY 是缩放比例,1.0=原始大小
   * 大于1放大,小于1缩小
   */
  async applyScale() {
    if (!this.pixelMap) return;

    try {
      const beforeInfo = await this.pixelMap.getImageInfo();

      // 执行缩放
      await this.pixelMap.scale(this.scaleX, this.scaleY);

      const afterInfo = await this.pixelMap.getImageInfo();
      this.transformInfo = `缩放 (${this.scaleX.toFixed(2)}, ${this.scaleY.toFixed(2)})\n` +
        `变换前: ${beforeInfo.size.width}×${beforeInfo.size.height}\n` +
        `变换后: ${afterInfo.size.width}×${afterInfo.size.height}`;

      this.pixelMap = this.pixelMap;
    } catch (error) {
      this.transformInfo = `缩放失败: ${(error as Error).message}`;
    }
  }

  /**
   * 中心裁剪:从图片中心裁出正方形
   */
  async centerCrop() {
    if (!this.pixelMap) return;

    try {
      const info = await this.pixelMap.getImageInfo();
      const minDim = Math.min(info.size.width, info.size.height);

      // 计算中心裁剪区域
      const x = Math.floor((info.size.width - minDim) / 2);
      const y = Math.floor((info.size.height - minDim) / 2);

      await this.pixelMap.crop({
        x: x,
        y: y,
        size: { width: minDim, height: minDim }
      });

      const afterInfo = await this.pixelMap.getImageInfo();
      this.transformInfo = `中心裁剪完成: ${afterInfo.size.width}×${afterInfo.size.height}`;

      this.pixelMap = this.pixelMap;
    } catch (error) {
      this.transformInfo = `裁剪失败: ${(error as Error).message}`;
    }
  }

  /**
   * 顶部裁剪:只保留图片上半部分
   */
  async topCrop() {
    if (!this.pixelMap) return;

    try {
      const info = await this.pixelMap.getImageInfo();

      await this.pixelMap.crop({
        x: 0,
        y: 0,
        size: {
          width: info.size.width,
          height: Math.floor(info.size.height / 2)
        }
      });

      const afterInfo = await this.pixelMap.getImageInfo();
      this.transformInfo = `顶部裁剪完成: ${afterInfo.size.width}×${afterInfo.size.height}`;

      this.pixelMap = this.pixelMap;
    } catch (error) {
      this.transformInfo = `裁剪失败: ${(error as Error).message}`;
    }
  }

  /**
   * 自由裁剪:裁剪到指定区域
   * 注意:裁剪区域不能超出图片边界
   */
  async freeCrop() {
    if (!this.pixelMap) return;

    try {
      const info = await this.pixelMap.getImageInfo();
      const cropWidth = Math.floor(info.size.width * 0.6);
      const cropHeight = Math.floor(info.size.height * 0.6);
      const x = Math.floor((info.size.width - cropWidth) / 3);
      const y = Math.floor((info.size.height - cropHeight) / 3);

      await this.pixelMap.crop({
        x: x,
        y: y,
        size: { width: cropWidth, height: cropHeight }
      });

      const afterInfo = await this.pixelMap.getImageInfo();
      this.transformInfo = `自由裁剪完成: ${afterInfo.size.width}×${afterInfo.size.height}`;

      this.pixelMap = this.pixelMap;
    } catch (error) {
      this.transformInfo = `裁剪失败: ${(error as Error).message}`;
    }
  }
}

3.3 矩阵变换:统一接口

当你需要组合多种变换时,逐个调用 rotate()scale()translate() 不仅效率低,而且精度会有累积误差。矩阵变换提供了一种统一的方式——把所有变换合并成一个矩阵,一次搞定。

import { image } from '@kit.ImageKit';
import { fileIo as fs } from '@kit.CoreFileKit';

@Entry
@Component
struct MatrixTransformPage {
  @State pixelMap: PixelMap | null = null;
  @State transformInfo: string = '';
  @State currentTransform: string = '无';

  build() {
    Scroll() {
      Column({ space: 16 }) {
        if (this.pixelMap) {
          Image(this.pixelMap)
            .width(280)
            .height(280)
            .objectFit(ImageFit.Contain)
            .border({ width: 1, color: '#444444' })
        }

        Text(`当前变换: ${this.currentTransform}`)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FF9800')

        Text(this.transformInfo)
          .fontSize(12)
          .fontColor('#AAAAAA')
          .fontFamily('monospace')

        // 预设变换
        Text('预设矩阵变换')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .width('100%')

        Row({ space: 8 }) {
          Button('旋转45°+缩放0.8')
            .fontSize(12)
            .onClick(() => this.comboRotateScale())
          Button('水平翻转+平移')
            .fontSize(12)
            .onClick(() => this.comboFlipTranslate())
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceEvenly)

        Row({ space: 8 }) {
          Button('剪切变换')
            .fontSize(12)
            .onClick(() => this.shearTransform())
          Button('自定义矩阵')
            .fontSize(12)
            .onClick(() => this.customMatrix())
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceEvenly)

        // 色彩空间转换
        Text('色彩空间转换')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .width('100%')

        Row({ space: 8 }) {
          Button('转灰度')
            .onClick(() => this.toGrayscale())
          Button('转黑白')
            .onClick(() => this.toBlackWhite())
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceEvenly)

        Button('加载图片')
          .width('80%')
          .onClick(() => this.loadImage())
      }
      .width('100%')
      .padding(20)
    }
  }

  async loadImage() {
    try {
      const filePath = getContext(this).filesDir + '/test_photo.jpg';
      const file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
      const imageSource = image.createImageSource(file.fd);
      this.pixelMap = await imageSource.createPixelMap({
        editable: true,
        desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
      });
      imageSource.release();
      fs.closeSync(file);
      this.transformInfo = '图片加载成功';
      this.currentTransform = '无';
    } catch (error) {
      this.transformInfo = `加载失败: ${(error as Error).message}`;
    }
  }

  /**
   * 组合变换:旋转45° + 缩放0.8
   * 仿射变换矩阵 = 旋转矩阵 × 缩放矩阵
   */
  async comboRotateScale() {
    if (!this.pixelMap) return;

    try {
      const angle = 45;
      const rad = angle * Math.PI / 180;
      const scaleVal = 0.8;

      // 旋转矩阵: [cosθ, sinθ, 0, -sinθ, cosθ, 0]
      // 缩放矩阵: [sx, 0, 0, 0, sy, 0]
      // 组合结果(先缩放后旋转):
      const cos = Math.cos(rad);
      const sin = Math.sin(rad);
      const sx = scaleVal;
      const sy = scaleVal;

      // 矩阵乘法: 旋转 × 缩放
      const transform: image.Transformer = {
        a: cos * sx,    // 旋转的cos × 缩放的sx
        b: sin * sx,    // 旋转的sin × 缩放的sx
        c: -sin * sy,   // 旋转的-sin × 缩放的sy
        d: cos * sy,    // 旋转的cos × 缩放的sy
        tx: 0,
        ty: 0,
      };

      await this.pixelMap.transform(transform);

      this.currentTransform = '旋转45° + 缩放0.8';
      this.transformInfo = `矩阵: [${transform.a.toFixed(3)}, ${transform.b.toFixed(3)}, ${transform.tx},\n` +
        `       ${transform.c.toFixed(3)}, ${transform.d.toFixed(3)}, ${transform.ty}]`;

      this.pixelMap = this.pixelMap;
    } catch (error) {
      this.transformInfo = `变换失败: ${(error as Error).message}`;
    }
  }

  /**
   * 组合变换:水平翻转 + 平移
   * 水平翻转: [-1, 0, width, 0, 1, 0]
   * 平移: [1, 0, tx, 0, 1, ty]
   */
  async comboFlipTranslate() {
    if (!this.pixelMap) return;

    try {
      const info = await this.pixelMap.getImageInfo();

      // 水平翻转后图片会"跑到左边",需要平移回来
      const transform: image.Transformer = {
        a: -1,                          // 水平翻转
        b: 0,
        c: 0,
        d: 1,                           // 垂直不变
        tx: info.size.width,            // 平移回画布内
        ty: 0,
      };

      await this.pixelMap.transform(transform);

      this.currentTransform = '水平翻转 + 平移';
      this.transformInfo = `矩阵: [${transform.a}, ${transform.b}, ${transform.tx},\n` +
        `       ${transform.c}, ${transform.d}, ${transform.ty}]`;

      this.pixelMap = this.pixelMap;
    } catch (error) {
      this.transformInfo = `变换失败: ${(error as Error).message}`;
    }
  }

  /**
   * 剪切变换(Shear)
   * 让图片产生"倾斜"效果
   */
  async shearTransform() {
    if (!this.pixelMap) return;

    try {
      const shearX = 0.3;   // 水平剪切因子
      const shearY = 0.0;   // 垂直剪切因子

      // 剪切矩阵: [1, shearY, 0, shearX, 1, 0]
      const transform: image.Transformer = {
        a: 1,
        b: shearY,
        c: shearX,
        d: 1,
        tx: 0,
        ty: 0,
      };

      await this.pixelMap.transform(transform);

      this.currentTransform = '剪切变换';
      this.transformInfo = `剪切因子: (${shearX}, ${shearY})\n` +
        `矩阵: [1, ${shearY}, 0, ${shearX}, 1, 0]`;

      this.pixelMap = this.pixelMap;
    } catch (error) {
      this.transformInfo = `变换失败: ${(error as Error).message}`;
    }
  }

  /**
   * 自定义矩阵变换
   * 你可以传入任意 2×3 仿射矩阵
   */
  async customMatrix() {
    if (!this.pixelMap) return;

    try {
      // 示例:轻微旋转 + 轻微缩放 + 轻微剪切
      const transform: image.Transformer = {
        a: 0.9,     // 缩放X + 旋转分量
        b: 0.1,     // 旋转 + 剪切分量
        c: -0.1,    // 旋转 + 剪切分量
        d: 0.9,     // 缩放Y + 旋转分量
        tx: 20,     // 平移X
        ty: 10,     // 平移Y
      };

      await this.pixelMap.transform(transform);

      this.currentTransform = '自定义矩阵';
      this.transformInfo = `矩阵: [${transform.a}, ${transform.b}, ${transform.tx},\n` +
        `       ${transform.c}, ${transform.d}, ${transform.ty}]`;

      this.pixelMap = this.pixelMap;
    } catch (error) {
      this.transformInfo = `变换失败: ${(error as Error).message}`;
    }
  }

  /**
   * 色彩空间转换:转灰度
   * 使用 ITU-R BT.601 标准权重
   * Gray = 0.299R + 0.587G + 0.114B
   */
  async toGrayscale() {
    if (!this.pixelMap) return;

    try {
      const info = await this.pixelMap.getImageInfo();
      const totalPixels = info.size.width * info.size.height;

      // 逐像素处理
      for (let y = 0; y < info.size.height; y++) {
        for (let x = 0; x < info.size.width; x++) {
          // 读取单个像素(RGBA)
          const pixel = await this.pixelMap.getImagePixel(x, y);
          const r = (pixel >> 24) & 0xFF;
          const g = (pixel >> 16) & 0xFF;
          const b = (pixel >> 8) & 0xFF;
          const a = pixel & 0xFF;

          // 计算灰度值
          const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);

          // 写回像素
          const newPixel = (gray << 24) | (gray << 16) | (gray << 8) | a;
          await this.pixelMap.setImagePixel(x, y, newPixel);
        }
      }

      this.currentTransform = '灰度转换';
      this.transformInfo = '使用 BT.601 标准转换为灰度';

      this.pixelMap = this.pixelMap;
    } catch (error) {
      this.transformInfo = `转换失败: ${(error as Error).message}`;
    }
  }

  /**
   * 色彩空间转换:转黑白(二值化)
   * 灰度值 > 阈值 → 白,否则 → 黑
   */
  async toBlackWhite() {
    if (!this.pixelMap) return;

    try {
      const info = await this.pixelMap.getImageInfo();
      const threshold = 128;  // 二值化阈值

      for (let y = 0; y < info.size.height; y++) {
        for (let x = 0; x < info.size.width; x++) {
          const pixel = await this.pixelMap.getImagePixel(x, y);
          const r = (pixel >> 24) & 0xFF;
          const g = (pixel >> 16) & 0xFF;
          const b = (pixel >> 8) & 0xFF;
          const a = pixel & 0xFF;

          const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
          const bw = gray > threshold ? 255 : 0;

          const newPixel = (bw << 24) | (bw << 16) | (bw << 8) | a;
          await this.pixelMap.setImagePixel(x, y, newPixel);
        }
      }

      this.currentTransform = '黑白二值化';
      this.transformInfo = `阈值: ${threshold}`;

      this.pixelMap = this.pixelMap;
    } catch (error) {
      this.transformInfo = `转换失败: ${(error as Error).message}`;
    }
  }
}

3.4 变换链:组合多种操作

实际开发中,经常需要组合多种变换。比如:先旋转校正,再裁剪,最后缩放到目标尺寸。

import { image } from '@kit.ImageKit';

/**
 * 图像变换工具类
 * 封装常用的变换组合操作
 */
export class ImageTransformHelper {

  /**
   * 变换链:旋转 → 裁剪 → 缩放
   * 典型场景:校正歪斜的照片,裁掉黑边,缩放到统一尺寸
   */
  static async rotateCropScale(
    pixelMap: PixelMap,
    rotateAngle: number,
    cropRegion: image.Region,
    targetWidth: number,
    targetHeight: number
  ): Promise<PixelMap> {
    // 第1步:旋转校正
    if (rotateAngle !== 0) {
      await pixelMap.rotate(rotateAngle);
    }

    // 第2步:裁剪掉多余区域
    await pixelMap.crop(cropRegion);

    // 第3步:缩放到目标尺寸
    const info = await pixelMap.getImageInfo();
    const scaleX = targetWidth / info.size.width;
    const scaleY = targetHeight / info.size.height;

    // 等比缩放取较小值,避免拉伸变形
    const scale = Math.min(scaleX, scaleY);
    await pixelMap.scale(scale, scale);

    return pixelMap;
  }

  /**
   * 智能旋转:根据 EXIF 方向信息自动旋转
   * 很多手机拍的照片 EXIF 中记录了方向,但像素数据本身没有旋转
   */
  static async autoRotateByExif(pixelMap: PixelMap, orientation: number): Promise<PixelMap> {
    const angleMap: Record<number, number> = {
      1: 0,     // 正常
      2: 0,     // 水平翻转(需额外处理)
      3: 180,   // 旋转180°
      4: 0,     // 垂直翻转(需额外处理)
      5: 90,    // 旋转90° + 水平翻转
      6: 90,    // 旋转90°
      7: 270,   // 旋转270° + 水平翻转
      8: 270,   // 旋转270°
    };

    const angle = angleMap[orientation] ?? 0;
    if (angle !== 0) {
      await pixelMap.rotate(angle);
    }

    // 处理翻转
    if (orientation === 2 || orientation === 5 || orientation === 7) {
      await pixelMap.flip(true, false);
    }
    if (orientation === 4) {
      await pixelMap.flip(false, true);
    }

    return pixelMap;
  }

  /**
   * 透视校正(简化版)
   * 通过矩阵变换模拟简单的透视校正
   * 完整的透视校正需要4点映射,这里仅做近似
   */
  static async perspectiveCorrect(
    pixelMap: PixelMap,
    skewX: number,
    skewY: number
  ): Promise<PixelMap> {
    const info = await pixelMap.getImageInfo();

    // 使用仿射变换近似透视校正
    const transform: image.Transformer = {
      a: 1,
      b: skewY,
      c: skewX,
      d: 1,
      tx: -skewX * info.size.height * 0.5,
      ty: -skewY * info.size.width * 0.5,
    };

    await pixelMap.transform(transform);
    return pixelMap;
  }
}

四、踩坑与注意事项

4.1 变换是原地操作

PixelMap 的 rotate()flip()scale()crop() 都是原地操作,会直接修改 PixelMap 本身。如果你需要保留原图,必须先复制一份。

// ❌ 错误:直接变换会丢失原图
const original = await imageSource.createPixelMap();
await original.rotate(90);  // original 已经被旋转了,无法回退

// ✅ 正确:先复制再变换
const original = await imageSource.createPixelMap();
const copy = await original.rotate(90);  // 等等,rotate 也是原地的!

// ✅ 正确做法:创建新的 PixelMap
const original = await imageSource.createPixelMap();
// HarmonyOS 没有直接的 clone 方法,需要通过编码→解码来复制
const packer = image.createImagePacker();
const data = await packer.packing(original, { format: 'image/png' });
const newSource = image.createImageSource(data);
const copy = await newSource.createPixelMap({ editable: true });
await copy.rotate(90);

4.2 旋转后画布尺寸变化

旋转 90° 或 270° 后,图片的宽高会互换。旋转 45° 这种非直角旋转,画布会变大——因为需要容纳旋转后的"角"。

// 旋转前: 1000×600
// 旋转90°后: 600×1000(宽高互换)
// 旋转45°后: 约 1131×1131(对角线长度决定新画布大小)

4.3 裁剪区域越界

crop() 的区域不能超出图片边界,否则会抛异常。

// ❌ 危险:裁剪区域可能越界
const cropRegion = {
  x: info.size.width - 100,   // 如果图片宽度 < 100,x 就变成负数了
  y: info.size.height - 100,
  size: { width: 200, height: 200 }
};

// ✅ 安全:先做边界检查
const safeX = Math.max(0, Math.min(cropRegion.x, info.size.width - cropRegion.size.width));
const safeY = Math.max(0, Math.min(cropRegion.y, info.size.height - cropRegion.size.height));

4.4 逐像素操作的性能问题

3.3 节中的灰度转换和二值化示例使用了 getImagePixel() / setImagePixel() 逐像素操作。这种方式在小图上还行,大图上会非常慢——一张 1000×1000 的图片要调用 200 万次 Native 方法。

优化方案:使用 readPixels() / writePixels() 批量读写像素数据:

/**
 * 高性能灰度转换:使用 readPixels/writePixels 批量操作
 * 比逐像素操作快 10-50 倍
 */
async function fastGrayscale(pixelMap: PixelMap): Promise<void> {
  const info = await pixelMap.getImageInfo();
  const bufferSize = info.size.width * info.size.height * 4;
  const buffer = new ArrayBuffer(bufferSize);

  // 批量读取所有像素
  await pixelMap.readPixelsToBuffer(buffer);

  // 在 ArrayBuffer 上直接操作
  const view = new Uint8Array(buffer);
  for (let i = 0; i < view.length; i += 4) {
    const r = view[i];
    const g = view[i + 1];
    const b = view[i + 2];
    // a = view[i + 3];  // alpha 通道不变

    const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
    view[i] = gray;
    view[i + 1] = gray;
    view[i + 2] = gray;
  }

  // 批量写回
  await pixelMap.writeBufferToPixels(buffer);
}

4.5 缩放比例过小导致信息丢失

将图片缩小到原来的 1/10,再放大回原始尺寸,画质会严重下降——因为缩小过程中像素信息已经丢失了。这是不可逆操作。

4.6 transform() 矩阵参数校验

transform() 方法对矩阵参数有严格要求:

  • 行列式不能为 0(a*d - b*c ≠ 0),否则图片会被"压扁"成一个点
  • 数值不能过大或过小,否则可能导致溢出
// ❌ 危险:行列式为0的矩阵
const bad: image.Transformer = {
  a: 1, b: 1,
  c: 1, d: 1,  // a*d - b*c = 1*1 - 1*1 = 0
  tx: 0, ty: 0
};

// ✅ 安全:先检查行列式
const det = transform.a * transform.d - transform.b * transform.c;
if (Math.abs(det) < 1e-6) {
  console.error('变换矩阵行列式接近0,可能导致异常');
}

五、HarmonyOS 6 适配

5.1 新增 Transformer 接口增强

HarmonyOS 6 对 Transformer 接口进行了增强,支持更丰富的变换参数:

// API 14+ 增强的 Transformer
interface TransformerV2 {
  a: number;       // 水平缩放/旋转
  b: number;       // 垂直旋转/剪切
  c: number;       // 水平旋转/剪切
  d: number;       // 垂直缩放/旋转
  tx: number;      // 水平平移
  ty: number;      // 垂直平移
  // API 14 新增
  perspectiveX?: number;  // 透视变换X分量
  perspectiveY?: number;  // 透视变换Y分量
}

5.2 硬件加速变换

HarmonyOS 6 对 rotate()scale()flip() 等常用变换增加了 GPU 硬件加速支持,在大图处理场景下性能提升显著:

// API 14+ 可指定是否使用硬件加速
await pixelMap.rotate(90, { hardwareAcceleration: true });

5.3 版本差异速查

特性 API 12 API 13 API 14
基础变换(rotate/flip/scale/crop)
矩阵变换 transform
透视变换
硬件加速变换 部分
readPixelsToBuffer
writeBufferToPixels

5.4 迁移指南

  1. 透视变换:API 14 新增的 perspectiveX/perspectiveY 参数,之前版本需要通过多次仿射变换近似模拟
  2. 硬件加速:API 14 的 hardwareAcceleration 选项默认关闭,大图场景建议手动开启
  3. 性能监控:API 14 新增变换耗时统计接口,可用于性能分析

六、总结

图像变换

几何变换

旋转 rotate

角度制 顺时针

90/270°宽高互换

非直角旋转画布变大

翻转 flip

水平翻转 左右镜像

垂直翻转 上下镜像

缩放 scale

比例缩放

双线性插值

不可逆操作

裁剪 crop

指定区域

边界检查

原地操作

平移 translate

水平/垂直偏移

矩阵变换

仿射矩阵 2×3

a/d 缩放

b/c 旋转剪切

tx/ty 平移

变换顺序

矩阵乘法不可交换

先缩放后旋转 ≠ 先旋转后缩放

行列式校验

a×d - b×c ≠ 0

色彩空间转换

灰度转换 BT.601

二值化 阈值分割

批量操作 readPixels/writePixels

注意事项

原地操作 需复制原图

裁剪越界检查

逐像素操作性能差

缩放不可逆

HarmonyOS 6

透视变换

硬件加速

性能监控

关键知识点回顾

知识点 要点
rotate() 顺时针旋转,90/270°宽高互换,非直角画布变大
flip() 水平/垂直翻转,可组合使用
scale() 比例缩放,默认双线性插值,不可逆
crop() 区域裁剪,必须做边界检查
transform() 矩阵变换,统一接口,注意行列式校验
变换顺序 矩阵乘法不可交换,顺序很重要
批量像素操作 readPixelsToBuffer/writeBufferToPixels 性能远优于逐像素
原地操作 所有变换都是原地修改,需保留原图时要先复制
HarmonyOS 6 透视变换、硬件加速、性能监控

图像变换是图像处理的"骨架"操作——它决定了像素的位置。下一篇我们将深入图像滤镜,看看如何改变像素的颜色,给图片加上模糊、锐化、色彩调整等效果。

Logo

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

更多推荐