空间重建输入看上去只是“图像、内参、位姿、时间戳”四类字段,但真正难排查的故障往往不在单个字段,而在字段之间的时间关系。HMS_SpatialRecon_DataFrame 的 timestamp 单位是纳秒;官方接口允许应用组装数据帧,再通过 HMS_SpatialRecon_PushFrame 送入会话。接口能判断一帧是否具备基本格式,却不替业务证明回调顺序、会话归属和时间源一定正确。

本文构造一个可审阅的演示工程 ChronoRecon,页面名为 FrameAdmissionPage。任务 ID 固定为 RECON-TS-0210,会话从 recon_t61 切换到 recon_t62。演示数据不是声称来自真实设备跑分,而是用来说明门禁行为的确定性样本:共观察 180 帧,接收 174 帧,拒绝重复 3 帧、倒退 2 帧、旧批次迟到 1 帧;诊断页显示重建进度 68%,状态最终回到 CAPTURING。

一、格式合法,不等于时间合法

空间重建的数据帧携带焦距、主点、畸变参数、图像尺寸、相机位置、四元数、时间戳和图像地址。这里最容易被低估的是 timestamp。它不是给日志看的装饰字段,而是把图像与相机姿态放回同一采集时刻的关键证据。

如果相机回调、位姿回调和格式转换位于不同线程,一帧完成封装的先后顺序不一定等于捕获顺序。比如帧 126 比帧 125 更早完成颜色转换,业务队列若按“完成即推送”处理,时间戳就可能从 8,412,066,000 ns 回退到 8,379,066,000 ns。两帧内容都非空,宽高也正确,但输入序列已经不再单调。

这类问题还有两个变体。第一个是重复:上游重试机制可能把同一时间戳、同一图像缓冲再次送达。第二个是跨批次迟到:页面重新开始采集后,旧会话的最后一个异步任务才完成。如果只比较时间戳,旧帧甚至可能比新会话首帧更大,从而穿过单调判断。

因此门禁至少要回答四个问题:帧属于哪个采集批次;时间戳是否大于零;同一批次内是否严格递增;通过判断后,图像缓冲的所有权何时转移。只有四个答案同时明确,PushFrame 才是提交点,而不是试错点。

二、先把状态写成可以拒绝的合同

演示页使用五段状态:CAPTURING、CLOCK_DRIFT、DRAINING、SESSION_REBUILT、CAPTURING。发现时间倒退后不继续“赌下一帧恢复”,而是进入 CLOCK_DRIFT,冻结当前批次的提交权;随后排空已封装但未提交的数据,销毁旧会话,增加 generation,再创建 recon_t62。

这里要区分“时间戳轻微抖动”和“顺序倒退”。时间戳来自采集时刻,不应该因为处理线程抖动而改变。演示规则使用严格递增,不设置把倒退帧改写成 last + 1 的容错。改写会制造一条从未发生过的采集时间线,还可能让图像与位姿失去原始对应关系。检测到 deltaNs <= 0 时,正确动作是拒绝并记录原值。

第一段代码解决的是纯 C++ 入口门禁。它不封装任何未公开的系统能力,只在调用官方 HMS_SpatialRecon_PushFrame 之前检查应用自己的批次和单调性。

enum class RejectReason {
  NONE, INVALID_TIMESTAMP, DUPLICATE_TIMESTAMP,
  CLOCK_ROLLBACK, STALE_GENERATION
};

struct FrameEnvelope {
  uint64_t generation;
  int64_t timestampNs;
  HMS_SpatialRecon_DataFrame frame;
};

struct GateDecision {
  bool accepted;
  RejectReason reason;
  int64_t deltaNs;
};

class MonotonicFrameGate {
public:
  explicit MonotonicFrameGate(uint64_t generation)
    : generation_(generation), lastTimestampNs_(0) {}

  GateDecision inspect(const FrameEnvelope& input) {
    std::lock_guard<std::mutex> guard(lock_);
    if (input.generation != generation_) {
      return {false, RejectReason::STALE_GENERATION, 0};
    }
    if (input.timestampNs <= 0) {
      return {false, RejectReason::INVALID_TIMESTAMP, 0};
    }
    const int64_t delta = input.timestampNs - lastTimestampNs_;
    if (lastTimestampNs_ != 0 && delta == 0) {
      return {false, RejectReason::DUPLICATE_TIMESTAMP, delta};
    }
    if (lastTimestampNs_ != 0 && delta < 0) {
      return {false, RejectReason::CLOCK_ROLLBACK, delta};
    }
    lastTimestampNs_ = input.timestampNs;
    return {true, RejectReason::NONE, delta};
  }

private:
  std::mutex lock_;
  uint64_t generation_;
  int64_t lastTimestampNs_;
};

