fix(studio-3d): 字段级来源与失效状态 —— 旧光流/算法失败不再冒充实时测量

- What:
  1. **采集侧声明每个派生数组的归属**(`acquisition/models.py`):`SdkPacket` 新增
     `flow_valid/flow_frame_id/depth_valid/depth_frame_id/forces_valid`(`None` = 未声明)。两个真实
     生产者都填写:`finger_worker`/`sdk_worker` 逐帧记录"该数组实际算出来的 frame_id",链失败时
     `*_valid=False` 并把上一次成功帧号带出来。光流与形变链分开判定(力读取失败不等于光流失败),
     但算法链失败时本帧的一切派生量都不可声明。
  2. **adapter 按声明收数据**(`scene3d/adapters.py`):`_field_validity()` 三态——未声明 → 拒绝并报
     `field_unverified`(没声明就不算数);声明为假 → `field_stale`(把 `invalid_reason` 带进诊断);
    声明帧号不是本帧 → `field_stale`(两个帧号都写进 details)。`_intersect_device_mask` 换成
     `_device_mask`:设备 mask 形状不符**拒绝该帧**(`field_domain_mismatch`,两个形状都写进诊断),
     既不拉伸也不静默忽略。发布的帧带 `computed_frame_id`,身份一路带到显示侧。
  3. **契约**(`scene3d/contracts.py`):`SceneState` 加 `"stale"`;诊断码加 `field_stale`/`field_unverified`
     (warning);`SurfaceFieldFrame.computed_frame_id` 与 `RenderFrame.computed_frame_id`(由 composer
     原样透传);`validate_surface_frame` 校验帧号非负且不晚于所属帧。新增 locale:
     `scene3d.state_stale`(中英各一条)。
  4. **协调器不再"边失败边报 live"**(`scene3d/coordinator.py`):新增 `_stale_since` 宽限状态机与
     `note_field_failure()`——字段被拒、帧被判 invalid、compose 抛错、或合成结果的 `computed_frame_id`
     落后于本帧,都立即把状态改为 `stale` 并保留最后一帧;宽限(`stale_grace_s`,默认 1.0 s)到期后
     回参考模型并转 `no_data`;收到好帧即回 `live` 并清掉宽限。顺带修掉"等待首帧时每个 tick 都把参考
     模型重新 present 一次"(`_rest_presented`,16 ms 一次的无谓上传)。
  5. **诊断真正到达界面**(`scene3d/view.py`):`submit_packet()` 在 adapter 拒绝时把
     `adapter.last_diagnostic` 交给 `note_field_failure()`;`tick()` 把协调器诊断的变化经已有
     `diagnosticRaised` 冒泡(协调器自身仍然零 Qt 依赖)。新增 `_snapshot_diagnostic()` 与 `_adapter`
     引用(后续"按能力禁用控件"也要问它)。
- Why: 审查报告 P1/R2:`FingerFrameAdapter.snapshot()` 从不读 `valid`/`stale_forces`/`invalid_reason`,
  只看到有限的 `flow` 就当作新数据上色(复现:`valid=False, stale_forces=True` + 有限 flow → 仍有有效
  着色点、diagnostic=None);compose 抛错只写 `last_diagnostic`,画面与 `state` 保持不变;
  `snapshot()` 返回 None 时诊断无人读取;mask 域不符时静默忽略会扩大可信范围。力读数失败不该机械地
  判死三维,但算法失败也不能因为旧数组是有限值就当成新数据——所以按**字段级来源**处理,并让失效有
  明确的、可见的状态而不是继续保持 live。
- Verification:
  · `test_scene3d_adapters.py` 23 通过(+6 新增):stale flow 被拒且带原因、未声明 flow → `field_unverified`、
  帧号不符 → `field_stale`(details 两个帧号)、新帧记录 `computed_frame_id`、box 侧"形变链失败仍用光流"、
  box 全部字段不可声明时拒绝;mask 域不符由"被忽略"改为"拒绝该帧"(原断言已改)。
  · `test_scene3d_coordinator.py` 30 通过(+6 新增):落后帧号的合成结果永不上屏且状态 stale、
  字段被拒 → 宽限内保留画面/宽限后回参考模型/好帧回 live、compose 抛错不再保持 live、invalid 帧也计入
  宽限。
  · `test_scene3d_view.py`(+3 新增,offscreen 可跑):adapter 拒绝 → 协调器 stale 且诊断经
  `diagnosticRaised` 回到面板、`scene3d.state_stale` 不是裸键;未声明字段 → `field_unverified`;
  新鲜字段照常上屏回到 live。
  · 全量:apps/studio 228 项 0 失败 14 跳过;shared 301 项 0 失败 1 跳过(`--junitxml` 计数)。
