Commit Graph

54 Commits

Author SHA1 Message Date
ykcheungab ccedb9ddd9 migrate pyqt5 to pyside6 2026-09-21 09:34:56 +08:00
ykcheungab e276b5de0f autocal layout first design 2026-09-20 21:15:39 +08:00
ykcheungab c538dbf074 fixed label under 3d view 2026-09-15 21:50:37 +08:00
ykcheungab c2293d4d41 界面:三维可选「深度响应 / 光流幅值」上色(默认深度),并加一个「诊断信息」开关收拾面板脚下的文字
- What(颜色来源): `DisplaySettings` 增 `field`(`"depth"` 默认 / `"flow"`),随显示设置持久化
  (`scene3d/prefs.py`:新增 `FIELD_CHOICES`/`DEFAULT_FIELD`,手改文件里的非法值修回默认)。
  `FingerFrameAdapter` 按所选字段先试、失败再用另一个,并把"换了量"这件事记在
  `last_substitution`(新诊断码 `field_substituted`,info 级);两个字段都拿不到时,
  `last_diagnostic` 取**信息量最大**的那条(域不符/过期/未声明 > 没这个字段),而不是"所选字段"的
  那条空话。设置对话框新增「颜色来源」下拉,下面一行 `field_note` 直接写"这一帧画的是 X,不是所选的 Y";
  图例(`legend_label`)永远标注**实际**画出来的量,所以设定与画面不一致时两处都能看出来。
  深度响应 `[1]`、光流幅值 `[px]`,两者都是 `approximate`——代码与文案都不把它们说成毫米。
- What(诊断信息开关): 视图菜单新增 `诊断信息`(可勾选、持久化 `show_diagnostics`、**默认关**,
  由 `DisplayPrefsController` 持有并推给两个面板)。关掉后:2D 面板只留"画面 + 计划说明 + 深度色标",
  三条解释流水线的标签(箭头按位移放大、原始图像 → 算法输出、全部帧)隐藏;3D 面板只留
  色条 + 一行图例 + 来源,info 级诊断隐藏。**永不隐藏**:warning/error 级诊断(测量是否过期、
  字段是否本帧的、是否与设备核对过——`geometry_unverified` 虽然是 info 也白名单保留)与
  深度色标。3D 图例改成 `ShrinkableLabel`(一行、裁切 + tooltip,不再折行),色标数值从 4 位有效数字
  收到 3 位("0.0334 – 0.899")。设置对话框控件宽度 88 → 108 px(不再需要迁就 240 px 面板)。
- What(取景脚本): `tools/ui_snapshot.py --scene flow3d` 现在同时给 flow 与 depth(各带 provenance),
  所以「颜色来源」开关有东西可切;再加 `.repro/shot_variants.py` 一次出四张图
  (深度版 / 光流版 / 诊断开 / 设置对话框)。
- What(测试): `test_scene3d_adapters.py` 增 3 项(默认深度、换量时给出 `field_substituted` 并在恢复后
  清掉、depth 不在场景栅格时按 `field_domain_mismatch` 拒绝而不是缩放);`test_scene3d_view.py` 增
  1 项(field 的持久化与修复,并把 `field in FIELD_CHOICES` 加进"手改文件会被修复"的参数化);
  `test_scene3d_pane_ui.py` 增 3 项(两种量可选 + 说明行 + 恢复的偏好会移动下拉;诊断开关只隐藏 info、
  保留 warning 与"未核对";2D 脚注三标签的显隐与窗口开关一致);`test_display_prefs.py` 增 1 项
  (开关推到两个面板、写偏好)。`test_ui_smoke.py` 与 `tools/ui_check.py` 里"箭头图例可见"的四条断言
  改为按新规则检查(默认隐藏、开诊断后可见、关掉再隐藏)。
- Why: ①"深度"比"光流"更贴近操作员口中的"压得多深",但两者都只是近似量,所以做成开关而不是替换——
  并且换量必须说出来(否则画面会被读成所选的量)。②面板脚下的解释文字是"内部管线"而不是"画面是什么",
  拍视频与日常看都只需要后者;用一个开关收起,比做一个滚动日志面板诚实(这些是每帧状态,不是事件)。
- Verification: pytest shared/tests apps/studio/tests(offscreen)658 passed / 0 failed / 15 skipped;
  真 GL 组(gl_smoke + view + pane_ui)70 passed / 0 failed / 1 skipped;
  `tools/ui_check.py` 590 项检查 0 失败;`.repro/locale_check.py` 0 缺失(新增 7 个 key 双语齐备)。
  目视验收:同一批合成数据渲染的四张图(深色主题)——深度版(默认)、光流版、诊断打开、设置对话框。
- Not in this change: 不改采集/记录/契约之外的三维渲染;不做页签容器与窄窗口回退;曲线量程仍是出厂 ±20 N。
  真机待验证:手指路径上 `packet.depth` 的栅格是否就是场景的 300×280(若不是,面板会写明并回退到光流)。
2026-09-15 21:23:18 +08:00
ykcheungab 0b87e3e36d 界面:三维显示设置对话框的关闭按钮说中文(Qt 自带翻译没有被安装)
- What: `Scene3dSettingsDialog.retranslate()` 把 `QDialogButtonBox` 的 Close 按钮文字设为
  `t("dialog.close")`;两个 locale 各加一个 close 键。
- Why: 应用没有安装 Qt 自带的翻译文件,于是按钮框在满屏中文里显示一个英文 Close;对话框是这个
  面板唯一的出口按钮,不该是外来语。
- Verification: `.repro/locale_check.py` 0 缺失(新键双语齐备);对话框截图目视确认
  (266x531,结尾按钮为「关闭」)。
- Not in this change: 不动其它对话框(它们用自己的按钮,本来就有中文文案)。
2026-09-15 20:57:21 +08:00
ykcheungab 650578d738 界面:三维面板只留画面与图例,全部配置进「三维显示设置」对话框;三维占比提为最宽
- What(隐藏配置): `ThreeDPanel` 把**所有设置**收进一个块
  (`controls_widget`:总开关、观察方向、显示外壳、放大形变+增益、色标模式与范围、显示帧率、
  复位按钮、显示统计),面板本身只保留读图需要的东西:色条 + 一行紧凑图例(数量/自动色标范围/
  来源)+ 状态行。新增 `scene3d/settings_dialog.py` 的 `Scene3dSettingsDialog`:非模态
  (调参时画面还在动),承载的就是面板那套控件本身(同一批 widget,信号通路不变),
  关闭只是隐藏(控件必须活着,下一次读设置还要用)。
  两个入口:视图菜单新增 `三维显示设置…`(`act_scene3d_settings` → `Mesh3dPanel.open_settings()`)
  与面板标题栏的齿轮;无三维可配时(box 传感器 / 无 GL / 无场景包)菜单项会把面板上的原因
  原样提示出来,而不是弹一个空对话框。标题栏另有「复位视角」图标(新画的 `reset_view` 图标,
  环形箭头,随主题着色)。
- What(提高占比): live/replay 预设把三维面板设为最宽:`VIEW_SPLITTER_SIZES` (620, 300, 520)、
  `DEFAULT_SPLITTER_SIZES` (620, 320, 500)、`DEFAULT_3D_PANE_WIDTH` 340 → 620、
  `MIN_PANEL_WIDTHS["mesh3d"]` 260 → 200(面板里没有表单要放了,画面才是重点)、
  `ViewLayoutController.apply_panel_visibility` 的拉伸权重 2 → 5(2D 3、曲线 4)。
  1440×880 实测:三维约 430 px(原约 280)、2D 约 265、曲线约 320。
- What(拍视频/评审用): `tools/ui_snapshot.py` 新增 `--scene flow3d`:驱动一批**带真实光流场**的
  packets(适配器只接受声明了 provenance 的字段),所以三维面板真的有颜色、曲线也真的在动;
  这是本次界面评审与演示视频的取景脚本。配 `--sensor finger --panels 3d --preset live`。
- What(测试): 新增 `apps/studio/tests/test_scene3d_pane_ui.py`(5 项):面板只有图例没有设置、
  对话框承载的是面板自己的控件且设置信号照旧、关闭只隐藏不销毁、标题栏两个动作可用、
  无场景时 `open_settings()` 返回 False、`reset_view` 转发、`reset_view` 图标两种主题都能画、
  视图菜单项能打开对话框(无 GL 时给原因)。`test_view_layout.py` 增一条"出厂 live 布局里
  三维最宽"的断言(并更正我自己那条 340 的旧期望)。
- 文档: `studio-3d-stages.md` 的「尚未完成」第 1 条(阶段 8 第二批)改写为"部分已做",
  写清已完成的部分与仍留后的四项(页签容器/窄窗口回退/曲线量程开关/独立诊断抽屉)。
- Why: 三维面板原来下半屏全是表单,画面被挤成一小块——拍视频要的是"三维是主角"。
  配置不是不需要,而是不该和画面抢地方;放进对话框后画面变大、参数反而更好找(一处看全)。
- Verification: pytest shared/tests apps/studio/tests(offscreen)650 passed / **2 failed** / 15 skipped,
  两个失败都是 `shared/tests/test_worker_frame_rate.py` 的**墙钟计时**用例(30 fps 设备被测成
  24–25 fps):已在 `d539ce9` 的干净 worktree 上复现同样的失败,属本机负载(DSH/Cursor/飞书 占 CPU)
  而非本次改动;真 GL 用例 `test_scene3d_gl_smoke` + `test_scene3d_view` 61 项 0 失败(1 跳过);
  `tools/ui_check.py` 587 项检查 0 失败;`.repro/locale_check.py` 0 缺失(新增 6 个 key 双语齐备)。
  目视验收:`--scene flow3d` 渲染的 1440×880 截图(拆分前后各一张)见提交说明附言。
- Not in this change: 不改三维渲染/契约/记录;不做页签容器与窄窗口回退;
  曲线量程仍是出厂 ±20 N(演示取景脚本会临时调窄,产品默认值另议)。
2026-09-15 20:55:33 +08:00
ykcheungab 81e39ca868 修复:设备的"清零已生效"确认从未被记录(worker 的 on_reset_applied 没有接线)
- What: `SessionController.ensure_workers` 现在把 `on_reset_applied=self.note_reset_applied`
  交给两个 worker,并在控制器上补回 `note_reset_applied()`(转发给记录通道的
  `recording.note_reset`)。窗口侧保留 `_on_reset_applied` 作为一层转发——tests 通过它
  模拟设备确认。
- Fix: 这是一个真实缺陷,不是重构副作用。请求路径一直是通的(`_on_reset` →
  worker.reset()),确认路径却从来没有人接:worker 的 `on_reset_applied` 默认 None,
  于是设备回报的 frame_id / mono_t 被丢掉,`recording.note_reset` 不会执行,
  `_drain_reset_events()` 也永远没有事件可发。后果是真机上按「清零」只会看到「指令已发出」,
  录制里不会留下 zeroing 事件(`session.json` 的 events 少一条),而 test_ui_smoke 里那条
  `test_a_zeroing_is_recorded_only_after_the_worker_confirms_it` 之所以一直是绿的,是因为
  它自己把 `stuck.on_reset_applied = window._on_reset_applied` 手工接上了——测试补上了
  产品缺的那根线。
- What: 新增 apps/studio/tests/test_session_controller.py:8 个用例直接驱动控制器
  (假 worker、假记录通道)——清零确认与标定确认都会到达记录、两个 worker 都挂上记录
  sink 与三维 sink、detach_sinks 会摘掉、一次运行从 connecting 到 running 到 idle 的
  相位与遥测(结束后旧遥测不可读、session id 递增)、connecting 时停止会被如实告知、
  慢连接提示只说一次、丢帧批次只报一次、采集率短少必须有实测值才判定且一会话只说一次。
- Why: 记录要说真话。清零在实验里是一个事件("这一刻起力被归零"),设备既然回报了
  它就应当进入记录;把接线补在控制器(A2 搬进来的那个所有者)而不是窗口里,也让
  "谁在跑、报了什么"这条链路只有一个所有者。
- Verification: pytest shared/tests apps/studio/tests(offscreen)644 passed / 0 failed /
  15 skipped(原 636 + 新增 8);apps/studio/tools/ui_check.py 587 项检查 0 失败;
  .repro/locale_check.py 0 缺失。真机上的验收仍未做(本机没有设备),
  已知边界:SDK worker 的 `_on_reset_applied` 只在 `sensor.reset()` 返回后调用,
  异常路径本来就不会报告确认——这一点没有改动。
- Not in this change: 不改变 `_on_reset` 的请求语义与状态栏文案;`_drain_reset_events`
  仍在窗口(它只是把事件变成一行状态)。