lastTimestampNs_ 只能在接受分支更新。若先赋值再判断,倒退帧会把基准拖回过去,后续本来重复的帧反而可能被接受。锁的作用也不是追求并发吞吐,而是把“比较”和“更新”变成一个原子决策;两个线程同时读到旧值时,不会双双通过。

FrameEnvelope 里的 generation 是应用字段,不是 Spatial Recon Kit 参数。它解决的是会话身份,而不是时间精度。recon_t61 的 generation 为 61,重建后变为 62。即使旧帧的时间戳比新批次更大,只要 generation 不等于 62,就会被归类为 STALE_GENERATION。

三、提交点必须同时管理缓冲所有权

只有检查还不够。imageData 是原始像素地址,应用需要保证调用期间数据有效,也要避免拒绝分支和成功分支重复释放。工程上可把缓冲保存在拥有明确析构行为的对象中,在真正调用 PushFrame 时才暴露指针。

第二段代码把门禁、官方推帧接口和统计放在同一条串行提交路径。示例中的 OwnedRgbFrame、AdmissionStats 都是项目自建类型,避免把应用封装误写成系统 API。

HMS_SpatialReconStatus ReconIngress::submit(OwnedRgbFrame input) {
  FrameEnvelope envelope {
    .generation = input.generation(),
    .timestampNs = input.timestampNs(),
    .frame = input.toDataFrame()
  };

  const GateDecision decision = gate_.inspect(envelope);
  stats_.observed++;
  if (!decision.accepted) {
    stats_.recordReject(decision.reason, decision.deltaNs);
    // input 在当前作用域析构,拒绝分支不会交出缓冲所有权。
    return SPATIAL_RECON_STATUS_INVALID_FRAME_DATA;
  }

  HMS_SpatialReconStatus status =
    HMS_SpatialRecon_PushFrame(session_, &envelope.frame);
  if (status == SPATIAL_RECON_STATUS_SUCCESS) {
    stats_.accepted++;
    input.markConsumed();
  } else {
    stats_.pushFailed++;
  }
  return status;
}

这里返回 SPATIAL_RECON_STATUS_INVALID_FRAME_DATA 只是为了让演示入口保持同一返回类型,真实项目更适合定义独立的应用错误码,避免把“业务门禁拒绝”误认为“系统接口判定失败”。日志中应同时保存 reason、generation、原始 timestampNs、lastTimestampNs 和 deltaNs,而不是只输出一句“push failed”。

演示中的关键倒退样本是 delta=-33,000,000 ns,也就是 -33 ms。重复帧的 delta=0。旧批次迟到帧来自 generation 61,而当前门禁已进入 62。三类拒绝要分开统计,因为修复方向不同:重复通常查上游重试;倒退查异步排序和时间源;旧批次查任务取消与生命周期。

上图是与正文数据一致的开发演示图,并非真实 DevEco Studio 运行证据。左侧工程树包含 FrameAdmissionPage.ets、MonotonicFrameGate.cpp 和 ReconIngress.cpp;中间代码停在 deltaNs < 0 的拒绝分支;右侧模拟器显示任务 RECON-TS-0210、进度 68%;底部 HiLog 依次记录 CLOCK_ROLLBACK -33000000ns、generation 61→62 与 stale frame dropped。

四、发现倒退后,不要在原会话里悄悄清零

一种看似省事的修复是检测到倒退后把 lastTimestampNs_ 清零,然后继续向同一会话推帧。这样做会把两段时间线拼成一个输入批次:系统会话仍然保存前半段内容,应用却把后半段当成新起点。诊断页也无法解释模型异常究竟发生在重建算法还是应用输入。

更稳妥的动作是封存当前批次。演示规则为:一旦出现 CLOCK_ROLLBACK,立即停止接受新帧;等待已经进入串行队列的任务完成;销毁旧会话;增加 generation;创建新会话与新门禁;最后恢复采集。销毁和重建要成对出现,不能只换 gate 不换 session,也不能只换 session 而继续接受旧 generation。

第三段代码在 ArkTS 侧组织诊断状态。Native 层回传的是应用自定义事件,不冒充系统回调。页面只提交与当前 generation 一致的快照。

type ReconUiState = 'CAPTURING' | 'CLOCK_DRIFT' |
  'DRAINING' | 'SESSION_REBUILT';

interface AdmissionSnapshot {
  generation: number;
  sessionId: string;
  progress: number;
  observed: number;
  accepted: number;
  duplicate: number;
  rollback: number;
  stale: number;
  lastDeltaNs: number;
}

