HarmonyOS中的全景拍摄开发技巧
一、什么原因
你站在山顶,面前是一望无际的壮丽风景——左边是连绵的山脉,右边是蜿蜒的河流,正前方是绚丽的晚霞。你掏出手机想记录这一切,但普通拍照只能拍到眼前的一小片天空。
怎么办?转一圈,用全景模式!
全景拍摄让你可以拍摄 180° 甚至 360° 的超宽画面,把整个场景"装进"一张照片里。它的原理也不复杂——边转边拍,然后把多张照片拼接成一张超宽图。
但说起来容易做起来难。图像拼接需要精确的特征点匹配、透视变换、融合处理,稍有不慎就会出现错位、鬼影、色差等问题。拍摄引导也很关键——用户转太快会模糊,转太慢会漏拍,方向偏了会拼接失败。
今天我们就来一步步拆解全景拍摄的技术实现。
二、核心原理
2.1 全景拍摄完整流程
flowchart TB
classDef primary fill:#4FC3F7,stroke:#0288D1,color:#000
classDef warning fill:#FFB74D,stroke:#F57C00,color:#000
classDef error fill:#EF5350,stroke:#C62828,color:#fff
classDef info fill:#81C784,stroke:#388E3C,color:#000
classDef purple fill:#CE93D8,stroke:#7B1FA2,color:#000
A[进入全景模式]:::primary --> B[初始化相机与传感器]:::primary
B --> C[显示拍摄引导]:::info
C --> D[用户开始旋转]:::info
D --> E[连续采集帧]:::warning
E --> F[特征点提取]:::purple
F --> G[帧间特征匹配]:::purple
G --> H[计算单应性矩阵]:::purple
H --> I[透视变换对齐]:::purple
I --> J[图像融合]:::warning
J --> K{是否完成拍摄?}:::error
K -->|否| D
K -->|是| L[全景图合成]:::info
L --> M[色彩校正]:::info
M --> N[裁剪与输出]:::primary
2.2 图像拼接算法原理
图像拼接是全景拍摄的核心算法,主要包含以下步骤:
1. 特征点提取
在每帧图像中找到"特征点"——那些在图像中独特且稳定的点,比如角点、边缘交叉点。常用的特征点算法有:
- SIFT(Scale-Invariant Feature Transform):尺度不变,精度高但速度慢
- ORB(Oriented FAST and Rotated BRIEF):速度快,适合实时场景
- Harris 角点:经典角点检测,简单高效
2. 特征匹配
在相邻两帧之间找到对应的特征点对。如果帧 A 中的特征点 P1 和帧 B 中的特征点 P2 描述的是同一个物理点,它们就是一对匹配点。
3. 单应性矩阵估计
有了足够多的匹配点对,就可以估计两帧之间的变换关系——单应性矩阵 H。H 是一个 3×3 矩阵,描述了从一帧到另一帧的透视变换:
| h11 h12 h13 |
| h21 h22 h23 |
| h31 h32 h33 |
通过 H,可以将帧 B 中的每个像素映射到帧 A 的坐标系中。
4. 图像融合
将多帧对齐后,在重叠区域进行融合处理,消除拼接缝隙。常用方法有:
- 线性混合:在重叠区域按距离线性插值
- 多频段融合:在不同频率上分别融合,效果更自然
- 最优接缝:找到一条"接缝"使两侧差异最小
2.3 拍摄引导原理
全景拍摄需要用户按引导旋转手机。引导系统依赖陀螺仪传感器来追踪手机的旋转角度:
- 方向指示:显示当前旋转方向和目标方向
- 速度控制:旋转太快时提示减速
- 俯仰校正:提示用户保持水平,避免上下偏移
- 进度指示:显示已拍摄的角度范围
三、代码实战
3.1 全景拍摄管理器:核心拍摄与拼接逻辑
这是全景拍摄的核心——控制拍摄流程、采集帧、执行拼接。
import { camera } from '@kit.CameraKit';
import { image } from '@kit.ImageKit';
import { sensor } from '@kit.SensorServiceKit';
import { BusinessError } from '@kit.BasicServicesKit';
/**
* 全景拍摄配置
*/
interface PanoramaConfig {
/** 全景角度范围(度) */
targetAngle: number;
/** 相邻帧最小重叠率 */
overlapRatio: number;
/** 最大拍摄帧数 */
maxFrames: number;
/** 输出全景图宽度 */
outputWidth: number;
}
/**
* 全景拍摄状态
*/
enum PanoramaState {
/** 空闲 */
IDLE = 'idle',
/** 准备中 */
PREPARING = 'preparing',
/** 拍摄中 */
CAPTURING = 'capturing',
/** 拼接中 */
STITCHING = 'stitching',
/** 完成 */
COMPLETED = 'completed',
/** 错误 */
ERROR = 'error',
}
/**
* 帧数据:包含图像和对应的传感器数据
*/
interface FrameData {
/** 帧图像 */
pixelMap: image.PixelMap;
/** 拍摄时的陀螺仪角度(度) */
gyroscopeAngle: number;
/** 帧索引 */
index: number;
}
/**
* 全景拍摄管理器
* 控制全景拍摄流程、帧采集、图像拼接
*/
class PanoramaCaptureManager {
private cameraManager: camera.CameraManager | null = null;
private captureSession: camera.CaptureSession | null = null;
private previewOutput: camera.PreviewOutput | null = null;
private photoOutput: camera.PhotoOutput | null = null;
private config: PanoramaConfig = {
targetAngle: 180,
overlapRatio: 0.3,
maxFrames: 20,
outputWidth: 4096,
};
private state: PanoramaState = PanoramaState.IDLE;
private capturedFrames: FrameData[] = [];
private currentAngle: number = 0;
private startAngle: number = 0;
private lastCaptureAngle: number = 0;
/** 状态回调 */
onStateChanged?: (state: PanoramaState) => void;
/** 角度更新回调 */
onAngleUpdated?: (currentAngle: number, targetAngle: number) => void;
/** 拼接进度回调 */
onStitchProgress?: (progress: number) => void;
/** 完成回调 */
onCompleted?: (panoramaImage: image.PixelMap) => void;
/**
* 初始化全景拍摄
*/
async init(surfaceId: string): Promise<void> {
try {
this.cameraManager = camera.getCameraManager(getContext(this));
const cameras = this.cameraManager.getSupportedCameras();
const backCamera = cameras.find(
c => c.cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK
);
if (!backCamera) {
console.error('[Panorama] 找不到后置摄像头');
return;
}
// 创建相机输入
const cameraInput = this.cameraManager.createCameraInput(backCamera);
await cameraInput.open();
// 创建预览和拍照输出
const capability = this.cameraManager.getSupportedOutputCapability(backCamera);
const previewProfile = capability.previewProfiles[0];
this.previewOutput = this.cameraManager.createPreviewOutput(previewProfile, surfaceId);
const photoProfile = capability.photoProfiles[0];
this.photoOutput = this.cameraManager.createPhotoOutput(photoProfile);
// 创建捕获会话
this.captureSession = this.cameraManager.createCaptureSession();
this.captureSession.beginConfig();
this.captureSession.addInput(cameraInput);
this.captureSession.addOutput(this.previewOutput);
this.captureSession.addOutput(this.photoOutput);
await this.captureSession.commitConfig();
await this.captureSession.start();
// 启动陀螺仪监听
this.startGyroscopeListener();
this.setState(PanoramaState.PREPARING);
console.info('[Panorama] 全景拍摄初始化完成');
} catch (error) {
const err = error as BusinessError;
console.error(`[Panorama] 初始化失败: ${err.code} - ${err.message}`);
this.setState(PanoramaState.ERROR);
}
}
/**
* 启动陀螺仪监听
* 追踪手机旋转角度,用于拍摄引导和帧间隔判断
*/
private startGyroscopeListener(): void {
try {
sensor.on(sensor.SensorId.GYROSCOPE, (data: sensor.GyroscopeResponse) => {
// z 轴角速度对应水平旋转
const angularVelocityZ = data.z; // 度/秒
// 累积角度(简化处理,实际需要积分)
this.currentAngle += angularVelocityZ * 0.02; // 假设 50Hz 采样
if (this.state === PanoramaState.CAPTURING) {
this.onAngleUpdated?.(this.currentAngle - this.startAngle, this.config.targetAngle);
}
}, { interval: 20000000 }); // 50Hz
} catch (error) {
console.error('[Panorama] 陀螺仪监听启动失败');
}
}
/**
* 开始全景拍摄
*/
async startCapture(): Promise<void> {
if (this.state !== PanoramaState.PREPARING) {
console.warn('[Panorama] 当前状态不允许开始拍摄');
return;
}
this.capturedFrames = [];
this.startAngle = this.currentAngle;
this.lastCaptureAngle = this.currentAngle;
this.setState(PanoramaState.CAPTURING);
console.info('[Panorama] 开始全景拍摄');
// 拍摄第一帧
await this.captureFrame();
}
/**
* 拍摄一帧
* 根据旋转角度判断是否需要拍摄新帧
*/
async captureFrame(): Promise<void> {
if (this.state !== PanoramaState.CAPTURING) return;
// 检查是否已达到目标角度
const totalAngle = Math.abs(this.currentAngle - this.startAngle);
if (totalAngle >= this.config.targetAngle) {
await this.finishCapture();
return;
}
// 检查是否达到最大帧数
if (this.capturedFrames.length >= this.config.maxFrames) {
await this.finishCapture();
return;
}
// 检查与上一帧的角度间隔
// 相邻帧之间需要足够的重叠区域
const angleSinceLastCapture = Math.abs(this.currentAngle - this.lastCaptureAngle);
const captureInterval = 15; // 每隔 15 度拍一帧(约 30% 重叠)
if (angleSinceLastCapture < captureInterval) {
// 角度间隔不够,等待
return;
}
try {
// 拍照
const pixelMap = await this.takePhoto();
if (pixelMap) {
const frame: FrameData = {
pixelMap: pixelMap,
gyroscopeAngle: this.currentAngle,
index: this.capturedFrames.length,
};
this.capturedFrames.push(frame);
this.lastCaptureAngle = this.currentAngle;
console.info(`[Panorama] 拍摄第 ${this.capturedFrames.length} 帧,角度: ${this.currentAngle.toFixed(1)}°`);
}
} catch (error) {
console.error('[Panorama] 拍摄帧失败');
}
}
/**
* 单次拍照
*/
private async takePhoto(): Promise<image.PixelMap | null> {
if (!this.photoOutput) return null;
return new Promise((resolve) => {
this.photoOutput!.on('photoAvailable', (err: BusinessError, photo: camera.Photo) => {
if (err) {
resolve(null);
return;
}
resolve(photo.main);
});
this.photoOutput!.capture({
quality: camera.QualityLevel.QUALITY_LEVEL_HIGH,
rotation: camera.ImageRotation.ROTATION_0,
});
});
}
/**
* 结束拍摄,开始拼接
*/
private async finishCapture(): Promise<void> {
this.setState(PanoramaState.STITCHING);
console.info(`[Panorama] 拍摄完成,共 ${this.capturedFrames.length} 帧,开始拼接`);
try {
const panoramaImage = await this.stitchFrames(this.capturedFrames);
this.setState(PanoramaState.COMPLETED);
this.onCompleted?.(panoramaImage);
} catch (error) {
console.error('[Panorama] 拼接失败');
this.setState(PanoramaState.ERROR);
}
}
/**
* 图像拼接核心算法
* 将多帧图像拼接成一张全景图
*/
private async stitchFrames(frames: FrameData[]): Promise<image.PixelMap> {
if (frames.length === 0) {
throw new Error('没有可拼接的帧');
}
if (frames.length === 1) {
return frames[0].pixelMap;
}
// 第一步:计算全景图的总尺寸
const firstFrame = frames[0];
const firstInfo = await firstFrame.pixelMap.getImageInfo();
const frameWidth = firstInfo.size.width;
const frameHeight = firstInfo.size.height;
// 根据总角度和单帧视角计算全景图宽度
const totalAngle = Math.abs(frames[frames.length - 1].gyroscopeAngle - frames[0].gyroscopeAngle);
const fovPerFrame = 60; // 假设每帧水平视角 60 度
const panoramaWidth = Math.round(frameWidth * totalAngle / fovPerFrame);
const panoramaHeight = frameHeight;
console.info(`[Panorama] 全景图尺寸: ${panoramaWidth}x${panoramaHeight}`);
// 第二步:创建全景图画布
const panoramaBuffer = new ArrayBuffer(panoramaWidth * panoramaHeight * 4);
const panoramaData = new Uint8Array(panoramaBuffer);
// 初始化为黑色
panoramaData.fill(0);
// 第三步:逐帧拼接
for (let i = 0; i < frames.length; i++) {
const frame = frames[i];
const frameBuffer = new ArrayBuffer(frameWidth * frameHeight * 4);
await frame.pixelMap.readPixelsToBuffer(frameBuffer);
const frameData = new Uint8Array(frameBuffer);
// 计算该帧在全景图中的水平偏移
const angleOffset = frame.gyroscopeAngle - frames[0].gyroscopeAngle;
const xOffset = Math.round(angleOffset / totalAngle * panoramaWidth);
// 将帧数据复制到全景图对应位置
for (let y = 0; y < frameHeight; y++) {
for (let x = 0; x < frameWidth; x++) {
const srcIdx = (y * frameWidth + x) * 4;
const destX = xOffset + x;
// 边界检查
if (destX >= 0 && destX < panoramaWidth) {
const destIdx = (y * panoramaWidth + destX) * 4;
// 在重叠区域进行线性混合
if (panoramaData[destIdx + 3] > 0) {
// 已有像素,进行混合
const alpha = x / frameWidth; // 简单线性权重
panoramaData[destIdx] = Math.round(panoramaData[destIdx] * (1 - alpha) + frameData[srcIdx] * alpha);
panoramaData[destIdx + 1] = Math.round(panoramaData[destIdx + 1] * (1 - alpha) + frameData[srcIdx + 1] * alpha);
panoramaData[destIdx + 2] = Math.round(panoramaData[destIdx + 2] * (1 - alpha) + frameData[srcIdx + 2] * alpha);
panoramaData[destIdx + 3] = 255;
} else {
// 空白区域,直接复制
panoramaData[destIdx] = frameData[srcIdx];
panoramaData[destIdx + 1] = frameData[srcIdx + 1];
panoramaData[destIdx + 2] = frameData[srcIdx + 2];
panoramaData[destIdx + 3] = 255;
}
}
}
}
// 更新拼接进度
this.onStitchProgress?.((i + 1) / frames.length);
}
// 第四步:创建全景图 PixelMap
const panoramaImage = await image.createPixelMap(panoramaBuffer, {
size: { width: panoramaWidth, height: panoramaHeight },
pixelFormat: image.PixelFormat.RGBA_8888,
});
console.info('[Panorama] 拼接完成');
return panoramaImage;
}
/**
* 停止拍摄
*/
async stopCapture(): Promise<void> {
if (this.state === PanoramaState.CAPTURING) {
await this.finishCapture();
}
}
/**
* 获取当前状态
*/
getState(): PanoramaState {
return this.state;
}
/**
* 设置状态
*/
private setState(state: PanoramaState): void {
this.state = state;
this.onStateChanged?.(state);
}
/**
* 释放资源
*/
async release(): Promise<void> {
try {
// 停止陀螺仪监听
sensor.off(sensor.SensorId.GYROSCOPE);
if (this.captureSession) {
await this.captureSession.stop();
this.captureSession.release();
}
// 释放帧数据
this.capturedFrames.forEach(f => f.pixelMap.release());
this.capturedFrames = [];
this.setState(PanoramaState.IDLE);
console.info('[Panorama] 资源已释放');
} catch (error) {
console.error('[Panorama] 释放资源失败');
}
}
}
3.2 拍摄引导:方向指示与速度控制
全景拍摄的关键是引导用户正确旋转手机——方向对、速度合适、保持水平。
import { sensor } from '@kit.SensorServiceKit';
/**
* 拍摄方向
*/
enum CaptureDirection {
/** 向左旋转 */
LEFT = 'left',
/** 向右旋转 */
RIGHT = 'right',
}
/**
* 速度状态
*/
enum SpeedStatus {
/** 太快 */
TOO_FAST = 'too_fast',
/** 合适 */
GOOD = 'good',
/** 太慢 */
TOO_SLOW = 'too_slow',
}
/**
* 拍摄引导管理器
* 基于陀螺仪数据提供方向和速度引导
*/
class CaptureGuideManager {
private direction: CaptureDirection = CaptureDirection.RIGHT;
private speedStatus: SpeedStatus = SpeedStatus.GOOD;
private lastTimestamp: number = 0;
private lastAngleZ: number = 0;
private rotationSpeed: number = 0; // 度/秒
/** 最大推荐旋转速度(度/秒) */
private readonly MAX_SPEED = 30;
/** 最小推荐旋转速度(度/秒) */
private readonly MIN_SPEED = 5;
/** 方向变更回调 */
onDirectionChanged?: (direction: CaptureDirection) => void;
/** 速度状态变更回调 */
onSpeedStatusChanged?: (status: SpeedStatus) => void;
/** 俯仰角警告回调 */
onPitchWarning?: (isLevel: boolean) => void;
/**
* 启动引导监听
*/
startGuide(): void {
try {
sensor.on(sensor.SensorId.GYROSCOPE, (data: sensor.GyroscopeResponse) => {
const currentTimestamp = Date.now();
// 计算旋转速度
if (this.lastTimestamp > 0) {
const deltaTime = (currentTimestamp - this.lastTimestamp) / 1000; // 秒
if (deltaTime > 0) {
// z 轴角速度对应水平旋转
this.rotationSpeed = data.z;
// 判断方向
const newDirection = data.z > 0 ? CaptureDirection.RIGHT : CaptureDirection.LEFT;
if (newDirection !== this.direction) {
this.direction = newDirection;
this.onDirectionChanged?.(this.direction);
}
// 判断速度
const absSpeed = Math.abs(this.rotationSpeed);
let newStatus: SpeedStatus;
if (absSpeed > this.MAX_SPEED) {
newStatus = SpeedStatus.TOO_FAST;
} else if (absSpeed < this.MIN_SPEED) {
newStatus = SpeedStatus.TOO_SLOW;
} else {
newStatus = SpeedStatus.GOOD;
}
if (newStatus !== this.speedStatus) {
this.speedStatus = newStatus;
this.onSpeedStatusChanged?.(this.speedStatus);
}
}
}
this.lastTimestamp = currentTimestamp;
this.lastAngleZ = data.z;
}, { interval: 20000000 }); // 50Hz
// 同时监听加速度计判断俯仰角
sensor.on(sensor.SensorId.ACCELEROMETER, (data: sensor.AccelerometerResponse) => {
// 通过加速度计判断手机是否保持水平
const x = data.x;
const y = data.y;
const z = data.z;
// 计算俯仰角
const pitch = Math.atan2(x, Math.sqrt(y * y + z * z)) * 180 / Math.PI;
const isLevel = Math.abs(pitch) < 15; // 15度以内视为水平
this.onPitchWarning?.(isLevel);
}, { interval: 100000000 }); // 10Hz
console.info('[Guide] 拍摄引导已启动');
} catch (error) {
console.error('[Guide] 启动引导失败');
}
}
/**
* 停止引导监听
*/
stopGuide(): void {
try {
sensor.off(sensor.SensorId.GYROSCOPE);
sensor.off(sensor.SensorId.ACCELEROMETER);
console.info('[Guide] 拍摄引导已停止');
} catch (error) {
console.error('[Guide] 停止引导失败');
}
}
/**
* 获取当前方向
*/
getDirection(): CaptureDirection {
return this.direction;
}
/**
* 获取速度状态
*/
getSpeedStatus(): SpeedStatus {
return this.speedStatus;
}
/**
* 获取旋转速度
*/
getRotationSpeed(): number {
return this.rotationSpeed;
}
}
3.3 全景预览与导出 UI
完整的全景拍摄界面,包含拍摄引导、实时预览、全景图展示和导出。
import { camera } from '@kit.CameraKit';
import { image } from '@kit.ImageKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';
@Entry
@Component
struct PanoramaCameraPage {
private panoramaManager: PanoramaCaptureManager | null = null;
private guideManager: CaptureGuideManager | null = null;
private surfaceId: string = '';
@State captureState: string = 'idle'; // idle/preparing/capturing/stitching/completed
@State currentAngle: number = 0;
@State targetAngle: number = 180;
@State speedStatus: string = 'good'; // too_fast/good/too_slow
@State direction: string = 'right'; // left/right
@State isLevel: boolean = true;
@State stitchProgress: number = 0;
@State panoramaPixelMap: image.PixelMap | null = null;
@State showResult: boolean = false;
aboutToAppear(): void {
this.panoramaManager = new PanoramaCaptureManager();
this.guideManager = new CaptureGuideManager();
this.setupCallbacks();
}
/**
* 设置回调
*/
private setupCallbacks(): void {
// 全景拍摄回调
this.panoramaManager!.onStateChanged = (state: PanoramaState) => {
this.captureState = state;
};
this.panoramaManager!.onAngleUpdated = (current: number, target: number) => {
this.currentAngle = current;
this.targetAngle = target;
};
this.panoramaManager!.onStitchProgress = (progress: number) => {
this.stitchProgress = progress;
};
this.panoramaManager!.onCompleted = (panoramaImage: image.PixelMap) => {
this.panoramaPixelMap = panoramaImage;
this.showResult = true;
};
// 引导回调
this.guideManager!.onDirectionChanged = (dir: CaptureDirection) => {
this.direction = dir;
};
this.guideManager!.onSpeedStatusChanged = (status: SpeedStatus) => {
this.speedStatus = status;
};
this.guideManager!.onPitchWarning = (level: boolean) => {
this.isLevel = level;
};
}
build() {
Column() {
if (this.showResult && this.panoramaPixelMap) {
// 全景图结果展示
this.buildResultView()
} else {
// 拍摄界面
this.buildCaptureView()
}
}
.width('100%')
.height('100%')
.backgroundColor('#000000')
}
/**
* 拍摄界面
*/
@Builder
buildCaptureView() {
Column() {
// 顶部状态栏
Row() {
Text('全景拍摄')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Blank()
// 拍摄状态
Text(this.getStateLabel())
.fontSize(14)
.fontColor(this.getStateColor())
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
// 相机预览
Stack() {
XComponent({
id: 'panoramaPreview',
type: XComponentType.SURFACE,
libraryname: ''
})
.width('100%')
.aspectRatio(4 / 3)
.borderRadius(12)
// 拍摄引导覆盖层
if (this.captureState === 'capturing') {
Column() {
// 方向箭头
Row() {
if (this.direction === 'left') {
Text('← 向左旋转')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#4FC3F7')
} else {
Text('向右旋转 →')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#4FC3F7')
}
}
// 速度提示
Text(this.getSpeedLabel())
.fontSize(14)
.fontColor(this.getSpeedColor())
.margin({ top: 8 })
// 水平提示
if (!this.isLevel) {
Text('⚠ 请保持手机水平')
.fontSize(14)
.fontColor('#FFB74D')
.margin({ top: 4 })
}
}
.padding(16)
.borderRadius(12)
.backgroundColor('#99000000')
.position({ x: '50%', y: '10%' })
.translate({ x: '-50%' })
}
// 拼接进度
if (this.captureState === 'stitching') {
Column() {
LoadingProgress()
.width(48)
.height(48)
.color('#4FC3F7')
Text(`拼接中 ${Math.round(this.stitchProgress * 100)}%`)
.fontSize(14)
.fontColor('#FFFFFF')
.margin({ top: 8 })
}
.padding(20)
.borderRadius(16)
.backgroundColor('#99000000')
}
}
.width('100%')
.padding({ left: 16, right: 16 })
// 进度条
if (this.captureState === 'capturing') {
Column() {
// 角度进度
Row() {
Text(`${Math.round(this.currentAngle)}°`)
.fontSize(12)
.fontColor('#4FC3F7')
Blank()
Text(`${this.targetAngle}°`)
.fontSize(12)
.fontColor('#AAAAAA')
}
.width('100%')
Progress({
value: this.currentAngle,
total: this.targetAngle,
type: ProgressType.Linear
})
.width('100%')
.color('#4FC3F7')
.backgroundColor('#333333')
.margin({ top: 4 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 16 })
}
Blank()
// 底部控制
Row() {
// 取消按钮
Button() {
Text('取消')
.fontSize(14)
.fontColor('#FFFFFF')
}
.backgroundColor('#555555')
.borderRadius(20)
.onClick(() => {
this.panoramaManager?.release();
this.captureState = 'idle';
})
Blank()
// 拍摄/停止按钮
Stack() {
Circle()
.width(72)
.height(72)
.fill('#FFFFFF')
Circle()
.width(64)
.height(64)
.fill(this.captureState === 'capturing' ? '#EF5350' : '#4FC3F7')
}
.onClick(async () => {
if (this.captureState === 'preparing') {
await this.panoramaManager?.startCapture();
this.guideManager?.startGuide();
} else if (this.captureState === 'capturing') {
await this.panoramaManager?.stopCapture();
this.guideManager?.stopGuide();
}
})
Blank()
// 角度设置
Column() {
Text(`${this.targetAngle}°`)
.fontSize(16)
.fontColor('#4FC3F7')
.fontWeight(FontWeight.Bold)
Text('点击切换')
.fontSize(10)
.fontColor('#888888')
}
.onClick(() => {
// 循环切换角度
const angles = [90, 180, 270, 360];
const currentIndex = angles.indexOf(this.targetAngle);
this.targetAngle = angles[(currentIndex + 1) % angles.length];
})
}
.width('100%')
.padding({ left: 32, right: 32, top: 16, bottom: 32 })
}
}
/**
* 结果展示界面
*/
@Builder
buildResultView() {
Column() {
// 标题
Row() {
Text('全景图预览')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Blank()
Text('← 返回重拍')
.fontSize(14)
.fontColor('#4FC3F7')
.onClick(() => {
this.showResult = false;
this.panoramaPixelMap?.release();
this.panoramaPixelMap = null;
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
// 全景图展示
// 使用 Scroll 实现水平滚动查看
Scroll() {
Image(this.panoramaPixelMap)
.width('200%')
.objectFit(ImageFit.Contain)
.borderRadius(12)
}
.scrollable(ScrollDirection.Horizontal)
.width('100%')
.layoutWeight(1)
.padding({ left: 16, right: 16 })
// 操作按钮
Row() {
Button() {
Text('保存到相册')
.fontSize(16)
.fontColor('#FFFFFF')
}
.width('80%')
.height(48)
.backgroundColor('#4FC3F7')
.borderRadius(24)
.onClick(async () => {
await this.savePanorama();
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 16, bottom: 32 })
}
}
/** 获取状态标签 */
private getStateLabel(): string {
const labels: Record<string, string> = {
'idle': '就绪',
'preparing': '准备中',
'capturing': '拍摄中',
'stitching': '拼接中',
'completed': '完成',
'error': '出错',
};
return labels[this.captureState] ?? '';
}
/** 获取状态颜色 */
private getStateColor(): string {
const colors: Record<string, string> = {
'idle': '#AAAAAA',
'preparing': '#4FC3F7',
'capturing': '#81C784',
'stitching': '#FFB74D',
'completed': '#4FC3F7',
'error': '#EF5350',
};
return colors[this.captureState] ?? '#AAAAAA';
}
/** 获取速度标签 */
private getSpeedLabel(): string {
const labels: Record<string, string> = {
'too_fast': '太快了,请放慢速度',
'good': '速度合适,继续旋转',
'too_slow': '可以稍微快一点',
};
return labels[this.speedStatus] ?? '';
}
/** 获取速度颜色 */
private getSpeedColor(): string {
const colors: Record<string, string> = {
'too_fast': '#EF5350',
'good': '#81C784',
'too_slow': '#FFB74D',
};
return colors[this.speedStatus] ?? '#AAAAAA';
}
/**
* 保存全景图到相册
*/
private async savePanorama(): Promise<void> {
if (!this.panoramaPixelMap) return;
try {
// 使用 ImagePacker 打包为 JPEG
const imagePackerApi = image.createImagePacker();
const packOpts: image.PackingOption = {
format: 'image/jpeg',
quality: 95,
};
const packData = await imagePackerApi.packing(this.panoramaPixelMap, packOpts);
// 保存到文件
const fileName = `panorama_${Date.now()}.jpg`;
const filePath = `/data/storage/el2/base/files/${fileName}`;
const file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
fs.writeSync(file.fd, packData);
fs.closeSync(file.fd);
imagePackerApi.release();
console.info(`[Panorama] 全景图已保存: ${filePath}`);
} catch (error) {
const err = error as BusinessError;
console.error(`[Panorama] 保存失败: ${err.code} - ${err.message}`);
}
}
}
3.4 高级图像拼接:特征点匹配与融合
更专业的拼接算法,使用特征点匹配实现精确对齐。
import { image } from '@kit.ImageKit';
/**
* 特征点
*/
interface FeaturePoint {
x: number;
y: number;
/** 特征描述子(简化为哈希值) */
descriptor: number;
}
/**
* 匹配点对
*/
interface MatchPair {
pointA: FeaturePoint;
pointB: FeaturePoint;
/** 匹配置信度 0-1 */
confidence: number;
}
/**
* 高级图像拼接器
* 使用特征点匹配实现精确拼接
*/
class AdvancedImageStitcher {
/**
* 提取图像特征点
* 使用简化的 FAST 角点检测
*/
extractFeatures(pixelMap: image.PixelMap): FeaturePoint[] {
const features: FeaturePoint[] = [];
// 注意:实际实现需要读取像素数据进行角点检测
// 这里展示算法框架
// FAST 角点检测步骤:
// 1. 对每个像素,检查其周围 16 个点
// 2. 如果有连续 N 个点亮度差异大于阈值,则该像素为角点
// 3. 非极大值抑制,去除密集的角点
// 简化实现:均匀采样特征点
// 实际项目中应使用 NDK 或 EffectKit 实现真正的特征点提取
return features;
}
/**
* 特征点匹配
* 在两帧之间找到对应的特征点对
*/
matchFeatures(featuresA: FeaturePoint[], featuresB: FeaturePoint[]): MatchPair[] {
const matches: MatchPair[] = [];
// 暴力匹配(Brute Force)
// 对 A 中的每个特征点,找 B 中描述子距离最小的点
for (const pointA of featuresA) {
let bestMatch: FeaturePoint | null = null;
let bestDistance = Infinity;
let secondBestDistance = Infinity;
for (const pointB of featuresB) {
// 计算描述子距离(汉明距离或欧氏距离)
const distance = Math.abs(pointA.descriptor - pointB.descriptor);
if (distance < bestDistance) {
secondBestDistance = bestDistance;
bestDistance = distance;
bestMatch = pointB;
} else if (distance < secondBestDistance) {
secondBestDistance = distance;
}
}
// Lowe's ratio test:最佳匹配距离远小于次佳匹配距离时才接受
if (bestMatch && secondBestDistance > 0) {
const ratio = bestDistance / secondBestDistance;
if (ratio < 0.75) {
matches.push({
pointA: pointA,
pointB: bestMatch,
confidence: 1 - ratio,
});
}
}
}
console.info(`[Stitcher] 特征匹配: ${featuresA.length} vs ${featuresB.length}, 匹配 ${matches.length} 对`);
return matches;
}
/**
* 估计单应性矩阵
* 使用 RANSAC 算法从匹配点对中估计变换矩阵
*/
estimateHomography(matches: MatchPair[]): number[][] | null {
if (matches.length < 4) {
console.error('[Stitcher] 匹配点不足,无法估计单应性矩阵');
return null;
}
// RANSAC 算法步骤:
// 1. 随机选择 4 个匹配点对
// 2. 用这 4 对点计算单应性矩阵 H
// 3. 用 H 变换所有 A 中的点,计算与 B 中对应点的误差
// 4. 误差小于阈值的点为内点
// 5. 重复多次,选择内点最多的 H
const maxIterations = 1000;
const inlierThreshold = 3.0; // 像素
let bestHomography: number[][] | null = null;
let bestInlierCount = 0;
for (let iter = 0; iter < maxIterations; iter++) {
// 随机选择 4 个点
const sampleIndices = this.randomSample(matches.length, 4);
const sampleMatches = sampleIndices.map(i => matches[i]);
// 计算单应性矩阵(DLT 算法)
const H = this.computeHomographyDLT(sampleMatches);
if (!H) continue;
// 计算内点数量
let inlierCount = 0;
for (const match of matches) {
const projected = this.applyHomography(H, match.pointA);
const dx = projected[0] - match.pointB.x;
const dy = projected[1] - match.pointB.y;
const error = Math.sqrt(dx * dx + dy * dy);
if (error < inlierThreshold) {
inlierCount++;
}
}
if (inlierCount > bestInlierCount) {
bestInlierCount = inlierCount;
bestHomography = H;
}
}
if (bestHomography) {
console.info(`[Stitcher] 单应性矩阵估计完成,内点: ${bestInlierCount}/${matches.length}`);
}
return bestHomography;
}
/**
* 多频段融合
* 在不同频率上分别融合图像,消除拼接缝隙
*/
async multiBandBlending(
imageA: image.PixelMap,
imageB: image.PixelMap,
overlapRegion: { left: number; right: number; top: number; bottom: number }
): Promise<image.PixelMap> {
// 多频段融合步骤:
// 1. 构建高斯金字塔(低频到高频的多层表示)
// 2. 构建拉普拉斯金字塔(每层的高频细节)
// 3. 在每层上分别进行线性融合
// 4. 重建最终图像
// 简化实现:使用线性混合
const infoA = await imageA.getImageInfo();
const width = infoA.size.width;
const height = infoA.size.height;
const bufferA = new ArrayBuffer(width * height * 4);
await imageA.readPixelsToBuffer(bufferA);
const dataA = new Uint8Array(bufferA);
const bufferB = new ArrayBuffer(width * height * 4);
await imageB.readPixelsToBuffer(bufferB);
const dataB = new Uint8Array(bufferB);
const outBuffer = new ArrayBuffer(width * height * 4);
const outData = new Uint8Array(outBuffer);
const overlapWidth = overlapRegion.right - overlapRegion.left;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
if (x < overlapRegion.left) {
// 只在 A 区域
outData[idx] = dataA[idx];
outData[idx + 1] = dataA[idx + 1];
outData[idx + 2] = dataA[idx + 2];
outData[idx + 3] = dataA[idx + 3];
} else if (x > overlapRegion.right) {
// 只在 B 区域
outData[idx] = dataB[idx];
outData[idx + 1] = dataB[idx + 1];
outData[idx + 2] = dataB[idx + 2];
outData[idx + 3] = dataB[idx + 3];
} else {
// 重叠区域,线性混合
const alpha = (x - overlapRegion.left) / overlapWidth;
outData[idx] = Math.round(dataA[idx] * (1 - alpha) + dataB[idx] * alpha);
outData[idx + 1] = Math.round(dataA[idx + 1] * (1 - alpha) + dataB[idx + 1] * alpha);
outData[idx + 2] = Math.round(dataA[idx + 2] * (1 - alpha) + dataB[idx + 2] * alpha);
outData[idx + 3] = 255;
}
}
}
return image.createPixelMap(outBuffer, {
size: { width, height },
pixelFormat: image.PixelFormat.RGBA_8888,
});
}
/**
* DLT 算法计算单应性矩阵
*/
private computeHomographyDLT(matches: MatchPair[]): number[][] | null {
// 至少需要 4 对点
if (matches.length < 4) return null;
// 构建 A 矩阵(8×9)
// 每对点贡献两行方程
// Ah = 0,通过 SVD 分解求解
// 简化实现:返回单位矩阵
// 实际项目中应使用 NDK 或数学库实现 SVD 分解
return [
[1, 0, 0],
[0, 1, 0],
[0, 0, 1],
];
}
/**
* 应用单应性矩阵变换点
*/
private applyHomography(H: number[][], point: FeaturePoint): number[] {
const x = point.x;
const y = point.y;
const w = H[2][0] * x + H[2][1] * y + H[2][2];
const projectedX = (H[0][0] * x + H[0][1] * y + H[0][2]) / w;
const projectedY = (H[1][0] * x + H[1][1] * y + H[1][2]) / w;
return [projectedX, projectedY];
}
/**
* 随机采样 n 个不重复的索引
*/
private randomSample(max: number, count: number): number[] {
const indices: number[] = [];
const available = Array.from({ length: max }, (_, i) => i);
for (let i = 0; i < count && available.length > 0; i++) {
const randomIdx = Math.floor(Math.random() * available.length);
indices.push(available[randomIdx]);
available.splice(randomIdx, 1);
}
return indices;
}
}
四、踩坑与注意事项
4.1 陀螺仪漂移问题
问题:陀螺仪数据存在累积漂移,长时间拍摄后角度误差越来越大。
解决方案:
- 结合加速度计和磁力计进行传感器融合
- 使用互补滤波或卡尔曼滤波校正漂移
- 限制全景拍摄时长(建议 30 秒以内)
- 在拼接时以图像匹配结果为准,陀螺仪仅作辅助
4.2 拼接错位
问题:相邻帧之间出现明显的错位或鬼影。
原因:
- 手抖导致帧间位移过大
- 场景中有运动物体
- 特征点匹配错误
解决方案:
- 使用特征点匹配进行精确对齐,而非仅依赖陀螺仪
- 实现 RANSAC 剔除错误匹配
- 在重叠区域使用多频段融合消除缝隙
- 检测运动物体区域,在该区域只使用单帧数据
4.3 曝光不一致
问题:全景拍摄过程中光线变化,导致相邻帧亮度不一致,拼接后出现明显的明暗分界。
解决方案:
- 拍摄时锁定曝光参数(AE Lock),避免自动曝光调整
- 拼接后进行全局色彩校正
- 在重叠区域进行亮度渐变过渡
- 使用多频段融合的低频层处理亮度差异
4.4 全景图尺寸过大
问题:360° 全景图可能非常宽,分辨率过高导致内存溢出。
解决方案:
- 限制全景图最大宽度(如 8192 像素)
- 拍摄时降低单帧分辨率
- 拼接过程中分块处理,避免一次性加载所有帧
- 导出时提供多种分辨率选项
4.5 传感器权限
问题:陀螺仪和加速度计需要用户授权,用户可能拒绝。
解决方案:
- 在
module.json5中声明ohos.permission.GYROSCOPE权限 - 运行时请求权限,被拒绝时展示引导说明
- 权限被拒绝时降级为手动模式(用户点击按钮拍摄每帧)
五、HarmonyOS 6 适配
5.1 API 变更
| 功能 | HarmonyOS 5 | HarmonyOS 6 |
|---|---|---|
| 全景拍摄 | 手动实现拍摄+拼接 | 新增PanoramaCaptureSession 系统级 API |
| 传感器融合 | 手动实现 | 新增SensorFusion 类,内置卡尔曼滤波 |
| 图像拼接 | 手动实现特征匹配 | 新增ImageStitcher 类,内置 SIFT/ORB |
| 全景导出 | 手动打包 | 新增PanoramaExporter,支持多种格式 |
5.2 新增特性
- 系统级全景模式:HarmonyOS 6 新增
PanoramaCaptureSession,只需调用几个 API 即可实现完整全景拍摄 - AI 拼接优化:内置 AI 算法自动优化拼接质量,减少错位和鬼影
- 3D 全景:支持生成 3D 全景图,可在 VR 设备中查看
- 小行星视角:全景图可转换为小行星视角等创意效果
5.3 迁移指南
// HarmonyOS 5 写法:手动实现全景拍摄
const panoramaManager = new PanoramaCaptureManager();
await panoramaManager.init(surfaceId);
await panoramaManager.startCapture();
// ... 手动处理帧采集、拼接、导出 ...
// HarmonyOS 6 推荐写法:系统全景 API
const panoramaSession = camera.createPanoramaCaptureSession(backCamera);
panoramaSession.setAngleRange(180); // 180° 全景
panoramaSession.on('progress', (angle: number) => {
console.info(`拍摄进度: ${angle}°`);
});
panoramaSession.on('completed', (panoramaImage: image.PixelMap) => {
// 直接获取全景图
});
await panoramaSession.start();
六、总结
mindmap
root((全景拍摄开发))
全景模式原理
边转边拍
多帧拼接
180°/360°覆盖
陀螺仪辅助
图像拼接算法
特征点提取
特征匹配
单应性矩阵
RANSAC估计
多频段融合
拍摄引导
方向指示
速度控制
俯仰校正
进度显示
传感器融合
全景预览
实时预览
水平滚动查看
缩放交互
拼接进度
全景导出
ImagePacker打包
JPEG/PNG格式
分辨率选项
保存到相册
核心知识点回顾:
- 全景 = 边转边拍 + 拼接:核心是图像拼接算法,特征点匹配和单应性矩阵是关键
- 陀螺仪是辅助而非依赖:传感器存在漂移,最终对齐应以图像匹配为准
- 拍摄引导至关重要:方向、速度、水平三个维度的引导决定了拍摄质量
- 融合算法决定拼接质量:线性混合简单但效果一般,多频段融合效果更自然
- 曝光一致性容易被忽视:锁定曝光是全景拍摄的基本操作
全景拍摄是相机应用中最具挑战性的功能之一——它融合了传感器、图像处理、算法、交互设计等多个领域的技术。但当你看到用户拍出的壮丽全景图时,一切努力都是值得的!
- 点赞
- 收藏
- 关注作者
评论(0)