HarmonyOS趣味相机实战第19篇:CameraKit输出Profile协商、宽高比评分与会话提交

HarmonyOS趣味相机实战第19篇:CameraKit输出Profile协商、宽高比评分与会话提交

摘要

CameraKit 返回了多个 previewProfilesphotoProfiles 时,直接取第一个元素虽然代码最短,却把画面比例、性能和设备排序规则交给了底层实现。不同设备上的第一个 Profile 可能宽高比不同、分辨率过高,甚至与页面 316 x 390 的竖屏 Cover 预览不匹配,最终造成裁剪过多、识别坐标难对齐或会话配置失败。

本文基于 D:/APP/1quweixiangji 趣味相机工程,复盘 CameraPreviewService.startPreview() 的输出能力协商。我们把"取第一个"升级为可测试的候选过滤、宽高比评分、分辨率预算、预览与拍照组合校验,并保留无 Profile 重载时的兼容降级。最后通过 PhotoSession 的 beginConfig/commitConfig 建立原子会话,避免半配置状态。

工程背景与源码定位

文件 当前责任 本文关注点
entry/src/main/ets/service/CameraPreviewService.ets 创建 CameraInput、PreviewOutput、PhotoOutput 与 PhotoSession Profile 选择和配置顺序
entry/src/main/ets/service/CameraDeviceService.ets 发现前后镜头并选择设备 每个镜头独立协商能力
entry/src/main/ets/pages/Index.ets 316 x 390 预览和三档照片质量 目标比例与业务偏好
entry/src/main/ets/service/CoreVisionHumanService.ets 将图像坐标映射到预览 Profile 尺寸和旋转输入
entry/src/main/module.json5 CAMERA 权限和手机设备声明 运行边界
build-profile.json5 target/compatible SDK 编译和 API 基线

环境与能力边界

项目 当前值 说明
应用版本 1.0.4 AppScope/app.json5
target SDK 6.0.2(22) 当前工程配置
场景模式 NORMAL_PHOTO 普通拍照会话
预览逻辑尺寸 316 x 390 ArkUI 工作台
页面照片档位 high/medium/low 映射 QualityLevel,不是 Profile 尺寸
当前设备类型 phone 模块配置
当前选择策略 数组第一个 Profile 需要增强为确定性评分

一、当前实现为什么能用

项目获取设备与能力:

ts 复制代码
const manager: camera.CameraManager =
  camera.getCameraManager(context);
const cameraPosition: camera.CameraPosition =
  position === 'front'
    ? camera.CameraPosition.CAMERA_POSITION_FRONT
    : camera.CameraPosition.CAMERA_POSITION_BACK;
const cameraDevice: camera.CameraDevice =
  manager.getCameraDevice(
    cameraPosition,
    camera.CameraType.CAMERA_TYPE_DEFAULT
  );

const capability: camera.CameraOutputCapability =
  manager.getSupportedOutputCapability(
    cameraDevice,
    camera.SceneMode.NORMAL_PHOTO
  );

随后选择第一个:

ts 复制代码
const profile: camera.Profile | null =
  capability.previewProfiles.length > 0
    ? capability.previewProfiles[0]
    : null;
const photoProfile: camera.Profile | null =
  capability.photoProfiles.length > 0
    ? capability.photoProfiles[0]
    : null;

并兼容不传 Profile 的重载:

ts 复制代码
const output: camera.PreviewOutput = profile
  ? manager.createPreviewOutput(profile, surfaceId)
  : manager.createPreviewOutput(surfaceId);
const photoOutput: camera.PhotoOutput = photoProfile
  ? manager.createPhotoOutput(photoProfile)
  : manager.createPhotoOutput();

这段代码具备两个优点:流程完整,空数组时仍有降级路径。但"第一个"没有表达产品偏好,无法保证跨设备一致。

二、Profile选择需要回答四个问题

对每个候选至少评估:

  1. 宽高比与预览容器是否接近。
  2. 像素数量是否落在设备性能预算内。
  3. 前置或后置镜头是否需要不同上限。
  4. 预览 Profile 与拍照 Profile 是否能组成当前场景的有效会话。

预览追求稳定帧率和低延迟,拍照追求细节。两者不能用同一个"分辨率越高越好"规则。

三、不要混淆Profile与照片质量档位

页面提供:

ts 复制代码
private resolutionLabels: string[] = [
  '12MP (4:3)',
  '8MP (4:3)',
  '5MP (4:3)'
];
private resolutionQualities: CameraPhotoQualityPreference[] = [
  'high',
  'medium',
  'low'
];

拍照时映射:

