HarmonyOS中的全景拍摄开发技巧

举报
Jack20 发表于 2026/06/25 21:37:44 2026/06/25
【摘要】 一、什么原因你站在山顶,面前是一望无际的壮丽风景——左边是连绵的山脉,右边是蜿蜒的河流,正前方是绚丽的晚霞。你掏出手机想记录这一切,但普通拍照只能拍到眼前的一小片天空。怎么办?转一圈,用全景模式!全景拍摄让你可以拍摄 180° 甚至 360° 的超宽画面,把整个场景"装进"一张照片里。它的原理也不复杂——边转边拍,然后把多张照片拼接成一张超宽图。但说起来容易做起来难。图像拼接需要精确的特征...

一、什么原因

你站在山顶,面前是一望无际的壮丽风景——左边是连绵的山脉,右边是蜿蜒的河流,正前方是绚丽的晚霞。你掏出手机想记录这一切,但普通拍照只能拍到眼前的一小片天空。

怎么办?转一圈,用全景模式!

全景拍摄让你可以拍摄 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格式
      分辨率选项
      保存到相册

核心知识点回顾

  1. 全景 = 边转边拍 + 拼接:核心是图像拼接算法,特征点匹配和单应性矩阵是关键
  2. 陀螺仪是辅助而非依赖:传感器存在漂移,最终对齐应以图像匹配为准
  3. 拍摄引导至关重要:方向、速度、水平三个维度的引导决定了拍摄质量
  4. 融合算法决定拼接质量:线性混合简单但效果一般,多频段融合效果更自然
  5. 曝光一致性容易被忽视:锁定曝光是全景拍摄的基本操作

全景拍摄是相机应用中最具挑战性的功能之一——它融合了传感器、图像处理、算法、交互设计等多个领域的技术。但当你看到用户拍出的壮丽全景图时,一切努力都是值得的!

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。