@ObservedV2
class FrameAdmissionStore {
  @Trace state: ReconUiState = 'CAPTURING';
  @Trace snapshot: AdmissionSnapshot = {
    generation: 61, sessionId: 'recon_t61', progress: 68,
    observed: 180, accepted: 174,
    duplicate: 3, rollback: 2, stale: 1,
    lastDeltaNs: -33000000
  };

  applyNativeSnapshot(next: AdmissionSnapshot): void {
    if (next.generation !== this.snapshot.generation) {
      console.info(`drop ui snapshot generation=${next.generation}`);
      return;
    }
    this.snapshot = next;
  }

  beginRebuild(): void {
    this.state = 'DRAINING';
    this.snapshot = { ...this.snapshot,
      generation: 62, sessionId: 'recon_t62' };
  }
}

示例使用 @ObservedV2 和 @Trace 管理页面可观察数据;如果项目仍使用其他状态管理方案,核心原则不变:状态快照本身必须携带 generation,页面不能依赖“最后回调自然就是最新回调”的假设。

五、把 180 帧拆成一张可解释的账

演示主页面在 21:18 显示任务 RECON-TS-0210。状态栏中的电量为 68%,页面进度也是 68%,但两者语义完全不同:前者是系统状态栏演示值,后者是任务诊断值。页面主体显示会话 recon_t62、generation 62、已观察 180、已接收 174,并给出“继续采集”按钮。

这张运行图的重点不是证明某台设备跑出了固定吞吐,而是把状态合同放到可核对界面中。用户看到的不是模糊的“质量异常”,而是明确的 174 / 180 accepted,以及 duplicate 3、rollback 2、stale 1。当状态重新回到 CAPTURING 时,页面还要显示当前会话已经是 recon_t62,避免误以为旧会话被原地修复。

诊断页则把六个拒绝样本按原因列出,并展示一次完整转换:

CAPTURING → CLOCK_DRIFT → DRAINING → SESSION_REBUILT → CAPTURING

其中 CLOCK_DRIFT 不是系统定义状态,而是应用对输入时间合同破坏的命名。它有两个好处:一是 HiLog 可以按状态筛选;二是 UI、Native 层和测试用例可以使用同一词汇。没有统一词汇时,页面叫“重试中”,Native 层叫“invalid frame”,脚本又叫“time reset”,最后很难对齐证据。

图中的红色细圈只标出三项:-33 ms 倒退、61→62 的批次切换、旧帧拒绝 1 次。它们分别对应时间、身份和生命周期三个边界。其他数据保持普通文本,避免把诊断图做成到处都是箭头的宣传海报。

六、测试不只喂一个倒退样本

门禁单元测试至少应覆盖八组输入:首帧正时间戳;正常递增;连续重复;小幅倒退;大幅倒退;零时间戳;负时间戳;旧 generation。并发测试还要让两个线程用相邻时间戳同时进入,确认比较和更新不会分裂。

会话测试则关注成对处理:进入 DRAINING 后不再接受新帧;旧队列完全退出后只销毁一次;generation 只增加一次;新会话创建成功后才恢复 CAPTURING;如果创建失败,应进入显式错误态而不是把状态提前写成成功。

还要验证资源边界。被拒绝的 OwnedRgbFrame 必须在本地释放;成功推送后的缓冲必须遵循接口所需的有效期;页面消失时要先停止采集源,再等待提交队列,最后销毁会话。顺序颠倒可能让正在执行的 PushFrame 持有失效 session 或像素地址。

性能方面,不要用“先把 180 帧全部排序”替代在线门禁。全量排序会掩盖上游乱序,并增加内存占用。入口应该拒绝破坏合同的帧;离线回放工具可以额外按时间排序,用于定位问题,但不能把修复后的序列当成生产输入证据。

七、能力边界与落地取舍

本文依据 2026 年 8 月更新的 Spatial Recon Kit C API 资料:HMS_SpatialRecon_DataFrame 明确包含纳秒时间戳,HMS_SpatialRecon_PushFrame 用于向会话推送数据帧。本文的 MonotonicFrameGate、generation、拒绝原因和诊断状态均是应用层设计,不是 HarmonyOS 新增接口。

严格单调门禁也不是所有媒体管线的通用答案。如果上游协议明确允许一个有限乱序窗口,应用可以先使用有界重排缓冲,再把排好序的结果送入提交队列;但窗口大小、超时策略和丢帧规则必须写进合同。对于重建输入,任意改写捕获时间戳通常比拒绝更危险。

最终判断可以压缩成一句话:PushFrame 的成功返回只说明这一调用被接口接受,不能替应用证明整段采集时间线可信。把时间戳、generation、会话生命周期和缓冲所有权放进同一个提交边界,才有资格把 68% 这样的进度解释为“当前批次的进度”。

参考资料:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