2026-09-15 09:58:45 +08:00
ykcheungab c62452ec4b 拆分收尾:标定归属规则移入 CalibrationService(app_window 2340→2304 行)
- What: `apps/studio/src/orisys_studio/calibration_service.py` 新增三条**无状态**规则,
  它们原来长在窗口里:`identity_of(path)`(这份系数文件声称属于哪台设备——用 SDK 自己的
  解析器读,与设备将要加载的是同一份文档;读不出来就什么都不声称)、`same_path(a, b)`
  (同一个配置路径的不同写法不算不一致;两个空值也不算)、
  `target_mismatch(path, *, sensor_type, config_ref)`(在确认框之前就说清楚"这份标定
  看起来不属于这台设备"的理由)。服务本来就是这个文件工作的所有者,规则回到了它这里,
  并且不需要窗口就能测。
- What: 窗口保留 `calibration_identity` / `calibration_target_mismatch` 两个转发
  (tests 与 tools/ui_check.py 通过窗口驱动它们),删除 `_same_path`。
  `_on_calibration_apply` 仍留在窗口:它要弹确认框、要把路径交给采集 worker,那是交互
  与会话,不是关于文件的规则;`_calibration_summary`、`_drain_calibration_events`、
  `_drain_reset_events`、`_on_calibration_point/cancelled/exported` 同理(面板与状态栏
  的胶水)。
- What: 新增 apps/studio/tests/test_calibration_service.py:4 个用例——身份读出
  sensor_type/sn/config_ref、读不出来的文件既无身份也无"不匹配"指控(不能把"不知道"
  当成"不一致")、另一台设备/另一份配置会被逐条报出而同一台不会、同一路径的不同写法
  不算不一致而非路径值算。
- Why: 这是 app_window 拆分计划里最后一块「有独立规则可搬」的部分。搬完之后
  "这份标定是谁的"只有一处实现,窗口只剩对话与转发。
- Verification: pytest shared/tests apps/studio/tests(offscreen)636 passed / 0 failed /
  15 skipped(原 632 + 新增 4);apps/studio/tools/ui_check.py 587 项检查 0 失败;
  .repro/locale_check.py 0 缺失。行为等价(同一份实现 + 一层转发)。
- Not in this change: 预览(_start_preview/_stop_preview/_on_preview_tick)与 2D 渲染
  (_render_run_image/_show_image_frame/_depth_range)仍留在窗口——它们是面板与相机的
  胶水,不属于任何一个已拆出的变更驱动;已知缺陷(本次未修,另行处理):worker 的
  on_reset_applied 从未接到 recording.note_reset,真机上按"清零"不会记录确认事件。
2026-09-15 09:54:04 +08:00
ykcheungab 0a42a452dd 拆分 C2:显示偏好移入 DisplayPrefsController(app_window 2347→2340 行 / 净减 110 行代码)
- What: 新增 apps/studio/src/orisys_studio/display_prefs.py:把「同一个测量值可以怎么
  显示」搬进 DisplayPrefsController——单位(newton / raw,含工具栏、曲线图、重算引擎、
  数值卡四处同步)、量程与时间窗(含默认值、应用到面板、供对话框读取)、箭头显示
  (按传感器决定旋转是否可用、图例倍率、旋转 90°)、2D 视图模式切换后的重绘、以及
  rail 上的帧率读数开关。控制器自己拥有 show_fps;其余偏好仍写在 preferences 里。
  `DisplayHost` Protocol 写明它向窗口借的五件事:_persist_prefs / _refresh_status_rail /
  _latest_fps(算滤波设计用的采样率)/ _drop_render_marker + _repaint_live_frame
  (2D 视图模式变了要重建那一帧)。
  _on_plot_settings *不搬*:它要开 QDialog、还要把滤波系数交给采集 worker,那是会话与
  窗口的事,不是显示偏好;它现在只向控制器取对话框要显示的值、把结果交回去应用。
- What: 窗口侧保留过渡 shim(_set_display_unit / _on_units_changed / _on_chart_unit_toggle /
  _on_image_view_mode_changed / _on_show_fps_toggled / _apply_plot_prefs_to_panel /
  _device_sample_rate / _apply_arrow_rotation_for_sensor / _apply_arrow_legend /
  _draw_style / _on_rotate_arrows,以及 _show_fps 可读写属性),供 tests 与
  tools/ui_check.py 继续驱动窗口(其中 window._on_plot_settings 仍可被测试整体替换)。
  窗口内部连线(toolbar.units_changed、arrow_panel.view_mode_changed、
  force_plots.unit_toggle_requested、act_show_fps.toggled、act_rotate_arrows.triggered)
  改为直连控制器;_sync_menu_checks 里的箭头部分也走控制器。
- What: 新增 apps/studio/tests/test_display_prefs.py:11 个用例——单位四处同步、
  工具栏改单位时不回写工具栏(防回声)、图表单位芯片翻转、对话框值取默认值、
  应用量程写偏好与面板、采样率优先用实测值再回落存储值与默认值、不可旋转的传感器
  不提供旋转且不写偏好、可旋转的重启后记住角度、图例倍率随 draw style 变化、
  切换 2D 模式只重绘一次、帧率开关同时更新偏好与 rail。
- Why: 「数字怎么显示」是 app_window 的第四个独立变更驱动:改一个显示偏好不该碰会话、
  回放或布局。搬完之后这条规则的唯一所有者在 display_prefs 里,且可以脱离窗口单测。
- Verification: pytest shared/tests apps/studio/tests(offscreen)632 passed / 0 failed /
  15 skipped(原 621 + 新增 11);apps/studio/tools/ui_check.py 587 项检查 0 失败;
  .repro/locale_check.py 0 缺失。行为等价(搬运 + 一层转发)。
- Not in this change: 标定交互(_on_calibration_apply / 身份与不匹配检查 / 导出与事件)
  仍未拆分;预览(_start_preview/_stop_preview/_on_preview_tick)留在窗口;
  _depth_range/_depth_scale_text 与 _show_image_frame 同属 2D 渲染,留在窗口;
  docs 的行数/方法数指标待全部切片落定后统一刷新。
2026-09-15 09:50:25 +08:00
ykcheungab aecde67c73 拆分 C1:面板布局与视图预设移入 ViewLayoutController(app_window 2499→2347 行)
- What: 新增 apps/studio/src/orisys_studio/view_layout.py:把「一个视图是什么」从
  app_window 搬进 ViewLayoutController——面板可见性与最小宽度、splitter 尺寸与 stretch、
  三个预设(live / replay / calibration)的切换、视图菜单三个勾选项的状态、3D 面板的
  开与关(含它看哪个传感器、恢复持久化的显示设置)、设置抽屉的展开状态。
  控制器自己拥有 view_preset / show_cal / show_finger_3d 三个状态(初值直接取自
  preferences,窗口不再留第二份),并直接驱动面板;`LayoutHost` Protocol 写明它向窗口
  借的四件事:_persist_prefs / _update_run_enabled / _refresh_status_rail /
  _current_source_kind(以及 3D 的 _device_geometry_provider)。
  预设切换同时通知 ReplayController(暂停 transport、把曲线图交还实时会话),
  这是 B 之后两个控制器之间唯一的方向性依赖,写在构造参数里而不是隐式查找。
- What: 窗口侧保留过渡 shim(_view_preset / _show_cal / _show_finger_3d 可读写属性,
  _apply_view_preset / _apply_panel_visibility / _shows_3d / _splitter_sizes_for /
  _set_settings_expanded / _sync_settings_action / _sync_3d_source / _restore_3d_settings /
  view_preset()),供 tests 与 tools/ui_check.py 继续驱动窗口;窗口内部调用点
  (brand_bar 导航、mesh3d_panel.enabledChanged、toolbar.settings_toggled、
  _build_menus 里的三个 QAction、_on_sensor_type_changed、_sync_menu_checks)改为直连
  控制器。_sync_menu_checks 只保留主题/语言两项(那是窗口的状态),视图三项交给
  layout_ctl.sync_view_checks()。
- What: 新增 apps/studio/tests/test_view_layout.py:10 个用例,用假面板/假 splitter/
  假 host 直接测布局规则——预设决定面板组合与 rail 角色、未知预设回落 custom、离开回放
  视图会停 transport 并清掉重算标签、3D 面板需要 finger + 开关且隐藏时不建 GL 视图、
  预设不给 3D 留宽度时从最宽面板取且总量不变、标定面板开关即视图开关、3D 开关不自我
  驱动(no-op 不写偏好)、菜单勾选跟随屏幕且 blockSignals、设置抽屉三处同步、
  3D 面板拿到正确的传感器与密钥。
- Why: 「家具」是 app_window 的第三个独立变更驱动。搬完之后「哪个视图显示哪些面板」
  只有一处规则,窗口不再同时持有布局状态与面板。布局与回放两个控制器的依赖是显式的
  单向调用。
- Verification: pytest shared/tests apps/studio/tests(offscreen)621 passed / 0 failed /
  15 skipped(原 611 + 新增 10);apps/studio/tools/ui_check.py 587 项检查 0 失败;
  .repro/locale_check.py 0 缺失。行为等价(搬运 + 一层转发)。
- Not in this change: C2 显示偏好(单位/量程/箭头/2D 模式/fps)与标定交互仍未拆分;
  预览(_start_preview/_stop_preview/_on_preview_tick)留在窗口,它既不是会话也不是布局;
  docs 的行数/方法数指标待全部切片落定后统一刷新。
2026-09-15 09:45:05 +08:00
ykcheungab fbf808d878 拆分 B:回放策略移入 ReplayController(app_window 2837→2499 行)
- What: 新增 apps/studio/src/orisys_studio/replay_controller.py:回放视图的**策略**
  (哪些入口能打开视频、transport 何时是播放何时是采集、从视频重算对面板与采集
  管线做什么)从 app_window 搬进 ReplayController,共 30 个公开方法。replay.py 仍是
  **引擎**(reader / 游标 / 异步深度重算),本模块是它外面的状态机。
  它直接驱动的对象只有五个:toolbar 的回放控件、曲线图(时间轴)、2D 面板、三维面板的
  来源标签、五个菜单项;其余依赖用 ReplayHost Protocol 写明(状态栏、轨道、视图切换、
  显示单位、2D 唯一入口 _show_image_frame、采集管线 _start_run/_stop_run)——这些是
  窗口级操作,所以留在窗口,并由 Protocol 把边界写清楚,而不是让控制器伸手进"上帝对象"。
- What: 播放时钟与定时器(33 ms)、_recompute_running、入口校验、菜单启用与提示、
  重算开关的全部规则都随之上移;窗口侧保留过渡 shim(_load_recording / _load_replay_video /
  _render_replay_frame / _start_playback / _pause_playback / _on_play_shortcut /
  _stop_replay_activity / _replay_owns_image / _can_recompute / _play_timer /
  _recompute_running)供 tests、tools/ui_check.py、tools/ui_snapshot.py 继续驱动窗口。
  窗口内部调用点(_apply_view_preset、_update_run_enabled、_sync_menu_checks、_on_run_toggled、
  _on_stop_shortcut、_on_reset、菜单与工具栏连线)改为直接调用 self.replay_ctl.*。
  删除窗口里已搬走的 _on_browse_video、_on_replay_position、_on_recording_changed、
  _wire_replay、_on_recompute_toggled 等,以及随之为空的导入(time、QFileDialog、
  SOURCE_REPLAY、PLAYBACK_TICK_MS)。顺带删掉一个从未接线的死方法
  _on_replay_unit_toggled(与 _on_chart_unit_toggle 重复,无人调用)。
- What: 新增 apps/studio/tests/test_replay_controller.py:8 个用例,用假的 host/engine/
  panel 直接测策略——播放在末尾从头开始、零长录制不播、playhead 跟墙钟并在末尾停下、
  重算开关打开时 transport 启动管线而非播放、2D 面板归属、裸视频被接受/在实时视图被拒、
  无标定录制回落到 raw、回放入口只在回放视图存在。
- Fix: 重算结束的 flag 由它自己的所有者清除(transport 暂停、开关关闭、stop_activity
  三条路径),不再依赖窗口的 _stop_run 顺手清;效果是"重算结束后停止按钮正确变为不可用",
  test_ui_smoke 里那个"手动补状态"的注释随之删除。
- Why: 回放是 app_window 的第二个独立变更驱动:录制格式变一次是引擎的事,操作员流程
  变一次不该动 2500 行窗口。搬完之后回放策略可单独阅读、单独测,窗口只剩装配与转发。
- Verification: pytest shared/tests apps/studio/tests(offscreen)611 passed / 0 failed /
  15 skipped(原 603 + 新增 8);apps/studio/tools/ui_check.py 587 项检查 0 失败;
  .repro/locale_check.py 0 缺失。行为等价(搬运 + 一层转发)。
- Not in this change: C1 视图排列、C2 显示偏好、标定交互仍未拆分;docs 的行数/方法数
  指标待全部切片落定后统一刷新。真机 GL 冒烟仍无法复跑(本机创建不出 GL 上下文,
  eglError 3005),13 例跳过、probe 用例按机器实况失败,与本次改动无关。
2026-09-15 09:38:40 +08:00
ykcheungab a35d1877d6 拆分 A2:会话状态与生命周期移入 SessionController(app_window 2900→2837 行)
- What: 新增 apps/studio/src/orisys_studio/session_controller.py(423 行 / 41 成员):
  它拥有两个 worker 及当前 worker、相位、会话计数、每会话遥测(fps / process_ms /
  capture_fps / capture_dropped / 读数失败计数 / 丢帧上报语义)和运行定时器(16 ms),
  并给出 reset_for_run / mark_running / mark_stopped / note_first_frame / note_connect_slow /
  note_sample_telemetry / check_capture_negotiation / take_dropped_message /
  take_session_error / consume_end_reason / detach_sinks。app_window 只剩「看到什么」:
  按钮、轨道、状态栏、面板,以及 16 ms 呈现槽 _on_run_tick。
  同时删除窗口里已成副本的 _check_capture_negotiation / _session_time / _current_frame_id /
  _report_invalid_sample / _report_dropped_samples / _on_worker_error / _on_worker_status,
  并让 _on_packet_recorded、_on_calibration_applied、_set_phase 只在窗口一层转发。
- What: 窗口保留过渡 shim(_running / _active_worker / _box_worker / _finger_worker /
  _run_timer / _session_phase / _session_counter / _connect_started_at 等属性与
  _ensure_workers / _worker_for / _session_time 等方法),因为这些名字是
  apps/studio/tests 与 tools/ui_check.py 驱动窗口的既有接口;它们随后续切片逐个删除。
  test_ui_smoke.py 里两处直接写窗口状态改为写 window.session.*(否则会绕开新所有者)。
- Why: app_window 是唯一 fan-in=1 的中心、也是四个独立变更驱动的交汇点。会话/采集/
  记录生命周期是其中第一个:设备与相位规则改一次,这个 2900 行文件就要跟着改一次。
  状态与机制搬进控制器后,「谁在跑、跑到哪一阶段、报了什么数」只有一个所有者,
  窗口只负责把这些事实画出来。
- Verification: pytest shared/tests apps/studio/tests(QT_QPA_PLATFORM=offscreen)
  603 passed / 0 failed / 15 skipped(与改动前基线一致);apps/studio/tools/ui_check.py
  587 项检查 0 失败;.repro/locale_check.py 325 个字面 key 0 缺失。
- Not in this change: 不改行为(纯搬运 + 一层转发)。有意的语义收敛只有一处:相位信号
  现在每次 set_phase 都发出(状态芯片据此重绘,幂等),窗口不再自留一份相位判据。
  B 回放控制器、C1 视图排列、C2 显示偏好、标定交互仍未拆分;docs 里的行数/方法数
  指标待全部切片落定后统一刷新。
  已知未接线(本次未动、也非本次引入):worker 的 on_reset_applied 从未接到
  recording.note_reset,所以真机上按「清零」只会看到「指令已发出」,确认事件不会
  进入记录;这是独立缺陷,另行处理。
  真机 GL 冒烟本轮无法复跑:本机当前创建不出 GL 上下文(eglError 3005),
  test_scene3d_gl_smoke 13 例跳过、probe 用例因机器无上下文而失败,与本次改动无关。
2026-09-15 09:23:52 +08:00
ykcheungab 384e24514b chore(studio): 去掉分拆后不再使用的 settings_from_mapping 导入 2026-09-15 08:52:32 +08:00
ykcheungab c2671e5c2f refactor(studio): 分拆 app_window 第一步 —— 会话事实(记录元数据/滤波器/采集协商)搬进 session_metadata
- What: 新增 `apps/studio/src/orisys_studio/session_metadata.py`(273 行,纯函数 + 一个 `SessionFacts`
  数据类,无 Qt、无状态),把 `StudioMainWindow` 里"记录必须自述"的那部分逻辑搬过去:
  `config_sha256` / `sdk_version` / `device_serial` / `capture_metadata` / `filter_metadata` /
  `filter_settings_from_prefs` / `video_fps` / `device_sample_rate` / `build_session_metadata`。
  `app_window` 侧只留一行转发(保留 `_config_hash`/`_sdk_version`/`_device_serial`/`_capture_metadata`/
  `_filter_metadata`/`_filter_settings`/`_video_fps`/`_device_sample_rate` 这些名字,因为测试与
  `ui_check` 会直接调它们),`_build_session_metadata` 缩减为"把只有窗口能看见的事实装进
  `SessionFacts`,然后交给模块"。`app_window.py` 3045 → 2901 行(-196/+51)。
- Why: 用户已同意分拆 `app_window.py`(当前 155 个方法、fan-out 36、唯一 fan-in=1 的中心),并选定四个
  新模块的命名。A 步(会话/采集/记录)本身约 1100 行、40 处调用点,一次搬完风险高,所以按"两种变化原因
  分开命名"的原则切成两步:**A1(本提交)= 无状态的事实与规则**,A2 = 带状态的 `SessionController`
  (worker 生命周期、phase、定时器、packet 扇出),后者将调用本模块组装记录块。
  顺带把这几条规则从"只在记录流程里被间接覆盖"变成可单测:录制视频的 fps 对相机取**配置值**、对文件取
  **实测值**、且永不写 0 fps;滤波器从**真正运行它们的 worker** 读回(旧 SDK 可能少应用),并把
  `unsupported_by_sdk` 明确记下;任何元数据读取失败都只是空块,绝不让一次记录失败。
- Verification: 新增 `apps/studio/tests/test_session_metadata.py` **15 项**:配置内容哈希(含缺失/空路径)、
  SDK 版本恒为字符串、设备序列号三种字段与嵌套 `info.SN`、capture 状态缺失/抛异常都返回空块、
  滤波器回读与 `unsupported_by_sdk`、空/损坏 preferences 等于"什么都不滤"、
  相机 vs 文件的 fps 规则与 0 fps 兜底、Nyquist 用的采样率来源与垃圾输入、完整 session 块(设备/版本/采集/
  标定/深度/约定/滤波/录像模式/video_config/3D 块逐项断言)、worker 消失且从未收到包时仍产出完整块、
  3D 描述原样透传、三种录像模式如实记录。全量:shared+apps 同一次运行 **603 项 0 失败 15 跳过**
  (较上一提交 +15)、`ui_check` 587 项 0 失败、`locale_check` 0 缺失。
- Not in this change: 行为零改动(同一批函数、同一批默认值),也没有搬任何**状态**——`_latest_fps`/
  `_latest_process_ms`/`_latest_capture_*`/`_record_error_count`/`_capture_checked`/`_dropped_reported`
  仍在窗口里,A2 连同 worker 生命周期、phase、定时器与 packet 扇出一起搬;
  回放(B)与视图/显示(C)两个控制器未开始。四个模块的名字已定:
  `SessionController` / `ReplayController`(引擎已改名 `ReplayEngine`)/ `ViewLayoutController` /
  `DisplayPrefsController`。
2026-09-15 08:51:17 +08:00
ykcheungab 8d374e5850 refactor(studio): 回放引擎改名 ReplayEngine —— 把「引擎」与「窗口级控制器」的名字分开
- What: `apps/studio/src/orisys_studio/replay.py` 的 `ReplayController` 改名为 `ReplayEngine`
  (引用只有 4 处:`app_window.py` 的 import 与构造、`ui_check.py` 的一处 import、模块内定义);
  模块 docstring 写明它为什么叫 engine(只回答"t 时刻是什么",不知道传输策略、不知道控件),
  并指路即将落地的窗口级 `ReplayController`;`app_window.replay` 这个属性名**不动**(36 处使用)。
  同步更新两份既有文档里的类名(`apps/studio/docs/shell-lifecycle.md`、`record-channel.md`、
  `docs/architecture/studio-architecture.md` 的框图标签)。
- Why: 用户已同意分拆 `app_window.py`,并选定"引擎改名 ReplayEngine + 新类叫 ReplayController"
  这一方案(另一方案是让两个名字 `ReplayController`/`ReplayCoordinator` 并存,容易进错门)。
  这一步是后续把窗口里那 30 个回放方法搬进 `ReplayController` 的前提:先把名字腾出来,
  否则搬迁时无法区分"引擎"与"控制器"。
- Verification: shared+apps 同一次运行 **588 项 0 失败 15 跳过**;`ui_check` 587 项 0 失败;
  全仓 `grep ReplayController` 只剩两处有意保留的提及(`replay.py` docstring 里指路、
  `app_window.py` 注释里指路),没有悬空引用。
- Not in this change: 不搬运任何方法、不改行为——`app_window.py` 仍是 3043 行;`SessionController`、
  `ReplayController`、`ViewLayoutController`、`DisplayPrefsController` 四个模块的搬迁尚未开始
  (顺序与契约见 docs/architecture/studio-architecture.md 建议 1)。
2026-09-15 08:41:44 +08:00
ykcheungab ef487098c9 feat(studio-3d): 限速、真实耗时统计、无位移复用资产几何,以及保护包密钥注入(阶段 9 上半)
- What:
  1. **显示限速真的生效**(R5):`ThreeDCoordinator.tick()` 用注入的单调时钟与
     `display_period_s() = 1/max_fps` 限制**合成启动**频率(窗口定时器仍是 16 ms,但"显示帧率"是给操作者的
     承诺,不是定时器的描述);声明边界(activate/begin_session/show_rest/隐藏)把预算清零,所以按下开始后的
     第一帧立刻合成。生产者侧改为**按需**跳过:`Scene3DView.submit_packet()` 在
     `coordinator.has_pending_frame()`(mailbox 里已有一帧等待)时不再构造快照——那是 O(H×W) 的拷贝,
     而 mailbox 只保留最新一帧,两次拷贝里必然丢一个;被跳过的数量计入新的 `skipped_producer`,
     与"显示跳帧"分开。**不丢任何本该显示的帧**(等待中的那一帧仍在)。
  2. **真实统计**:`RenderFrame.compose_ms`(composer 在构造帧之前测量并写入,不再读会被下一任务改写的
     `last_stats`);`GLRenderer` 的绘制计数改名为 `paint_calls`,`RenderStats.displayed_frames` 由
     **widget 按帧身份**统计(相机旋转/色标改动只是重画,不再是"多显示了一帧");`render_totals()` 增
     `compose_ms`/`latency_ms`(合成开始→被接受的单调耗时)/`paint_calls`/`skipped_producer`,并且
     `shown`/`compose_ms` 改从 widget 读(无 GL 的机器也能报告,之前只有画过才有数)。
  3. **无位移时复用资产几何**(R5 建议):`SceneComposer` 缓存 `asset_hash → (positions, normals)`;
     变形时照旧从形变后的位置重算法线,不变形时直接用资产自己的(只读、零拷贝)数组。20k 顶点实测:
     compose p50 **6.15 ms → 1.03 ms**(法线段 4.9 ms → 0.0001 ms,剩余是采样 1.01 ms);
     真实发布包(1032 顶点)compose p50 **0.50 ms → 0.17 ms**。
  4. **保护包密钥注入**(R4):新增 `scene3d/keys.py`——从 preference 读取
     `scene3d_verify_key`(验签公钥 PEM)/`scene3d_content_key_file` + `scene3d_content_key_id`
     (内容密钥,经 `protect.load_key_file`)/`scene3d_require_signature`(发布模式,拒绝无签名包),
     组装成 `SceneKeys` 交给 `SceneAssetLoader` → `load_package` → `PackagedAssetLoader.load(keys=…,
     verify_key_pem=…)`;`AppWindow._sync_3d_source()` 解析它并 `Mesh3dPanel.set_keys()`。
     缺失/不可读的密钥是**状态而不是崩溃**(`describe()` 里带原因),密钥材料永不进日志;
     `Mesh3dPanel.describe()` 的 scene 块记录 `keys`(布尔 + 路径)。
- Why: 审查报告 R5(`max_fps` 只被存储与显示、`draw()` 硬编码 `compose_ms=0.0`、`_displayed_frames` 把
  重画也算新帧、`SceneComposer.last_stats` 被显示链路完全未读)与 R4(底层支持签名/加密包,Studio 加载
  入口却从不传验签公钥与 KeyProvider,合法保护包打不开,只能发明文包或关掉校验)。R5 的另一半是
  "当前只有曲面着色、没有 displacement 时,composer 仍每帧重算法线并重新打包位置"——缓存后 20k 顶点
  降低到六分之一。
- Verification:
  · `test_scene3d_coordinator.py` +6:15 fps 的假时钟下 1.32 s 内只启动约 20 次合成(60 fps 对照 ≥55 次)、
  `display_period_s()` 跟随设置、结果延迟是真实单调差值、`has_pending_frame()` 随 mailbox 变化;
  `test_scene3d_live.py` +3:无位移时发布帧直接**就是**资产自己的(只读)数组、force_deform 但仍无位移时
  给出 `field_unavailable` 诊断、有标定位移时仍重算、`compose_ms` 属于该帧且不被下一次合成改写;
  `test_scene3d_view.py` +2:等待中的帧让生产者跳过第二个快照且取出后立即恢复、`render_totals()` 的
  compose 成本与延迟是真实值;新增 `test_scene3d_protected_package.py` 7 项:Studio 入口打开签名包、
  用 preference 里的内容密钥打开加密包、缺验签公钥 → `asset_auth_failed`、密钥文件缺失/无 key id →
  状态而非崩溃、发布模式拒绝无签名包而开发模式接受、`SceneAssetLoader` 把密钥交给 worker、面板携带密钥。
  · 全量:shared+apps 同一次运行 **588 项 0 失败 15 跳过**;真 GL 冒烟(`QT_QPA_PLATFORM=windows`)
  **14 项 0 失败**;ui_check 587 项 0 失败;locale_check 0 缺失。
  · 顺带修掉两个**既有的顺序依赖**真 GL 用例(不是本次引入:在上一提交上单跑同样失败):
  `test_the_producer_side_never_waits_for_the_gpu` 的收尾没有排空 mailbox(留下的一帧会被下一个测试
  上传),`test_an_automatic_colour_scale_follows_the_frame` 在渲染之前就断言"范围来自本帧",
  且用"不同颜色数"当自动量程的代理(对可见面积敏感)。现在前者排空并断言排空完成,后者先渲染再断言,
  判据改为**平均亮度**(固定 0..1000 把 3..7 压进 LUT 最暗的一小段,自动量程铺满整条色带)。
  · 性能报告(`.repro/perf-cpu-*-after-9.json`,`bench_scene.py --cpu-only`):合成 p50
  合成 poc 0.016 ms / 20k 顶点 1.03 ms / 真实包 0.17 ms;对比已提交的 `perf-report-windows-dev.json`
  (20k 6.15 ms、真实包 0.50 ms)。
- Not in this change: **画布布局第二批**(2D/3D 页签容器、参数抽屉、窄窗口回退、诊断抽屉)与曲线量程开关
  仍未做;阶段 9 的门禁部分(`soak_scene.py --minutes 120` GUI 模式、Jetson Orin Nano 与 QCS6490 上的
  `probe_gl/bench_scene/soak_scene` 报告)需要长时间运行与板卡,本轮未跑,结论不写"通过";
  签名私钥仍只在构建机(本提交只引入"读"的一半)。
2026-09-14 21:52:02 +08:00
ykcheungab e2570925e8 feat(studio-3d): 能力控件、真实色标与复位语义 —— 只提供数据源真正喂得出来的控制(阶段 8 第一批)
- What:
  1. **能力声明**:`contracts.SceneCapabilities`(quantity/unit/displacement_available/
     displacement_quality/source_kind);两个适配器都声明 `displacement_available = False`(它们只把标量
     画在表面上,从不发布位移);`Scene3DView.capabilities()` 从**实际绑定的适配器**读出并推给面板。
     `ThreeDPanel.set_capabilities()` 因此把"放大形变"/增益置灰并给出原因(新键 `scene3d.deform_unavailable`),
     `settings()` 在能力缺失时**强制** `deform_enabled=False`(否则持久化的增益会悄悄作用在没有东西可放大的源上);
     禁用状态在 `set_enabled`/`apply_settings` 之后仍然成立(原来 `apply_settings` 只按复选框状态使能增益)。
  2. **真实色标**:`GLRenderer` 记录并暴露本次绘制**真正使用的**上下限与模式(`effective_scale()`),
     写进 `RenderStats.scale_lower/scale_upper/scale_mode`;`_scalar_range` 在 `release()` 与
     `upload_scene()` 重置(原来换场景/重建 context 后首个自动范围沿用旧场景);面板新增 `ColorScaleBar`
     (用 `gl_backend.lut_rgba` 的同一张 256×1 表绘制),图例改为
     `{quantity} [{unit}] · {自动|固定}色标 {lower} – {upper} · {quality}`(新键 `scene3d.legend_effective`/
     `legend_auto`/`legend_fixed`),自动模式下不再让被禁用的输入框冒充"当前范围"。
  3. **文案**:`scene3d.quantity_flow_magnitude`="光流幅值"/`quantity_depth_response`,preset "default" 显示为
     "默认视角"(数据仍是资产里的真名),`scene3d.stats` 的"丢弃"改为"显示跳帧"(与录像丢帧区分)。
  4. **复位语义**:`Scene3DView.reset_view()` 改为回到**资产自己的默认视角**(`camera_presets[0]`),
     与首次显示一致;只有在没有 preset 时才退回 `OrbitCamera.reset()`。原来它把 yaw/pitch 清零并 refit,
     与首帧呈现的 preset 视角不是同一个画面。
  5. `describe()` 记录 `capabilities`(含 `displacement_available`)与 `effective_scale`(实际上下限与模式),
     这样一条录像能说明"当时的颜色代表什么",而不是只留下操作者输入框里的数字。
- Why: 审查报告 R6:`FingerFrameAdapter` 只输出光流幅值、`displacement_sensor_m` 始终为 None,而面板仍允许
  "放大形变"和 0×..500×——用户能操作一个没有真实输入的功能;自动色标每帧按实际范围变化,但面板范围输入框
  仍是保存的 0..1,没有把颜色和数值联系起来的图例;`reset_view()` 与首次显示用的资产 preset 语义不一致。
  报告明确要求"由 SceneCapabilities / 当前 FieldDescriptor 控制控件可用性""新增独立 effective_scale 输出"
  "复位也应统一语义"。
- Verification:
  · `test_scene3d_view.py` +3:无位移源 → 形变控件禁用且带原因、`settings().deform_enabled` 仍为 False、
  `describe()["capabilities"]["displacement_available"] is False`;图例显示实际上下限(0.25–7.5)与"自动"、
  不再出现内部键、`describe()["effective_scale"]` 三项一致;复位后的相机矩阵等于
  `apply_preset(camera_presets[0])` 的结果(先 orbit+zoom 离开再复位)。
  · `test_scene3d_skeleton.py` 的图例断言改为"不含内部键、含操作者语言的名字"。
  · 全量:apps/studio 240 项 0 失败 14 跳过;真 GL 冒烟 14 项 0 失败 0 跳过;ui_check 587 项 0 失败;
  locale_check 0 缺失。
- Not in this change: **画布布局(2D/3D 页签容器、参数收进"显示设置"抽屉、窄窗口回退)留作阶段 8 的第二批**
  (需要同时改 `ui_check` 的布局断言与截图对比,风险与工作量都更大,独立提交更易审阅);曲线量程的
  "可见区间自动/固定"开关(报告建议项)也留到第二批;诊断抽屉的结构调整同理;阶段 9(限速、真实耗时统计、
  保护包密钥注入、ARM 与 2 小时门禁)未开始。
2026-09-14 21:35:47 +08:00
ykcheungab 7e415c4ae9 feat(studio-2d): 回放可一键重算;实时界面不再有任何回放入口
- What:
  1. **回放里一键重算**:`ui/toolbar.py` 的 replay 组新增 `btn_recompute`(可选中的"由视频重算…",
     信号 `recompute_clicked`、`set_recompute_enabled/checked`、tooltip 说明"2D 面板会显示处理后的图像,
     录像本身只存了原始相机图像");`app_window` 把它接到 `act_recompute`(同一个开关,所以菜单、rail 与
     `_on_recompute_toggled` 不会各走一套),并在 `_refresh_replay_state`/`_sync_replay_actions` 里同步
     可用与选中状态。
  2. **实时界面禁止回放入口**:新增 `_sync_replay_actions()`——`act_open_video`/`act_playback`/
     `act_playback_latest` 只在 `_view_preset == "replay"` 时可用,否则禁用并把原因写进 tooltip
     (`toolbar.replay_view_only` = "请在「历史回放」视图里打开录像");`_load_replay_video()` 与
     `_open_latest_recording()` 在非 replay 预设下**直接拒绝并提示**,不再改动 `source_mode`/`video_path`
     (原来打开一段没有 forces.csv 的视频会走到 `_load_replay_video` 的裸视频分支,把采集源切成文件而 2D
     面板什么都没变)。rail 的回放按钮本来就在 replay 分组(`toolbar.py:476/598`),不改动;空格键原本就按
     `run_role == "playback"` 收口,加了断言。
  3. locale:新增 `toolbar.recompute`/`toolbar.recompute_tip`/`toolbar.replay_view_only`(中英各一套)。
- Why: 用户报的第二个 bug——「实时检测界面还是可以回放视频,建议回放会自动切到历史回放上面」,并在两个
  选项里选了"实时界面禁止回放入口(反向做法)"。核对确认:`_on_browse_video`/`_load_replay_video` 在实时
  预设下可用且只设 `source_mode="video"` + `video_path`,不切视图预设;随后 `_render_replay_frame()` 因
  `_view_preset != "replay"` 直接返回、`_can_recompute()` 同因返回 False,于是界面停在"实时"却已经把采集源
  换成了视频——按运行键就"在实时界面跑视频"。配套地,回放里"看不到处理后的图像"这一个用户期待,由
  「由视频重算」给出:它现在就在回放视图的 rail 上,点一下即可,2D 面板与实时共用同一个后端(见上一提交)。
  注意区分:实时视图用视频**当采集源**(`source_mode == "video"` 显示"回到摄像机")是另一项既有能力,保留
  不变,只是不再由回放路径隐式设置。
- Verification:
  · `test_ui_smoke.py` +1(`test_the_replay_entries_exist_only_in_the_replay_view`):实时预设下三个文件
  菜单动作禁用且 tooltip 带原因、rail 无回放/重算按钮、`_load_replay_video()` 返回 False 且
  `replay.has_video()`/`source_mode()`/播放定时器都不变、空格不启动回放;切到历史回放后三者可用、裸视频
  仍能打开(`plan_modes() == ("recorded",)`)、rail 的重算按钮可点并同步 `act_recompute`。
  · 既有用例继续通过:`test_a_bare_video_plays_even_though_it_is_not_a_recording`(先切到历史回放再打开,
  播放与逐帧解码不受影响)、`test_depth_view_...`、三维与记录相关用例。
  · 全量:apps/studio 237 项 0 失败 14 跳过;ui_check 587 项 0 失败;locale_check 0 缺失(zh/en 键集合
  仍一致)。
- Not in this change: 没有改成"打开视频时自动切视图"(按用户选择,改为禁止 + 说明);`source_mode == "video"`
  在实时视图的既有行为未动("由视频当相机"仍可用,只是入口不再是回放路径);阶段 8(界面与有效控制)与
  阶段 9(限速/统计/保护包)未开始。
2026-09-14 21:28:07 +08:00
ykcheungab 8e6f9658b4 refactor(studio-2d): 二维图像单一后端 —— 视图模式由帧内容决定,四条路径共用一个渲染入口
- What:
  1. 新增 `shared/.../acquisition/image_backend.py`(纯 NumPy,无 Qt):`ImageFrame`(底图 + flow/depth/
     valid_mask + source/frame_id/t_rel_s/stale)、`ImagePlan`、`plan_image()`、`render_image()`,以及模式
     常量(`arrows_pure`/`arrows_stitched`/`depth`/`recorded`)与来源常量(live/recompute/replay/preview)。
     `plan_image()` 分两个问题回答:**可选项**由来源决定(live/recompute = 三种派生视图;录像/摄像头预览
     = 只有 `recorded`),**这一帧能不能画**由 `missing` 说明(某帧缺 flow/depth 不会悄悄改掉操作者选的
     视图,而是在页脚写一行)。`render_image()` 是唯一渲染入口:内部复用 `draw_arrow_display`/
     `depth_map_to_bgr`/`flow_colormap`(公共签名不变),深度图**改用主题色带**(与面板色条同一张表——
     原来窗口没传 colormap,深度图走 OpenCV JET 而色条画品牌色带,图例与图片两张表)。
  2. `ui/arrow_panel.py`:新增 `bgr_to_qimage()`(全应用唯一一份 BGR→QImage,`app_window._bgr_to_qimage`
     成为它的别名、`replay.py` 的重复实现删除);新增 `set_plan(plan)`/`plan_modes()`——模式集来自 plan,
     `view_modes()` 在无 plan 时回退旧的预设式列表(`set_source_kind` 保留"命名/文案"职责,并支持
     `recompute` 状态与 `panels.recompute_pipeline`);页脚新增一行 `note_label` 说明 plan(如
     "录像只保存原始相机图像…"),箭头放大/有效区注解改由"本帧是否支持箭头视图"决定;
     `set_view_mode()` 与 `_populate_view_combo()` 按当前模式集校验(不再只认三个常量,否则 `recorded`
     会被强行改回 `arrows_pure`)。
  3. `app_window.py`:新增唯一入口 `_show_image_frame(ImageFrame)`(plan → 渲染 → 一个 QImage 给面板),
     **四条生产者全部改走它**:实时/重算(`_render_run_image`,来源按 `_recompute_running` 区分)、回放
     (`_render_replay_frame` 用 `replay.video_frame_bgr()`,不再自己转 QImage)、摄像头预览
     (`_on_preview_tick`)。`_render_run_image` 里那套按 `mode` 分支画箭头的代码整体删除。
  4. `replay.py`:新增 `video_frame_bgr()`(按光标缓存 BGR),`video_image()` 保留为兼容薄封装;删除
     本模块的 `_bgr_to_qimage`。
  5. locale:新增 `panels.replay_raw_note`(录像只有原始图像的说明 + 指路「由视频重算」)、
     `panels.frame_missing_note`(`{fields}` 参数化)、`panels.field_flow`/`panels.field_depth`、
     `panels.recompute_pipeline(_tip)`(中英各一套)。
- Why: 用户报的两个 bug 之一——「历史回放面板的 2 维图只能显示原始采集图像,没有进行任何处理」,并建议
  "每一个 2D 图的后端保持一致"。核对后确认根因是架构而非漏了一步渲染:二维有三个(实际四个)互不一致的
  生产者——实时/重算走 `draw_arrow_display`/`depth_map_to_bgr`,回放与预览把 BGR 直接交给面板;模式集与
  注解由**视图预设**决定(`arrow_panel.set_source_kind("replay")` 才切成 `recorded`),于是同一段视频在
  "回放"和"重算"下是两个成像栈,且 `_bgr_to_qimage` 有两份实现。用户已选定"统一后端 + 一键重算(不改记录
  格式)",本提交做前半段;"一键重算"的入口在下一提交。