ts 复制代码
const setting: camera.PhotoCaptureSetting = {
  quality: captureQualityLevel(qualityPreference),
  mirror: activeCameraPosition === 'front'
};

QualityLevel 是编码质量偏好,不等于选择 12MP、8MP、5MP 的 Photo Profile。若 UI 明确展示像素级分辨率,就必须让选项与实际 photoProfile.size 对应;否则应把文案改为"高清/标准/省空间"。

四、先把候选转换成可评分数据

ts 复制代码
interface ProfileCandidate {
  profile: camera.Profile;
  width: number;
  height: number;
  longEdge: number;
  shortEdge: number;
  pixels: number;
  aspect: number;
}

function toCandidate(profile: camera.Profile): ProfileCandidate {
  const width = profile.size.width;
  const height = profile.size.height;
  return {
    profile,
    width,
    height,
    longEdge: Math.max(width, height),
    shortEdge: Math.min(width, height),
    pixels: width * height,
    aspect: Math.max(width, height) / Math.min(width, height)
  };
}

使用长短边计算比例,可以避免候选以横向尺寸表示,而页面实际竖屏显示时误判。

五、目标比例来自产品容器

页面容器为 316 x 390

ts 复制代码
const targetAspect = 390 / 316;

这个比例约为 1.234,不是常见 4:3 的 1.333。相机图像通过 Cover 填充后一定会裁剪一部分。选择时既要尊重实际容器,也要考虑常见相机输出比例。

可把产品目标设为 4:3,并让 UI 容器承担少量裁剪;也可以严格按 390:316 评分。关键是产品、识别映射和测试使用同一规则。

六、宽高比评分用相对误差

ts 复制代码
function aspectPenalty(
  candidateAspect: number,
  targetAspect: number
): number {
  return Math.abs(candidateAspect - targetAspect) / targetAspect;
}

相对误差比绝对差更容易跨比例比较。误差越小越好。

可计算 Cover 裁剪比例:

ts 复制代码
function coverCropRatio(
  sourceAspect: number,
  targetAspect: number
): number {
  if (sourceAspect > targetAspect) {
    return 1 - targetAspect / sourceAspect;
  }
  return 1 - sourceAspect / targetAspect;
}

裁剪比例直接表达丢失的边缘区域,更贴近用户感知和坐标对齐风险。

七、预览分辨率要设置性能预算

预览 Profile 过高会增加:

  • Surface 带宽。
  • createPixelMapFromSurface() 成本。
  • CoreVision 采样分析成本。
  • 图像旋转和坐标映射压力。
  • 部分设备的会话启动时间。

可以设置软目标与硬上限:

ts 复制代码
interface PreviewBudget {
  preferredPixels: number;
  maxPixels: number;
  minShortEdge: number;
}

const phoneBudget: PreviewBudget = {
  preferredPixels: 1280 * 720,
  maxPixels: 1920 * 1080,
  minShortEdge: 640
};

这些数值是策略示例,必须用项目目标设备测试确定,不应写死为所有 HarmonyOS 设备的通用结论。

八、组合一个确定性评分函数

ts 复制代码
function previewScore(
  candidate: ProfileCandidate,
  targetAspect: number,
  budget: PreviewBudget
): number {
  if (candidate.shortEdge < budget.minShortEdge) {
    return Number.POSITIVE_INFINITY;
  }
  if (candidate.pixels > budget.maxPixels) {
    return Number.POSITIVE_INFINITY;
  }

  const crop = coverCropRatio(candidate.aspect, targetAspect);
  const pixelDistance = Math.abs(
    candidate.pixels - budget.preferredPixels
  ) / budget.preferredPixels;

  return crop * 1000 + pixelDistance * 100;
}

权重明确后,候选顺序变化也不会改变结果。分数相同时再按像素、宽度、格式等稳定字段排序。

九、过滤失败时要有二级降级

如果所有候选都超过硬上限,不能直接返回 null。降级顺序:

text 复制代码
满足比例和预算的候选
  -> 忽略 preferredPixels,只保留 maxPixels
  -> 选择像素最小的有效候选
  -> 使用 createPreviewOutput(surfaceId) 重载
  -> 返回明确启动错误

实现:

ts 复制代码
function choosePreviewProfile(
  profiles: camera.Profile[],
  targetAspect: number,
  budget: PreviewBudget
): camera.Profile | null {
  if (profiles.length === 0) {
    return null;
  }
  const candidates = profiles.map(toCandidate);
  const ranked = candidates
    .map(candidate => ({
      candidate,
      score: previewScore(candidate, targetAspect, budget)
    }))
    .filter(item => Number.isFinite(item.score))
    .sort((a, b) => a.score - b.score ||
      a.candidate.pixels - b.candidate.pixels);

  if (ranked.length > 0) {
    return ranked[0].candidate.profile;
  }
  candidates.sort((a, b) => a.pixels - b.pixels);
  return candidates[0].profile;
}

