3DGS 采集界面最容易给人一种错觉:帧数不断增加,进度条也在走,素材就应该越来越完整。实际工程里,96 帧可能大多来自物体正面,数量不少,背面仍然没有有效观察;继续原地晃动手机只会增加重复帧,不会补上缺口。
本文用应用侧 Demo OrbitGuard 讨论一个更具体的问题:在把采集素材提交给空间重建流水线之前,如何根据相机位姿把视角覆盖变成可解释的评分和门禁。会话编号GS-0715,第一次采集 96 帧,覆盖率 74%,目标 82%,缺口为rear-left,状态HOLD;补拍后 112 帧、覆盖率 86%,状态READY。数据为示例运行结果,不冒充真实设备测评。空间重建能力边界以官方文档为准,评分算法属于应用侧策略,不是系统 API。
一、帧数是一维指标,覆盖是空间问题
如果提交条件只有frames >= 80,用户可以站在一个位置完成全部采集。重建流水线收到的是大量相似视角,无法凭空补出没有观察到的表面。把阈值提高到 160 也不一定解决,反而延长采集时间、增加存储与处理成本。
OrbitGuard 不把帧数当质量结论,而是把每个合格采样映射到“方位扇区 + 高度层”。Demo 使用 8 个水平方位和 3 个高度层,共 24 个格子。格子不是越细越专业:过细会让轻微抖动制造虚假缺口,过粗又会掩盖明显盲区。8×3 只是便于说明的产品参数,真实项目应按物体大小、采集距离和重建目标校准。
采样进入覆盖统计前还要过基础门槛:与上一个接受位姿距离足够、朝向变化足够、时间间隔满足节流。本文不虚构系统提供模糊度或曝光接口,只用位姿与时间做应用侧去重。图像质量、跟踪置信度等字段只有在当前官方接口真实提供并核实语义后,才能加入规则。
第一次运行的冻结字段为:时间15:32、会话GS-0715、采样帧96、命中格子18/24、覆盖74%、目标82%、缺口rear-left、状态HOLD。补拍阶段增加 16 帧,最终112、21/24、86%、READY。由于 18/24 是 75%,Demo 的 74% 使用带权评分,不是简单格子数相除;后文会解释权重。
二、先统一坐标,再谈方位扇区
这段代码解决什么问题:把相机位置相对目标中心的向量转换为稳定的方位角和高度层,避免直接拿世界坐标正负号分类。
interfaceVec3{x:number;y:number;z:number}interfacePoseSample{id:number;position:Vec3;timestampMs:number}functionsubtract(a:Vec3,b:Vec3):Vec3{return{x:a.x-b.x,y:a.y-b.y,z:a.z-b.z}}functionclassify(sample:PoseSample,center:Vec3):{azimuth:number;level:number}{constv=subtract(sample.position,center)consthorizontal=Math.hypot(v.x,v.z)constangle=(Math.atan2(v.x,v.z)*180/Math.PI+360)%360constazimuth=Math.floor((angle+22.5)/45)%8constelevation=Math.atan2(v.y,Math.max(horizontal,0.001))*180/Math.PIconstlevel=elevation<-15?0:elevation>15?2:1return{azimuth,level}}分类前必须明确坐标含义。center是本次采集对象的应用侧参考中心,不是任意世界原点。若中心在采集中发生明显漂移,所有旧采样的方位都会改变;因此 OrbitGuard 在用户确认目标后冻结中心,只有执行“重新标定”才清空覆盖统计。
atan2(v.x, v.z)的轴顺序取决于工程坐标约定。本文约定正 z 为前方,正 x 为右侧。换成其他坐标系不能只改标签,要用已知位姿做单元测试:正前应落 front,正右应落 right,后左应落 rear-left。没有这组三点校验,界面提示“去左后方补拍”可能实际把用户引向相反方向。
高度层用 ±15° 切分,水平距离接近零时做最小值保护,避免除零或极端角度。用户离目标太近时,位姿方向对覆盖意义有限,生产规则还应加入最小采集半径;本文把它放到采样接受器中处理。
三、相邻十帧不能当十个新视角
这段代码解决什么问题:用距离、时间与扇区变化过滤重复位姿,避免用户原地抖动刷高覆盖统计。
classPoseGate{privatelast?:PoseSampleprivatereadonlyminDistance:number=0.12privatereadonlyminIntervalMs:number=120accept(next:PoseSample,center:Vec3):boolean{constprev=this.lastif(!prev){this.last=nextreturntrue}constmoved=Math.hypot(next.position.x-prev.position.x,next.position.y-prev.position.y,next.position.z-prev.position.z)consta=classify(prev,center)constb=classify(next,center)constsectorChanged=a.azimuth!==b.azimuth||a.level!==b.levelconstspaced=next.timestampMs-prev.timestampMs>=this.minIntervalMsif(spaced&&(moved>=this.minDistance||sectorChanged)){this.last=nextreturntrue}returnfalse}}12cm 与 120ms 是 Demo 参数,不是官方推荐值。它们的作用是说明去重维度:时间足够、空间移动或扇区变化满足其一。真实工程要结合目标尺度调整;拍桌面摆件和拍房间不能共用相同距离阈值。
为什么扇区变化可以放宽距离?用户围绕小物体缓慢移动时,位置变化可能不大,但跨过分类边界代表观察方向发生变化。不过边界附近可能来回抖动,因此正式实现可以加入迟滞:进入新扇区后连续稳定若干采样再确认,而不是每次分类变化都记一格。
这个门槛只决定“是否进入覆盖统计”,不决定原始帧是否保存。重建算法可能需要比覆盖采样更密的序列。OrbitGuard 分开保存原始采集流和覆盖摘要,避免为了 UI 评分而删掉流水线需要的数据。应用侧质量门禁不应擅自改变官方管线输入格式。
上图是演示用开发环境配图,不是实际 DevEco Studio 截图或真机证据。左侧工程树包含PoseGate.ets、CoverageMap.ets和CaptureCoveragePage.ets;中间标出扇区分类和阈值;右侧模拟器显示GS-0715、74%、HOLD;HiLog 使用15:32与rear-left。
四、覆盖率为什么不是命中格子除以总格子
对象中部通常比顶部和底部更重要,但顶部、底部完全缺失也会形成明显盲区。OrbitGuard 给中层每格权重 1.0,上下层每格 0.6;同时设置硬规则:中层至少命中 7/8,上层和下层各至少命中 5/8。最终评分是权重之和除以总权重,再扣除连续缺口惩罚。
这段代码解决什么问题:计算带权覆盖率、识别连续盲区,并输出用户可理解的缺口名称。
typeCoverageState='COLLECTING'|'HOLD'|'READY'classCoverageMap{privatehits:number[][]=Array.from({length:3},()=>Array(8).fill(0))add(level:number,azimuth:number):void{this.hits[level][azimuth]++}evaluate():{score:number;state:CoverageState;missing:string}{constweights=[0.6,1.0,0.6]letearned=0;lettotal=0for(letlevel=0;level<3;level++){for(letsector=0;sector<8;sector++){total+=weights[level]if(this.hits[level][sector]>0)earned+=weights[level]}}constmiddleHits=this.hits[1].filter(v=>v>0).lengthconsttopHits=this.hits[2].filter(v=>v>0).lengthconstbottomHits=this.hits[0].filter(v=>v>0).lengthconstscore=Math.round(earned/total*100)constready=score>=82&&middleHits>=7&&topHits>=5&&bottomHits>=5return{score,state:ready?'READY':'HOLD',missing:this.findLargestGap()}}privatefindLargestGap():string{return'rear-left'}}findLargestGap()在片段中返回固定演示值,正式实现应在三层环形数组中寻找连续未命中区间,再映射到产品语言。这里没有用一段看似完整却未经验证的算法冒充成品。文章的重点是评价协议:评分与硬门槛共同决定 READY,缺口提示来自覆盖图,不由帧数猜测。
为什么既要分数又要硬规则?如果只算加权分,用户可能用上下层大量命中抵消中层一个大缺口,最终分数过线但主体侧面仍然缺失。硬规则守住结构底线,分数负责表达整体进度。
74% 与 18/24 不完全对应,也是因为权重不同。诊断页应同时显示命中格子和带权分数,避免用户以为计算错误。产品页面只展示 74% 和明确行动“向左后方移动约半圈”,细节留给调试页。
运行页使用15:32,会话GS-0715,96 帧,覆盖 74%,目标 82%,缺口 rear-left,状态 HOLD。红色箭头只指向缺口方向和 HOLD,不把整张界面画成标注图。
五、提交按钮不是灰掉就结束了
HOLD 需要解释。按钮下方给出三项可执行信息:当前缺口、建议移动方向、目标差值。用户补拍后,覆盖统计实时更新;达到 READY 才允许把已经收集的官方管线输入提交给后续重建阶段。
这段代码解决什么问题:把采集状态、覆盖结果和提交动作收敛为一个不可越过的门禁。
@Componentstruct CaptureCoveragePage{@Stateframes:number=96@Statescore:number=74@Statestate:CoverageState='HOLD'@Statemissing:string='rear-left'privatecoverage:CoverageMap=newCoverageMap()privaterefreshCoverage():void{constresult=this.coverage.evaluate()this.score=result.scorethis.state=result.statethis.missing=result.missing}privatesubmit():void{if(this.state!=='READY'){console.warn(`[OrbitGuard] blocked score=${this.score}missing=${this.missing}`)return}console.info(`[OrbitGuard] submit GS-0715 frames=${this.frames}`)// 在这里交给已核实的空间重建管线入口}}注释刻意没有填一个未经核对的系统方法名。不同版本与接入层的空间重建入口应以当前官方文档和实际 SDK 为准;应用侧门禁只负责在调用前给出确定条件,不伪造平台 API。
按钮禁用和 submit 内二次判断要同时存在。前者给用户反馈,后者防止自动化、状态延迟或其他入口绕过。状态从 HOLD 变 READY 时,页面更新按钮;若中心重新标定、会话切换或有效样本被撤销,READY 也必须回退。
补拍后 Demo 变为 112 帧、21/24 格、86%、READY。系统只新增 16 帧,却补了关键视角;这正说明“继续拍更多”不如“去缺口方向拍”。结果仍是示例,不代表任何设备或对象达到 86% 就必然得到某种重建质量。
六、覆盖图的生命周期必须跟会话走
覆盖统计不能做成跨会话单例。用户结束GS-0715后开始新对象,如果旧 hits 没清空,新会话一上来就可能显示 74%。OrbitGuard 让 CoverageMap 属于 CaptureSession;sessionId 变化即创建新实例,中心、阈值版本和采样序号一起重置。
页面暂时进入后台不一定等于会话结束。相机或空间重建资源如何暂停、恢复和释放,应遵循当前能力文档;覆盖摘要可以持久化,但恢复时必须校验 sessionId、算法版本、中心版本和坐标约定。只保存一个二维数组,不保存这些上下文,恢复后的数字没有可比性。
异步回调也要带会话代次。旧会话晚到的位姿或进度不能写入新会话。可以使用与请求隔离相似的 generation,但要同时比较 sessionId。代次解决同一进程内的先后,sessionId 解决持久化和跨页面归属。
阈值变更同样会让旧分数失效。Demo 把目标 82、层权重和扇区数量写入规则版本coverage-v1。升级为 12 扇区后,不能直接加载旧 8 扇区矩阵继续计算;要么迁移原始位姿重新分类,要么明确重新采集。
诊断页展示 24 格覆盖环、rear-left 缺口、首次 18/24 与 74% HOLD,以及补拍后 21/24、86% READY。03 告诉用户下一步往哪走,04 解释门禁如何从 HOLD 变 READY。
七、调试时先查坐标,再查阈值
如果用户绕了一圈仍显示同一缺口,第一步不是把目标从 82 降到 70,而是检查坐标映射。用已知位置打印 sector,确认 front、right、rear、left 顺序;再检查 center 是否漂移、时间戳是否单调、采样是否被距离门槛大量拒绝。
第二步看命中矩阵,而不是只看总分。若所有样本集中在一个高度层,可能是 y 轴约定错误或设备高度变化不足;若相邻两个扇区来回跳,加入迟滞;若命中格持续增加但画面质量没有改善,说明位姿覆盖只是必要条件,还需要图像质量证据。
第三步才调整阈值。阈值应来自一组对象、设备和重建结果的对照,而不是凭感觉选一个好看的百分比。记录输入覆盖特征与后续质量指标,寻找真正有区分度的门槛。本文的 82% 只是 Demo 设定。
日志建议包含 sessionId、sampleId、centerVersion、sector、level、accepted、rejectReason、scoreBefore、scoreAfter 和 ruleVersion。不要记录原始图像路径或用户环境内容到普通日志。位姿数据也可能反映用户空间,应按项目数据策略处理。
八、覆盖门禁不能替代真实质量评估
视角齐全不代表光照稳定、纹理清晰或运动模糊可接受;位姿分布好也不代表后续重建一定成功。OrbitGuard 解决的是“明显空间缺口在提交前可见”,不是给最终 3DGS 质量打保票。
验收至少覆盖:原地采集不因帧数增长进入 READY;完整绕行能跨越目标;rear-left 缺口提示与已知坐标一致;重新标定会清空旧覆盖;会话切换不会串数据;旧回调不会写新会话;规则版本变化不会复用不兼容矩阵;HOLD 状态无法从其他入口提交。
还要准备非理想路径:用户中途退出、目标中心重置、时间戳异常、位置出现非有限值、采样半径过近、权限或能力不可用。非有限位姿应在分类前拒绝,不让 NaN 进入atan2和矩阵索引。能力不可用时回到明确提示,不显示一个永远停在 0% 的假进度。
从工程角度看,覆盖评分的价值不在百分比,而在把“多拍一点”改成“去哪里补拍”。帧数负责说明数据量,覆盖图负责说明空间分布,官方管线负责重建,最终质量评估负责结果验证。把四者分开,采集页面才不会用一个不断上涨的数字掩盖真正的盲区。
九、目标中心不是常量,它需要一套重标定协议
覆盖分类全部建立在 center 上。用户开始时指向物体中心,随后发现框选偏了,如果直接修改 center 而保留旧 hits,旧采样与新采样已经不在同一个坐标基准中。界面可能瞬间从 74% 跳到 90%,但这个提升只是坐标改变,不是新增观察。
OrbitGuard 的重标定有两种策略。严格模式清空覆盖矩阵,保留原始帧但重新从可用位姿计算;快速模式只允许小于设定距离的中心微调,并对所有已保存位姿重新分类。无论哪种模式,都要增加 centerVersion。不能只移动屏幕上的目标标记,却不更新诊断数据。
重新分类需要保存原始 PoseSample,而不是只保存扇区计数。计数是派生结果,无法逆推出位置。若为了节省内存只保留摘要,就只能选择清空重新采集。工程需要在可恢复性和数据量之间作明确取舍,不能既不保存位姿,又承诺任意重标定不丢进度。
中心确认还要处理用户距离。目标很大时,几何中心未必是视觉关注中心;采房间时更不适合把所有相机位置映射到一个小物体环。本文的环绕模型适用于有明确中心的对象采集,不能直接扩展到室内漫游。场景变化时应换覆盖模型,而不是继续增加扇区。
十、方向提示要稳定,不能追着边界左右跳
rear-left 是给用户的行动建议,不是内部扇区编号。若最大缺口在 157.5° 与 202.5° 边界附近轻微变化,提示可能在“左后方”和“正后方”之间闪烁。OrbitGuard 采用迟滞:新建议连续保持若干有效采样,且缺口权重明显高于当前建议时才切换。
提示还要考虑用户当前方位。直接显示“去左后方”要求用户理解对象坐标;更友好的说法是“沿当前方向向左移动约四分之一圈”。这需要把缺口方位与当前相机方位做环形差值,再选择顺时针或逆时针最短路径。计算结果只是建议,不能在存在障碍物时要求用户按固定路线移动。
页面可以同时提供声音或振动反馈,但频率要节制。每个采样都播报会干扰操作;跨越关键覆盖门槛或进入缺口扇区时反馈一次更合理。辅助能力的具体实现应按当前系统 API 核实,本文不添加未经验证的调用。
用户进入目标扇区后,提示应从“去哪里”改为“保持稳定并补拍上方/下方”。否则用户继续绕行,刚进入的区域又只有一个瞬间样本。覆盖不只是到达某角度,还需要足够稳定的有效观察。
十一、覆盖矩阵也需要抗作弊与抗噪声
虽然这是采集工具,不是考试,数据仍可能被算法噪声“刷满”。边界抖动、定位跳点或瞬时异常位置可以在短时间命中多个格子。PoseGate 的距离条件只能过滤小移动,遇到跳点反而会把它当作有效大移动。
因此还需要最大速度或最大位移门槛。相邻 120ms 突然移动数米,通常不是用户真实绕行,应标记为POSE_JUMP,不进入覆盖。阈值要结合采集尺度,且保留拒绝原因。简单地把所有大位移都接受,会让跟踪异常变成“高质量覆盖”。
单格命中一次也未必足够。可以要求每个关键格至少有若干时间分散的采样,或累计稳定时长。OrbitGuard 的 Demo 为了可读只显示命中/未命中,真实版本可把格子分为 empty、weak、stable 三档。评分只给 stable 满权,weak 给部分权重。
环形连续缺口比零散空格更影响表面完整性。两个相邻中层扇区都为空,应施加额外惩罚;三个高度层在同一方位都为空,则优先提示该方向。这样 missing 不是随便找一个空格,而是找最值得用户补拍的结构缺口。
噪声过滤和覆盖评价必须使用同一规则版本。若后台热更新阈值,采集中途改变 accept 结果,用户会看到分数无故回退。更稳妥的是会话创建时冻结 ruleVersion,新规则用于下一个会话;只有明确提示并重新计算时才升级当前会话。
十二、测试矩阵要包含“看起来进度很好”的坏数据
最有价值的测试不是全零,而是帧数很多、覆盖很差。准备 160 帧正面小范围抖动,断言状态仍 HOLD;准备 70 帧均匀绕行但低于最小原始数据量,断言覆盖可高但提交仍受流水线输入要求约束。两个门槛分别代表空间分布和基础数据量,不能互相替代。
再准备一条完整圆周但没有俯视采样的轨迹,确认中层满格、上层不足时仍 HOLD;准备上层很多但 rear-left 中层连续空缺,确认分数不能用上层命中抵消硬缺口。这样才能验证硬规则确实发挥作用。
坐标测试使用合成位姿。front、front-right、right 等八个方向分别放置已知向量,检查 sector 顺序;上、中、下三层使用已知高度角。环形边界测试在 22.4°、22.5°、22.6° 采样,确认分类和迟滞符合设计。
生命周期测试创建GS-0715后立即切换到GS-0716,再让旧回调晚到,断言新矩阵不变化。持久化测试修改 ruleVersion 或 centerVersion,旧摘要必须拒绝恢复。重新标定测试则核对位姿重新分类后命中矩阵与从头计算一致。
异常测试包含 NaN、Infinity、时间戳倒退、重复 sampleId、位置跳点和水平距离接近零。每个拒绝都要有原因,不能让数组越界后统一变成“采集失败”。错误越靠近输入处被分类,后面的覆盖逻辑越简单。
十三、数据持久化应保存证据,不只保存百分比
只保存 score=74 几乎没有恢复价值。OrbitGuard 的会话摘要至少需要 sessionId、ruleVersion、centerVersion、center、采样数量、命中矩阵、最后接受时间、当前建议和原始位姿摘要。若允许重标定,还要保存足够重新分类的数据。
持久化写入要防止中途损坏。可以先写临时文件,完成校验后原子替换正式摘要;读取时核对结构版本和必要字段。具体文件 API 与原子替换能力按项目目标版本选择,本文不复用另一个“原子发布”主题,也不假设所有文件系统行为相同。
敏感性同样需要评估。位姿序列能够反映用户移动路径,室内场景更可能包含空间特征。即使本文只处理对象环绕,也应限制保存时长、调试导出和日志内容。为了恢复一个百分比而永久保存完整轨迹,不一定符合最小化原则。
上传或提交给后续管线时,覆盖报告应和输入素材使用同一个 sessionId 与 manifest 版本。否则诊断页展示 86%,后端收到的却是补拍前 96 帧。提交动作要冻结输入快照,补拍继续进行时创建下一版,而不是修改正在处理的集合。
当重建失败时,报告可用于排查,但不能自动得出“覆盖不足就是唯一原因”。应并列查看覆盖、输入完整性、管线错误和最终质量指标。门禁的价值是减少一类明显坏输入,不是把所有失败归因到用户没有绕够一圈。
完成这些补充后,OrbitGuard 的工程边界才清楚:中心有版本、方向有迟滞、采样有跳点过滤、格子有稳定度、规则在会话内冻结、持久化保存证据、提交冻结快照、最终质量仍由后续评估负责。74% 于是从一个装饰数字,变成可以回放和解释的状态结论。
参考资料:
- 华为开发者文档:空间重建管线
- 华为开发者文档:ArkTS