- Verification:
  · 新增 `shared/tests/test_image_backend.py` 15 项:plan 的三类来源(live/recompute 三种派生视图、
  replay/preview 只有 recorded)、`missing`/`note_key`/`note_fields`、模式回退、**与旧渲染逐像素一致**
  (arrows_pure/arrows_stitched 对 `draw_arrow_display`、depth 对 `depth_map_to_bgr` 带主题色带)、
  原始帧不做缩放/不替图、缺 depth → None、缺 flow → 底图、灰度帧只在这里加宽一次。
  · `apps/studio/tests/test_ui_smoke.py` +2:同一段视频在回放路径只出 `recorded` 且有说明行、在重算/实时
  路径出三种派生视图且都经过同一个 `render_image`(用 spy 断言来源与模式)、面板 `note_label` 不是裸键、
  combo 项数与模式集一致;以及"全应用只有一处 BGR→QImage"的结构断言。
  · 既有 `test_depth_view_reports_missing_data_and_fixes_its_scale`(缺深度必须报"本帧没有深度数据"而不是
  回退显示普通形变图)继续通过——新的"缺字段说明 + 不回退替图"语义与它一致。
  · 全量:apps/studio 236 项 0 失败 14 跳过;shared 331 项 0 失败 1 跳过;ui_check 587 项 0 失败;
  locale_check 0 缺失。
