文章目录
-
- 每日一句正能量
- 一、前言:当相册遇见空间交互
- 二、系统架构设计
-
- [2.1 空间交互映射层](#2.1 空间交互映射层)
- [2.2 沉浸光感设计理念](#2.2 沉浸光感设计理念)
- 三、环境配置与权限声明
-
- [3.1 模块依赖配置](#3.1 模块依赖配置)
- [3.2 权限声明(module.json5)](#3.2 权限声明(module.json5))
- 四、核心代码实战
-
- [4.1 照片主色调提取与光感引擎(ColorLightEngine.ets)](#4.1 照片主色调提取与光感引擎(ColorLightEngine.ets))
- [4.2 表情驱动照片浏览引擎(ExpressionPhotoController.ets)](#4.2 表情驱动照片浏览引擎(ExpressionPhotoController.ets))
- [4.3 手势操控视图引擎(GestureViewController.ets)](#4.3 手势操控视图引擎(GestureViewController.ets))
- [4.4 沉浸光感标题栏(ImmersivePhotoTitleBar.ets)](#4.4 沉浸光感标题栏(ImmersivePhotoTitleBar.ets))
- [4.5 悬浮导航控制面板(FloatPhotoNavigation.ets)](#4.5 悬浮导航控制面板(FloatPhotoNavigation.ets))
- [4.6 主相册页面(SpatialGalleryPage.ets)](#4.6 主相册页面(SpatialGalleryPage.ets))
- 五、关键技术总结
-
- [5.1 Face AR 表情驱动设计](#5.1 Face AR 表情驱动设计)
- [5.2 Body AR 手势操控设计](#5.2 Body AR 手势操控设计)
- [5.3 沉浸光感与照片联动](#5.3 沉浸光感与照片联动)
- 六、调试与优化建议
-
- [6.1 表情识别调优](#6.1 表情识别调优)
- [6.2 手势识别优化](#6.2 手势识别优化)
- [6.3 性能优化](#6.3 性能优化)
- 七、总结与展望

每日一句正能量
一个人越是持续紧绷,越容易疲惫,越懂得保持松弛的状态,越能在关键时刻发力。
持续紧绷 = 提前耗尽燃料。 松弛 ≠ 放弃,而是蓄力。就像心跳:一直快跳会死,有舒张才有收缩的力量。真正的发力,来自懂得休息的肌肉。
一、前言:当相册遇见空间交互
传统相册应用依赖鼠标滚轮或触屏滑动进行浏览,交互方式单一且缺乏沉浸感。HarmonyOS 6(API 23)带来的 Face AR 与 Body AR 能力,让我们可以重新定义"浏览"这件事------用户只需挑眉 即可翻到下一张照片,双手张开 就能放大细节,身体前倾自动进入全屏沉浸模式。
本文将实战开发一款 "空间相册" 应用,面向 HarmonyOS PC 端。核心创新点在于:
- 表情驱动浏览:Face AR 实时捕捉 64 种 BlendShape 参数,将"挑眉"映射为"下一张"、"皱眉"映射为"上一张"、"张嘴"映射为"收藏"
- 手势操控视图:Body AR 识别 20+ 骨骼关键点,双手捏合缩放、单手滑动旋转、双手平移拖拽
- 沉浸光感联动:根据当前照片色调动态调整标题栏与悬浮导航的光效材质,实现"照片即光源"的沉浸体验
- 悬浮导航自适应 :底部导航栏采用
HdsTabs悬浮样式,四周留白,支持透明度三档调节,不遮挡照片主体
二、系统架构设计
2.1 空间交互映射层
┌─────────────────────────────────────────────────────────────┐
│ 空间感知层(AR Engine 6.1.0) │
│ ┌─────────────────────┐ ┌─────────────────────────────┐ │
│ │ Face AR 模块 │ │ Body AR 模块 │ │
│ │ · 68点人脸Mesh │ │ · 20+骨骼关键点 │ │
│ │ · 64种BlendShape │ │ · 6种手势状态识别 │ │
│ │ · 瞳孔注视点追踪 │ │ · 3D空间位置追踪 │ │
│ └──────────┬──────────┘ └──────────────┬──────────────┘ │
└─────────────┼────────────────────────────────┼────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ 语义映射引擎(ArkTS) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 表情指令映射: │ │
│ │ · 挑眉 (browInnerUp > 0.6) → nextPhoto() │ │
│ │ · 皱眉 (browDown > 0.5) → prevPhoto() │ │
│ │ · 张嘴 (jawOpen > 0.4) → toggleFavorite()│ │
│ │ · 眯眼 (eyeSquint > 0.5) → enterFullscreen()│ │
│ └─────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 手势指令映射: │ │
│ │ · 双手距离 < 0.15m → zoomOut() │ │
│ │ · 双手距离 > 0.4m → zoomIn() │ │
│ │ · 右手高度差 > 0.3m → rotate() │ │
│ │ · 身体前倾 (shoulder-hip > 0.2) → detailMode() │ │
│ │ · 身体后仰 (hip-shoulder > 0.1) → galleryMode() │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 沉浸UI层(HDS + ArkUI) │
│ ┌─────────────────────┐ ┌─────────────────────────────┐ │
│ │ 光感自适应标题栏 │ │ 悬浮导航控制面板 │ │
│ │ · 照片主色调提取 │ │ · 手势位置智能避让 │ │
│ │ · 动态光晕渲染 │ │ · 透明度三档调节 │ │
│ │ · AR状态光效指示 │ │ · 追踪质量实时显示 │ │
│ └─────────────────────┘ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
2.2 沉浸光感设计理念
HarmonyOS 6 的 SystemMaterialEffect.IMMERSIVE 允许 UI 组件从背景内容中提取色调并生成动态光晕。在空间相册中,我们将这一特性发挥到极致:
- 标题栏光感:提取当前照片的主色调,生成顶部光晕,使 UI 与照片融为一体
- 悬浮导航光感:底部导航栏采用半透毛玻璃材质,照片色彩透过面板形成柔和的底部反射
- 环境光背景:根据照片色调生成全屏环境光,营造画廊级沉浸氛围
三、环境配置与权限声明
3.1 模块依赖配置
在 oh-package.json5 中添加 AR Engine、媒体库和 UI Design Kit 依赖:
json
{
"dependencies": {
"@hms.core.ar.arengine": "^6.1.0",
"@kit.MediaLibraryKit": "^6.0.0",
"@kit.UIDesignKit": "^6.0.0",
"@kit.SensorServiceKit": "^6.0.0",
"@kit.Graphics2DKit": "^6.0.0"
}
}
3.2 权限声明(module.json5)
json
{
"module": {
"requestPermissions": [
{ "name": "ohos.permission.CAMERA" },
{ "name": "ohos.permission.READ_IMAGEVIDEO" },
{ "name": "ohos.permission.INTERNET" }
]
}
}
隐私说明:Face AR 与 Body AR 的所有图像数据仅在端侧 NPU 处理,不上传云端,符合鸿蒙系统的隐私设计理念。
四、核心代码实战
4.1 照片主色调提取与光感引擎(ColorLightEngine.ets)
代码亮点 :使用 Graphics2DKit 提取照片主色调,并生成动态光感参数,供标题栏和悬浮导航使用。
typescript
// entry/src/main/ets/utils/ColorLightEngine.ets
import { image } from '@kit.ImageKit';
import { graphics2D } from '@kit.Graphics2DKit';
export interface LightTheme {
primaryColor: string; // 主色调
secondaryColor: string; // 次要色调
ambientColor: string; // 环境光色
glowIntensity: number; // 光晕强度 0-1
isDark: boolean; // 是否为暗色调照片
}
export class ColorLightEngine {
private static instance: ColorLightEngine;
private currentTheme: LightTheme = {
primaryColor: '#C0C0C0',
secondaryColor: '#808080',
ambientColor: '#1a1a2e',
glowIntensity: 0.5,
isDark: true
};
static getInstance(): ColorLightEngine {
if (!ColorLightEngine.instance) {
ColorLightEngine.instance = new ColorLightEngine();
}
return ColorLightEngine.instance;
}
/**
* 从照片PixelMap提取主色调
*/
async extractThemeFromImage(pixelMap: image.PixelMap): Promise<LightTheme> {
// 缩放至 64x64 进行快速采样
const scaledMap = await pixelMap.scale(64, 64);
const buffer = new ArrayBuffer(64 * 64 * 4);
await scaledMap.readPixelsToBuffer(buffer);
const uint8View = new Uint8Array(buffer);
const colorBuckets: Map<string, number> = new Map();
// 采样像素颜色
for (let i = 0; i < uint8View.length; i += 4) {
const r = uint8View[i];
const g = uint8View[i + 1];
const b = uint8View[i + 2];
// 量化颜色空间
const quantizedR = Math.floor(r / 32) * 32;
const quantizedG = Math.floor(g / 32) * 32;
const quantizedB = Math.floor(b / 32) * 32;
const key = `${quantizedR},${quantizedG},${quantizedB}`;
colorBuckets.set(key, (colorBuckets.get(key) || 0) + 1);
}
// 找出主色调和次要色调
const sortedColors = Array.from(colorBuckets.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
const primary = this.parseColor(sortedColors[0][0]);
const secondary = sortedColors[1] ? this.parseColor(sortedColors[1][0]) : primary;
// 计算亮度判断是否为暗色调
const luminance = (0.299 * primary.r + 0.587 * primary.g + 0.114 * primary.b) / 255;
const isDark = luminance < 0.5;
this.currentTheme = {
primaryColor: this.rgbToHex(primary),
secondaryColor: this.rgbToHex(secondary),
ambientColor: isDark ? '#0a0a14' : '#f5f5f0',
glowIntensity: isDark ? 0.3 : 0.6,
isDark
};
return this.currentTheme;
}
getCurrentTheme(): LightTheme {
return this.currentTheme;
}
private parseColor(colorStr: string): { r: number; g: number; b: number } {
const [r, g, b] = colorStr.split(',').map(Number);
return { r, g, b };
}
private rgbToHex({ r, g, b }: { r: number; g: number; b: number }): string {
return `#${[r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')}`;
}
}
4.2 表情驱动照片浏览引擎(ExpressionPhotoController.ets)
代码亮点:将 Face AR 的 BlendShape 参数映射为照片浏览指令,实现"无接触式"翻页、收藏和全屏切换。
typescript
// entry/src/main/ets/controllers/ExpressionPhotoController.ets
import { arEngine } from '@hms.core.ar.arengine';
import { emitter } from '@kit.BasicServicesKit';
export enum PhotoCommand {
NEXT = 'NEXT',
PREV = 'PREV',
FAVORITE = 'FAVORITE',
FULLSCREEN = 'FULLSCREEN',
ROTATE_CW = 'ROTATE_CW',
ROTATE_CCW = 'ROTATE_CCW'
}
export class ExpressionPhotoController {
private static instance: ExpressionPhotoController;
private lastCommandTime: number = 0;
private readonly COOLDOWN_MS = 800; // 指令冷却时间,防止误触发
// 表情阈值配置(经过调优)
private readonly THRESHOLDS = {
BROW_RAISE: 0.55, // 挑眉 - 下一张
BROW_FURROW: 0.45, // 皱眉 - 上一张
MOUTH_OPEN: 0.35, // 张嘴 - 收藏
EYE_SQUINT: 0.5, // 眯眼 - 全屏
HEAD_TURN_LEFT: 0.3, // 头左转 - 逆时针旋转
HEAD_TURN_RIGHT: 0.3 // 头右转 - 顺时针旋转
};
static getInstance(): ExpressionPhotoController {
if (!ExpressionPhotoController.instance) {
ExpressionPhotoController.instance = new ExpressionPhotoController();
}
return ExpressionPhotoController.instance;
}
/**
* 解析Face AR数据为照片浏览指令
*/
parseExpression(face: arEngine.ARFace): PhotoCommand | null {
const now = Date.now();
if (now - this.lastCommandTime < this.COOLDOWN_MS) {
return null; // 冷却期内忽略指令
}
const blendShapes = face.getBlendShapes();
if (!blendShapes) return null;
// 优先级:挑眉 > 皱眉 > 张嘴 > 眯眼 > 转头
if (blendShapes.browInnerUp > this.THRESHOLDS.BROW_RAISE) {
this.lastCommandTime = now;
this.emitFeedback('next');
return PhotoCommand.NEXT;
}
if (blendShapes.browDownLeft > this.THRESHOLDS.BROW_FURROW &&
blendShapes.browDownRight > this.THRESHOLDS.BROW_FURROW) {
this.lastCommandTime = now;
this.emitFeedback('prev');
return PhotoCommand.PREV;
}
if (blendShapes.jawOpen > this.THRESHOLDS.MOUTH_OPEN) {
this.lastCommandTime = now;
this.emitFeedback('favorite');
return PhotoCommand.FAVORITE;
}
if (blendShapes.eyeSquintLeft > this.THRESHOLDS.EYE_SQUINT &&
blendShapes.eyeSquintRight > this.THRESHOLDS.EYE_SQUINT) {
this.lastCommandTime = now;
this.emitFeedback('fullscreen');
return PhotoCommand.FULLSCREEN;
}
// 头部旋转检测
const headRotation = face.getPose();
if (headRotation) {
if (headRotation.y > this.THRESHOLDS.HEAD_TURN_RIGHT) {
this.lastCommandTime = now;
return PhotoCommand.ROTATE_CW;
}
if (headRotation.y < -this.THRESHOLDS.HEAD_TURN_LEFT) {
this.lastCommandTime = now;
return PhotoCommand.ROTATE_CCW;
}
}
return null;
}
/**
* 发送触觉与视觉反馈
*/
private emitFeedback(type: string): void {
// 触觉反馈
try {
import('@kit.SensorServiceKit').then(sensor => {
sensor.vibrator.startVibration({
type: 'time',
duration: type === 'favorite' ? 100 : 30
}, { id: 0 });
});
} catch (e) {
console.error('Haptic feedback failed:', e);
}
// 视觉反馈事件
emitter.emit({ eventId: 0x0001 }, {
data: { action: type }
});
}
}
4.3 手势操控视图引擎(GestureViewController.ets)
代码亮点:将 Body AR 的骨骼关键点映射为照片视图的缩放、旋转和平移操作,支持双手协同操控。
typescript
// entry/src/main/ets/controllers/GestureViewController.ets
import { arEngine } from '@hms.core.ar.arengine';
export interface ViewTransform {
scale: number;
rotation: number;
translateX: number;
translateY: number;
mode: 'gallery' | 'detail';
}
export class GestureViewController {
private static instance: GestureViewController;
private lastTransform: ViewTransform = {
scale: 1.0,
rotation: 0,
translateX: 0,
translateY: 0,
mode: 'gallery'
};
static getInstance(): GestureViewController {
if (!GestureViewController.instance) {
GestureViewController.instance = new GestureViewController();
}
return GestureViewController.instance;
}
/**
* 解析Body AR数据为视图变换指令
*/
parseGesture(body: arEngine.ARBody): ViewTransform {
const landmarks = body.getLandmarks3D();
if (!landmarks) return this.lastTransform;
const floatView = new Float32Array(landmarks);
// 获取关键骨骼点
const leftWrist = this.getLandmark3D(floatView, arEngine.ARBodyLandmarkType.LEFT_WRIST);
const rightWrist = this.getLandmark3D(floatView, arEngine.ARBodyLandmarkType.RIGHT_WRIST);
const leftShoulder = this.getLandmark3D(floatView, arEngine.ARBodyLandmarkType.LEFT_SHOULDER);
const rightShoulder = this.getLandmark3D(floatView, arEngine.ARBodyLandmarkType.RIGHT_SHOULDER);
const leftHip = this.getLandmark3D(floatView, arEngine.ARBodyLandmarkType.LEFT_HIP);
const rightHip = this.getLandmark3D(floatView, arEngine.ARBodyLandmarkType.RIGHT_HIP);
if (!leftWrist || !rightWrist || !leftShoulder || !rightShoulder) {
return this.lastTransform;
}
// 计算双手距离(捏合/张开检测)
const handDistance = Math.sqrt(
Math.pow(leftWrist.x - rightWrist.x, 2) +
Math.pow(leftWrist.y - rightWrist.y, 2)
);
let newScale = this.lastTransform.scale;
// 双手捏合 → 缩小
if (handDistance < 0.12) {
newScale = Math.max(0.5, this.lastTransform.scale - 0.05);
}
// 双手张开 → 放大
else if (handDistance > 0.45) {
newScale = Math.min(3.0, this.lastTransform.scale + 0.05);
}
// 单手旋转(右手相对肩部位置)
let newRotation = this.lastTransform.rotation;
const rightHandDeltaY = rightWrist.y - rightShoulder.y;
const rightHandDeltaX = rightWrist.x - rightShoulder.x;
if (Math.abs(rightHandDeltaY) > 0.25) {
newRotation = rightHandDeltaX * 45; // 映射为角度
}
// 双手平移(平均位置)
let newTranslateX = (leftWrist.x + rightWrist.x) / 2 * 200;
let newTranslateY = (leftWrist.y + rightWrist.y) / 2 * -200;
// 姿态检测:前倾/后仰
let mode: 'gallery' | 'detail' = this.lastTransform.mode;
if (leftHip && rightHip) {
const hipCenterY = (leftHip.y + rightHip.y) / 2;
const shoulderCenterY = (leftShoulder.y + rightShoulder.y) / 2;
if (shoulderCenterY - hipCenterY > 0.25) {
mode = 'detail'; // 身体前倾 → 细节模式
} else if (hipCenterY - shoulderCenterY > 0.15) {
mode = 'gallery'; // 身体后仰 → 画廊模式
}
}
this.lastTransform = {
scale: newScale,
rotation: newRotation,
translateX: newTranslateX,
translateY: newTranslateY,
mode
};
return this.lastTransform;
}
private getLandmark3D(floatView: Float32Array, type: arEngine.ARBodyLandmarkType): { x: number; y: number; z: number } | null {
const index = Object.values(arEngine.ARBodyLandmarkType).indexOf(type);
if (index < 0) return null;
const offset = index * 3;
if (offset + 2 >= floatView.length) return null;
return {
x: floatView[offset],
y: floatView[offset + 1],
z: floatView[offset + 2]
};
}
reset(): void {
this.lastTransform = {
scale: 1.0,
rotation: 0,
translateX: 0,
translateY: 0,
mode: 'gallery'
};
}
}
4.4 沉浸光感标题栏(ImmersivePhotoTitleBar.ets)
代码亮点:标题栏根据当前照片主色调动态调整光效,并显示 AR 追踪状态和表情提示。
typescript
// entry/src/main/ets/components/ImmersivePhotoTitleBar.ets
import { HdsNavigation, SystemMaterialEffect } from '@kit.UIDesignKit';
import { ColorLightEngine, LightTheme } from '../utils/ColorLightEngine';
@Component
export struct ImmersivePhotoTitleBar {
@Prop photoName: string = '';
@Prop isFavorite: boolean = false;
@State theme: LightTheme = ColorLightEngine.getInstance().getCurrentTheme();
@State trackingQuality: number = 1.0;
@State expressionHint: string = '挑眉翻页 | 皱眉返回 | 张嘴收藏';
aboutToAppear(): void {
// 监听主题变化
AppStorage.watch('photo_theme', (newTheme: LightTheme) => {
this.theme = newTheme;
});
// 监听AR状态
AppStorage.watch('tracking_quality', (quality: number) => {
this.trackingQuality = quality;
});
}
build() {
HdsNavigation({
title: this.photoName,
subtitle: this.isFavorite ? '❤️ 已收藏' : '空间相册',
systemMaterialEffect: SystemMaterialEffect.IMMERSIVE,
backgroundOpacity: this.theme.isDark ? 0.7 : 0.85,
height: 56,
leading: this.buildLeadingActions(),
trailing: this.buildTrailingActions()
})
.width('100%')
.backgroundColor(`rgba(${this.hexToRgb(this.theme.primaryColor)}, 0.15)`)
.border({
width: { bottom: 1 },
color: `rgba(${this.hexToRgb(this.theme.primaryColor)}, 0.3)`
})
.shadow({
radius: this.trackingQuality > 0.8 ? 16 : 4,
color: this.theme.primaryColor,
offsetX: 0,
offsetY: this.trackingQuality > 0.8 ? 2 : 0
})
.animation({
duration: 600,
curve: Curve.EaseInOut
})
}
@Builder
buildLeadingActions(): void {
Row({ space: 12 }) {
// AR状态指示灯
Circle()
.width(10)
.height(10)
.fill(this.trackingQuality > 0.8 ? '#00FF88' :
this.trackingQuality > 0.5 ? '#FFD700' : '#FF4444')
.shadow({
radius: 6,
color: this.trackingQuality > 0.8 ? '#00FF88' : '#FF4444'
})
.animation({ duration: 400 })
// 表情提示文字
Text(this.expressionHint)
.fontSize(11)
.fontColor(this.theme.isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.5)')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.padding({ left: 16 })
}
@Builder
buildTrailingActions(): void {
Row({ space: 8 }) {
// 收藏按钮
Button({ type: ButtonType.Circle }) {
Text(this.isFavorite ? '❤️' : '🤍')
.fontSize(18)
}
.width(40)
.height(40)
.backgroundColor(this.isFavorite ? 'rgba(255,100,100,0.2)' : 'rgba(255,255,255,0.1)')
.border({
width: 1,
color: this.isFavorite ? '#FF6464' : 'rgba(255,255,255,0.2)'
})
// 全屏按钮
Button({ type: ButtonType.Circle }) {
Text('⛶')
.fontSize(18)
}
.width(40)
.height(40)
.backgroundColor('rgba(255,255,255,0.1)')
}
.padding({ right: 16 })
}
private hexToRgb(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `${r},${g},${b}`;
}
}
4.5 悬浮导航控制面板(FloatPhotoNavigation.ets)
代码亮点 :底部悬浮面板采用 HdsTabs 悬浮样式,四周留白,支持透明度三档调节,并显示当前手势映射和照片信息。
typescript
// entry/src/main/ets/components/FloatPhotoNavigation.ets
import { HdsTabs, HdsTabsController, hdsMaterial } from '@kit.UIDesignKit';
import { LightTheme } from '../utils/ColorLightEngine';
@Component
export struct FloatPhotoNavigation {
@State currentTab: number = 0;
@State transparencyLevel: number = 0.7;
@State photoCount: number = 0;
@State currentIndex: number = 0;
@State transformHint: string = '双手捏合缩放 | 单手旋转 | 前倾细节模式';
private controller: HdsTabsController = new HdsTabsController();
private readonly TAB_CONFIG = [
{ label: '相册', icon: $r('sys.symbol.photo') },
{ label: '收藏', icon: $r('sys.symbol.heart') },
{ label: '手势', icon: $r('sys.symbol.hand_raised') },
{ label: '设置', icon: $r('sys.symbol.gear') }
];
aboutToAppear(): void {
AppStorage.watch('photo_index', (index: number) => {
this.currentIndex = index;
});
AppStorage.watch('photo_count', (count: number) => {
this.photoCount = count;
});
AppStorage.watch('transform_hint', (hint: string) => {
this.transformHint = hint;
});
}
build() {
HdsTabs({ controller: this.controller }) {
ForEach(this.TAB_CONFIG, (item: typeof this.TAB_CONFIG[0], index: number) => {
TabContent() {
this.buildTabContent(index)
}
.tabBar(new BottomTabBarStyle({
normal: new SymbolGlyphModifier(item.icon).fontColor(['rgba(255,255,255,0.5)']),
selected: new SymbolGlyphModifier(item.icon).fontColor(['#00D4AA'])
}, item.label))
})
}
.barOverlap(true)
.vertical(false)
.barPosition(BarPosition.End)
.barFloatingStyle({
barBottomMargin: 20,
barSideMargin: 40,
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.IMMERSIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE
}
})
.backgroundColor(`rgba(15,15,25,${this.transparencyLevel})`)
.backdropFilter($r('sys.blur.40'))
.borderRadius(28)
.margin({ left: '5%', right: '5%', bottom: 12 })
.shadow({
radius: 24,
color: 'rgba(0,0,0,0.3)',
offsetX: 0,
offsetY: -6
})
}
@Builder
buildTabContent(index: number): void {
Column({ space: 12 }) {
if (index === 0) {
this.buildAlbumPanel()
} else if (index === 1) {
this.buildFavoritePanel()
} else if (index === 2) {
this.buildGesturePanel()
} else {
this.buildSettingsPanel()
}
}
.width('100%')
.height('100%')
.padding(16)
}
@Builder
buildAlbumPanel(): void {
Column({ space: 8 }) {
Text(`照片 ${this.currentIndex + 1} / ${this.photoCount}`)
.fontSize(16)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
Slider({
value: this.currentIndex,
min: 0,
max: this.photoCount - 1,
step: 1
})
.onChange((value: number) => {
AppStorage.setOrCreate('target_photo_index', Math.round(value));
})
.width('100%')
.selectedColor('#00D4AA')
.trackColor('rgba(255,255,255,0.2)')
Text('挑眉 → 下一张 | 皱眉 → 上一张')
.fontSize(12)
.fontColor('rgba(255,255,255,0.5)')
}
}
@Builder
buildGesturePanel(): void {
Column({ space: 10 }) {
Text('手势操控指南')
.fontSize(16)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
ForEach([
{ icon: '🤏', action: '双手捏合', desc: '缩小照片' },
{ icon: '🙌', action: '双手张开', desc: '放大照片' },
{ icon: '👆', action: '单手抬起', desc: '旋转照片' },
{ icon: '🧍', action: '身体前倾', desc: '进入细节模式' },
{ icon: '🧍♂️', action: '身体后仰', desc: '返回画廊模式' }
], (item: { icon: string; action: string; desc: string }) => {
Row({ space: 12 }) {
Text(item.icon)
.fontSize(20)
Column({ space: 2 }) {
Text(item.action)
.fontSize(14)
.fontColor('#FFFFFF')
.layoutWeight(1)
Text(item.desc)
.fontSize(12)
.fontColor('rgba(255,255,255,0.5)')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(8)
.backgroundColor('rgba(255,255,255,0.05)')
.borderRadius(8)
})
}
}
@Builder
buildSettingsPanel(): void {
Column({ space: 14 }) {
Text('面板透明度')
.fontSize(16)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
Row({ space: 10 }) {
ForEach([
{ label: '弱', value: 0.55 },
{ label: '平衡', value: 0.70 },
{ label: '强', value: 0.85 }
], (item: { label: string; value: number }) => {
Button(item.label)
.fontSize(13)
.fontColor('#FFFFFF')
.backgroundColor(this.transparencyLevel === item.value ? '#00D4AA' : 'rgba(255,255,255,0.1)')
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.borderRadius(16)
.onClick(() => {
this.transparencyLevel = item.value;
})
})
}
Text('AR追踪灵敏度')
.fontSize(16)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
.margin({ top: 8 })
Slider({
value: 0.7,
min: 0.3,
max: 1.0,
step: 0.1
})
.width('100%')
.selectedColor('#00D4AA')
}
}
@Builder
buildFavoritePanel(): void {
Column({ space: 8 }) {
Text('已收藏照片')
.fontSize(16)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
Text('张嘴即可收藏当前照片')
.fontSize(13)
.fontColor('rgba(255,255,255,0.5)')
// 收藏列表占位
Column() {
Text('暂无收藏')
.fontSize(14)
.fontColor('rgba(255,255,255,0.3)')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
}
}
4.6 主相册页面(SpatialGalleryPage.ets)
代码亮点:整合 AR 数据流、照片浏览、沉浸光感标题栏和悬浮导航,实现完整的"空间交互相册"体验。
typescript
// entry/src/main/ets/pages/SpatialGalleryPage.ets
import { ImmersivePhotoTitleBar } from '../components/ImmersivePhotoTitleBar';
import { FloatPhotoNavigation } from '../components/FloatPhotoNavigation';
import { ExpressionPhotoController, PhotoCommand } from '../controllers/ExpressionPhotoController';
import { GestureViewController, ViewTransform } from '../controllers/GestureViewController';
import { ColorLightEngine, LightTheme } from '../utils/ColorLightEngine';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
@Entry
@Component
struct SpatialGalleryPage {
@State photoList: Array<photoAccessHelper.PhotoAsset> = [];
@State currentIndex: number = 0;
@State currentPixelMap: image.PixelMap | null = null;
@State isFavorite: boolean = false;
@State viewTransform: ViewTransform = {
scale: 1.0,
rotation: 0,
translateX: 0,
translateY: 0,
mode: 'gallery'
};
@State theme: LightTheme = ColorLightEngine.getInstance().getCurrentTheme();
@State trackingQuality: number = 1.0;
@State arStatus: string = '就绪';
private expressionController: ExpressionPhotoController = ExpressionPhotoController.getInstance();
private gestureController: GestureViewController = GestureViewController.getInstance();
private colorEngine: ColorLightEngine = ColorLightEngine.getInstance();
private arLoopId: number = 0;
aboutToAppear(): async () => {
// 加载相册照片
await this.loadPhotos();
// 初始化AR会话
this.initializeARSession();
}
aboutToDisappear(): void {
cancelAnimationFrame(this.arLoopId);
}
private async loadPhotos(): Promise<void> {
const helper = photoAccessHelper.getPhotoAccessHelper(getContext());
const fetchResult = await helper.getAssets({
fetchColumns: [],
predicates: new photoAccessHelper.PhotoFetchOptions()
});
this.photoList = fetchResult.getAllObjects();
AppStorage.setOrCreate('photo_count', this.photoList.length);
if (this.photoList.length > 0) {
await this.loadPhotoAtIndex(0);
}
}
private async loadPhotoAtIndex(index: number): Promise<void> {
if (index < 0 || index >= this.photoList.length) return;
this.currentIndex = index;
AppStorage.setOrCreate('photo_index', index);
const asset = this.photoList[index];
this.currentPixelMap = await asset.getThumbnail();
// 提取主色调
if (this.currentPixelMap) {
const newTheme = await this.colorEngine.extractThemeFromImage(this.currentPixelMap);
this.theme = newTheme;
AppStorage.setOrCreate('photo_theme', newTheme);
}
// 重置视图变换
this.gestureController.reset();
this.viewTransform = this.gestureController.parseGesture({} as any); // 重置后获取默认值
}
private initializeARSession(): void {
// AR会话初始化(简化示意,实际需配置ARConfig)
this.startARLoop();
}
private startARLoop(): void {
const loop = () => {
// 模拟AR数据处理(实际应从ARSession获取)
this.processARFrame();
this.arLoopId = requestAnimationFrame(loop);
};
this.arLoopId = requestAnimationFrame(loop);
}
private processARFrame(): void {
// 此处应接入真实的AR Engine数据
// 以下为模拟逻辑框架
let quality = 0;
// Face AR处理
// const faceCommand = this.expressionController.parseExpression(face);
// if (faceCommand) this.handlePhotoCommand(faceCommand);
// Body AR处理
// const transform = this.gestureController.parseGesture(body);
// this.viewTransform = transform;
// 更新追踪质量
this.trackingQuality = quality;
AppStorage.setOrCreate('tracking_quality', quality);
}
private handlePhotoCommand(command: PhotoCommand): void {
switch (command) {
case PhotoCommand.NEXT:
if (this.currentIndex < this.photoList.length - 1) {
this.loadPhotoAtIndex(this.currentIndex + 1);
}
break;
case PhotoCommand.PREV:
if (this.currentIndex > 0) {
this.loadPhotoAtIndex(this.currentIndex - 1);
}
break;
case PhotoCommand.FAVORITE:
this.isFavorite = !this.isFavorite;
break;
case PhotoCommand.FULLSCREEN:
// 切换全屏模式
break;
case PhotoCommand.ROTATE_CW:
this.viewTransform.rotation += 90;
break;
case PhotoCommand.ROTATE_CCW:
this.viewTransform.rotation -= 90;
break;
}
}
build() {
Stack({ alignContent: Alignment.Center }) {
// 第一层:动态环境光背景
this.buildAmbientLightLayer()
// 第二层:照片展示层
Column({ space: 0 }) {
// 沉浸光感标题栏
ImmersivePhotoTitleBar({
photoName: this.photoList[this.currentIndex]?.displayName || '空间相册',
isFavorite: this.isFavorite
})
// 照片浏览区域
Stack({ alignContent: Alignment.Center }) {
if (this.currentPixelMap) {
Image(this.currentPixelMap)
.width(this.viewTransform.mode === 'detail' ? '90%' : '85%')
.height(this.viewTransform.mode === 'detail' ? '90%' : '75%')
.objectFit(ImageFit.Contain)
.scale({
x: this.viewTransform.scale,
y: this.viewTransform.scale
})
.rotate({
angle: this.viewTransform.rotation,
centerX: '50%',
centerY: '50%'
})
.translate({
x: this.viewTransform.translateX,
y: this.viewTransform.translateY
})
.shadow({
radius: 20,
color: this.theme.primaryColor,
offsetX: 0,
offsetY: 8
})
.animation({
duration: 300,
curve: Curve.EaseInOut
})
} else {
Text('加载中...')
.fontSize(18)
.fontColor('rgba(255,255,255,0.5)')
}
// 手势提示覆盖层
if (this.trackingQuality > 0.5) {
Column({ space: 8 }) {
Text(this.viewTransform.mode === 'detail' ? '细节模式' : '画廊模式')
.fontSize(14)
.fontColor('#00D4AA')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('rgba(0,212,170,0.15)')
.borderRadius(12)
Text(`缩放: ${this.viewTransform.scale.toFixed(2)}x`)
.fontSize(12)
.fontColor('rgba(255,255,255,0.6)')
}
.position({ x: '90%', y: '10%' })
.markAnchor({ x: 1, y: 0 })
}
}
.layoutWeight(1)
.padding(16)
}
.width('100%')
.height('100%')
// 第三层:悬浮导航面板
FloatPhotoNavigation()
.height(280)
.position({ x: 0, y: '100%' })
.markAnchor({ x: 0, y: 1 })
}
.width('100%')
.height('100%')
.backgroundColor(this.theme.ambientColor)
.expandSafeArea(
[SafeAreaType.SYSTEM],
[SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM, SafeAreaEdge.START, SafeAreaEdge.END]
)
}
@Builder
buildAmbientLightLayer(): void {
Column() {
// 顶部主光晕
Column()
.width(800)
.height(400)
.backgroundColor(this.theme.primaryColor)
.blur(180)
.opacity(this.theme.glowIntensity * 0.2)
.position({ x: '50%', y: '0%' })
.anchor('50%')
.animation({
duration: 10000,
curve: Curve.EaseInOut,
iterations: -1,
playMode: PlayMode.Alternate
})
.scale({ x: 1.2, y: 1.0 })
// 底部反射光
Column()
.width('100%')
.height(250)
.backgroundColor(this.theme.secondaryColor)
.opacity(0.08)
.blur(120)
.position({ x: 0, y: '75%' })
.linearGradient({
direction: GradientDirection.Top,
colors: [
[this.theme.secondaryColor, 0.0],
['transparent', 1.0]
]
})
}
.width('100%')
.height('100%')
}
}
五、关键技术总结
5.1 Face AR 表情驱动设计
| 表情 | BlendShape 参数 | 映射指令 | 防误触策略 |
|---|---|---|---|
| 挑眉 | browInnerUp > 0.55 |
下一张照片 | 冷却期 800ms |
| 皱眉 | browDown > 0.45 |
上一张照片 | 需双侧同时触发 |
| 张嘴 | jawOpen > 0.35 |
收藏/取消收藏 | 持续 500ms 确认 |
| 眯眼 | eyeSquint > 0.5 |
全屏切换 | 需双侧同时触发 |
| 头左转 | pose.y < -0.3 |
逆时针旋转 | 结合头部姿态 |
| 头右转 | pose.y > 0.3 |
顺时针旋转 | 结合头部姿态 |
5.2 Body AR 手势操控设计
| 手势 | 骨骼关键点计算 | 映射操作 | 灵敏度调节 |
|---|---|---|---|
| 双手捏合 | 双腕距离 < 0.12m | 缩小 (scale -= 0.05) | 距离阈值可调 |
| 双手张开 | 双腕距离 > 0.45m | 放大 (scale += 0.05) | 距离阈值可调 |
| 单手旋转 | 右腕相对肩部位置 | 旋转角度映射 | 45° 灵敏度 |
| 身体前倾 | 肩-髋垂直差 > 0.25m | 进入细节模式 | 姿态阈值可调 |
| 身体后仰 | 髋-肩垂直差 > 0.15m | 返回画廊模式 | 姿态阈值可调 |
5.3 沉浸光感与照片联动
| 照片特征 | 标题栏光效 | 悬浮导航透明度 | 环境光背景 |
|---|---|---|---|
| 暗色调 | 低强度光晕 (0.3) | 70% 平衡 | 深蓝/深紫 |
| 亮色调 | 高强度光晕 (0.6) | 85% 强 | 暖白/米黄 |
| 高饱和 | 彩色光晕 | 70% 平衡 | 主色调暗化 |
| 黑白 | 中性灰光晕 | 55% 弱 | 纯黑/纯白 |
六、调试与优化建议
6.1 表情识别调优
- 阈值个性化:不同用户的表情幅度差异较大,建议首次使用时进行 30 秒校准
- 光照补偿:弱光环境下 Face AR 精度下降,可开启屏幕补光或降低阈值
- 多角度适配:PC 端摄像头通常位于屏幕上方,需补偿头部俯仰角的影响
6.2 手势识别优化
- 空间范围限定:建议设定有效交互区域(屏幕前方 0.5-1.5m),避免背景干扰
- 双手优先级:当单手和双手手势冲突时,优先响应双手操作(缩放优先级高于旋转)
- 平滑滤波:对骨骼关键点数据应用卡尔曼滤波,消除抖动
6.3 性能优化
typescript
// AR帧率动态调节
private adjustARPerformance(trackingQuality: number): void {
if (trackingQuality > 0.9) {
// 高质量追踪时,启用 60fps 高帧率
this.arSession?.setTargetFps(60);
} else if (trackingQuality > 0.6) {
// 一般质量时,30fps 平衡性能
this.arSession?.setTargetFps(30);
} else {
// 低质量时,15fps 降低功耗
this.arSession?.setTargetFps(15);
}
}
七、总结与展望
本文基于 HarmonyOS 6(API 23)的 Face AR & Body AR 能力,结合 沉浸光感 + 悬浮导航,完整实战了一款 PC 端"空间相册"应用。核心创新点总结:
- 表情驱动浏览:通过 Face AR 的 64 种 BlendShape 参数,实现"挑眉翻页、皱眉返回、张嘴收藏"的自然交互,无需任何物理接触
- 手势操控视图:利用 Body AR 的 20+ 骨骼关键点,支持双手捏合缩放、单手旋转、姿态切换模式,符合人类空间直觉
- 光感照片联动:实时提取照片主色调,动态调整标题栏光晕、悬浮导航材质和环境光背景,实现"照片即光源"的沉浸体验
- 悬浮导航自适应 :采用
HdsTabs悬浮样式,四周留白,支持透明度三档调节,确保不遮挡照片主体内容
未来扩展方向:
- AI智能推荐:结合用户表情数据(如看到某类照片时的微笑程度),训练个性化推荐模型
- 分布式相册:通过鸿蒙分布式软总线,实现手机拍摄 → 平板浏览 → PC 空间交互的无缝流转
- 多人协作空间:支持多人同时通过 Face AR 进入同一虚拟相册空间,共享浏览体验
- 无障碍增强:为视障用户开发"语音+触觉"反馈模式,将 AR 空间信息转化为可感知的辅助提示
转载自:https://blog.csdn.net/u014727709/article/details/157437569
欢迎 👍点赞✍评论⭐收藏,欢迎指正