HarmonyOS开发中的图像变换:旋转、翻转、缩放、裁剪与矩阵变换
HarmonyOS开发中的图像变换:旋转、翻转、缩放、裁剪与矩阵变换
核心要点:掌握 PixelMap 图像变换 API,理解仿射变换矩阵原理,熟练运用旋转/翻转/缩放/裁剪操作,实现色彩空间转换
一、背景与动机
想象一下这个场景:你在开发一个图片编辑器,用户拍了张照片,发现歪了——想旋转 15 度;又觉得左右反了——想水平翻转;还想把远处的人放大看看——要缩放;最后只想要中间那块——得裁剪。
这些操作有个共同点:它们都在改变图像的几何结构,而不是改变像素的颜色值。这就是"图像变换"和"图像滤镜"的本质区别——变换改的是"位置",滤镜改的是"颜色"。
HarmonyOS 的 PixelMap 提供了一套完整的变换 API,从简单的旋转翻转,到复杂的矩阵变换,基本覆盖了日常开发的所有需求。但这里面有不少细节值得深挖,比如旋转后的画布尺寸怎么算?缩放的插值算法选哪个?矩阵变换的数学原理是什么?今天我们就来一一搞清楚。
二、核心原理
2.1 图像变换的分类
图像变换可以分为两大类:
| 类别 | 操作 | 特点 |
|---|---|---|
| 几何变换 | 旋转、翻转、缩放、裁剪、平移 | 改变像素的位置关系 |
| 色彩变换 | 色彩空间转换、亮度/对比度调整 | 改变像素的颜色值 |
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 迁移指南
- 透视变换:API 14 新增的
perspectiveX/perspectiveY参数,之前版本需要通过多次仿射变换近似模拟 - 硬件加速:API 14 的
hardwareAcceleration选项默认关闭,大图场景建议手动开启 - 性能监控:API 14 新增变换耗时统计接口,可用于性能分析
六、总结
关键知识点回顾:
| 知识点 | 要点 |
|---|---|
| rotate() | 顺时针旋转,90/270°宽高互换,非直角画布变大 |
| flip() | 水平/垂直翻转,可组合使用 |
| scale() | 比例缩放,默认双线性插值,不可逆 |
| crop() | 区域裁剪,必须做边界检查 |
| transform() | 矩阵变换,统一接口,注意行列式校验 |
| 变换顺序 | 矩阵乘法不可交换,顺序很重要 |
| 批量像素操作 | readPixelsToBuffer/writeBufferToPixels 性能远优于逐像素 |
| 原地操作 | 所有变换都是原地修改,需保留原图时要先复制 |
| HarmonyOS 6 | 透视变换、硬件加速、性能监控 |
图像变换是图像处理的"骨架"操作——它决定了像素的位置。下一篇我们将深入图像滤镜,看看如何改变像素的颜色,给图片加上模糊、锐化、色彩调整等效果。
更多推荐


所有评论(0)