- Not in this change: 「一键重算」按钮与"实时界面禁止回放入口"(7.2/7.3);深度图改用主题色带改变了 depth
  视图的颜色(这是修正:色条一直画的就是主题色带);三维(scene3d)未动;录像格式未动(仍只存原始相机图像)。
2026-09-14 21:24:28 +08:00
ykcheungab 3c0424b90c feat(studio-3d): 设备几何身份接入 —— 同尺寸不同映射/不同表面被拒绝,资产加载只采用最新请求
- What:
  1. **设备侧自己说几何**(新增 `shared/.../acquisition/geometry.py`):`describe_sensor_geometry()`
     从运行时真正加载的文件推导——`manifest.json` 的 `assets_dir`、`sensor.json` 的
     `camera.resolution_stitch`/`source_scale`、以及据此命名的 unroll map(`unroll_map_uv_s0.4.npz`)。
     产出 `DeviceGeometry(expected, verified, detail)`:`field_domain_id` + `shape_hw`(映射身份,含
     flip/scale/映射内容摘要)与 **无单位** 的 `source_geometry_id`(地图里 `world_xyz` 的内容摘要)。
     米制 `geometry_id` 需要"1 源单位 = N 米"这句只有人能做的声明,设备侧**故意留空**。两类失败区分对待:
     定位不到 bundle / 没写 `assets_dir` → `verified=False`(可显示,但不声明已校验);bundle 变了
     (map 缺失、与 `resolution_stitch` 不符)→ 直接拒绝并指名两个形状/文件。
  2. **契约**:`ExpectedGeometry += source_geometry_id`;`SceneAssets += source_geometry_id,
     compatible_sensor_model, compatible_hardware_revisions`;诊断码 `geometry_unverified`(info);
     `digest.source_geometry_digest(world_xyz)`;`legacy.convert()` 写入 `source_geometry_id`;
     `package.py` 的 manifest 增 `source_geometry` 块(数组名与单位写明),读取时把 `compatible` 与
     `source_geometry` 一并还原(旧包读到空串)。`validate_assets()` 的 expected 段重写:**只比较已声明的
     字段**,`source_geometry_id` 不符即拒绝,包内未记录该身份 → 返回 `geometry_unverified` 警告而不是
     静默通过;顺带修掉 `expected.sensor_model != assets.parts[0].role` 这行毫无意义的比较,改成真正的
     型号/硬件版本比对(`*` = 任意版本)。
  3. **接线**:`app_window._sync_3d_source()` 传 `expected_provider=`(一个闭包,指向当前传感器配置);
     `SceneAssetLoader` 在**工作线程**上解析它(`_resolve_and_load`);`view.use_scene(assets, source_id,
     expected)` 改为 `validate_assets(assets, expected)` 并把警告送到面板,同时记录 `device_verified`;
     `_make_snapshot()` 用**设备**的身份构造 adapter(设备没声明的字段才回退资产),使
     "帧与场景匹配" 第一次成为独立检查而不是自证;`describe()` 记录 `scene.device_verified` 与设备身份。
  4. **资产加载请求身份(R7-a)**:`SceneAssetLoader` 增请求号与 pending 槽——忙时不再丢弃新请求,而是
     保留最新一个并在前一个结束后启动;完成信号带请求号(`loaded(assets, 描述, id, device)` /
     `failed(diag, id)`),`Mesh3dPanel` 丢弃过期完成(否则旧文件的场景会落回新选择上)。失败后可重试。
  5. **工具与包**:`compile_scene.py`/`validate_scene.py` 报告 `geometry_id`/`field_domain_id`/
     `source_geometry_id`/兼容信息;README 写明无单位身份是什么、为什么设备侧用它;**重编发布包**
     `apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene`(geometry_id 仍为
     `1a2e0e92…`、1032 顶点 / 1974 三角形,新增 `source_geometry_id=46a03a5f…`,因此 asset_hash 变为 `ea4beab6…`)。
- Why: 审查报告 P1/R3:主窗口只传 sensor_type 与 package_override,`Mesh3dPanel._expected` 永远是 None,
  `validate_assets(assets, None)` 跳过全部设备比对,而 adapter 的 geometry_id/field_domain_id/shape_hw
  全部来自**已选中的资产**——于是收到的数据被贴上"资产自己期望的身份"。结果:shape 相同但 UV 映射、
  flip_v、source_scale 或相机配置不同时无法区分。P1/R7:`SceneAssetLoader.load()` 在忙时直接 return,
  既没保留最新请求也没有 generation,`_on_loaded` 又用当前 `_source` 标记已加载路径,快速切换配置时旧
  文件的任务会回到新选择上。
- Verification:
  · 证据探针 `.repro/probe_stage6_device.py` → `probe_stage6_device.json`(真实 bundle + 真实包):
    设备描述 `verified=true`、domain `feb7f71e…`、source geometry `46a03a5f…`、shape 300x280、
    米制 id 未声明、map = `unroll_map_uv_s0.4.npz`;发布包通过该描述;**同尺寸不同映射**(只改 map 一个
    像素的坐标)→ domain 变化、shape 不变、包被 `field_domain_mismatch` 拒绝;设备什么都不声明 →
    接受 + `geometry_unverified`。
  · 新增 `shared/tests/test_scene3d_device_geometry.py` 9 项:描述来源、发布包通过、同尺寸不同映射拒绝、
    同 map 不同表面拒绝、定位不到 bundle → unverified、`resolution_stitch` 与 map 不符拒绝、map 缺失
    指名文件、无 `assets_dir` → unverified、map 命名规则。
  · `test_scene3d_contracts.py` +4(未声明身份 → 警告而非匹配、source geometry 不符拒绝/缺记录 → 警告、
    型号与硬件版本、`source_geometry_digest` 的稳定性与形状校验);`test_scene3d_package.py` +2
    (`source_geometry` 往返、旧包读成"未声明");`test_scene3d_view.py` +6(adapter 身份来自设备、
    设备 domain 不符 → 拒绝且不上屏、无描述 → `geometry_unverified` 且 `device_verified=false`、
    **最新请求才运行**、面板丢弃过期完成、失败后可重试)。
  · 全量:apps/studio 0 失败(`--junitxml`:231→236 项);shared 0 失败(含 device_geometry 9 项);
    `validate_scene.py` 对重编包全项 ok。
- Not in this change: 生产侧仍不声明米制 `geometry_id`(那句单位声明只有人能写;要设备端复算它需要把
  `meters_per_source_unit` 与刚体变换记进配置,属可选加强项,已在本提交的 `DeviceGeometry.detail` 里留位);
  `ExpectedGeometry.mapping_digest` 未加——`field_domain_id` 本身就是由映射内容摘要派生的,再加一个字段
  只是同一个事实的第二个名字;`docs/architecture/studio-3d-contracts.pyi` 与本阶段新契约的同步放在阶段 6
  的文档提交里;保护包密钥注入(R4)、限速与真实统计、二维后端统一均未动。
2026-09-14 21:10:53 +08:00
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
ykcheungab d06e116c15 fix(studio-3d): 取消 epoch —— 停止/隐藏/换会话之后,已在跑的后台合成不能再把旧测量放回屏幕
- What:
  1. `ThreeDCoordinator` 新增 **epoch**(显示声明代次)与 `_Inflight` 记录:`work()` 不再直接写
     `_composed` mailbox,只 `return` 合成好的帧;结果在 GUI 线程的 `_collect_inflight()` 里被接受,
     且必须同时满足 `item.epoch == self._epoch` 与 `_identity_is_current(identity)` 才会入队。
     `activate()`/`begin_session()`/`set_visible(False)`/`show_rest()`/`close()` 统一走新的
     `_invalidate()`:epoch+1、清空两块 mailbox、清 `_last_request_id`/`_last_composed_identity`/
     `_last_field_frame`/`_settings_dirty`,并**尝试**取消 future。
  2. **未成功取消的 future 仍占用唯一合成槽**:`_inflight` 只在 `future.done()` 时由 `_collect_inflight()`
     释放,所以反复隐藏/显示不会在线程池里排起第二个合成;`CancelledError` 单独吞掉(取消不是失败,
     不产生诊断),其它异常仍记成 `field_unavailable` 诊断(文案由 "compose raised" 回到 "compose failed")。
  3. **request_id 单调**:`submit()` 拒绝小于本 epoch 已见值的 request_id(>0 时)并计 `stale` + 给
     `stale_frame` 诊断;`_identity_is_current()` 同样比较,所以"晚到的旧结果"也进不了屏幕。
  4. 新增 `ThreeDCoordinator.epoch` 与 `last_presented()`(帧号 + 单调时刻,供后续面板帧计数用);
     构造参数新增关键字 `clock`(默认 `time.monotonic`)与 `stale_grace_s`(默认 1.0,本提交尚未使用)。
- Why: 审查报告 P1/R1 的第一条。`Future.cancel()` 无法中断已经开始运行的函数,而旧代码在
  `_cancel_inflight()` 里就把 `_inflight` 置空并只比较 generation —— 于是"停止显示"之后旧 work 结束仍然
  `offer` 进 `_composed`,下一次 tick 取到它并把状态改回 live(审查用可控后台任务复现:
  `valid_after_show_rest=false` → 放行旧 compose → `valid_after_old_worker_finishes=true`、`state=live`)。
  同一个洞也适用于"停止重算后定时器仍继续 tick"的场景。把接受点移到 GUI 线程后,"发布前校验"和
  "接收时校验"是同一处代码,epoch 让"这一刻的显示声明"成为可比较的事实。
- Verification:
  · `test_scene3d_coordinator.py` +7(24 通过):合成已进入后被 `show_rest`/`set_visible(False)`/
    `begin_session` 撤销 → 旧结果永不 present 且 state 不为 live、epoch 递增、槽位在 work 真正结束后释放;
    `ManualExecutor` 复现"提交后立即取消" → `CancelledError` 不产生诊断且槽位可复用;
    20 轮 begin_session→submit→tick→show_rest 循环里最后一个画面上永远没有有效测量、`max_in_flight==1`;
    request_id 逆序在入口被拒(`stale` + `stale_frame`)且画面停在较新的请求上,
    "较旧请求的结果晚到"也在接收侧被丢弃。
  · 审查复现脚本对照(`.repro/probe_stage6_r1.py` → `probe_stage6_r1.json`):
    `stopped_frame_returns` 由 `{valid_after_old_worker_finishes: true, state: "live"}` 变为
    `{false, "no_data", epoch_bumped: true, slot_free_again: true}`;
    `request_id_not_checked` 由 `{old_submit: "accepted", shown_request: 1}` 变为
    `{old_submit: "stale", shown_request: 2, diagnostic: "stale_frame"}`。
  · apps/studio 全量 pytest 退出码 0(含真 GL 冒烟用例)。
- Not in this change: `stale_grace_s`/`clock` 只落地参数与 `last_presented()` 的记录,**失效状态的迁移
  (`stale` 状态、宽限到期回参考模型)留给下一提交**;adapter 侧的字段级有效性、设备几何身份、
  资产加载请求号、二维后端统一均未动;审查报告里那句"用 `coordinator._inflight` 当 Future 用"的复现
  脚本无法再原样运行(槽位现在是 `_Inflight` 记录),改用上面那份等价探针。
2026-09-14 20:53:38 +08:00
ykcheungab 6de6b8b703 feat(studio): 回放可以直接播放「一条视频」——不必先按「从视频重算」
- What: 新增 `record/playback.py::VideoOnlySource`(一个视频文件的时间轴:时长来自
  `CAP_PROP_FRAME_COUNT/FPS`,按位置解码、顺序前进时只 step 不 seek、越界钳到最后一帧;没有帧计数或帧率就
  拒绝而不是猜);`ReplayController` 增加 `open_video()`/`has_video()`/`is_video_only()`/`video_path()`,
  `duration_s`/`set_position`/`_refresh_video`/`close` 都覆盖这条路径,并在 `warnings_text()` 里说明
  "这段视频没有配套记录";`app_window._load_replay_video()` 改为按"这条视频是什么"处理:
  同目录有 `forces.csv` → 当记录打开(原行为),否则**当视频播放**(曲线为空、明确提示),只有连解码都不行
  才拒绝并给出 `从视频重算` 的出路;transport/`_render_replay_frame`/`_replay_owns_image`/状态栏 tooltip
  一律改用 `has_video()`(而查曲线的 `_apply_replay_source` 仍用 `is_loaded()`)。
- Why: 用户要求"只导入视频也能用回放按钮"。此前有录像必须走「从视频重算」才能动,因为一条裸视频被当成
  "不是记录"而拒绝(更早是当成空记录"成功"打开→零长度时间轴→按回放没反应)。这两种都不对:视频就是可以看的
  东西,只是没有力值;把它按视频播放、把"没有记录"如实写在标签与提示里,才既不骗人也不挡人。
- Verification: `shared/tests/test_playback.py` +3(VideoOnlySource:时长/按位置取帧/顺序前进/越界钳位/关闭后
  返回空/不可解码与无时间轴被拒;顺带修回我在插入新类时弄丢的 `list_recordings` 的 `base = Path(root)`);
  `test_ui_smoke.py` 改 1 增 1(裸视频可播放:`is_video_only`、transport 可用、tooltip 说明曲线为空、
  按下后位置推进且解出画面、停止有效;不可解码文件仍被拒并提示);ui_check 587 项 0 失败(回放段改为验证
  "裸视频能播");apps/studio 与 shared 全量通过;locale_check 0 缺失。
- Not in this change: 裸视频的曲线(没有 CSV 就没有力值,这是事实而不是待办);裸视频的三维形变(要形变得走
  「从视频重算」,面板标签在回放里仍然是"录像不含三维场");多段视频拼接播放。
2026-09-14 20:21:13 +08:00
ykcheungab c9a28e7c3a fix(studio-3d): 审计三维改动 —— 修「第一个会话之后画面永久冻结」,并把面板/视图接口显式化
- What:
  1. **会话代际(真 bug)**:`ThreeDCoordinator.begin_session()` 新增——新采集会话开始时要显式宣告
     (清掉已锁定的 session_id、generation+1、清空 mailbox、把参考模型放回屏幕);`Scene3DView.begin_session()`、
     `Mesh3dPanel.begin_session()` 转发,`app_window._start_run()` 在启动 worker 后调用。
     另外 `submit()` 在"会话不符"时不再只计数,也给出 `stale_frame` 诊断(信息级),否则界面只是"不动"。
  2. **接口显式化(可维护性)**:`Mesh3dPanel.attach_view()` 成为唯一装配入口(真实视图与测试替身都走它,
     测试不再写 `_view`);`_view` 类型改为 `Optional["Scene3DView"]`(TYPE_CHECKING 导入,保持 GL 惰性);
     容器里 7 处 `hasattr(...)` 鸭子调用改为直接调用(方法改名会立刻报错,而不是静默不生效);
     `Scene3DView.describe()`/`render_totals()` 与 `Mesh3dPanel.describe()`/`render_totals()` 让面板自己回答
     "我是什么"——`app_window` 里那段走 5 个属性路径拼 `visualization3d` 的代码缩成一次调用 + 一个 try;
     `scene3d/panel.py` 对 coordinator/widget 的导入移到 TYPE_CHECKING;`scene3d/__init__.py` 写上各模块职责图
     (原文还写着"应用尚未导入本包")。
- Why: 用户要求全面回顾三维改动并按"实时/回放/接口清晰/性能"四条检查 bug。审计确实抓到一个严重缺陷:
  协调器记住第一个会话的 session_id 后,**之后每个会话的每一帧都被判为 stale 而拒绝**(实测 session 1 正常、
  session 2 stale=3 且 0 帧上屏),而状态仍显示 live —— 表现为"第一次运行之后三维就再也不动了"。
  这是"回放不太行"的直接原因之一(回放里的重算通道就是第二个会话)。顺带把"面板↔窗口"的隐式耦合收紧。