- Not in this change: `docs/architecture/studio-3d-contracts.pyi` 与新契约的同步放在阶段 6 的文档提交里
  一起做(本阶段契约改动较多,避免每提交改一次文档);设备几何身份(R3)、资产加载请求号(R7)、
  二维后端统一、限速与真实统计均未动;真机上"链失败时 VRAW 是否也过期"这一条仍需实测(若证明链失败
  不影响 VRAW,`flow_valid` 放宽为只看读取结果是一行改动,已写进计划的风险项)。
This commit is contained in:
ykcheungab
2026-09-14 21:03:07 +08:00
parent d06e116c15
commit 7d1fca4c93
15 changed files with 721 additions and 38 deletions
@@ -418,6 +418,10 @@ class FingerAcquisitionWorker(RecordSinkMixin):
# Per-session monotonic frame index: the join key shared by the video,
# the CSV and the calibration records.
frame_id = 0
#: The last frame each derived array was *actually* produced for, so a stale
#: array can say which frame it belongs to instead of being read as new data.
last_flow_frame_id: Optional[int] = None
last_depth_frame_id: Optional[int] = None
#: When this session's loop started, for the file-source deadline below.
session_started = time.perf_counter()
@@ -519,6 +523,22 @@ class FingerAcquisitionWorker(RecordSinkMixin):
raw_frame = _ensure_bgr(source_frame)
# The frame the tactile chain consumed, kept for depth replay.
self.note_preroll(raw_frame)
# Provenance for anything that colours a derived array (the 3D pane):
# when the chain failed, the arrays this frame carries may still be the
# previous frame's, so they are not this frame's measurement — a
# consumer must not treat finite values as fresh ones.
flow = forces["flow"]
depth = (
forces.get("depth")
if forces.get("depth") is not None
else chain_depth
)
flow_valid = (flow is not None) and not compute_error
if flow_valid:
last_flow_frame_id = frame_id
depth_valid = (depth is not None) and not compute_error
if depth_valid:
last_depth_frame_id = frame_id
packet = SdkPacket(
raw_frame=raw_frame,
processed_frame=_ensure_bgr(unrolled),
@@ -545,13 +565,14 @@ class FingerAcquisitionWorker(RecordSinkMixin):
# place; the app must not record them as this frame's data.
stale_forces=bool(compute_error),
raw_available=bool(forces.get("raw_available", True)),
flow=forces["flow"],
depth=(
forces.get("depth")
if forces.get("depth") is not None
else chain_depth
),
flow=flow,
depth=depth,
valid_mask=runtime.valid_mask,
flow_valid=flow_valid,
flow_frame_id=frame_id if flow_valid else last_flow_frame_id,
depth_valid=depth_valid,
depth_frame_id=frame_id if depth_valid else last_depth_frame_id,
forces_valid=bool(forces.get("valid", True)) and not compute_error,
)
with self._lock:
self._latest = packet
@@ -86,6 +86,23 @@ class SdkPacket:
flow: Optional[np.ndarray] = None
depth: Optional[np.ndarray] = None
valid_mask: Optional[np.ndarray] = None
# --- per-field provenance -------------------------------------------------
# The 3D view colours *one* array, and "the readout failed" is not the same
# statement as "the tracker's field is stale". ``valid`` above is about the force
# channels (and about the whole frame's record value); these say which arrays this
# frame may claim. ``None`` means the producer made no statement, and a consumer
# must not invent one: an unstated field is unverified, not fresh.
#: Whether ``flow`` was produced for this frame's tracker pass.
flow_valid: Optional[bool] = None
#: The frame id ``flow`` belongs to, when it is not this frame's.
flow_frame_id: Optional[int] = None
#: Whether ``depth`` is this frame's deformation output (a failed chain leaves the
#: previous frame's depth in place).
depth_valid: Optional[bool] = None
depth_frame_id: Optional[int] = None
#: Whether the force triplet belongs to this frame (False when the chain or the
#: readout failed, whatever ``valid`` says about the record).
forces_valid: Optional[bool] = None
def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode:
@@ -857,6 +857,10 @@ class SdkAcquisitionWorker(RecordSinkMixin):
# Per-session monotonic frame index: the join key shared by the video,
# the CSV and the calibration records.
frame_id = 0
#: The last frame each derived array was *actually* produced for (see the
#: finger worker: a stale array must name the frame it belongs to).
last_flow_frame_id: Optional[int] = None
last_depth_frame_id: Optional[int] = None
#: When this session's loop started, for the file-source deadline below.
session_started = time.perf_counter()
@@ -1003,6 +1007,17 @@ class SdkAcquisitionWorker(RecordSinkMixin):
# The frame the chain above actually consumed, kept as the
# history an offline depth replay needs.
self.note_preroll(raw_frame)
# Per-field provenance for anything that colours a derived array.
flow = forces["flow"]
depth = (
forces.get("depth") if forces.get("depth") is not None else chain_depth
)
flow_valid = (flow is not None) and not compute_error
if flow_valid:
last_flow_frame_id = frame_id
depth_valid = (depth is not None) and not compute_error
if depth_valid:
last_depth_frame_id = frame_id
packet = SdkPacket(
raw_frame=raw_frame,
processed_frame=_ensure_bgr(processed),
@@ -1040,8 +1055,13 @@ class SdkAcquisitionWorker(RecordSinkMixin):
# place; the app must not record them as this frame's data.
stale_forces=bool(compute_error),
raw_available=bool(forces.get("raw_available", True)),
flow=forces["flow"],
depth=forces.get("depth") if forces.get("depth") is not None else chain_depth,
flow=flow,
depth=depth,
flow_valid=flow_valid,
flow_frame_id=frame_id if flow_valid else last_flow_frame_id,
depth_valid=depth_valid,
depth_frame_id=frame_id if depth_valid else last_depth_frame_id,
forces_valid=bool(forces.get("valid", True)) and not compute_error,
)
with self._lock:
self._latest = packet
@@ -133,6 +133,9 @@ class SceneComposer:
valid=fields.valid,
scalar_descriptor=frame.scalar_descriptor,
diagnostics=tuple(diagnostics),
# Carried through untouched: only the display side knows whether the frame
# it is about to show is still the current one.
computed_frame_id=frame.computed_frame_id,
)
validate_render_frame(result, vertices=int(rest_positions.shape[0]))
self.last_stats.total_ms = (time.perf_counter() - started) * 1000.0
@@ -37,7 +37,7 @@ Quality = Literal["calibrated", "approximate", "display_only"]
Profile = Literal["desktop33", "gles30"]
OfferResult = Literal["accepted", "replaced", "stale", "disabled", "invalid"]
SceneState = Literal[
"disabled", "loading", "ready", "live", "no_data", "suspended", "unavailable"
"disabled", "loading", "ready", "live", "no_data", "suspended", "unavailable", "stale"
]
Severity = Literal["info", "warning", "error"]
@@ -54,6 +54,13 @@ DIAGNOSTIC_SEVERITY: Mapping[str, Severity] = {
"geometry_mismatch": "error",
"field_domain_mismatch": "error",
"field_unavailable": "warning",
#: The field a frame carries is not this frame's measurement: a failed chain
#: left the previous frame's array in place. A warning, because the fix is to
#: show *why* the picture stopped being current, not to abort the session.
"field_stale": "warning",
#: A producer made no statement about where its array came from. The consumer
#: must not assume it is fresh (see ``SdkPacket.flow_valid``).
"field_unverified": "warning",
"stale_frame": "info",
"gl_unsupported": "error",
"shader_compile_failed": "error",
@@ -168,6 +175,11 @@ class SurfaceFieldFrame:
displacement_sensor_m: Optional[F32] = None # [H,W,3], before display gain
displacement_quality: Optional[Quality] = None
diagnostic: Optional[Diagnostic] = None
#: The acquisition frame the scalar array was actually computed for, when the
#: producer can say so. ``None`` means "unstated" (the frame's own identity is the
#: only claim). When it is older than ``identity.frame_id`` the array is a stale
#: measurement: a consumer must not colour it as the current instant.
computed_frame_id: Optional[int] = None
# -------------------------------------------------------------------- assets
@@ -511,6 +523,9 @@ class RenderFrame:
valid: Mask # [N]
scalar_descriptor: FieldDescriptor
diagnostics: tuple[Diagnostic, ...] = ()
#: The acquisition frame the coloured values belong to, carried through from the
#: field frame so the *display* side can re-check it before presenting.
computed_frame_id: Optional[int] = None
@dataclass(frozen=True)
@@ -683,6 +698,23 @@ def validate_surface_frame(frame: SurfaceFieldFrame) -> tuple[Diagnostic, ...]:
)
if not frame.scalar_descriptor.unit.strip():
out.append(diagnostic("field_unavailable", "the scalar has no unit"))
if frame.computed_frame_id is not None:
try:
computed = int(frame.computed_frame_id)
except (TypeError, ValueError):
raise error(
"field_stale", "computed_frame_id must be an integer frame id"
) from None
if computed < 0:
raise error("field_stale", "computed_frame_id must not be negative")
if computed > int(frame.identity.frame_id):
# An array cannot have been computed for a frame that has not happened.
raise error(
"field_stale",
"computed_frame_id is newer than the frame it is attached to",
computed=str(computed),
frame=str(frame.identity.frame_id),
)
return tuple(out)