十、拍照Profile使用不同目标

拍照输出可以更高,但仍不应盲目选择最大值。需要考虑:

  • 用户选择的业务档位。
  • 可接受的 JPEG 大小。
  • ImageSource 与 PixelMap 解码内存。
  • CoreVision 拍照后分析耗时。
  • 保存和转文档的处理时间。

可以按设备候选动态生成三档:

text 复制代码
high   -> 同比例候选中较高分辨率
medium -> 同比例候选中位数
low    -> 满足最低清晰度的较低分辨率

这样 UI 显示的是设备真实支持的尺寸,而不是固定假设。

十一、建立实际分辨率选项模型

ts 复制代码
interface PhotoResolutionOption {
  key: 'high' | 'medium' | 'low';
  label: string;
  profile: camera.Profile;
  megapixels: number;
}

function profileLabel(profile: camera.Profile): string {
  const pixels = profile.size.width * profile.size.height;
  const megapixels = Math.round(pixels / 100000) / 10;
  return `${megapixels}MP (${profile.size.width}x${profile.size.height})`;
}

选项应在镜头切换后重新生成。前置镜头可能没有后置镜头的高分辨率档位,页面不能继续展示旧选项。

十二、预览与拍照Profile必须按同一设备重新获取

切换镜头后,当前流程会:

text 复制代码
stopPreview
  -> getCameraDevice(newPosition)
  -> getSupportedOutputCapability(newDevice, NORMAL_PHOTO)
  -> choose previewProfile
  -> choose photoProfile
  -> create outputs

不要缓存后置镜头的 Profile 给前置镜头。Profile 的有效性属于设备、场景模式和输出类型组合。

十三、PhotoSession配置保持原子顺序

项目使用:

ts 复制代码
const session: camera.PhotoSession =
  manager.createSession<camera.PhotoSession>(
    camera.SceneMode.NORMAL_PHOTO
  );

session.beginConfig();
session.addInput(input);
session.addOutput(output);
session.addOutput(photoOutput);
if (metadataOutput !== null) {
  session.addOutput(metadataOutput);
}
await session.commitConfig();
await session.start();

正确原则:

  1. beginConfig()commitConfig() 之间完成输入输出组合。
  2. commitConfig() 成功后再 start()
  3. 只有启动成功才把局部资源发布到静态字段。
  4. 任一失败进入统一清理。

十四、局部资源失败时的所有权风险

当前资源在启动成功后才赋值:

ts 复制代码
CameraPreviewService.cameraInput = input;
CameraPreviewService.previewOutput = output;
CameraPreviewService.photoOutput = photoOutput;
CameraPreviewService.session = session;

如果 commitConfig() 之前抛错,stopPreview() 看不到这些局部资源,可能无法释放。可以使用启动上下文:

ts 复制代码
interface OpeningResources {
  input?: camera.CameraInput;
  preview?: camera.PreviewOutput;
  photo?: camera.PhotoOutput;
  metadata?: camera.MetadataOutput;
  session?: camera.PhotoSession;
}

catch/finally 中释放局部资源,或创建后立即登记到可清理字段并用 generation 防止并发污染。

十五、MetadataOutput是可选能力

项目先检查:

ts 复制代码
const supported: boolean =
  capability.supportedMetadataObjectTypes.some(type => {
    return type === camera.MetadataObjectType.FACE_DETECTION;
  });

不支持人脸 Metadata 时,预览和拍照仍应启动,只是识别链路降级到定时 Surface 采样。可选输出不能让基础会话失败。

如果加入 MetadataOutput 后 commitConfig() 失败,可以尝试一次不含 MetadataOutput 的组合,而不是立即判定整个相机不可用。

十六、记录选型结果而不是输出完整设备信息

建议日志:

ts 复制代码
interface ProfileDecisionLog {
  cameraPosition: 'front' | 'back';
  previewWidth: number;
  previewHeight: number;
  photoWidth: number;
  photoHeight: number;
  previewCandidateCount: number;
  photoCandidateCount: number;
  usedFallback: boolean;
}

日志不需要包含用户图像、设备序列标识或照片内容。只记录能力数量、选中尺寸和降级原因即可。

十七、纯函数测试

构造 Profile 大小夹具:

ts 复制代码
const sizes = [
  { width: 640, height: 480 },
  { width: 1280, height: 720 },
  { width: 1440, height: 1080 },
  { width: 1920, height: 1080 },
  { width: 4000, height: 3000 }
];

断言:

  • 候选数组顺序变化不影响最终选择。
  • 4:3 目标优先 4:3 候选。
  • 超过 maxPixels 的候选被过滤。
  • 全部超预算时选择最小可用候选。
  • 空数组返回 null 并进入重载降级。
  • 横向尺寸和竖向尺寸得到相同长短边比例。
  • 前后镜头分别生成自己的照片档位。

十八、真机兼容矩阵

场景 操作 验收
单后摄设备 启动预览 正常选中 Profile
前后摄设备 连续切换 20 次 每次重新协商且无黑屏
低端设备 开启实时识别 预览帧率稳定
高像素设备 选择 high 解码内存不过量
不支持人脸 Metadata 启动会话 预览可用、识别降级
候选顺序变化 重启应用 选型结果确定
Surface 重建 前后台切换 新 Surface 使用新 Output

十九、常见问题排查

现象 可能原因 排查方式
某些设备预览裁剪很多 第一个 Profile 比例不匹配 输出候选比例和评分
识别耗时明显升高 预览 Profile 像素过高 设置预览像素预算
UI 显示12MP但实际不是 把 QualityLevel 当分辨率 根据 Photo Profile 生成文案
前置切换后档位错误 复用后置候选 每个设备重新获取 capability
加 Metadata 后会话失败 输出组合不兼容 无 Metadata 重试一次
commit失败后相机仍占用 局部资源未登记 引入 OpeningResources 清理
同机重启选择结果变化 依赖数组原始顺序 使用确定性评分和稳定排序
空 Profile 数组直接崩溃 未检查长度 使用重载或明确错误降级

二十、上线前验收清单

  • 不再默认依赖 Profile 数组第一个元素。
  • 预览和拍照使用不同评分目标。
  • 宽高比按长短边和 Cover 裁剪计算。
  • 预览 Profile 有像素预算和最小清晰度。
  • UI 分辨率标签来自真实 Photo Profile。
  • QualityLevel 文案不会伪装成具体 MP。
  • 前后镜头切换后重新获取能力。
  • 候选排序具有确定性。
  • 空候选和全部超预算都有降级路径。
  • MetadataOutput 失败不影响基础拍照会话。
  • beginConfig/commitConfig/start 顺序正确。
  • 启动中途失败能释放所有局部资源。
  • 日志记录尺寸和降级,不记录用户图像。
  • 多机型完成切镜头、前后台和识别压力测试。

总结

CameraKit 的输出能力协商不是"取一个能用的 Profile",而是把产品容器、设备性能、照片清晰度和会话兼容性统一到确定性策略中。预览优先稳定和低延迟,拍照优先真实分辨率档位,MetadataOutput 则作为可选增强能力。

当 Profile 选择变成可测试的纯函数,并在 PhotoSession 提交前完成组合验证,趣味相机才能在候选顺序、镜头差异和设备能力变化下保持一致。页面显示的分辨率也会与真实输出对应,不再把编码质量与像素尺寸混为一谈。

相关推荐
ldsweet8 小时前
《HarmonyOS技术精讲-Basic Services Kit》上传下载进阶:多任务并发与后台长时任务
华为·harmonyos
qizayaoshuap10 小时前
# [特殊字符] 宠物模拟器 — 鸿蒙ArkTS完整技术解析(最终篇)
华为·harmonyos·宠物
不言鹅喻10 小时前
HarmonyOS ArkTS 实战:实现一个校园自动售货机库存与补货记录应用
华为·harmonyos
世人万千丶10 小时前
鸿蒙Flutter TextStyle样式配置
学习·flutter·harmonyos·鸿蒙
不肥嘟嘟右卫门14 小时前
鸿蒙原生ArkTS布局方式之LazyForEach懒加载布局深度解析
华为·harmonyos
程序员黑豆14 小时前
鸿蒙开发入门:以 Text 组件为例,掌握内置组件用法
前端·harmonyos
xd18557855515 小时前
道歉话术生成:基于鸿蒙生态的智能情感沟通助手
人工智能·华为·harmonyos·鸿蒙
程序员黑豆15 小时前
鸿蒙应用开发之模拟器安装与使用教程
前端·harmonyos
SameX17 小时前
HarmonyOS 用 relationalStore 做本地数据库的完整实战 —— 一个真实项目的 5 个决策
harmonyos
绝世番茄18 小时前
鸿蒙原生 ArkTS 布局方式之 Button+Shake 抖动按钮实战全解
华为·harmonyos·鸿蒙