- Verification:
  · 回归用例:`test_scene3d_coordinator.py` +2(新会话在宣告前 stale 且带诊断、宣告后接受并回到 live;
    `begin_session` 在无场景/已关闭时安全)、`test_scene3d_view.py` +1(每次 _start_run 代际递增、第二个会话的帧被接受)。
  · 实测(真 GPU,apps/studio 配置里那个包):`audit_live_path.py` —— 三个连续会话 state=live、stale=0、
    每次都有 26260 个非背景像素、descriptor=flow_magnitude;
    `audit_replay_path.py` —— 回放时间轴正常(1.97 s 记录,播放 0.6 s 后位置 0.60 s,2D 有图,停止按钮生效,
    面板标签"回放:录像不含三维场"),随后**真实重算通道**跑完(59 个样本、2.6 s、正常报"视频已结束"),
    三维面板进入 state=live(62 帧上屏、2 帧显示丢弃、stale=0),标签切到"重算通道:由录像重算,非记录值"。
  · 性能(真 GPU,shipped 包):submit p50 0.26 / p95 0.38 ms,upload p50 0.18 ms,paint p50 0.90 ms,
    latency p50 33.08 ms(= 30 fps 上限);20k 顶点 CPU-only compose p50 5.97 ms(与已提交报告的 6.15 ms 一致)。
  · apps/studio 全量通过;ui_check 583 项 0 失败(连跑两次);locale_check 0 缺失。
  · 修掉 ui_check 自身一处错误断言:场景已加载但本机无 GL context 时,面板显示"无法创建 context"是**正确**行为,
    原断言却要求"不显示任何消息"(偶发失败的原因就是它,与产品无关)。
- Not in this change: 测试里仍直接读 `view._camera`/`widget._renderer`/`coordinator._composer` 等私有量(测试替身已收敛到
  `attach_view`,其余留待需要时再抽);`update_surface` 里每帧重算色标范围(`_valid_scalar_range`)可以做条件化,
  实测开销很小故未动;SDK 对极小合成视频的解码问题(640×480 mp4v)——现在会 10 秒后明确报错而不是空转。
2026-09-14 19:58:33 +08:00
ykcheungab eb1dfbce8a fix(studio): 回放视图加一个无条件「停止」(Esc 同效),并让「文件一帧都不出」直接报错而不是一直等
- What: `ToolbarWidget` 新增 `set_reset_role("reset"/"stop")`,回放视图把轨道第二个按钮变成「停止」;
  `StudioMainWindow._stop_replay_activity()` 一次停掉回放(play_timer)与「从视频重算」通道(worker),
  并清掉三维面板上的重算画面,状态栏说明"已停止了什么";`_on_reset`/`_on_stop_shortcut`(Esc) 按角色路由;
  `_start_playback`/`_pause_playback` 结束时调用 `_update_run_enabled()`,让停止按钮在有东西可停时才可用;
  两个采集 worker 新增 `FILE_FIRST_FRAME_TIMEOUT_S=10s`:文件源一帧都没出来就报错结束会话
  (原先 `saw_frame` 为假时 EOF 分支永不执行,循环空转到天荒地老、窗口一直停在"连接中")。
- Why: 用户报告"视频播放完之后无法再次按回放,卡住了",并建议加手动停止。复现得到两种状态:
  ① 普通回放播到末尾后再按回放**是可以**重新从头播的(探针实测 0.967→0→0.371,计时器在跑),
  不是这条路径的问题;② 「从视频重算」通道跑文件时,工作线程可能既不出帧也不结束
  (实测:1.5 s 的合成视频跑 25 s,packets=0 且 is_running=True),于是界面永远停在运行/连接状态,
  操作者看到"卡住"。所以既给一个无论什么状态都能按的停止,也让"读不出帧"变成一条明确的错误。
- Verification: 新增/更新用例:`test_worker_frame_rate.py` 的"文件一帧都不出→error+消息"(缩短时限跑,
  真实 worker);`test_ui_smoke.py` 三项:回放视图的停止按钮(播放中可用、按下停回放、重算中停下重算、
  无事可停时说明)、Esc 同样能停、实时视图仍是「重置」;apps/studio 全量通过;shared 全量通过;
  ui_check 583 项 0 失败(新增回放停止的 5 项检查,并给"场景已加载却仍在显示消息"那条加了诊断细节,
  它此前偶发失败过一次、这次跑通)。
- Not in this change: 普通回放"播到末尾→再按回放"的路径(实测正常,未改);让回放直接播放"非记录"的视频
  (仍走「从视频重算」);`_recompute_running` 与 worker 真实状态的进一步合并(现在停止按钮同时看两者)。
2026-09-14 17:40:49 +08:00
ykcheungab a26f77bb67 fix(studio): 回放视图里选了不是记录的视频时,明确说明原因并给出可行路径
- What: `_load_replay_video()` 先用与 `list_recordings()` 相同的判据确认"这是一条记录"(视频同目录
  必须有 `forces.csv`);不是记录时不再把该目录当成记录打开,而是给出原因(状态栏 + 2D 面板常驻文案)、
  在 rail 上保留该视频路径,并把「从视频重算力值」置为可用;`_can_recompute()` 统一"重算有没有东西可读"
  (记录或已选视频),`_sync_menu_checks`/`_refresh_replay_state`/`_on_recompute_toggled` 共用它;
  回放按钮在"已选视频但不是记录"时给出对应 tooltip;`_start_playback()` 遇到零长度时间轴时改为明确提示,
  不再静默取消勾选。
- Why: 用户报告"历史回放面板,选择视频后按回放键没有反应"。复现确认:`RecordingReader` 容忍空目录,
  所以随便一段视频所在目录都会被"成功"打开成一条空记录,`duration_s()==0`,按回放→`_start_playback`
  直接静默 return——按钮点了像没点。一条记录的定义(录像 + forces.csv + session.json)此前只写在
  `list_recordings` 里,回放入口没有用同一个判据。
- Verification: 复现脚本三种状态(未选/裸视频/记录内视频)行为符合预期;新增三项 UI 用例
  (裸视频被拒且说明原因并能重算、记录内视频可打开且能播、缺视频段的记录给出提示);
  apps/studio 全量通过;ui_check 576 项 0 失败(新增 6 项覆盖同一行为);locale_check 0 缺失。
- Not in this change: 让回放直接播放"非记录"的视频(那需要一条没有力值的纯视频通道;现在用「从视频重算」
  覆盖这个需求);`RecordingReader` 对空目录的容忍行为(它要允许正在写入、尚未写 session.json 的记录)。
2026-09-14 17:21:29 +08:00
ykcheungab 981c250559 feat(studio-3d): 历史回放里的三维面板 —— 由「重算」通道喂它,并标注来源
- What: `VIEW_SHOWS_3D` 加入 `replay`;`app_window` 新增 `_shows_3d()`(可见性与布局共用一个判据)
  与 `_splitter_sizes_for()`(预设表里的 3D 槽是"二维布局"的描述,面板打开时从最宽的面板借
  `DEFAULT_3D_PANE_WIDTH=340`);`ThreeDPanel` 新增第三个来源标签(实时采集 / 回放:录像不含三维场 /
  重算通道:由录像重算,非记录值)与 `set_source_kind()`;`ThreeDCoordinator.show_rest()` 把参考模型
  放回屏幕并停止主张测量值(重算停止时调用,否则最后一帧会留在屏幕上);`Mesh3dPanel` 转发这两者,
  并新增"加载中"状态的修正:切换传感器再切回来时若该包已加载,直接显示已就绪的画面而不是一条永远
  不会被替换的"正在加载"。
- Why: 录像里没有三维场(raw_*.mp4 是原始相机图像,`depth.stored=false`,CSV 只有力值),所以纯回放只能
  显示参考模型;但「从视频重算力值」本来就把同一个运行路径指向录像的原始视频,而采集线程上的三维 sink
  早已注册——因此重算一开始面板就会收到真实的形变帧。这两件事必须由界面区分:一个是"没有测量",
  一个是"当前算法对这段录像会说什么",把后者当成记录值展示就是另一种 `display_illustrative`。
- Verification: apps/studio 全量通过(test_scene3d_view.py 32 项,含"回放里面板可见且标注正确""重算时
  标注切换""停止重算后清回参考模型""实时视图标为实时";test_scene3d_coordinator.py 15 项,含 show_rest
  与"停止时在飞的帧不会随后被显示");ui_check 570 项 0 失败(新增"已安装包必须被加载并标注来源");
  locale_check 0 缺失;仓库内工程包经 validate_scene 全部 ok。
- Not in this change: 逐帧保存展开场/深度(记录格式变更,设计文档明确否掉);重算通道本身(已存在,本次
  只是把它的帧接到面板上);示意位移(仍需 SDK lift + 已确认尺度)。
2026-09-14 17:13:37 +08:00
ykcheungab 5cd92254e1 feat(studio-3d): 阶段 5 —— 发布保护(签名/可选加密/平台锁定)与性能长时报告
- What: 新增 `scene3d/protect.py`(封套 v2:Ed25519 签名 + 可选 AES-256-GCM,nonce/salt 每包唯一、
  元数据作 AAD、明文摘要进签名、KeyProvider 只问 get_content_key)与 `record/visualization3d.py`
  (session.json 的 visualization3d 块:写入版本化、读取时缺块=不支持);`package.py` 支持 v2
  (payload=plain 仅签名 / aes-gcm 签名+加密)并继续读 v1,新增解压比上限与"写入后自读回"校验;
  `contracts.py` 新增 PlatformLock/PlatformInfo/check_platform_lock、Sealing、platform_mismatch 诊断码;
  `Scene3DView` 在装配前查 OS/架构、拿到 capabilities 后查图形半区,违反即撤下场景并在面板显示原因;
  工具 `compile_scene.py` 新增 --sign-key/--write-verify-key/--encrypt-key-id/--key-file/--lock-from-probe,
  `validate_scene.py` 新增 --key-file/--key-id/--verify-key/--require-signature(发布门禁),
  新增 `bench_scene.py`(p50/p95 分阶段 + --cpu-only + --vertices N + --package)与
  `soak_scene.py`(RSS 四分位速率 + 队列上界 + 抽稀采样);两份报告落到 docs/architecture/。
- Why: 规格 §5.2 的保护目标是"提高复制成本"而不是"防提取",所以要的是签名(来源)+ 可选认证加密(机密性)
  + 密钥隔离,而不是自制加密或把 ZIP 当加密;§12 的"包保护/性能/长时"三行需要可复现的数字与判定标准,
  而不是一次性的观感。平台锁定还必须只降级三维:锁定不通过时二维照常。
- Verification: shared 全量通过(新增 test_scene3d_protect.py 35 项:封套往返/篡改/密钥/锁定/记录块/
  compile→validate 端到端发布流程;test_scene3d_package.py 20 项含 v1↔v2 兼容);apps/studio 全量通过
  (test_scene3d_view.py 30 项含锁定拒绝与 session.json 块);ui_check 566 项 0 失败;locale_check 0 缺失;
  真实资产端到端:1032 顶点编译出 signed+sealed+locked 包(56 927 B 密文),validate --require-signature 退出 0,
  改一个字节退出 2;性能报告(perf-report-windows-dev.json):1032 顶点 compose p50 0.50 ms(30 fps 上限内),
  20022 顶点/39480 三角形 compose p50 6.15 ms(约 33 ms 帧的 18%),采集线程 submit p95 < 0.4 ms(合成器被人为放慢到 50 ms);
  长时报告(soak-report-windows-dev.json):无窗口 10 min/60 fps 尾段 0.354 MB/min,GUI 4 min/30 fps 尾段 0.809 MB/min,
  队列上界(mailbox=1、单飞行合成)两者均成立。过程中修掉:GUI 进程里的合成器计时被 GIL/vsync 污染(98.8 ms vs 真值 6.1 ms)、
  跨线程读 last_stats 撕裂、key id 无 provider 时会静默写明文包、soak 工具自身记时间列表会无界增长、RSS 读取失败伪装成零增长、
  平台锁定不是终态诊断。
- Not in this change: ARM 板卡的能力报告与 2 小时门禁运行(按用户要求暂缓;命令与判定标准已写入阶段 5 文档);
  GPU 显存随时间的测量(无可移植 API,报告说明渲染器缓冲估计恒定且更新路径不新建 GPU 对象);
  `cryptography` 在此环境无法联网安装,本机验收用的是本机已有的 cp310 构建(pyproject 里已声明为可选 extra
  `protected-assets`)。
2026-09-14 16:44:33 +08:00
ykcheungab ac4dcd643f feat(studio): 三维视图接入产品(阶段 4:界面、生命周期与记录隔离)
- What: `Scene3DView` 装配(widget/相机/渲染器/合成器/协调器/适配器),`asset_source`
  只查找并后台加载已编译的 `.oryscene` 包,`scene3d/prefs` 持久化并**修复**显示设置,
  `ui/mesh3d_panel` 用真实容器替换占位标签(无 GL/无包/包被拒/传感器不匹配各显示一行原因),
  `main.py` 在 `QApplication` 之前选定 GL profile,`app_window` 接线:3D 作为采集线程上的
  第二个 sink、`_on_run_tick` 驱动 `tick()`、视图切换与 `closeEvent` 释放、preferences 落盘。
- Why: 阶段 4 的验收要求"三维开关不影响采集/记录/力曲线",并把阶段 1 留下的
  "开/关三维 CSV 一致"这条补上;同时把"没有 context"从"看起来像在工作"变成可显示的终态。
- Verification: app 侧 test_scene3d_view 28 项、test_scene3d_gl_smoke 14 项(真实 GPU)、
  adapters 17 项、skeleton 4 项、stage1 24 项、coordinator 13 项、ui_smoke 86 项;
  shared 侧 contract 26 / assets 33 / package 19 / live 25;`ui_check.py` 566 项检查 0 失败
  (新增 "3D pane" 段 12 项);`locale_check.py` 302 个字面 key 0 缺失、中英各 377 key 对齐;
  记录隔离:同一 96 帧会话挂/不挂 3D 消费者,`forces.csv` 逐字节相同、有效样本数与记录问题数相同;
  生产者预算:合成器放慢到 50 ms 时 `submit_packet` p99 < 1 ms。
- Not in this change: 示意位移(`displacement_sensor_m`)仍未接入(需 SDK lift 与已确认尺度);
  ARM 板卡适配(按用户要求暂缓);包签名/AES-GCM 与长时性能报告(阶段 5);
  仓库中仍未提供 `.oryscene` 包(发布产物,且其单位与变换必须由人声明)。
