Files
orisys-app/apps/studio/tools
ykcheungab 7d1fca4c93 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` 放宽为只看读取结果是一行改动,已写进计划的风险项)。
2026-09-14 21:03:07 +08:00
..
按"硬件问题、配置不变"收尾:记录里写下实测帧率,探针加逐颗验收模式 - What: worker 把本会话实测到的采集速率记进 self._measured_capture_fps(box 与 finger 两条循环都算:finger 的帧率也从"1 秒窗口内计数"改成 (n-1)/span 的速率,两条视图对 fps 的含义必须一致),capture_status() 因此多出 measured_fps —— session.json 的 capture 块现在同时有 requested / negotiated / measured_fps / dropped_frames,一颗摄像头到底交付了多少 fps 由记录自证。status.capture_slow 的中英文案改写:删掉已被实测否证的"降低分辨率/换格式"建议,改成"这是采集端(摄像头/驱动)的问题、软件改不了,换一颗真能交付 30 fps 的(并先确认线材/端口没把它降速)再测"。tools/camera_probe.py 新增 --check 验收模式:只测 bundle 用的那一个格式(MJPG 1920x1080@30,120 帧),按**实测**速率打印一行 PASS/FAIL 并给出退出码,用于逐颗过检;无摄像头时报 "No camera answered on index N (is the app closed?)" 并返回 1。tests:假设备用上 capture_status() 的 measured_fps(0.05 s 粒度等它出现,断言 20–35 fps)、探针 --check 的无摄像头路径。 - Why: 用户判定这是驱动/硬件问题(同型号产品此前能跑 30 fps),决定**配置不变**、以后用能到 30 fps 的产品,并在之后逐颗检测。配置不变意味着应用继续按 30 fps 申请(这是对的:申请 15 只会把问题上移),但由此产生两件必须收尾的事:(1) 提示语里"降低分辨率/换格式"是错的建议——探针已经证明 720p MJPG 一样是 15 fps、YUYV 只有 1 fps;(2) 既然要逐颗检测,就必须有一个能回答"这一颗交付了多少"的权威数字,而驱动自报值不可信(DSHOW 回读),所以把实测值写进记录并给探针一个单行验收模式。 - Verification: shared 全量 161 passed;studio 91 passed;ui_check 554 项检查 0 失败;locale_check 中英键集一致;探针 --check 在无摄像头时打印指定文案并返回 1,--help 列出该选项。 - Not in this change: 未改任何采集配置(resolution_raw/fps/fourcc 都不动);未做自动降级;frames.csv 仍未记录 process_ms/capture_fps(留到加未滤波 17 列、记录格式升版时一起)。
2026-09-14 13:48:08 +08:00
采集循环只认新帧,选区统计移到数值卡,状态栏改为会过期的通知行 - What: 1) 选区统计不再挤在曲线下方(中文长句会顶出面板右边界)——框选后三个数值卡的大号数字变成该窗口的峰值、下方小字为该窗口的均值·σ(chart.selection_detail),峰值语义放在卡片 tooltip;曲线下方只保留「选区 4.00s / 121 点」(完整一行留在它的 tooltip 里)。「全程总览」不算选区(那是默认适配),此时卡片回到"游标处样本";切换单位/换数据源/返回最新都会正确切回。2) 上方的 63.0 FPS 是采集循环自己的轮询速率,根因是同一帧被反复当成新样本:CaptureSource.read_latest 返回后台线程的最新帧,没有新帧时把同一帧再给一次,循环未做判断,于是 30 fps 的设备录出约 63 行/秒(frame_id 还各不相同)。新增 _capture_sequence():读 capture_source.status.last_seq,计数未变即"还没有新帧",睡 2 ms 重试、不进处理链、不发 packet;只有带后台读取线程的实时相机有该标记,视频文件是同步解码、每次都取下一帧,因此忽略(行为不变)。packet.fps 改为速率 (窗口帧数-1)/(首末到达差),窗口 1 s——按"1 秒内到达几帧"数数会系统性偏大。容器速率仍取传感器配置值,与此层无关。3) 底部状态栏只留一次性通知:删除右下角常驻「配置 sensor.json」(顶栏摘要按钮已写明),删除与顶栏重复的 status.run_started(运行中(0))与 status.connecting(正在连接…);QStatusBar 换成 _NoticeBar——Qt 的 showMessage(text) 默认 msecs=0 表示永不消失,这正是那句「运行中(0)」一直挂到底部的原因,子类统一补 6 秒超时;status.units_raw_fallback 之前每帧都发(等于永久占住通知行),改为每次运行只提示一次。附带修复:英文下表头模式名被截断(长句移入 tooltip,标签只留模式名 chart.mode_*_tip);数据源切换后曲线下方的选区文案会残留,改为在 _on_source_changed 里清零。 - Why: 用户评审 3 点——选区统计显示不全、顶部 fps 应受硬件限制最大 30、右下角配置文件名与左下角「运行中(0)」和顶栏信息重复。第 2 点查出根因是重复采样(数据缺陷,不只是显示问题),修掉后设备速率与记录行数才真正一致。 - Verification: ui_check.py 546 项 0 失败(新增:选区统计在卡片、卡片小字不裁切、状态栏无常驻标签且消息过期、顶栏 FPS 即 packet 交付速率、表头模式名中英不裁切);test_ui_smoke.py 71 项 0 失败(新增选区读数与通知行过期两例);drive_shared.py 148 项 0 失败(新增 shared/tests/test_worker_frame_rate.py:同一帧返回 3 次时 packet 数≈交付帧数,去掉判据为 11317:39,且 fps 与交付速率一致);locale_studio.py 267 键齐全、zh==en、prefs 对齐;reproduce.py 退出 0;ui_snapshot.py 新增 --select A,B,六张复核图(选区中英、实时、录制中、回放、标定 1280x720)。 - Not in this change: 录制文件格式与 CSV 列序不变;去重只影响"哪些帧算一帧",视频文件源行为不变;曲线下方不再显示三通道峰/均/σ(数值在卡片上,完整一行在 tooltip 里)。
2026-09-13 21:19:23 +08:00