2026-09-14 15:47:32 +08:00
ykcheungab ea1027e5a6 阶段 3:实时数据 —— 采样、合成、最新帧调度与适配器 - What: sampling.py 实现 SurfaceSampler(向量化四点双线性:无效 tap 不计入也不致无效、任一**正权** tap 无效即整顶点无效且值为 NaN、域/形状不符先拒;采样表只在加载时构建)与 remap_to_domain(按 unroll 映射 gather,空洞保持 NaN 且 valid=False,保留尾随轴以支持矢量场)。composer.py 实现 SceneComposer.compose(域校验 → 采样 → 位移 → **从形变后位置重算法线**;scalar 绝不成以 display_gain;approximate 位移产生 display_illustrative(info) 诊断、calibrated 不产生、无位移则着色并报 field_unavailable)与 build_rest_frame(参考帧:scalar 全 NaN、valid 全假、descriptor=unavailable/display_only),gl_backend.rest_surface 改为委托它,避免两处定义。coordinator.py 实现完整调度:容量 1 双 mailbox、generation/来源/会话三重过期拒绝并计数、**最多一次合成在飞行**、可见性挂起/恢复、设置变更(倍率变化重合成**屏幕上那一帧**)、close 幂等;submit 只做身份与形状的廉价校验,绝不等显示。adapters.py 实现 Finger/Box 适配器:|flow| 或 depth 响应、可选经 unroll 映射 remap、设备 valid_mask 优先、域不符时**发布空**并报出所见的两个形状;一律不造位移(未标定的毫米不如没有),并新增 display_illustrative 到诊断码表(info)。assets.py 从"桩类堆"改成门面:re-export 真正实现 + SceneCompiler.compile(外壳 GLB 与预算检查;加密请求明确拒绝并指向阶段 5)。测试:shared/tests/test_scene3d_live.py 25 项(采样/无效语义/倍率只影响位置/示意形变标注/法线重算/remap 空洞/参考帧/只读数组)、apps/studio/tests/test_scene3d_coordinator.py 13 项(状态机、过期、替换计数、单飞行、submit p99<1ms、切换场景丢弃在飞结果、隐藏挂起、倍率重合成、close 幂等)、test_scene3d_adapters.py 13 项;并把三处"桩仍未实现"的过时断言改成对已实现行为的断言。docs/architecture/studio-3d-stages.md 阶段 3 记为完成。 - Why: 阶段 3 决定"屏幕上的颜色和力值到底对应什么"。三条最容易错且肉眼看不出来的规则必须由代码而不是约定来保证:无效点不是 0(NaN 与 valid 分离,否则缺口会被画成一个测量值)、显示倍率只动位置不动测量(否则色标悄悄变了含义)、以及显示永远不能给采集施加背压(容量 1 mailbox + 单飞行合成 + 廉价 submit)。 - Verification: shared 全量通过(scene3d 相关 103 项:contracts 26、assets 33、package 19、live 25);studio 全量通过(scene3d 相关 54 项:stage1 24、skeleton 4、coordinator 13、adapters 13;GL 冒烟 10 项在真实 context 下另行通过);ui_check 554 项 0 失败。过程中修掉 3 个真实缺陷:倍率变化时用 incoming.peek() 取不到"屏幕上那一帧"(已被消费)导致改了倍率画面不动——改为保留 _last_field_frame;display_illustrative 不在码表里被默认成 error;assets.py 里与 package/legacy 同名的重复桩类(会误导集成者,已改为门面)。另修正两处我自己的错误断言(对称凸起顶点法线本就竖直;pytest.approx 不能直接 .all())。 - Not in this change: 手势与面板(阶段 4);示意位移的真实来源(需要 SDK lift + 已确认尺度,现阶段 adapter 一��� displacement=None);app_window 接线与记录隔离(阶段 4)。 2026-09-14 15:21:39 +08:00
ykcheungab 5767b4bf11 阶段 1:平台 PoC —— 真实 GL 上跑通静态场景,并修掉它抓出的相机/上下文错误 - What: 按 docs/architecture/studio-3d-stages.md 阶段 1 落地图形栈。OrbitCamera 全部实现(target/distance/yaw/pitch 表示;fit 按包围球与较窄的那个视场轴定距离;orbit/pan/zoom 有界;set_preset 从 eye/target 反解角度使拖动可延续;matrices 输出只读 float32 view/projection;另加 depth_range_m() 便于断言)。gl_backend:GLDispatch.bind_current_context 真正绑定 PyOpenGL 并读 vendor/renderer/GL/GLSL/max texture、判软件渲染、profile 不达标时抛 ThreeDUnsupported 并带回能力;GLRenderer 实现 initialize(两个 program,desktop33/gles30 共用同一份着色器主体、只换头部)、upload_scene(每 part 一套 VAO/VBO/EBO,交错 layout position/normal/scalar/valid = 8 float)、update_surface(只对感知面 glBufferSubData,绝不重建索引)、draw(绑 Qt 的 defaultFramebufferObject、深度测试、按 role 选 program、housing 隐藏即跳过、scalar 经 LUT 纹理与 range uniform 上色,Python 不生成 RGB 数组)、release(幂等);纯函数 pack_part/pack_scene_part/estimate_gpu_bytes/rest_surface/shader_source/shader_header/lut_rgba/is_software_renderer/profile_from_renderable_type 都可在无 GPU 环境测试。Sensor3DWidget 实现 initializeGL/paintGL/resizeGL/release_resources/aboutToBeDestroyed/showEvent/hideEvent 与鼠标事件;加载场景时先采用资产自带的默认视角再 fit;一帧几何不匹配只"拒绝该帧"(计数 + 诊断 + 用 rest_surface 清掉上次测量)而不是把整个视图打成不可用。shared/scene3d/synthetic.py 提供 PoC 与测试共用的已知场景(20mm 感知板 + 外壳盒,米、sensor 坐标系、带默认三视角预设)。tools/scene_assets/probe_gl.py 真实现:真实窗口取 context 并输出 JSON 报告(平台/Qt/PyOpenGL/vendor/renderer/GL/GLSL/DPR/framebuffer/是否软件渲染),退出码即门禁(0 通过、3 软件渲染、4 无上下文);apps/studio/pyproject.toml 增加 [three-d] 可选依赖(仅 PyOpenGL;Open3D 不进运行时)。测试:apps/studio/tests/test_scene3d_stage1.py 24 项(相机数学、PoC 场景合规、打包布局、着色器两方言共用主体、LUT、软件渲染识别、renderableType 映射、GPU 预算、参考帧语义,全部无需 GPU)+ test_scene3d_gl_smoke.py 8 项(真实 context:能力、两个 part 都画出来、外壳可隐藏、参考帧中性 vs 色图有差异、动态更新不动 housing 缓冲、坏几何帧被拒且视图存活、resize 跟随、release 幂等且新 context 能重传场景、探针如实报告);offscreen 下后者自动 skip。docs/architecture/gl-report-windows-dev.json 为本机平台报告。 - Why: 用户要求"从骨架搭起→计划阶段→逐阶段实现与验收",并明确 ARM 暂缓。阶段 1 的产出本身就是一份能回答"这块板的图形栈能不能用"的报告,所以先把它做实、把 profile 冻结点定在真实 context 上,而不是先堆屏幕上的东西。 - Verification: 真实 context(QT_QPA_PLATFORM=windows)下 8 项 GL 验收全过;offscreen 下 24 项 CPU 项全过、GL 项按设计 skip;shared 全量 187 passed;studio 全量 116 passed(含新 32 项);ui_check 554 项 0 失败;probe_gl 输出 verdict=ok(NVIDIA RTX 4080 SUPER、GL 3.3 Core、GLSL 3.30、非软件渲染)。过程中这套验收抓出并修掉了 7 个真实缺陷:Qt renderableType 映射反了(NVIDIA 兼容 ES 着色器所以本机看不出,Intel/ARM 会直接失败)、观察相机是左手基导致整模型镜像(det=-1)、近平面过近使 NDC z 全挤在 0.99(深度精度归零、外壳与感知面会 z-fighting)、手动 makeCurrent 早于 Qt 首次绘制会把 GPU 对象建在被丢弃的 context 里(glBindVertexArray 报 INVALID_OPERATION)、context 重建后不重传场景、一帧几何不匹配把整个视图打死、以及 grabFramebuffer 是 BGRA+stride 导致颜色断言全部读错通道。 - Not in this change: 未接入 app_window/main.py(三维对现有应用仍完全惰性,故"开/关三维 CSV 行序一致"这条验收随阶段 4 做);阶段 2 的资产导入/坐标/包格式;ARM 平台实测(probe 已可在板上直接运行)。 2026-09-14 14:55:45 +08:00
ykcheungab 0b309e7edf 三维显示骨架:可执行合同 + 模块签名 + 分阶段计划 - What: 按 docs/architecture/studio-3d-design.md 与 studio-3d-contracts.pyi 搭建骨架(阶段 0)。shared/src/orisys_app_shared/scene3d/:contracts.py 把 .pyi 的规格变成可执行代码——全部 DTO/枚举(FrameIdentity/FieldDescriptor/SurfaceFieldFrame/MeshPart/SurfaceBinding/CameraPreset/ExpectedGeometry/SceneAssets/ColorScale/DisplaySettings/VertexFields/RenderFrame/CameraMatrices/GLCapabilities/RenderStats/CompileOptions/CompileReport)、标准诊断码表(DIAGNOSTIC_SEVERITY,含 stale_frame=info、field_unavailable=warning 等)、ThreeDError、freeze()(拥有数据 + C 连续 + 只读)、五个校验器(validate_surface_frame/validate_binding/validate_mesh_part/validate_assets/validate_display_settings/validate_render_frame)、LatestFrameMailbox(容量 1、替换计数、锁内只换引用)、UNAVAILABLE_DESCRIPTOR/rest_descriptor() 与 summarize_parts(),以及全部 Protocol;该模块不 import Qt/OpenGL/SDK。assets.py/sampling.py/composer.py 为签名骨架(未实现处抛 NotImplementedError 并写明归属阶段)。apps/studio/src/orisys_studio/scene3d/:camera/adapters/coordinator/gl_backend/widget/panel 签名骨架,其中 configure_surface_format() 已实现(QApplication 之前调用,desktop33=GL3.3 Core / gles30=GLES3.0,深度 24、无 MSAA);模块级一律不 import GL 绑定,PyOpenGL 只在 GLDispatch.bind_current_context 里加载。tools/scene_assets/{probe_gl,validate_scene,compile_scene}.py 提供入口与参数,未实现时输出 not_implemented 并返回 2(避免被 CI 当成通过)。docs/architecture/studio-3d-stages.md:阶段 0-5 的产出、每阶段的验收矩阵、并行度与回归门。测试:shared/tests/test_scene3d_contracts.py 26 项(freeze 拥有数据、可写/非连续数组被拒、scalar 与 valid 形状一致、位移缺 quality 被拒、非有限 scalar 标为 valid 只报警不静默、绑定越界/-1 约定/权重和为 1、三角形索引越界、感知法线单位长度、恰好一个 sensing part、binding 必须属于 sensing part、设备几何/域/尺寸不符被拒、色标 lower<upper、邮箱替换计数与多线程不阻塞生产者、身份区分 source/generation、骨架可导入且不引入 PyOpenGL);apps/studio/tests/test_scene3d_skeleton.py 4 项(骨架不 import OpenGL、surface format 两个 profile、widget 构造不需要 context、各 stub 报出归属阶段)。 - Why: 用户要求"先从骨架搭起,然后计划功能实现顺序,最后逐阶段实现与验收"。骨架阶段的价值在于把接口变成可执行的(.pyi 本身无法被测试),并把"数组只读、单位与质量必须声明、近似位移不得当毫米、参考帧不得冒充测量、显示丢帧不得计入记录丢帧"这些容易在实现时被绕过的规则先固定下来;同时明确不 import Qt/OpenGL 的边界,使采集线程与 CI 能在无 GPU 环境导入它。 - Verification: shared 全量 187 passed(原 161 + 26);studio 95 passed(原 91 + 4);ui_check 554 项检查 0 失败;compileall 通过;probe_gl --profile desktop33 在未实现时返回 2 并说明将产出什么报告。 - Not in this change: 三维功能本身(阶段 1 起);app_window/main.py 未接线,骨架对现有应用完全惰性(无任何模块被应用导入)。 2026-09-14 14:32:38 +08:00
ykcheungab a44eb2d65d 达不到帧率的判据改用实测速率(DSHOW 回读值会说谎),探针加帧率扫描 - What: short_capture_fallacy() 改为以**实测**速率为准(新增 measured_fps 关键字参数),消息改成"it delivers about 15 fps while the config asks for 30 (1920x1080 reported 30, MJPG/DSHOW)"——把驱动自报值降级为"矛盾的一部分"而不是判据;app_window 的 _check_capture_negotiation() 相应改为用 packet 的 capture_fps(实测)判据、实测为 0 时不下结论(等窗口填满)。tools/camera_probe.py 重写:probe() 返回结构体、format_row() 把 reported 与 measured 分列并在达不到 90% 时标 "<-- misses the request";新增 --samples 与**帧率扫描**(同一格式依次申请 60/30/20/15/10 fps,逐行给实测值与"跟随申请 / 只有申请的 x 倍"的判断,最后给"本次看到的ceiling")。tests/test_worker_frame_rate.py 的归因测试同步更新,并显式覆盖"驱动自报 30 而实测 15 必须判为不达标"与"没有实测值时不说话"。 - Why: 用户跑探针的结果推翻了我上一轮的判据:MJPG 1920x1080@30 -> MJPG 1920x1080 @ 30.0 measured 15.0。DSHOW 后端下 CAP_PROP_FPS 只是回读你设的值,所以 SDK 的 status.fps 同样不可信 —— 我原来比较"申请 vs 协商"等于拿申请跟自己比,那个提示永远不会出现(用户实际看到的只有轨道上的实测 16.7)。这也解释了为什么这件事必须靠实测:真实速率只能测出来。另外探针显示 1280x720 MJPG 实测也是 15、YUYV 只有 1 fps,说明换分辨率不解决问题,需要区分"摄像头本身的上限"与"我们申请多少就被砍半"。 - Verification: shared 全量 161 passed(含新增/更新的 3 项断言);studio 91 passed;ui_check 554 项检查 0 失败;探针在本机(无摄像头)仍返回 1 并给出解释,format_row/_decode 单测通过("reported 30.0 measured 15.0 ... <-- misses the request")。 - Not in this change: 未改 SDK 采集申请顺序/后端;未做自动降级;frames.csv 仍未记录 process_ms/capture_fps。 2026-09-14 13:38:57 +08:00
ykcheungab 50122d19a2 把摄像头协商结果记进 session.json,并在达不到配置帧率时明说 - What: sdk_worker 新增 capture_status_of(sensor)(把采集源的 config 与 status 合成 {requested:{width,height,fps,fourcc}, negotiated:{width,height,fps,fourcc,backend}, dropped_frames})与 short_capture_fallacy(status)(当协商帧率低于申请值 90% 时给出一句话描述,含分辨率/实际帧率/申请帧率/格式/后端;两者都齐全时才说话);两个 worker 都加 capture_status() 访问器(box 取当前 sensor,finger 取 runtime.tactile.raw_sensor)。app_window:session.json 新增顶层 capture 块(_capture_metadata(),走同一个访问器);新增 _check_capture_negotiation()——每个会话在驱动第一次报出速率后检查一次,达不到就通过 _notify 明确提示(不是每帧刷屏),新键 status.capture_slow(中英)写明"软件改不了、记录按实际帧率保存、请降分辨率/换格式或换摄像头"。tests/test_worker_frame_rate.py:假设备补上 CaptureConfig 与 CaptureStatus 的 width/height/fps/fourcc/backend_name 字段,新增 test_a_low_camera_rate_is_recorded_as_a_negotiation(1920x1080、申请 30、协商 16.67 时 capture_status() 两半都在、描述里含 1920x1080/16.67/30;协商到位时描述为空串)。 - Why: 用户按上一轮的读数报告"还是摄像头 16.7",即摄像头自身只发布 16.7 —— 这排除了流水线(每帧只 12 ms)与应用侧节拍,剩下的只能是采集格式协商:配置申请的是 resolution_raw 1920x1080 @ 30 fps(fourcc 走 CaptureConfig 默认 MJPG)。这类"驱动只提供这个格式"的情况软件无法改变,但记录里必须留下证据、操作员必须被告知,否则一个 16.7 的采集和一次正常的 30 fps 采集在文件里长得一样,事后再也无法解释。 - Verification: shared 全量 161 passed(新增 1 项);studio 90 passed;ui_check 554 项检查 0 失败;locale_check 287 字面键全部命中、中英键集一致(343+1=344? 见下方键数)。 - Not in this change: 未改 SDK 的采集参数(仍是 1920x1080/30/MJPG、后端 auto);frames.csv 仍未记录 process_ms/capture_fps(下次改记录格式时一并加);未做自动降级(例如检测到 16.7 就自动改用更低分辨率)——那会改变传感器几何,必须由人决定。 2026-09-14 13:27:33 +08:00
ykcheungab a1314a02cb 界面显示每帧耗时与摄像头自身帧率,让低帧率可归因 - What: SdkPacket 新增 capture_fps(采集源自报的发布速率,与 fps 同一窗口)与 capture_dropped(SDK 后台读取线程累计的"发布了但没人取走"的帧数);sdk_worker 的循环记录 (t0, 已发布序号) 并在每帧算出摄像头自身的发布速率,同时读 dropped_frames;新增 _capture_source()/_capture_dropped() 两个取值助手(原 _capture_sequence 里的取值逻辑抽出来复用)。app_window 在 视图▸显示帧率 打开时把这一段一起显示在轨道摘要上:16.7 FPS · 12 ms · 摄像头 30.0 (-13)——速率是我们消费的、耗时是每帧全部开销、摄像头速率是采集源自己发布的;两个速率不一致时后面的 (-N) 就是"摄像头有、我们没接住"的帧数。新增 locale 键 status.rail_camera_fps(中英)。tests/test_worker_frame_rate.py:假设备的采集源现在也像 orisys.capture.source 一样统计"发布时上一帧还没被取走"的丢帧,并支持给 get_img 加一个可配置的每帧耗时(PROCESS_COST_S);新增 test_the_camera_s_own_rate_is_reported_next_to_ours:循环跟得上时摄像头速率≈30、丢帧 0;把流水线放到 60 ms/帧后我们的速率掉到 22 以下、**摄像头速率仍报 30**、丢帧 >5。 - Why: 用户报"上方显示 16.7 fps 而不是实际 30",并且给出了关键数据:16.7 fps 但每帧只耗 12 ms。12 ms 的工作量塞进 60 ms 的周期里,说明循环大部分时间在等——而"等"有两种完全不同的原因:(a) 摄像头/驱动只发布 16.7(USB 带宽、MJPG/YUYV、格式协商),(b) 摄像头发布 30 而我们只取到一半(节拍与轮询问题)。原来的读数只有"我们消费了多少",两种情形长得一模一样,无法判断,所以这不是显示 bug 而是**读数缺一半**。SDK 的 status.last_seq 与 dropped_frames 正好提供另一半(last_seq 每发布一帧就自增,与消费者无关;dropped_frames 在发布时若上一帧仍在缓存里就 +1,即消费者的亏欠),因此把它读出来即可判定。 - Verification: shared 全量 160 passed(新增 2 项,含上面的归因测试);studio 90 passed;ui_check 554 项检查 0 失败;locale_check 287 字面键全部命中、中英键集一致。过程中自查出一个我自己引入的问题:最初把 (t0, 序号) 的记录与按时间裁剪放在**每次轮询**里,而等待期间该分支每秒会进几万次,列表推导把 GIL 抢光,假摄像头的发布速率从 30 掉到 8(test_the_same_frame_is_not_counted_twice 立刻变红)——改为"仅在该序号发生变化时记录",即为每发布帧 O(1) 而非每轮询 O(n)。 - Not in this change: 未改节拍/等待逻辑(假设备上 30 fps 与 60 ms 慢流水线两种情形的消费速率都符合预期,未发现节拍 bug);frames.csv 仍未记录 process_ms/capture_fps(下次改记录格式时一并加)。 2026-09-14 12:07:28 +08:00
ykcheungab 5ef4d955fb 帧率读数改为"采集时刻"的到达率,并在旁边显示每帧耗时 - What: sdk_worker 的采集循环里把帧率与时间戳的取样点从"算完之后"(t1) 改成"取到这一帧的时刻"(t0)——frame_times、SIGPkt.mono_t(实时分支)与 last_frame_mono(断线判定)都用 t0,所以 16.7 FPS 现在明确表示"帧是以这个速率到达的",而不是把流水线耗时当成帧率;process_ms 的终点从 process_chain 之后改到 read_forces_from_sensor 之后(t_done),因此它终于涵盖这一帧的**全部**开销(此前漏掉力值读取);last_frame_at 仍取 t1(compute chain 结束)作为节拍相位参考,行为不变(帧率测试仍然通过)。app_window 在 视图▸显示帧率 打开时把每帧耗时一起显示在轨道摘要里:"16.7 FPS · 58 ms",并新增 _latest_process_ms(随会话/回放清空)。tests/test_ui_smoke.py 增加一项断言:轨道同时给出速率与耗时,且没有耗时数据时仍只显示速率。 - Why: 用户看到 Studio 上方显示 16.67 fps 而不是 30,问是不是显示错了。查证结论:这个数字来自 packet.fps,是采集循环真实的"到达率",记录里的行数也是它——不是界面 bug;但它有两个缺陷让这个数字**无法单独判读**:(1) 取样点在算完之后,于是它把本机流水线耗时混进了"设备帧率";(2) 旁边没有每帧耗时,而"16.7 FPS"既可能是摄像头只给 16.7,也可能是流水线跟不上 30(实测:每帧 ≤30 ms 时循环仍能跑满 29.3 fps;40/50/60/70 ms 分别掉到 24.7/19.8/16.5/14.1 fps——16.67 恰好对应每帧约 60 ms)。本机 app 配置(365x365 拼接、dis_mode=3)在 480p 素材上 process_chain+力值读取只要 ~10 ms(上限约 100 fps),因此本次报的 16.67 不可能是这台机器上这个配置的流水线瓶颈;两种可能必须靠耗时数据区分。 - Verification: pytest shared/tests/test_worker_frame_rate.py 4 项全过(30 fps 设备仍被消费在 29.3 fps);studio 全量 90 passed(新增 1 项);ui_check 554 项检查 0 失败;真实 SDK 复测:文件源 55.4 fps 到达率、每帧 18.2 ms(含力值读取)。 - Not in this change: frames.csv 仍未记录 process_ms(下次改记录格式、加未滤波 17 列时一并加,避免连做两次版本升级);未动节拍逻辑本身。 2026-09-14 11:22:44 +08:00
ykcheungab a9f75a868d 界面不摆还没接线的开关:先撤掉“同时显示未滤波曲线” - What: plot_settings_dialog 去掉"同时显示未滤波曲线"复选框(连同 reset 里的清位、retranslate 的两行文案、filter_values() 里的读取),filter_values() 里写明 show_unfiltered 暂时固定 False 及原因;两个 locale 删掉 filter_show_unfiltered / filter_show_unfiltered_tip 两个键。FilterSettings.show_unfiltered 字段保留(session.json 的 filters 块仍在记它,等第二 buffer 接上后再把开关放回对话框)。 - Why: 上一版把这个复选框放进了对话框,但"未滤波曲线"还没有数据通路——力值卡片不画、CSV 也没有 *_unfiltered 列,等于界面上摆了一个承诺了却不做事的开关;这正是这一轮一直在清理的那类问题(文件说的/界面说的与实际做的不一致)。宁可先不给入口,也不要给一个假的。 - Verification: .repro/locale_check.py:287 个字面键全部命中、两个 locale 键集合相同(341/341);studio 全量 89 passed;ui_check 554 项检查 0 失败。 - Not in this change: CSV 的 17 列与未滤波第二 buffer 仍未接线(这是把开关放回来的前提)。 2026-09-14 11:10:28 +08:00
ykcheungab 92e0b95ca2 曲线图表头加齿轮,绘图设置对话框里可以直接调滤波 - What: ui/icons.py 新增 settings(齿轮)图标(八个齿 + 圆环,颜色取自主题 token,与另外三个图标同一套画法);force_plots.py 表头新增 btn_settings 与 plot_settings_requested 信号(放在缩放按钮之后、单位芯片之前),换主题时一并重绘图标,retranslate 里补 tooltip/可访问名;plot_settings_dialog.py 增加"滤波"分区:预设下拉(off/low_latency/balanced/low_noise + 自定义,显示的就是写进 sensor.json 的那个名字)、光流 EMA α(0.05–1.00,1.00 = 关)、中值窗口(1–31,1 = 关)、低通截止(0–60 Hz,0 = 关)、低通阶数(1–6)、采样率("自动跟随帧率"复选框 + 固定值)、"同时显示未滤波曲线";选预设即写入该预设的数值,手改任一数值自动切到"自定义",选中的预设名会随 filter_values() 一起带出(preset 字段),"_reset_defaults" 回到"什么都不滤";对话框底部按当前数值给提示:光流 EMA 打开时提示"这是测量设置、两次录制不可比"、全关时提示"当前不滤波"、录制中显示"录制中不能更改滤波"并禁用整个分区;截止频率达到或超过奈奎斯特(帧率一半)时用 dialog.filter_nyquist 明确说出"设备不会滤",其中 cutoff/nyquist/rate 三个数字由 FilterSettings.nyquist_issue() 提供(problems() 仍返回英文串供日志与测试用,判断条件只有一处)。filters.py 新增 FALLBACK_PRESETS 与 available_presets()(SDK 能导入就用 SDK 的 FILTER_PRESETS,导入不了才用这份文档副本,测试保证两者逐值一致)。app_window.py:把 plot_settings_requested 接到 _on_plot_settings,打开对话框时传入当前 _filter_settings()、filters_locked(正在跑或正在录)、以及 _device_sample_rate()(实测帧率优先,否则 30 fps,用于奈奎斯特检查);接受后把 filter_values().as_dict() 逐键写成 plot_* 偏好(与 _filter_settings() 读回的是同一组键),未在采集时立刻 set_filter_settings() 交给两个 worker,并在状态栏回报"已保存:{describe}(下次采集生效)"。两个 locale 文件补齐 21+3 个新键,中英各 343 键一一对应。tests/test_ui_smoke.py 新增 4 项:齿轮确实触发设置信号、预设/自定义切换与数值一致、录制中分区被禁用且给出理由、对话框写的键与 _filter_settings 读回的键是同一组。 - Why: 用户要求"建议曲线图增加一个齿轮的配置按键,可以调节曲线图的上限和滤波等"。此前滤波只能靠手改 preferences.json 的 plot_* 键(上一轮留下的口子),而 app 默认已改成"什么都不滤"——没有这个入口,等于把默认变成了唯一选择。 - Verification: studio 全量 89 passed(原 85 + 新增 4);shared 全量 158 passed;ui_check 554 项检查 0 失败;.repro/locale_check.py 扫描 64 个源文件里 287 个 t("…") 字面键,两个 locale 全部命中且键集合完全相同(343/343);离屏实测对话框:预设 balanced→flowEMA(0.5)+median(3)、改中值→自定义、选 low_noise→带低通、30 Hz 采样率下填 30 Hz 截止→弹出奈奎斯特警告、恢复默认→off/不滤波。 - Not in this change: CSV 的 17 列与未滤波第二 buffer(SessionStore/记录格式版本)、回放界面显示滤波前曲线、标定面板显示 filters 块、发布说明里"默认不再滤波"的措辞。 2026-09-14 11:08:25 +08:00
ykcheungab 148573934f 采集端统一由 SDK 滤波、显示端不再二次滤波,session.json 记录生效值 - What: sdk_worker/finger_worker 删除硬编码的 sensor.configure(lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ, lowpass_sample_rate_hz=...) —— 它会让界面上的曲线与 CSV 走两条不同的滤波路径;改为在会话打开设备后调用 FilterSettings.apply_to(sensor),并把两件事分别记下来:self._filter_state = effective_filters(sensor)(设备报的**实际**生效值,供界面与记录使用)与 self._filters_unsupported(该 SDK 应用不了的键,老 SDK 必须被看见而不是被吞掉);新增 worker 的 set_filter_settings()/filter_settings()/filter_state()/filters_unsupported() 访问器,start() 时清空上一会话的 filter_state(与 _calibration_version 同样的理由:新会话不能继承旧会话的说法)。finger_worker 保留原有的"runtime 可能拒绝 configure"的容错,但改成记日志并把全部键标为未应用。app_window 新增 _filter_settings()(从 preferences 的 plot_* 键读,空/坏值即"什么都不滤")与 _filter_metadata()(向**正在跑的** worker 要设备实际状态,写成 session.json 的 filters 块:measurement/readout/describe/source,SDK 缺键时附 unsupported_by_sdk);_start_session 在 start() 之前把设置交给 worker。ui/force_plots.py 把 MetricHistory 的 lowpass_cutoff_hz 从 LOWPASS_CUTOFF_HZ 改成 0.0:SDK 已经滤过一遍,且这个 buffer 就是进记录的那个,显示端再滤会让曲线画出 CSV 里根本没有的数据(原来用的还是零相位 filtfilt,非因果)。acquisition/constants.py 删掉 LOWPASS_CUTOFF_HZ("app 在 10 Hz 滤波"这件事不存在了),FORCE_SAMPLE_RATE_HZ 保留。tests/test_force_filters.py 增 3 项:worker 未开设备时 filter_state() 为 {}("没问过"不等于"没在滤")、effective_filters() 对没有 filters_state() 的 SDK 返回 {}、以及对着真实 SDK 的端到端断言(external-frame 构造 box Sensor → apply_to → filters_state 回报 flowEMA(0.5)+median(3)+lowpass(10Hz,order2) → force_triplets 两个 buffer 都在 → 全关也可表达)。apps/studio/tools/ui_check.py 的 _StuckWorker 补上新合约方法(set_filter_settings/filter_settings/filter_state/filters_unsupported),否则窗口一启动就 AttributeError。 - Why: 用户的核心要求是"显示的和记录的必须一致,包括所有滤波",并决定整条链留在 SDK、app 只通过 Sensor.configure 驱动。此前 app 硬编码 10 Hz 低通(SDK 侧)**加上**显示端 filtfilt(app 侧),同一次采集的两份数据经过两条不同的滤波路径;session.json 里的 filter 块也只是照抄常量,说的不是设备在做的事。 - Verification: shared 全量 156 passed(含新增的真实 SDK 端到端断言);studio 85 项测试通过(修好 _StuckWorker 合约后);ui_check 554 项检查、4 个场景 0 失败;编译通过。 - Not in this change: 曲线表头的齿轮按钮与滤波设置对话框(因此现在还只能改 preferences.json 的 plot_* 键来换滤波)、CSV 的 17 列与未滤波第二 buffer、回放界面显示滤波前曲线、发布说明里"默认不再滤波"的措辞。 2026-09-14 11:01:55 +08:00
ykcheungab da8165cf03 删除深度重算引擎;修好回放视频每帧重开文件导致的无法播放,运输控制改到状态轨主按钮 - What: 1) 彻底删除深度重算引擎:shared/.../record/depth.py(DepthRecomputer/DepthResult)与 depth_backends.py(BoxReplay/FingerReplay)、其专属测试 test_depth_replay.py,record/__init__ 不再导出,playback.py::replay_warnings 不再报 depth_recomputed;apps/studio/replay.py 由 515 行瘦身为只做读盘+游标+视频帧+提示,回放视图的 2D 面板只提供「原始图像」;session.json 的 depth.replay 由 recompute 改为 none;删除 31 个只剩深度使用的文案键(depth_*/warn_state_*/warn_reset_*),warn_config_changed 与 warn_sdk_changed 改为描述"由视频重算";ui_check 深度段、studio 两个深度用例、test_playback 三例、drive_shared 的 test_depth_replay 与 reproduce.py 的深度段一并清理(shared 套件 150→134 项)。2) 无法播放的根因是 RecordingReader.video_frame_at 每帧都 cv2.VideoCapture 重开并 seek:小片段已 ~10 ms/帧,1080p H.264 每次 seek 需从关键帧解码(上百 ms),而播放每 33 ms 要一帧且全在 UI 线程上,视频因此看着不动;改为按分段缓存解码器、顺序帧用 read() 步进(实测 10 ms → 0.5 ms/帧)、跳转才 seek,新增 RecordingReader.close() 释放。运输控制按用户建议上移到状态轨主按钮:toolbar.set_run_role("playback") 使回放视图的主按钮变成「▶ 回放」/「⏸ 暂停」(实时视图仍是「▶ 运行」),点按以 1× 推进游标(墙钟计时,漏拍不变慢)、到末尾自动停,拖动游标/框选/滚轮/拖总览条经 ForcePlotsPanel.timeline_taken_over 自动暂停,空格键同效;曲线表头不再放第二个播放按钮;按钮样式在回放角色下不再套用"停止"的红色语义。 - Why: 用户要求先清掉深度重算引擎、并反馈"现在无法播放视频",建议把上方播放按钮在历史回放形式下改为回放。 - Verification: ui_check.py 554 项 0 失败(新增 seek<250ms/步进<60ms 的解码预算、回放按钮文字随播放/暂停切换、手动拖游标即暂停、切回实时视图按钮恢复运行、按深度视图已移除);test_ui_smoke.py 71 项 0 失败;drive_shared.py 134 项 0 失败(深度引擎用例随代码删除);locale_studio.py 262 键齐全、zh==en;reproduce.py 退出 0;.repro/probe_play.py 实测按「回放」后 0.75 s 内游标推进 0.689 s、面板 12/12 帧都在更新;两张复核图(回放视图的「▶ 回放」、实时视图的「▶ 运行」)。 - Not in this change: 录制文件格式与 CSV 列序不变(raw_*.mp4 与 forces.csv 继续一起写);「由视频重算」复核模式保留(它复用实时管线,是有状态、只能顺序前进的一遍)。 2026-09-14 09:31:21 +08:00
ykcheungab 9b3c5fe46c 回放与实时统一为一套界面;修复 30 fps 设备被以 16.7 fps 消费的计时器缺陷 - What: 1) 删除 ui/replay_bar.py(进度条、录制目录选择、重算深度按钮、提示行)与回放条的全部接线:历史回放现在与实时监测同布局(分割比例同为 0,360,1080,0)、同一条状态轨、同一套曲线手势,唯一差别是来源槽——实时是摄像机,回放是「选择录制视频…」(toolbar.btn_replay_video,与文件菜单「打开视频…」同一条 _load_replay_video 路径:视频所在目录即录制目录,曲线自动配对同目录 forces.csv)。时间轴改为曲线游标本身:表头新增播放/暂停图标按钮(icons.play/pause,空格键同效)以 1× 推进(按墙钟计时,漏拍不变慢),到末尾自动停;拖动游标、框选、滚轮平移或拖总览条一律自动暂停(ForcePlotsPanel.pause_playback)。回放的降采样/跨版本提示移入来源摘要 tooltip,打开录制时另发一条 6 秒通知。修复视频文件名残留在实时视图的问题:_refresh_status_rail 只在 _view_preset == "replay" 时写视频名。2) 录制格式不变:raw_*.mp4 与 forces.csv/frames.csv/session.json 继续一起写。按「两者都要」新增 视图 → 由视频重算力值(act_recompute):默认曲线读录制的 CSV,打开后把录制视频重新跑一遍视觉管线(进 SessionStore、2D 面板交给管线绘制、只能顺序前进,播放按钮即开始/暂停,关闭即挂回 CSV),启动路径唯一(set_playing → _on_chart_play_toggled → _start_run),并让文件源的时间轴改为视频自身时间(mono_t = 帧号 × 1/fps),否则重算结果会落在与 CSV 不同的横轴上。回放 2D 面板不再提供深度视图(REPLAY_VIEW_MODES=("recorded",)),离线深度重算引擎保留但无界面入口。3) 采集只有 16.67 fps 的根因是上一轮引入的等待:Windows 默认计时器分辨率 15.6 ms,time.sleep(0.002) 实测睡 15.5 ms,重复帧等两拍即跨过 30 fps 的 33 ms 周期,30 fps 设备被以 ~16.7 fps 消费并隔帧丢弃(配置本身正确:mini/sensor.json 写 camera.fps=30;离线实测整套管线 15.2 ms/帧≈66 fps,余量充足)。新增 _high_resolution_timer() 在会话期间把分辨率提到 1 ms(结束还原),并把重复帧等待改为 min(距下一帧时间, 半个周期)、到期只 sleep(0)。 - Why: 用户评审 3 点——回放界面与实时不一致且拖拽条效果一般、不想为回放录制深度信息、想同时保留视频与 CSV 记录、以及采集帧率只有 16.67 fps(应为 30)。 - Verification: ui_check.py 562 项 0 失败(新增两视图同布局/无 bar、来源槽随视图切换且无残留、播放暂停与手动手势暂停、重算模式切换与视频源、回放提示在 tooltip);test_ui_smoke.py 73 项 0 失败(新增视图统一、传输控制、重算模式);drive_shared.py 150 项 0 失败(新增 shared/tests/test_worker_frame_rate.py::test_a_file_source_runs_on_the_video_s_own_timeline,并把 30 fps 用例改成必须落在 27–31.5 fps;同文件实测无 1 ms 计时器 21.6 fps、有则 30.0 fps,同一帧返回 3 次时 11317:39);locale_studio.py 266 键齐全、zh==en;reproduce.py 退出 0;ui_snapshot.py 三张复核图(统一后的回放、实时、选区中英)。 - Not in this change: 录制文件格式与 CSV 列序不变;离线深度重算引擎(ReplayController.request_depth / DepthRecomputer)保留并有独立测试,但界面已无入口,可下一步删除;重算模式用有损 mp4,数值与原 CSV 会有差异(这正是它要显式呈现的)。 2026-09-13 22:04:01 +08:00
ykcheungab e6a1e77a5b 采集循环只认新帧,选区统计移到数值卡,状态栏改为会过期的通知行 - 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
ykcheungab 824544fc7a 回放并入主窗口,并修掉开场尖峰 / 录制时长 / 录制帧率 / 回放被摄像头覆盖等 9 项评审问题 - What: 回放不再是独立窗口——删除 PlaybackDialog,拆成 replay.py::ReplayController(读盘、游标、深度重算,只发信号)与 ui/replay_bar.py(录制选择、可拖拽进度条、单位、重算深度、提示行),品牌轨「历史回放」直接换主窗口布局,文件菜单两条入口都切到该视图,曲线改读 RecordingCurveSource(区间按需读盘 + 缓存包络),2D 面板切「原始图像 / 深度图」;传感器与标定合并到 SensorDialog(类型/配置下拉 + 当前生效标定 + 最近导出系数),标定面板加纵向滚动区、九按钮两行、六列表头 ResizeToContents。评审 9 项:1) 开场尖峰两个根因——push() 遇单位变化只记 _unit 而保留旧单位样本(raw 计数被画在牛顿轴上),改为「切换即换单位」:清缓冲按新单位重建、触发切换的那一帧仍刷数值卡;_start_run 先 session_store.reset() 再 set_store(),新会话不再带上次的尾巴(复现脚本 .repro/probe_item1.py:旧 39 样本峰值 9.841 → 新 37 样本峰值 2.390)。2) 箭头页脚改为「箭头按位移放大 ×{factor}」。3) 表头单位芯片由只读 QLabel 改为可点按钮,一次点击同时改曲线、数值卡与设置区下拉。4-6) 全程总览/框选/返回最新 改为 ui/icons.py 按主题令牌运行时绘制的图标,tooltip 与 accessibleName 保留文字,全程总览区域名仍是文字。7) stats() 在录制进行中即返回 _last_t - _t0,记录条开始计时(此前只在 _finalise 写、整场停在 00:00.0)。8) 容器 fps 改用传感器配置的速率(packet.fps 是"每秒完成多少帧"的计数器,突发/文件源会读到 48),视频按 1/fps 节流并把超前的帧计入 resampled_video(frames.csv/forces.csv 一行不少,报告写明原因),session.json 同时记 fps 与 measured_fps。9) _replay_owns_image() 让回放视图独占 2D 面板,实时帧不再覆盖;push() 在录制源挂载时连单位都不切,曲线/数值卡/单位一并归录制。附带:数值卡改读游标处样本(_readout_at_cursor)、回放按录制实际有的单位打开(_replay_unit_for + ReplayController.set_unit,避免 raw-only 录制在 newton 偏好下画三张空图)、视频速率下限。 - Why: 用户评审 9 点,其中 1/7/8/9 是代码缺陷(开场尖峰、录制时长不更新、录制 48fps 与实际 30fps 不符、回放时 2D 面板仍被摄像头刷新),2-6 是可读性问题(×2 含义不明、单位与视图控制应可点/应符号化)。 - Verification: ui_check.py 524 项 0 失败(新增 chart header and unit chip / acquisition correctness / replay owns the 2D pane 三段)、test_ui_smoke.py 69 项 0 失败(新增 7 个回归用例:单位切换不混单位、单位芯片、图标语义、录制中时长、传感器帧率+节流、回放独占面板、回放游标读数与单位回退)、drive_shared.py 146 项 0 失败、locale_studio.py(268 键齐全、zh==en、prefs 对齐)、reproduce.py、ui_snapshot.py 四张复核图(深色实时/回放、浅色 1280x720 实时与标定)。 - Not in this change: 录制文件格式(forces.csv 列序、frames.csv、session.json 路径与分段规则)不变,节流只影响"哪些帧进视频",并显式计数上报;三维面板仍是占位符。 2026-09-13 20:46:27 +08:00
ykcheungab 35e86ec1f7 界面改用「精密仪器工作台」布局:顶栏压缩、视图切布局、标定与回放面板不再裁切
- What: 品牌条 40→28px 并加 实时监测/历史回放/标定 导航;导航改为切换主界面三套布局(实时=2D图像+曲线、回放=自动全程浏览、标定=标定面板 880px+曲线,2D 图像整体缩小);传感器选择(传感器 / 传感器配置 两个带 tooltip 的下拉 + 产品徽标)移到状态轨最左侧,记录模式移入设置区;新增 RecordStatusChip(记录中时长、丢帧、写入异常)与「同一时刻只有一个实心按钮」(新增 dangerOutline 描边危险样式);三轴读数移到曲线上方(16pt 等宽)、只保留最下方时间轴、网格淡化、全程浏览条可拖动;三轴配色统一为蓝/橙/绿并把语义色对比度审计扩展到 toolbar_bg;箭头页脚显示放大倍率与有效区域,深度模式才显示色条与范围;左侧面板改名「2D 图像」且固定显示图像宽高比;标定面板加纵向滚动区、九按钮分两行、六列表头 ResizeToContents;PlaybackDialog 补上从未调用的 retranslate;main() 在事件循环结束后 flush 日志并 os._exit,避免设备 SDK 原生线程把进程挂在解释器收尾。
- Why: 评审提出的 7 点:顶部占高与装饰过多、记录状态不突出、配置切换看不懂且藏得太深、切视图应换主界面布局、标定与回放面板文字被裁、关窗后命令行不退出、2D 图像无固定宽高比且名字不符。
- Verification: ui_check.py(430 项,新增 top area / instrument panels / clean shutdown 段,含中英×1280/1440 的轨不挤压检查)、test_ui_smoke.py(57 项,新增视图切换、标定不裁切、2D 宽高比、回放按钮文字、传感器选择器等用例)、drive_shared.py(146 项)、locale_studio.py(261 键齐全、zh==en、prefs 对齐)、reproduce.py、ui_snapshot.py 四张复核图。
- Not in this change: 录制文件格式、CSV/MP4 写入路径与记录通道数据流不变;三维面板仍是占位符;回放对话框(含离线深度重算)本身只修了按钮文字。
2026-09-13 18:54:31 +08:00
ykcheungab 0f4552cd4e fix backend issues 2026-09-13 17:51:14 +08:00
ykcheungab 73592f085f Browse a long recording from disk instead of loading it into memory
What
- record/index.py: RecordingIndex streams forces.csv once and keeps only the
  small things — one time, one validity flag, one frame id per row plus a byte
  offset every 4096 rows (~6.5 MB per 500k rows). row_range() bisects the times,
  read_rows() seeks to the nearest checkpoint and scans forward, and overview()
  folds every row into its bucket during the same pass to build a per-axis
  min/max envelope.
- read_rows(max_points, unit) decimates **while streaming**: each bucket keeps
  the extreme row of every axis in the requested unit, so a long interval never
  has to exist in memory in full and a shear spike survives a flat normal channel.
- RecordingReader: info() decimates above max_rows (default 200k) and marks
  itself decimated, with total_rows and a duration taken from the frame index
  rather than from the view; window()/window_all()/overview()/statistics() read
  the interval being asked about. list/window semantics unchanged for small
  recordings.
- force/source.py: StoreCurveSource (live memory) and RecordingCurveSource (disk
  interval + cached envelope) implement one interface — extent, window,
  envelope, statistics, duration_s — so ForcePlotsPanel asks a *source* instead
  of knowing where the data lives.
- force_plots: reads through the source (window, envelope, statistics, extent),
  which also replaces its last direct store accesses.
- playback_dialog: draws the whole-session view through window_all() in the unit
  being shown (bounded by CHART_MAX_POINTS), and says so when the view is
  decimated ("显示 N / 共 M 行") instead of letting the operator count points.
- experiment._run: a failure that lands while the files are opening can no longer
  be overwritten by the writer's own transition to "recording" (found by a flaky
  test; the state check now covers STATE_FAILED).

Why
The store releases old blocks above ~4.6 h and the replay loaded every CSV row
into Python lists before drawing anything, so neither the live browser nor the
replay could show an earlier interval of a long recording. The index makes the
disk the data source and memory a cache of what is on screen.

Verification
- shared/tests/test_history_source.py (new, 8 cases) over a 12 000-row recording:
  the index reports its rows and reads a bounded interval; a large recording is
  marked decimated with its true duration and total rows; a small one is not; an
  interval read sees the exact rows including a single peak; a decimated window
  keeps the peak; the envelope fits the whole session in ≤200 points, keeps the
  spike and is cached; the disk and memory sources agree on extent, values and
  statistics; the whole session can be read without holding it all. Shared
  suites: 146 tests, 0 failures (three consecutive runs).
- apps/studio/tools/ui_check.py: the replay section now checks the index row
  count, that an interval read spans only the interval, the envelope size and
  total_rows. 324 checks, 0 failures.
- apps/studio/tests/test_ui_smoke.py: 43 tests, 36 pass; the 7 failures are the
  unchanged sandbox/harness artifacts.
- D:/orisys_ws/_review_phase12/reproduce.py: all ten lines unchanged;
  compileall, locale and prefs parity clean.
- apps/studio/docs/record-channel.md §8 documents the index, the reader API and
  the unified source, and lists what is still hardware work.

Not in this change
- Maximum RSS / backlog / loss measurements at real resolution on the target ARM
  board (needs the hardware), and range CSV export.
2026-09-13 16:54:26 +08:00
ykcheungab 22cc59c402 Fix the phase-1/2 review findings in recording, calibration and depth replay
What
- record: one packet->row conversion (record/sample.py). A failed compute chain
  records no force value at all, a newton value is written only for an axis that
  really has a calibration curve, and mono_t (not the wall clock) orders history
  with a per-row calibration hash.
- record lifecycle: explicit starting/recording/stopping/stopped/failed states
  owned by the writer thread; stop() reports completed=False on timeout and does
  not write ended_at; consecutive write failures stop acceptance immediately.
- record capacity: byte budget for the video queue (512 MiB default); a full
  queue sheds the *frame* and parks the tiny data row, so a video overflow no
  longer deletes CSV rows. Lost rows leave a dropped=1 index row and are counted
  as dropped_gaps / unrecorded_gaps.
- calibration identity: sha256 content hash instead of a path, minted from the
  calibration the sensor actually loaded (reported before the first frame) and
  confirmed by the acquisition thread; events are timestamped when they happen.
- calibration diagnostics: the fit summary, residual and validation error now
  come from the model that export/apply would use (piecewise_linear); validation
  points are held out of the build; repeated reference loads are merged with an
  n-weighted mean and pooled std.
- calibration record: every point keeps its full three-axis sample series
  (frame id, time, raw, newtons, valid) plus diagnostics, written to an
  independent calibration_session_*.json that can be reopened for inspection;
  save record / export coefficients / apply to device are three separate actions.
- depth replay: the cursor's frame is bound to the depth result by request
  sequence and frame id, so a moved cursor never shows another frame's map; the
  SDK is stepped from its current position instead of re-reading from the start;
  the live chain (box deformation+contact, finger unroll+tactile) is replayed
  with the exact resolved config path; algorithm state is rebuilt from a reset
  marker or from the recorded pre-roll, and says so when it cannot be.

Why
The review (D:/orisys_ws/_review_phase12/review.md) showed the app writing raw
counts into the newton columns, recording a stale value after a failed compute,
losing CSV rows when the video queue filled, reporting success while a writer was
still running, diagnosing a model nobody applies, applying a calibration before
the device confirmed it, dropping the samples behind a calibration point, showing
a stale depth map under a moved cursor and recomputing depth of the wrong frame.

Verification
- D:/orisys_ws/_review_phase12/reproduce.py: all ten acceptance lines flipped
  (log saved as _review_phase12/reproduce_after.log). Including 25/25 rows kept
  with 8 video frames shed, frames [2]->[3], depth 8.0/8.0, fit 2/2 points.
- apps/studio/tools/ui_check.py: 249 checks, 0 failures (twice).
- shared suites: 91 tests, 0 failures (new: test_record_semantics,
  test_record_lifecycle, test_calibration_model, test_calibration_record,
  test_depth_replay).
- apps/studio/tests/test_ui_smoke.py: 23/30; the 7 remaining are harness
  artifacts of the sandbox (4 tmp_path PermissionError, 3 parametrized tests the
  shim cannot drive), unchanged from before this change.
- compileall clean; locale parity zh/en + t() key coverage: 0 failures.

Not in this change
- P2#10 disk-based browsing of a long recording and range CSV export (next
  round, per your choice).
- Stability-triggered calibration sampling, replay fixed colour scale, PyOpenGL
  three-dimensional view.
- Hardware verification: no camera attached, so the SDK's real processing chain
  and the pre-roll's effect on a live tracker were exercised with fakes only.
2026-09-13 14:55:53 +08:00
ykcheungab 9354d7be1a cd D:\orisys_ws\orisys-app
git add apps/studio/config/locales/zh_CN.json `
        apps/studio/config/locales/en_US.json `
        apps/studio/tests/test_locales.py `
        apps/studio/tools/ui_check.py `
        apps/studio/tests/test_ui_smoke.py `
        apps/studio/src/orisys_studio/app_window.py `
        apps/studio/src/orisys_studio/ui/cal_panel.py `
        apps/studio/src/orisys_studio/ui/playback_dialog.py `
        apps/studio/src/orisys_studio/ui/force_plots.py `
        apps/studio/docs/record-channel.md `
        shared/src/orisys_app_shared/force/calibration_recorder.py `
        shared/src/orisys_app_shared/record/__init__.py `
        shared/src/orisys_app_shared/record/playback.py `
        shared/src/orisys_app_shared/record/depth.py `
        shared/tests/test_record_channel.py `
        shared/tests/test_playback.py
2026-09-13 14:01:24 +08:00
ykcheungab 92da51217c add session browsing, calibration diagnostics and the 2D layout preset
Phase 2 of the review: browse the whole session instead of only the rolling
window, diagnose a calibration from the recorded points, and provide the
reviewed two-panel layout.

What
- SessionStore gains extent(), visible(t0, t1, max_points) and
  statistics(t0, t1, unit). visible() walks only the blocks that overlap the
  window and decimates by taking each bucket's minimum and maximum, so a spike
  survives decimation and browsing a long session does not copy all of it.
- ForcePlotsPanel gains follow/browse modes, a whole-session overview strip
  with the current window, pan and wheel zoom, a rubber-band zoom toggle,
  a shared time cursor across all three channels, and a selection readout
  (duration, valid samples, per-axis peak/mean/std).
- CalibrationPoint records a bounded per-axis trace, an excluded flag, a
  validation flag and its fit residual. CalibrationRecorder gains
  set_excluded/set_validation/fit; the fit is a least-squares line over the
  non-excluded, non-validation points with per-point residuals and an
  independent validation error.
- The calibration table becomes the reviewed diagnostic view
  (reference load | FN | FX | FY | stability | state) with the residual shown
  next to the loaded axis, plus exclude/restore and mark-validation actions and
  a fit summary line. Double clicking a row opens the point's three-axis trace.
- Add a layout preset: free layout or 2D mode (depth map on the left, force
  charts on the right, calibration folded away), persisted in preferences.

Why
- Browsing is a display pause, not a capture pause: the record channel keeps
  running while the charts show an older window.
- Excluding a point removes it from the fit and from the exported calibration
  but keeps it on record, so the evidence for the decision is not destroyed.
- The non-loaded axes are recorded but never given a reference load: their true
  load is unknown during a single-axis calibration, so it stays empty.

Verification
- shared/tests/test_record_channel.py grows to 18 cases, covering extent,
  peak-preserving decimation, windowed reads, inverted/empty windows, unit-aware
  statistics and the time origin.
- tools/ui_check.py gains "session browsing" and "layout preset" sections
  (223 checks, 0 failures overall).
- apps/studio/tests/test_ui_smoke.py covers the same invariants.

Not in this change: synchronised playback of video, depth and curves. It needs
a decision on how depth is stored for replay (per-frame PNG, reduced-rate PNG,
or offline recomputation); the cursor plumbing (cursor_moved / set_cursor) is
already in place for it.
2026-09-13 13:34:26 +08:00
ykcheungab 817f830565 record the full experiment: video, CSV and metadata
Phase 1 of the review asked for one directory per experiment, a force CSV,
video of the pre-processing image, and metadata that ties them together. The
record channel from the previous change already kept the whole session; this
adds the on-disk format.

What
- Add record/experiment.py: ExperimentRecorder writes one directory per
  session containing session.json, forces.csv, frames.csv, events.csv,
  raw_NNNN.mp4 (segmented) and an optional lossless PNG sequence.
- forces.csv uses the reviewed column order
  (session_id, frame_id, t_rel_s, FN_raw, FX_raw, FY_raw, FN_N, FX_N, FY_N,
  valid, calibration_id) and leaves a cell empty when a value is unavailable:
  an uncalibrated newton value or a failed readout is never written as 0.
- frames.csv is the frame-index mapping: frame_id, t_rel_s, acquisition wall
  clock, segment, segment_frame, in_video. forces.csv and frames.csv share the
  packet frame_id and the same t_rel_s origin, so video and data can be
  aligned after dropped or segmented frames.
- session.json carries format version, session id, start/end time in UTC,
  device and config (with a sha256 of the config file), app/shared/SDK
  versions, calibration reference, axis convention, filter parameters, video
  codec and the lossy-MP4 caveat, plus the event log and end-of-run statistics.
  It is written once at start so a crash still leaves identifiable metadata.
- Add a record control to the toolbar: mode (video+data / data only) and a
  start/stop toggle that stays enabled while recording.
- Log zeroing, calibration application and session end as timestamped events.

Why
- The recorder is fed from the worker sink list on the acquisition thread and
  only appends to a bounded queue, so disk I/O can never stall acquisition.
- On_packet copies the raw frame instead of holding a reference: the packet
  frame may alias a buffer the SDK reuses between frames.
- A queue overflow is counted and alarmed (rate limited), and a write or encode
  failure is counted, reported and after 5 consecutive errors stops the
  recording with failed=true rather than silently losing data.

Verification
- shared/tests/test_experiment_recorder.py: 12 cases covering the directory
  layout, the CSV contract, frame-id/time-base agreement, segment rolling,
  data-only and lossless modes, queue overflow accounting, encoder failure,
  metadata fields, crash-before-stop, and that on_packet never blocks.
- tools/ui_check.py gains an "experiment record" section (169 checks, 0
  failures overall), driving a recording through the UI and asserting the
  layout, the empty-cell rule and the event sequence.
- apps/studio/tests/test_ui_smoke.py mirrors those invariants.
- docs/record-channel.md documents the format and the remaining phase-2 work.

Not in this change: CSV export of a selected time range, full-session curve
browsing, calibration diagnostics (outlier exclusion, fit residuals) and
PyOpenGL 3D.
2026-09-13 13:14:10 +08:00